33
44import Database from '@nozbe/watermelondb/Database' ;
55
6+ import { getPosts } from '@actions/local/post' ;
67import { ActionType } from '@constants' ;
78import { SYSTEM_IDENTIFIERS } from '@constants/database' ;
9+ import { PostTypes } from '@constants/post' ;
810import DatabaseManager from '@database/manager' ;
911import TestHelper from '@test/test_helper' ;
12+ import { logError } from '@utils/log' ;
1013
1114import {
1215 storeConfig ,
@@ -17,6 +20,7 @@ import {
1720 setLastServerVersionCheck ,
1821 setGlobalThreadsTab ,
1922 dismissAnnouncement ,
23+ expiredBoRPostCleanup ,
2024} from './systems' ;
2125
2226import type { DataRetentionPoliciesRequest } from '@actions/remote/systems' ;
@@ -251,3 +255,269 @@ describe('dismissAnnouncement', () => {
251255 } ) ;
252256} ) ;
253257
258+ describe ( 'expiredBoRPostCleanup' , ( ) => {
259+ it ( 'should delete expired BoR posts' , async ( ) => {
260+ const database = operator . database ;
261+ jest . spyOn ( database . adapter , 'unsafeExecute' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
262+
263+ const channel : Channel = TestHelper . fakeChannel ( {
264+ id : 'channelid1' ,
265+ team_id : 'teamid1' ,
266+ } ) ;
267+ await operator . handleChannel ( { channels : [ channel ] , prepareRecordsOnly : false } ) ;
268+
269+ const now = Date . now ( ) ;
270+
271+ const borPostExpiredForAll = TestHelper . fakePost ( {
272+ id : 'postid1' ,
273+ channel_id : channel . id ,
274+ type : PostTypes . BURN_ON_READ ,
275+ props : { expire_at : now - 10000 } ,
276+ } ) ;
277+
278+ const borPostExpiredForMe = TestHelper . fakePost ( {
279+ id : 'postid2' ,
280+ channel_id : channel . id ,
281+ type : PostTypes . BURN_ON_READ ,
282+ props : { expire_at : now + 100000 } ,
283+ metadata : { expire_at : now - 10000 } ,
284+ } ) ;
285+
286+ await operator . handlePosts ( {
287+ actionType : ActionType . POSTS . RECEIVED_IN_CHANNEL ,
288+ order : [ borPostExpiredForAll . id , borPostExpiredForMe . id ] ,
289+ posts : [ borPostExpiredForAll , borPostExpiredForMe ] ,
290+ prepareRecordsOnly : false ,
291+ } ) ;
292+
293+ // verify channel posts
294+ const fetchedPosts = await getPosts ( serverUrl , [ borPostExpiredForAll . id , borPostExpiredForMe . id ] ) ;
295+ expect ( fetchedPosts . length ) . toBe ( 2 ) ;
296+
297+ await expiredBoRPostCleanup ( serverUrl ) ;
298+
299+ expect ( database . adapter . unsafeExecute ) . toHaveBeenCalledWith ( {
300+ sqls : [
301+ [ `DELETE FROM Post where id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
302+ [ `DELETE FROM Reaction where post_id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
303+ [ `DELETE FROM File where post_id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
304+ [ `DELETE FROM Draft where root_id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
305+ [ `DELETE FROM PostsInThread where root_id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
306+ [ `DELETE FROM Thread where id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
307+ [ `DELETE FROM ThreadParticipant where thread_id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
308+ [ `DELETE FROM ThreadsInTeam where thread_id IN ('${ borPostExpiredForMe . id } ','${ borPostExpiredForAll . id } ')` , [ ] ] ,
309+ ] ,
310+ } ) ;
311+ } ) ;
312+
313+ it ( 'should not run cleanup when called again within 15 minutes' , async ( ) => {
314+ const database = operator . database ;
315+ const unsafeExecuteSpy = jest . spyOn ( database . adapter , 'unsafeExecute' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
316+
317+ // Set up a recent last run time (within 15 minutes)
318+ const recentRunTime = Date . now ( ) - ( 10 * 60 * 1000 ) ; // 10 minutes ago
319+ await operator . handleSystem ( {
320+ systems : [ {
321+ id : SYSTEM_IDENTIFIERS . LAST_BOR_POST_CLEANUP_RUN ,
322+ value : recentRunTime ,
323+ } ] ,
324+ prepareRecordsOnly : false ,
325+ } ) ;
326+
327+ const channel : Channel = TestHelper . fakeChannel ( {
328+ id : 'channelid1' ,
329+ team_id : 'teamid1' ,
330+ } ) ;
331+ await operator . handleChannel ( { channels : [ channel ] , prepareRecordsOnly : false } ) ;
332+
333+ const now = Date . now ( ) ;
334+ const borPostExpired = TestHelper . fakePost ( {
335+ id : 'postid1' ,
336+ channel_id : channel . id ,
337+ type : PostTypes . BURN_ON_READ ,
338+ props : { expire_at : now - 10000 } ,
339+ } ) ;
340+
341+ await operator . handlePosts ( {
342+ actionType : ActionType . POSTS . RECEIVED_IN_CHANNEL ,
343+ order : [ borPostExpired . id ] ,
344+ posts : [ borPostExpired ] ,
345+ prepareRecordsOnly : false ,
346+ } ) ;
347+
348+ // Call cleanup - should not run because last run was within 15 minutes
349+ await expiredBoRPostCleanup ( serverUrl ) ;
350+
351+ // Verify that unsafeExecute was not called (no cleanup performed)
352+ expect ( unsafeExecuteSpy ) . not . toHaveBeenCalled ( ) ;
353+ } ) ;
354+
355+ it ( 'should handle no server database gracefully' , async ( ) => {
356+ // Try to run cleanup on a non-existent server
357+ await expect ( expiredBoRPostCleanup ( 'nonexistent.server.com' ) ) . resolves . not . toThrow ( ) ;
358+ } ) ;
359+
360+ it ( 'should handle no BoR posts gracefully' , async ( ) => {
361+ const database = operator . database ;
362+ const unsafeExecuteSpy = jest . spyOn ( database . adapter , 'unsafeExecute' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
363+
364+ const channel : Channel = TestHelper . fakeChannel ( {
365+ id : 'channelid1' ,
366+ team_id : 'teamid1' ,
367+ } ) ;
368+ await operator . handleChannel ( { channels : [ channel ] , prepareRecordsOnly : false } ) ;
369+ await expiredBoRPostCleanup ( serverUrl ) ;
370+
371+ // Verify that unsafeExecute was not called (no BoR posts to clean)
372+ expect ( unsafeExecuteSpy ) . not . toHaveBeenCalled ( ) ;
373+ } ) ;
374+
375+ it ( 'should handle BoR posts that are not expired' , async ( ) => {
376+ const database = operator . database ;
377+ const unsafeExecuteSpy = jest . spyOn ( database . adapter , 'unsafeExecute' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
378+
379+ const channel : Channel = TestHelper . fakeChannel ( {
380+ id : 'channelid1' ,
381+ team_id : 'teamid1' ,
382+ } ) ;
383+ await operator . handleChannel ( { channels : [ channel ] , prepareRecordsOnly : false } ) ;
384+
385+ const now = Date . now ( ) ;
386+
387+ // Create BoR posts that are not expired
388+ const borPostNotExpired = TestHelper . fakePost ( {
389+ id : 'postid1' ,
390+ channel_id : channel . id ,
391+ type : PostTypes . BURN_ON_READ ,
392+ props : { expire_at : now + 100000 } , // Future expiry
393+ } ) ;
394+
395+ const borPostNotExpiredForMe = TestHelper . fakePost ( {
396+ id : 'postid2' ,
397+ channel_id : channel . id ,
398+ type : PostTypes . BURN_ON_READ ,
399+ props : { expire_at : now + 10000 } ,
400+ metadata : { expire_at : now + 100000 } ,
401+ } ) ;
402+
403+ await operator . handlePosts ( {
404+ actionType : ActionType . POSTS . RECEIVED_IN_CHANNEL ,
405+ order : [ borPostNotExpired . id , borPostNotExpiredForMe . id ] ,
406+ posts : [ borPostNotExpired , borPostNotExpiredForMe ] ,
407+ prepareRecordsOnly : false ,
408+ } ) ;
409+
410+ await expiredBoRPostCleanup ( serverUrl ) ;
411+
412+ // Verify that unsafeExecute was not called (no expired BoR posts)
413+ expect ( unsafeExecuteSpy ) . not . toHaveBeenCalled ( ) ;
414+ } ) ;
415+
416+ it ( 'should handle mixed expired and non-expired BoR posts' , async ( ) => {
417+ const database = operator . database ;
418+ jest . spyOn ( database . adapter , 'unsafeExecute' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
419+
420+ const channel : Channel = TestHelper . fakeChannel ( {
421+ id : 'channelid1' ,
422+ team_id : 'teamid1' ,
423+ } ) ;
424+ await operator . handleChannel ( { channels : [ channel ] , prepareRecordsOnly : false } ) ;
425+
426+ const now = Date . now ( ) ;
427+
428+ const borPostExpired = TestHelper . fakePost ( {
429+ id : 'postid1' ,
430+ channel_id : channel . id ,
431+ type : PostTypes . BURN_ON_READ ,
432+ props : { expire_at : now - 10000 } , // Expired
433+ } ) ;
434+
435+ const borPostNotExpired = TestHelper . fakePost ( {
436+ id : 'postid2' ,
437+ channel_id : channel . id ,
438+ type : PostTypes . BURN_ON_READ ,
439+ props : { expire_at : now + 100000 } , // Not expired
440+ } ) ;
441+
442+ await operator . handlePosts ( {
443+ actionType : ActionType . POSTS . RECEIVED_IN_CHANNEL ,
444+ order : [ borPostExpired . id , borPostNotExpired . id ] ,
445+ posts : [ borPostExpired , borPostNotExpired ] ,
446+ prepareRecordsOnly : false ,
447+ } ) ;
448+
449+ await expiredBoRPostCleanup ( serverUrl ) ;
450+
451+ // Should only delete the expired post
452+ expect ( database . adapter . unsafeExecute ) . toHaveBeenCalledWith ( {
453+ sqls : [
454+ [ `DELETE FROM Post where id IN ('${ borPostExpired . id } ')` , [ ] ] ,
455+ [ `DELETE FROM Reaction where post_id IN ('${ borPostExpired . id } ')` , [ ] ] ,
456+ [ `DELETE FROM File where post_id IN ('${ borPostExpired . id } ')` , [ ] ] ,
457+ [ `DELETE FROM Draft where root_id IN ('${ borPostExpired . id } ')` , [ ] ] ,
458+ [ `DELETE FROM PostsInThread where root_id IN ('${ borPostExpired . id } ')` , [ ] ] ,
459+ [ `DELETE FROM Thread where id IN ('${ borPostExpired . id } ')` , [ ] ] ,
460+ [ `DELETE FROM ThreadParticipant where thread_id IN ('${ borPostExpired . id } ')` , [ ] ] ,
461+ [ `DELETE FROM ThreadsInTeam where thread_id IN ('${ borPostExpired . id } ')` , [ ] ] ,
462+ ] ,
463+ } ) ;
464+ } ) ;
465+
466+ it ( 'should handle database errors gracefully' , async ( ) => {
467+ const database = operator . database ;
468+
469+ // Mock database query to throw an error
470+ jest . spyOn ( database , 'get' ) . mockImplementation ( ( ) => {
471+ throw new Error ( 'Database error' ) ;
472+ } ) ;
473+
474+ // Should not throw an error, just log it
475+ await expect ( expiredBoRPostCleanup ( serverUrl ) ) . resolves . not . toThrow ( ) ;
476+ expect ( logError ) . toHaveBeenCalledWith ( 'An error occurred while performing BoR post cleanup' , expect . any ( Error ) ) ;
477+ } ) ;
478+
479+ it ( 'should handle updateLastBoRCleanupRun error gracefully' , async ( ) => {
480+ const database = operator . database ;
481+ jest . spyOn ( database . adapter , 'unsafeExecute' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
482+
483+ // Mock handleSystem to throw an error when updating last cleanup run
484+ const handleSystemSpy = jest . spyOn ( operator , 'handleSystem' ) . mockImplementation ( ( ) => {
485+ throw new Error ( 'System update error' ) ;
486+ } ) ;
487+
488+ const channel : Channel = TestHelper . fakeChannel ( {
489+ id : 'channelid1' ,
490+ team_id : 'teamid1' ,
491+ } ) ;
492+ await operator . handleChannel ( { channels : [ channel ] , prepareRecordsOnly : false } ) ;
493+
494+ // Restore handleSystem for channel creation, then mock it again for the cleanup run update
495+ handleSystemSpy . mockRestore ( ) ;
496+ jest . spyOn ( operator , 'handleSystem' ) . mockImplementation ( ( args ) => {
497+ if ( args . systems ?. [ 0 ] ?. id === SYSTEM_IDENTIFIERS . LAST_BOR_POST_CLEANUP_RUN ) {
498+ throw new Error ( 'System update error' ) ;
499+ }
500+ return Promise . resolve ( [ ] ) ;
501+ } ) ;
502+
503+ const now = Date . now ( ) ;
504+ const borPostExpired = TestHelper . fakePost ( {
505+ id : 'postid1' ,
506+ channel_id : channel . id ,
507+ type : PostTypes . BURN_ON_READ ,
508+ props : { expire_at : now - 10000 } ,
509+ } ) ;
510+
511+ await operator . handlePosts ( {
512+ actionType : ActionType . POSTS . RECEIVED_IN_CHANNEL ,
513+ order : [ borPostExpired . id ] ,
514+ posts : [ borPostExpired ] ,
515+ prepareRecordsOnly : false ,
516+ } ) ;
517+
518+ // Should not throw an error, just log it
519+ await expect ( expiredBoRPostCleanup ( serverUrl ) ) . resolves . not . toThrow ( ) ;
520+ expect ( logError ) . toHaveBeenCalledWith ( 'Failed updateLastBoRCleanupRun' , expect . any ( Error ) ) ;
521+ } ) ;
522+
523+ } ) ;
0 commit comments