@@ -7,16 +7,28 @@ const mockModel = {
77 findOneAndUpdate : mockFindOneAndUpdate ,
88} ;
99
10+ const mockDispatchWebhooks = jest . fn ( ) ;
11+
1012jest . mock ( '@urbackend/common' , ( ) => ( {
1113 Project : {
1214 findOne : mockFindOne ,
1315 } ,
1416 getConnection : jest . fn ( ) . mockResolvedValue ( { } ) ,
1517 getCompiledModel : jest . fn ( ( ) => mockModel ) ,
16- enqueueCollectionCleanup : jest . fn ( ) . mockResolvedValue ( true )
18+ dispatchWebhooks : mockDispatchWebhooks ,
19+ enqueueCollectionCleanup : jest . fn ( ) . mockResolvedValue ( true ) ,
20+ syncCollectionCleanup : jest . fn ( ) . mockResolvedValue ( true ) ,
21+ AppError : class AppError extends Error {
22+ constructor ( statusCode , message ) {
23+ super ( message ) ;
24+ this . statusCode = statusCode ;
25+ this . isOperational = true ;
26+ }
27+ }
1728} ) ) ;
1829
19- const { deleteRow } = require ( '../controllers/project.controller' ) ;
30+ const { deleteRow, recoverRow } = require ( '../controllers/project.controller' ) ;
31+ const mongoose = require ( 'mongoose' ) ;
2032
2133function makeReq ( ) {
2234 return {
@@ -43,6 +55,7 @@ describe('Soft Delete in dashboard project.controller', () => {
4355 test ( 'deleteRow sets isDeleted: true instead of hard deleting' , async ( ) => {
4456 const req = makeReq ( ) ;
4557 const res = makeRes ( ) ;
58+ const next = jest . fn ( ) ;
4659
4760 // Mock project
4861 const project = {
@@ -59,7 +72,7 @@ describe('Soft Delete in dashboard project.controller', () => {
5972 lean : jest . fn ( ) . mockResolvedValue ( doc )
6073 } ) ;
6174
62- await deleteRow ( req , res ) ;
75+ await deleteRow ( req , res , next ) ;
6376
6477 expect ( mockFindOne ) . toHaveBeenCalled ( ) ;
6578 expect ( mockFindOneAndUpdate ) . toHaveBeenCalledWith (
@@ -75,14 +88,12 @@ describe('Soft Delete in dashboard project.controller', () => {
7588 data : { id : '507f1f77bcf86cd799439011' } ,
7689 message : "Document moved to trash"
7790 } ) ;
78-
79- // Should not save project (to update databaseUsed) since it's a soft delete
80- expect ( project . save ) . not . toHaveBeenCalled ( ) ;
8191 } ) ;
8292
83- test ( 'deleteRow returns 404 if document is already soft-deleted or not found' , async ( ) => {
93+ test ( 'deleteRow returns 404 via next(AppError) if document is already soft-deleted or not found' , async ( ) => {
8494 const req = makeReq ( ) ;
8595 const res = makeRes ( ) ;
96+ const next = jest . fn ( ) ;
8697
8798 // Mock project
8899 const project = {
@@ -97,13 +108,137 @@ describe('Soft Delete in dashboard project.controller', () => {
97108 lean : jest . fn ( ) . mockResolvedValue ( null )
98109 } ) ;
99110
100- await deleteRow ( req , res ) ;
111+ await deleteRow ( req , res , next ) ;
101112
102- expect ( res . status ) . toHaveBeenCalledWith ( 404 ) ;
113+ expect ( next ) . toHaveBeenCalledWith ( expect . objectContaining ( {
114+ statusCode : 404 ,
115+ message : "Document not found."
116+ } ) ) ;
117+ } ) ;
118+
119+ test ( 'recoverRow restores a soft-deleted document' , async ( ) => {
120+ const req = makeReq ( ) ;
121+ const res = makeRes ( ) ;
122+ const next = jest . fn ( ) ;
123+
124+ // Mock project
125+ const project = {
126+ _id : 'proj_1' ,
127+ resources : { db : { isExternal : false } } ,
128+ collections : [ { name : 'posts' , model : [ ] } ]
129+ } ;
130+
131+ // recoverRow uses Project.findOne({ _id: projectId, owner: req.user._id }).lean()
132+ const mockProjectFind = {
133+ lean : jest . fn ( ) . mockResolvedValue ( project )
134+ } ;
135+ mockFindOne . mockReturnValue ( mockProjectFind ) ;
136+
137+ // Mock document
138+ const restoredDoc = { _id : '507f1f77bcf86cd799439011' , isDeleted : false , deletedAt : null } ;
139+ mockFindOneAndUpdate . mockReturnValue ( {
140+ lean : jest . fn ( ) . mockResolvedValue ( restoredDoc )
141+ } ) ;
142+
143+ await recoverRow ( req , res , next ) ;
144+
145+ expect ( mockFindOne ) . toHaveBeenCalledWith ( { _id : 'proj_1' , owner : 'user_1' } ) ;
146+ expect ( mockFindOneAndUpdate ) . toHaveBeenCalledWith (
147+ expect . objectContaining ( {
148+ _id : '507f1f77bcf86cd799439011' ,
149+ isDeleted : true ,
150+ deletedAt : expect . objectContaining ( { $gte : expect . any ( Date ) } )
151+ } ) ,
152+ expect . objectContaining ( {
153+ $set : { isDeleted : false , deletedAt : null }
154+ } ) ,
155+ { new : true }
156+ ) ;
157+
103158 expect ( res . json ) . toHaveBeenCalledWith ( {
104- success : false ,
105- data : { } ,
106- message : "Document not found."
159+ success : true ,
160+ data : restoredDoc ,
161+ message : "Document recovered from trash"
162+ } ) ;
163+
164+ expect ( mockDispatchWebhooks ) . toHaveBeenCalledWith ( expect . objectContaining ( {
165+ action : 'recover' ,
166+ document : restoredDoc ,
167+ projectId : 'proj_1'
168+ } ) ) ;
169+ const { syncCollectionCleanup } = require ( '@urbackend/common' ) ;
170+ expect ( syncCollectionCleanup ) . toHaveBeenCalledWith ( 'proj_1' , 'posts' ) ;
171+ } ) ;
172+
173+ test ( 'recoverRow returns 404 via next(AppError) if document is not in trash' , async ( ) => {
174+ const req = makeReq ( ) ;
175+ const res = makeRes ( ) ;
176+ const next = jest . fn ( ) ;
177+
178+ // Mock project
179+ const project = {
180+ _id : 'proj_1' ,
181+ resources : { db : { isExternal : false } } ,
182+ collections : [ { name : 'posts' , model : [ ] } ]
183+ } ;
184+ const mockProjectFind = {
185+ lean : jest . fn ( ) . mockResolvedValue ( project )
186+ } ;
187+ mockFindOne . mockReturnValue ( mockProjectFind ) ;
188+
189+ mockFindOneAndUpdate . mockReturnValue ( {
190+ lean : jest . fn ( ) . mockResolvedValue ( null )
107191 } ) ;
192+
193+ await recoverRow ( req , res , next ) ;
194+
195+ expect ( next ) . toHaveBeenCalledWith ( expect . objectContaining ( {
196+ statusCode : 404 ,
197+ message : "Document not found or recovery window expired (30 days)."
198+ } ) ) ;
199+ } ) ;
200+
201+ test ( 'recoverRow returns 409 via next(AppError) if document restoration causes a unique field conflict' , async ( ) => {
202+ const req = makeReq ( ) ;
203+ const res = makeRes ( ) ;
204+ const next = jest . fn ( ) ;
205+
206+ const project = {
207+ _id : 'proj_1' ,
208+ resources : { db : { isExternal : false } } ,
209+ collections : [ { name : 'posts' , model : [ ] } ]
210+ } ;
211+ mockFindOne . mockReturnValue ( {
212+ lean : jest . fn ( ) . mockResolvedValue ( project )
213+ } ) ;
214+
215+ const error = new Error ( 'Duplicate key' ) ;
216+ error . code = 11000 ;
217+ mockFindOneAndUpdate . mockReturnValue ( {
218+ lean : jest . fn ( ) . mockRejectedValue ( error )
219+ } ) ;
220+
221+ await recoverRow ( req , res , next ) ;
222+
223+ expect ( next ) . toHaveBeenCalledWith ( expect . objectContaining ( {
224+ statusCode : 409 ,
225+ message : expect . stringContaining ( "unique field value conflicts" )
226+ } ) ) ;
227+ } ) ;
228+
229+ test ( 'recoverRow returns 400 via next(AppError) if document ID is invalid' , async ( ) => {
230+ const req = {
231+ params : { projectId : 'proj_1' , collectionName : 'posts' , id : 'invalid-id' } ,
232+ user : { _id : 'user_1' }
233+ } ;
234+ const res = makeRes ( ) ;
235+ const next = jest . fn ( ) ;
236+
237+ await recoverRow ( req , res , next ) ;
238+
239+ expect ( next ) . toHaveBeenCalledWith ( expect . objectContaining ( {
240+ statusCode : 400 ,
241+ message : "Invalid document ID format."
242+ } ) ) ;
108243 } ) ;
109244} ) ;
0 commit comments