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
44import { followRoutes } from '../routes/follow.js' ;
55
66vi . 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