/opt/mawid/apps/api/src/agent
Edit: /opt/mawid/apps/api/src/agent/agent.e2e.spec.ts (13296B)
/**
* E2E agent tests with a scripted (mocked) Claude — PROJECT_PLAN Phase 3 task 4:
* happy booking path, reschedule, cancel, waitlist, handoff (+ rate limit).
* Requires the docker-compose postgres on localhost:5432.
*/
import type OpenAI from 'openai';
import { PrismaService } from '../prisma/prisma.service';
import type { ReminderService } from '../reminders/reminder.service';
import type { WaitlistService } from '../waitlist/waitlist.service';
import { AgentService } from './agent.service';
import { BookingToolsService } from './booking-tools.service';
import type { AgentLlmClient } from './openai.client';
import { wallTimeToUtc } from './tz';
type Completion = OpenAI.Chat.Completions.ChatCompletion;
type CreateParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming;
process.env.DATABASE_URL ??= 'postgresql://mawid:mawid@localhost:5432/mawid';
const TZ = 'Europe/Istanbul';
const ALL_WEEK = Object.fromEntries(
['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((d) => [d, [{ start: '09:00', end: '17:00' }]]),
);
function textResponse(text: string): Completion {
return {
choices: [{ message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
} as Completion;
}
function toolResponse(name: string, input: object, text?: string): Completion {
return {
choices: [
{
message: {
role: 'assistant',
content: text ?? null,
tool_calls: [
{
id: `call_${name}_${Math.random().toString(36).slice(2, 8)}`,
type: 'function',
function: { name, arguments: JSON.stringify(input) },
},
],
},
finish_reason: 'tool_calls',
},
],
} as Completion;
}
class ScriptedLlm implements AgentLlmClient {
calls: CreateParams[] = [];
private script: Completion[] = [];
enqueue(...responses: Completion[]) {
this.script.push(...responses);
}
chat = {
completions: {
create: async (params: CreateParams): Promise
=> {
// Snapshot: the agent mutates its messages array between iterations.
this.calls.push(JSON.parse(JSON.stringify(params)));
const next = this.script.shift();
if (!next) throw new Error('ScriptedLlm: no scripted response left');
return next;
},
},
};
}
describe('AgentService E2E (mocked Claude)', () => {
const prisma = new PrismaService();
let clinicId: string;
let staffId: string;
let serviceId: string;
// Tomorrow 10:00 and 11:00 clinic-local — always inside the all-week hours.
const tomorrow = new Date(Date.now() + 24 * 3600_000);
const tomorrowDate = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const t10 = (() => {
const d = new Date(tomorrow);
return wallTimeToUtc(TZ, d.getFullYear(), d.getMonth() + 1, d.getDate(), 10, 0);
})();
const t11 = (() => {
const d = new Date(tomorrow);
return wallTimeToUtc(TZ, d.getFullYear(), d.getMonth() + 1, d.getDate(), 11, 0);
})();
beforeAll(async () => {
const clinic = await prisma.clinic.create({
data: {
name: `agent-e2e-${Date.now()}`,
phone: '+900000000099',
timezone: TZ,
defaultLanguage: 'tr',
workingHours: ALL_WEEK,
},
});
clinicId = clinic.id;
const service = await prisma.service.create({
data: {
clinicId,
name: { ar: 'فحص', tr: 'Muayene', en: 'Checkup' },
durationMinutes: 30,
},
});
serviceId = service.id;
const staff = await prisma.staff.create({
data: {
clinicId,
name: 'Dr. E2E',
workingHours: ALL_WEEK,
services: { connect: [{ id: serviceId }] },
},
});
staffId = staff.id;
});
afterAll(async () => {
await prisma.appointment.deleteMany({ where: { clinicId } });
await prisma.clinic.delete({ where: { id: clinicId } });
await prisma.$disconnect();
});
async function makeConversation(suffix: string) {
const patient = await prisma.patient.create({
data: {
clinicId,
waPhone: `+90599${Date.now().toString().slice(-7)}${suffix}`,
name: `Patient ${suffix}`,
language: 'tr',
},
});
const conversation = await prisma.conversation.create({
data: { clinicId, patientId: patient.id },
});
return { patient, conversation };
}
function makeAgent() {
const llm = new ScriptedLlm();
const reminders = {
scheduleForAppointment: async () => {},
cancelForAppointment: async () => {},
} as unknown as ReminderService;
const waitlist = { onAppointmentCancelled: async () => {} } as unknown as WaitlistService;
const agent = new AgentService(
prisma,
new BookingToolsService(prisma, reminders, waitlist),
llm,
);
return { llm, agent };
}
it('happy path: availability is checked, then the appointment is created as pending', async () => {
const { patient, conversation } = await makeConversation('a');
const { llm, agent } = makeAgent();
llm.enqueue(
textResponse('booking'), // intent gate
// Pin the range: without fromDate the result is capped at 24 slots from
// "now", which only reaches tomorrow when run late in the day.
toolResponse('get_availability', { serviceId, fromDate: tomorrowDate }),
toolResponse('create_appointment', {
serviceId,
staffId,
startsAtUtc: t10.toISOString(),
}),
textResponse('Randevunuz oluşturuldu, yarın 10:00’da bekleriz!'),
);
const reply = await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'Yarın muayene için randevu almak istiyorum',
});
expect(reply).toContain('Randevunuz oluşturuldu');
const appointment = await prisma.appointment.findFirst({
where: { clinicId, patientId: patient.id },
});
expect(appointment?.status).toBe('pending');
expect(appointment?.source).toBe('whatsapp');
expect(appointment?.startsAt.toISOString()).toBe(t10.toISOString());
// get_availability really returned the booked slot (never invent availability)
const availabilityResult = JSON.parse(llm.calls[2].messages.at(-1)!.content as string);
expect(availabilityResult.ok).toBe(true);
expect(
availabilityResult.slots.some((s: { startsAtUtc: string }) => s.startsAtUtc === t10.toISOString()),
).toBe(true);
// guardrail: every tool call audited
const audits = await prisma.auditLog.findMany({ where: { clinicId, actor: 'agent' } });
expect(audits.map((a) => a.action)).toEqual(
expect.arrayContaining(['tool:get_availability', 'tool:create_appointment']),
);
});
it('double-booking the same slot is rejected', async () => {
const { patient, conversation } = await makeConversation('b');
const { llm, agent } = makeAgent();
llm.enqueue(
textResponse('booking'),
toolResponse('create_appointment', { serviceId, staffId, startsAtUtc: t10.toISOString() }),
textResponse('Maalesef bu saat az önce doldu.'),
);
const reply = await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'Yarın 10:00 uygun mu?',
});
expect(reply).toContain('doldu');
const toolResult = JSON.parse(llm.calls[2].messages.at(-1)!.content as string);
expect(toolResult.ok).toBe(false);
const count = await prisma.appointment.count({
where: { clinicId, startsAt: t10, status: 'pending' },
});
expect(count).toBe(1); // only the appointment from the happy-path test
});
it('reschedule moves the appointment to the new slot', async () => {
const { patient, conversation } = await makeConversation('c');
const appointment = await prisma.appointment.create({
data: {
clinicId,
patientId: patient.id,
staffId,
serviceId,
startsAt: t11,
endsAt: new Date(t11.getTime() + 30 * 60_000),
status: 'confirmed',
source: 'whatsapp',
},
});
const t14 = new Date(t11.getTime() + 3 * 3600_000);
const { llm, agent } = makeAgent();
llm.enqueue(
textResponse('booking'),
toolResponse('get_patient_appointments', {}),
toolResponse('reschedule_appointment', {
appointmentId: appointment.id,
newStartsAtUtc: t14.toISOString(),
}),
textResponse('Randevunuz 14:00’a alındı.'),
);
const reply = await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'Randevumu öğleden sonraya alabilir miyiz?',
});
expect(reply).toContain('14:00');
const updated = await prisma.appointment.findUnique({ where: { id: appointment.id } });
expect(updated?.startsAt.toISOString()).toBe(t14.toISOString());
expect(updated?.status).toBe('pending'); // re-confirmation required after move
});
it('cancel sets status and keeps the reason', async () => {
const { patient, conversation } = await makeConversation('d');
const appointment = await prisma.appointment.create({
data: {
clinicId,
patientId: patient.id,
staffId,
serviceId,
startsAt: new Date(t10.getTime() + 5 * 3600_000),
endsAt: new Date(t10.getTime() + 5.5 * 3600_000),
status: 'confirmed',
source: 'whatsapp',
},
});
const { llm, agent } = makeAgent();
llm.enqueue(
textResponse('booking'),
toolResponse('cancel_appointment', { appointmentId: appointment.id, reason: 'seyahat' }),
textResponse('Randevunuz iptal edildi, geçmiş olsun.'),
);
const reply = await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'Yarınki randevumu iptal etmem gerekiyor',
});
expect(reply).toContain('iptal');
const cancelled = await prisma.appointment.findUnique({ where: { id: appointment.id } });
expect(cancelled?.status).toBe('cancelled');
expect(cancelled?.cancelReason).toBe('seyahat');
});
it('waitlist entry is created when no slot fits', async () => {
const { patient, conversation } = await makeConversation('e');
const { llm, agent } = makeAgent();
llm.enqueue(
textResponse('booking'),
toolResponse('add_to_waitlist', {
serviceId,
preferredWindow: { note: 'sadece cumartesi öğleden sonra' },
}),
textResponse('Sizi bekleme listesine ekledim.'),
);
await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'Sadece cumartesi öğleden sonra gelebilirim',
});
const entry = await prisma.waitlistEntry.findFirst({
where: { clinicId, patientId: patient.id },
});
expect(entry?.status).toBe('active');
expect((entry?.preferredWindow as { note: string }).note).toBe('sadece cumartesi öğleden sonra');
});
it('handoff flags the conversation and silences the agent afterwards', async () => {
const { patient, conversation } = await makeConversation('f');
const { llm, agent } = makeAgent();
llm.enqueue(
textResponse('other'),
toolResponse(
'handoff_to_human',
{ reason: 'patient is upset about billing' },
'Anlıyorum, bir yetkili kısa süre içinde sizinle ilgilenecek.',
),
);
const reply = await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'Bu klinikten şikayetçiyim! Fatura yanlış!',
});
expect(reply).toContain('yetkili');
const updated = await prisma.conversation.findUnique({ where: { id: conversation.id } });
expect((updated?.state as { handedOff: boolean }).handedOff).toBe(true);
// agent stays silent once handed off — no LLM call happens
const callsBefore = llm.calls.length;
const second = await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'hala bekliyorum',
});
expect(second).toBeNull();
expect(llm.calls.length).toBe(callsBefore);
});
it('rate limit: 16th turn within an hour hands off with the i18n notice', async () => {
const { patient, conversation } = await makeConversation('g');
const now = Date.now();
await prisma.conversation.update({
where: { id: conversation.id },
data: { state: { agentTurnsMs: Array.from({ length: 15 }, () => now - 60_000) } },
});
const { llm, agent } = makeAgent();
const reply = await agent.handleInbound({
clinicId,
patientId: patient.id,
conversationId: conversation.id,
text: 'tekrar deneyelim',
});
expect(reply).toBe(
'Klinik ekibimizden biri kısa süre içinde sizinle iletişime geçecek, sabrınız için teşekkürler 🙏',
);
expect(llm.calls.length).toBe(0);
const updated = await prisma.conversation.findUnique({ where: { id: conversation.id } });
expect((updated?.state as { handedOff: boolean }).handedOff).toBe(true);
});
});