code
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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: 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[0];
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user