import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest' /** * The pairing service fans out via the websocket plugin (`sendToUser`) and FCM * (`sendPushNotification`). We mock both so tests assert on intent (which event * was sent to which user) without needing a real WS client or FCM credentials. * * Mocks must be declared at the top level so vi.mock hoists them above the * service imports. */ vi.mock('../../src/plugins/websocket.js', () => ({ // Default: pretend the user is not connected so the service falls back to FCM — // matches the "customer is in the app but socket isn't open" path. sendToUser: vi.fn(() => false), sendToSessionParticipant: vi.fn(() => false), registerWebSocketPlugin: vi.fn(), registerWebSocketRoute: vi.fn(), isUserOnlineWs: vi.fn(() => false), getSessionConnections: vi.fn(() => ({})), })) vi.mock('../../src/services/notification.service.js', () => ({ sendPushNotification: vi.fn(async () => true), registerDeviceToken: vi.fn(async () => {}), })) // Imports BELOW the mocks (vi.mock is hoisted, but keeping the order explicit aids // readability and matches Vitest docs). const { sendToUser } = await import('../../src/plugins/websocket.js') const { createPairingRequest, declinePairingRequest, cancelPairingRequest, } = await import('../../src/services/pairing.service.js') const { createPaymentSession, confirmPaymentSession } = await import('../../src/services/payment.service.js') const { WsMessage, PairingFailureCause, PaymentSessionStatus, SessionStatus, } = await import('../../src/constants.js') const { db, resetDb, resetAppConfig } = await import('../helpers/db.js') const { createCustomer, createMitra } = await import('../helpers/fixtures.js') describe('pairing.service', () => { let customer let mitra beforeAll(async () => { await resetAppConfig() }) beforeEach(async () => { await resetDb() sendToUser.mockClear() customer = await createCustomer({ callName: 'Alice' }) mitra = await createMitra({ callName: 'MitraOne', isOnline: true }) }) afterEach(() => { vi.clearAllMocks() }) it('single-recipient general blast → mitra declines → retryable ALL_MITRAS_REJECTED, payment stays confirmed', async () => { // Arrange: confirmed, non-targeted payment session. const pay = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 30000, }) await confirmPaymentSession(pay.id, customer.id) // Act: customer fires the general blast — only one mitra is online. const session = await createPairingRequest(customer.id, { paymentSessionId: pay.id, }) expect(session.status).toBe(SessionStatus.PENDING_ACCEPTANCE) // The single recipient declines. With the /simplify fix this is correctly // classified as a general-blast all-rejected, NOT a targeted reject. await declinePairingRequest(session.id, mitra.id) // Assert: pairing_failures audit row carries ALL_MITRAS_REJECTED. const sql = db() const failures = await sql` SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id} ` expect(failures).toHaveLength(1) expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED) // Payment session stays CONFIRMED — the customer can re-blast on the same // payment via the S7 Timeout "coba cari lagi" CTA. const [paySession] = await sql`SELECT status FROM payment_sessions WHERE id = ${pay.id}` expect(paySession.status).toBe(PaymentSessionStatus.CONFIRMED) // Customer was notified with PAIRING_FAILED carrying is_terminal=false so // the client renders the retryable variant of the S7 timeout screen. const pairingFailedCalls = sendToUser.mock.calls.filter( ([, , data]) => data?.type === WsMessage.PAIRING_FAILED, ) expect(pairingFailedCalls).toHaveLength(1) expect(pairingFailedCalls[0][2].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED) expect(pairingFailedCalls[0][2].is_terminal).toBe(false) }) it('cancelPairingRequest does NOT push PAIRING_FAILED to the customer', async () => { // Arrange: a confirmed payment + an in-flight pairing request the customer is about to cancel. const pay = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 30000, }) await confirmPaymentSession(pay.id, customer.id) const session = await createPairingRequest(customer.id, { paymentSessionId: pay.id, }) // Act: customer cancels. await cancelPairingRequest(session.id, customer.id) // Assert: the customer must NOT receive a PAIRING_FAILED event for their own cancel. // Mitras still get CHAT_REQUEST_CLOSED (that's the dismiss event) — we only assert on // the customer-targeted events. const customerEvents = sendToUser.mock.calls.filter( // sendToUser signature: (userType, userId, data) ([userType, userId]) => userId === customer.id, ) const customerEventTypes = customerEvents.map(([, , data]) => data?.type) expect(customerEventTypes).not.toContain(WsMessage.PAIRING_FAILED) // Payment session is still terminated (CUSTOMER_CANCELLED) — the failure row exists // for ops accounting, just no real-time push to the customer who initiated the cancel. const sql = db() const failures = await sql`SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}` expect(failures).toHaveLength(1) expect(failures[0].cause_tag).toBe(PairingFailureCause.CUSTOMER_CANCELLED) }) })