import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; // Mock the database singleton so the route runs without a real Postgres. vi.mock('../lib/database.js', () => ({ db: { query: vi.fn() }, })); import Fastify, { type FastifyInstance } from 'fastify'; import { db } from '../lib/database.js'; import { sdkRoutes } from './sdk.js'; const mockQuery = db.query as unknown as Mock; const INSTALL_ID = '11111111-1111-4111-8111-111111111111'; const LINK_ID = '22222222-2222-4222-8222-222222222222'; const SESSION_ID = '33333333-3333-4333-8333-333333333333'; const CLICK_ID = '44444444-4444-4444-8444-444444444444'; const EVENT_ID = '55555555-5555-4555-8555-555555555555'; async function buildApp(): Promise { const app = Fastify(); await app.register(sdkRoutes); await app.ready(); return app; } const EVENT_TS = '2026-06-05T10:05:00.000Z'; const LINK_OPENED_AT = '2026-06-05T10:00:00.000Z'; const stampedEvent = { installId: INSTALL_ID, eventName: 'add_to_cart', eventData: { sku: 'abc' }, timestamp: EVENT_TS, attributedLinkId: LINK_ID, attributedClickId: CLICK_ID, linkOpenedAt: LINK_OPENED_AT, sessionId: SESSION_ID, sdkName: 'react-native', sdkVersion: '1.4.0', }; describe('POST /api/sdk/v1/links', () => { beforeEach(() => { mockQuery.mockReset(); }); it('accepts payloads without originalUrl and derives a fallback destination', async () => { mockQuery.mockResolvedValueOnce({ rows: [{ id: 'default-template-id' }], }); mockQuery.mockResolvedValueOnce({ rows: [{ id: LINK_ID, short_code: 'abc123', original_url: 'https://example.com/emoji/detail', }], }); const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/links', payload: { deepLinkParameters: { emojiSetId: 'uex1k7qy', path: '/emoji/detail' }, utmParameters: { campaign: 'emoji_share' }, }, }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ shortCode: 'abc123', originalUrl: 'https://example.com/emoji/detail', }); const insertCall = mockQuery.mock.calls[1]; expect(insertCall[0]).toMatch(/INSERT INTO links/); expect(insertCall[1][3]).toBe('https://example.com/emoji/detail'); await app.close(); }); }); describe('POST /api/sdk/v1/event — last-click attribution stamp', () => { beforeEach(() => { mockQuery.mockReset(); }); it('persists the attribution stamp on the in_app_events row', async () => { // 1) install lookup (link_id null so the webhook block is skipped) mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); // 2) the event INSERT mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true }); const insertCall = mockQuery.mock.calls[1]; expect(insertCall[0]).toMatch(/INSERT INTO in_app_events/); expect(insertCall[0]).toMatch(/attributed_link_id/); // params: install, name, dataJson, ts, link, click, openedAt, session expect(insertCall[1]).toEqual([ INSTALL_ID, 'add_to_cart', JSON.stringify({ sku: 'abc' }), EVENT_TS, LINK_ID, CLICK_ID, LINK_OPENED_AT, SESSION_ID, 'react-native', '1.4.0', ]); await app.close(); }); it('stays backward compatible: an event with no stamp stores null attribution + a sessionId-less row', async () => { mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: { installId: INSTALL_ID, eventName: 'signup' }, }); expect(res.statusCode).toBe(200); const params = mockQuery.mock.calls[1][1]; expect(params[4]).toBeNull(); // attributed_link_id expect(params[5]).toBeNull(); // attributed_click_id expect(params[6]).toBeNull(); // attributed_at expect(params[7]).toBeNull(); // session_id await app.close(); }); it('never loses an event when the attributed link is stale: falls back to no-link insert on FK violation', async () => { mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); // first INSERT rejects with the attributed_link_id FK violation mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_attributed_link_id_fkey' })); // fallback INSERT (without attributed_link_id) succeeds mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true }); // the fallback INSERT omits attributed_link_id but keeps click/session const fallbackCall = mockQuery.mock.calls[2]; expect(fallbackCall[0]).not.toMatch(/attributed_link_id/); expect(fallbackCall[1]).toEqual([ INSTALL_ID, 'add_to_cart', JSON.stringify({ sku: 'abc' }), EVENT_TS, CLICK_ID, LINK_OPENED_AT, SESSION_ID, 'react-native', '1.4.0', ]); await app.close(); }); it('rethrows a non-link FK violation (e.g. install deleted mid-request) instead of mislabeling it as a link problem', async () => { mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); // install_id's FK lost to a concurrent install delete — a 23503 that is NOT the link FK mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_install_id_fkey' })); const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent }); // Surfaces as a real error; no misleading no-link fallback insert is attempted. expect(res.statusCode).toBe(500); expect(mockQuery.mock.calls.length).toBe(2); // install lookup + the failed insert only await app.close(); }); it('returns 404 when the install does not exist', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent }); expect(res.statusCode).toBe(404); await app.close(); }); });