Skip to content

Commit 427ef34

Browse files
authored
Merge branch 'main' into feat/teams-api
Signed-off-by: Prashantkumar Khatri <96608160+ShantKhatri@users.noreply.github.com>
2 parents 51d599c + 29324d2 commit 427ef34

16 files changed

Lines changed: 1615 additions & 503 deletions

File tree

apps/backend/src/__tests__/analytics.test.ts

Lines changed: 466 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 261 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,63 @@
1-
import Fastify from 'fastify';
2-
import { describe, expect, it, vi } from 'vitest';
1+
import Fastify, { FastifyInstance } from 'fastify';
2+
import { describe, expect, it, vi, beforeAll, beforeEach, afterAll } from 'vitest';
33

44
import { followRoutes } from '../routes/follow.js';
55

66
vi.mock('../utils/encryption.js', () => ({
77
decrypt: vi.fn(() => 'fake-access-token'),
88
}));
99

10-
describe('POST /api/follow/:platform/:targetUsername', () => {
11-
it('returns 400 when API follow is not supported for the platform', async () => {
12-
const app = Fastify({ logger: false });
10+
// ── Shared mock data ──────────────────────────────────────────────────────────
1311

14-
const findUnique = vi.fn().mockResolvedValue({
15-
id: 'token-1',
16-
userId: 'user-1',
17-
platform: 'unknown',
18-
accessToken: 'encrypted-token',
19-
});
12+
const MOCK_USER_ID = 'user-uuid-001';
2013

21-
app.decorate('prisma', {
22-
oAuthToken: {
23-
findUnique,
24-
},
25-
followLog: {
26-
create: vi.fn(),
27-
},
28-
}as any);
14+
const MOCK_OAUTH_TOKEN = {
15+
id: 'token-1',
16+
userId: MOCK_USER_ID,
17+
platform: 'unknown',
18+
accessToken: 'encrypted-token',
19+
};
2920

30-
app.decorate('authenticate', async (request: any) => {
31-
request.user = { id: 'user-1' };
32-
});
21+
// ── App factory ───────────────────────────────────────────────────────────────
22+
23+
function buildApp(overrides: {
24+
oAuthToken?: Record<string, unknown>;
25+
followLog?: Record<string, unknown>;
26+
} = {}): FastifyInstance {
27+
const app = Fastify({ logger: false });
3328

34-
await app.register(followRoutes, { prefix: '/api/follow' });
35-
await app.ready();
29+
app.decorate('prisma', {
30+
oAuthToken: {
31+
findUnique: vi.fn(),
32+
...overrides.oAuthToken,
33+
},
34+
followLog: {
35+
create: vi.fn(),
36+
deleteMany: vi.fn(),
37+
...overrides.followLog,
38+
},
39+
} as any);
40+
41+
app.decorate('authenticate', async (request: any) => {
42+
request.user = { id: MOCK_USER_ID };
43+
});
44+
45+
return app;
46+
}
47+
48+
async function makeApp(overrides?: Parameters<typeof buildApp>[0]): Promise<FastifyInstance> {
49+
const app = buildApp(overrides);
50+
await app.register(followRoutes, { prefix: '/api/follow' });
51+
await app.ready();
52+
return app;
53+
}
54+
55+
// ─────────────────────────────────────────────────────────────────────────────
56+
57+
describe('POST /api/follow/:platform/:targetUsername — API follow', () => {
58+
it('returns 400 when API follow is not supported for the platform', async () => {
59+
const findUnique = vi.fn().mockResolvedValue(MOCK_OAUTH_TOKEN);
60+
const app = await makeApp({ oAuthToken: { findUnique } });
3661

3762
const response = await app.inject({
3863
method: 'POST',
@@ -46,7 +71,7 @@ describe('POST /api/follow/:platform/:targetUsername', () => {
4671
expect(findUnique).toHaveBeenCalledWith({
4772
where: {
4873
userId_platform: {
49-
userId: 'user-1',
74+
userId: MOCK_USER_ID,
5075
platform: 'unknown',
5176
},
5277
},
@@ -56,20 +81,7 @@ describe('POST /api/follow/:platform/:targetUsername', () => {
5681
});
5782

5883
it('returns webview strategy and url for webview-strategy platforms (e.g. linkedin)', async () => {
59-
const app = Fastify({ logger: false });
60-
61-
app.decorate('prisma', {
62-
followLog: {
63-
create: vi.fn(),
64-
},
65-
} as any);
66-
67-
app.decorate('authenticate', async (request: any) => {
68-
request.user = { id: 'user-1' };
69-
});
70-
71-
await app.register(followRoutes, { prefix: '/api/follow' });
72-
await app.ready();
84+
const app = await makeApp();
7385

7486
const response = await app.inject({
7587
method: 'POST',
@@ -84,56 +96,233 @@ describe('POST /api/follow/:platform/:targetUsername', () => {
8496

8597
await app.close();
8698
});
99+
});
100+
101+
// ─────────────────────────────────────────────────────────────────────────────
102+
103+
describe('POST /api/follow/:platform/:targetUsername/log — follow log validation', () => {
104+
let app: FastifyInstance;
105+
let createLog: ReturnType<typeof vi.fn>;
106+
107+
// One app instance shared across all log tests; mock reset between each test.
108+
beforeAll(async () => {
109+
createLog = vi.fn();
110+
app = await makeApp({ followLog: { create: createLog } });
111+
});
112+
113+
afterAll(async () => {
114+
await app.close();
115+
});
116+
117+
beforeEach(() => {
118+
createLog.mockReset();
119+
createLog.mockResolvedValue({ id: 'log-uuid-001' });
120+
});
121+
122+
// ── Valid payloads ────────────────────────────────────────────────────────
123+
124+
it('200 — accepts status: success, layer: foreground', async () => {
125+
const res = await app.inject({
126+
method: 'POST',
127+
url: '/api/follow/linkedin/testuser/log',
128+
payload: { status: 'success', layer: 'foreground' },
129+
});
130+
131+
expect(res.statusCode).toBe(200);
132+
expect(res.json()).toMatchObject({ status: 'success', logId: 'log-uuid-001' });
133+
expect(createLog).toHaveBeenCalledOnce();
134+
expect(createLog.mock.calls[0][0].data.status).toBe('success');
135+
});
87136

88-
it('successfully logs a webview follow action', async () => {
89-
const app = Fastify({ logger: false });
137+
it('200 — accepts status: failed', async () => {
138+
const res = await app.inject({
139+
method: 'POST',
140+
url: '/api/follow/linkedin/testuser/log',
141+
payload: { status: 'failed', layer: 'foreground' },
142+
});
90143

91-
const createLog = vi.fn().mockResolvedValue({
92-
id: 'log-1',
93-
followerId: 'user-1',
94-
targetUsername: 'testuser',
95-
platform: 'linkedin',
96-
status: 'success',
97-
layer: 'webview',
144+
expect(res.statusCode).toBe(200);
145+
expect(createLog).toHaveBeenCalledOnce();
146+
expect(createLog.mock.calls[0][0].data.status).toBe('failed');
147+
});
148+
149+
it('200 — accepts status: pending, layer: background', async () => {
150+
const res = await app.inject({
151+
method: 'POST',
152+
url: '/api/follow/linkedin/testuser/log',
153+
payload: { status: 'pending', layer: 'background' },
98154
});
99155

100-
app.decorate('prisma', {
101-
followLog: {
102-
create: createLog,
103-
},
104-
} as any);
156+
expect(res.statusCode).toBe(200);
157+
expect(createLog).toHaveBeenCalledOnce();
158+
expect(createLog.mock.calls[0][0].data.layer).toBe('background');
159+
});
160+
161+
// ── Invalid status values — analytics integrity ───────────────────────────
105162

106-
app.decorate('authenticate', async (request: any) => {
107-
request.user = { id: 'user-1' };
163+
it('400 — rejects invalid status "error" (old unvalidated internal value)', async () => {
164+
const res = await app.inject({
165+
method: 'POST',
166+
url: '/api/follow/linkedin/testuser/log',
167+
payload: { status: 'error', layer: 'foreground' },
108168
});
109169

110-
await app.register(followRoutes, { prefix: '/api/follow' });
111-
await app.ready();
170+
expect(res.statusCode).toBe(400);
171+
expect(res.json()).toMatchObject({ error: 'Invalid follow log payload' });
172+
// DB must NOT be written — this is the analytics integrity guarantee
173+
expect(createLog).not.toHaveBeenCalled();
174+
});
112175

113-
const response = await app.inject({
176+
it('400 — rejects arbitrary status string injection', async () => {
177+
const res = await app.inject({
114178
method: 'POST',
115179
url: '/api/follow/linkedin/testuser/log',
116-
payload: {
117-
status: 'success',
118-
layer: 'webview',
119-
},
180+
payload: { status: '"; DROP TABLE follow_logs; --', layer: 'foreground' },
120181
});
121182

122-
const body = response.json();
183+
expect(res.statusCode).toBe(400);
184+
expect(createLog).not.toHaveBeenCalled();
185+
});
186+
187+
// ── Invalid layer values — analytics integrity ────────────────────────────
188+
189+
// 'webview' was the old unvalidated default — it is now explicitly rejected.
190+
// Any existing caller sending layer: 'webview' must migrate to 'foreground'
191+
// (in-app WebView session) or 'background' (passive deep-link strategy).
192+
it('400 — rejects legacy layer "webview" (old unvalidated default)', async () => {
193+
const res = await app.inject({
194+
method: 'POST',
195+
url: '/api/follow/linkedin/testuser/log',
196+
payload: { status: 'success', layer: 'webview' },
197+
});
198+
199+
expect(res.statusCode).toBe(400);
200+
expect(res.json()).toMatchObject({ error: 'Invalid follow log payload' });
201+
expect(createLog).not.toHaveBeenCalled();
202+
});
203+
204+
it('400 — rejects invalid layer "api"', async () => {
205+
const res = await app.inject({
206+
method: 'POST',
207+
url: '/api/follow/linkedin/testuser/log',
208+
payload: { status: 'success', layer: 'api' },
209+
});
210+
211+
expect(res.statusCode).toBe(400);
212+
expect(createLog).not.toHaveBeenCalled();
213+
});
214+
215+
// ── Malformed / missing payloads ──────────────────────────────────────────
216+
217+
it('400 — rejects missing status field', async () => {
218+
const res = await app.inject({
219+
method: 'POST',
220+
url: '/api/follow/linkedin/testuser/log',
221+
payload: { layer: 'foreground' },
222+
});
223+
224+
expect(res.statusCode).toBe(400);
225+
expect(createLog).not.toHaveBeenCalled();
226+
});
227+
228+
it('400 — rejects missing layer field', async () => {
229+
const res = await app.inject({
230+
method: 'POST',
231+
url: '/api/follow/linkedin/testuser/log',
232+
payload: { status: 'success' },
233+
});
234+
235+
expect(res.statusCode).toBe(400);
236+
expect(createLog).not.toHaveBeenCalled();
237+
});
238+
239+
it('400 — rejects empty body', async () => {
240+
const res = await app.inject({
241+
method: 'POST',
242+
url: '/api/follow/linkedin/testuser/log',
243+
payload: {},
244+
});
245+
246+
expect(res.statusCode).toBe(400);
247+
expect(createLog).not.toHaveBeenCalled();
248+
});
249+
250+
// ── Correct data persisted to DB ──────────────────────────────────────────
251+
252+
it('persists exactly the validated platform, targetUsername, status, and layer', async () => {
253+
const res = await app.inject({
254+
method: 'POST',
255+
url: '/api/follow/twitter/janedoe/log',
256+
payload: { status: 'pending', layer: 'background' },
257+
});
258+
259+
expect(res.statusCode).toBe(200);
260+
expect(createLog).toHaveBeenCalledOnce();
261+
262+
const written = createLog.mock.calls[0][0].data;
263+
expect(written).toMatchObject({
264+
followerId: MOCK_USER_ID,
265+
targetUsername: 'janedoe',
266+
platform: 'twitter',
267+
status: 'pending',
268+
layer: 'background',
269+
});
270+
});
271+
272+
// ── Response does not leak validation internals ───────────────────────────
273+
274+
it('400 response only exposes { error } — no schema internals or stack traces', async () => {
275+
const res = await app.inject({
276+
method: 'POST',
277+
url: '/api/follow/linkedin/testuser/log',
278+
payload: { status: 'bad', layer: 'bad' },
279+
});
280+
281+
expect(res.statusCode).toBe(400);
282+
const body = res.json();
283+
expect(body).not.toHaveProperty('issues');
284+
expect(body).not.toHaveProperty('stack');
285+
expect(Object.keys(body)).toEqual(['error']);
286+
});
287+
288+
// ── DB failure after valid payload ────────────────────────────────────────
289+
290+
it('500 — returns 500 when DB write fails after successful validation', async () => {
291+
createLog.mockRejectedValueOnce(new Error('DB connection lost'));
292+
293+
const res = await app.inject({
294+
method: 'POST',
295+
url: '/api/follow/linkedin/testuser/log',
296+
payload: { status: 'success', layer: 'foreground' },
297+
});
298+
299+
expect(res.statusCode).toBe(500);
300+
expect(res.json()).toMatchObject({ error: 'Failed to log follow event' });
301+
});
302+
});
303+
304+
// ─────────────────────────────────────────────────────────────────────────────
305+
306+
describe('DELETE /api/follow/:platform/:targetUsername/log — clear follow log', () => {
307+
it('clears follow log entries for the authenticated user', async () => {
308+
const deleteMany = vi.fn().mockResolvedValue({ count: 1 });
309+
const app = await makeApp({ followLog: { deleteMany } });
310+
311+
const response = await app.inject({
312+
method: 'DELETE',
313+
url: '/api/follow/linkedin/testuser/log',
314+
});
123315

124316
expect(response.statusCode).toBe(200);
125-
expect(body.status).toBe('success');
126-
expect(body.logId).toBe('log-1');
127-
expect(createLog).toHaveBeenCalledWith({
128-
data: {
129-
followerId: 'user-1',
130-
targetUsername: 'testuser',
317+
expect(response.json()).toMatchObject({ status: 'cleared' });
318+
expect(deleteMany).toHaveBeenCalledWith({
319+
where: {
320+
followerId: MOCK_USER_ID,
131321
platform: 'linkedin',
132-
status: 'success',
133-
layer: 'webview',
322+
targetUsername: 'testuser',
134323
},
135324
});
136325

137326
await app.close();
138327
});
139-
});
328+
});

0 commit comments

Comments
 (0)