Files
linkforty/src/routes/sdk.event.test.ts
T
2026-07-31 12:06:25 +09:00

196 lines
6.7 KiB
TypeScript

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<FastifyInstance> {
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();
});
});