@@ -141,3 +141,155 @@ describe('v1 rich progress', () => {
141141 }
142142 } ) ;
143143} ) ;
144+
145+ describe ( 'removing a synced book' , ( ) => {
146+ const OTHER_DOC = 'ffeeddccbbaa99887766554433221100' ;
147+
148+ const BOOK_STATS = {
149+ v : 5 ,
150+ sessions : 9 ,
151+ seconds : 8400 ,
152+ pages : 310 ,
153+ completed : false ,
154+ avg_fwd : 12 ,
155+ pace_n : 250 ,
156+ eta : 5400 ,
157+ start_manual : false ,
158+ finish_manual : false ,
159+ start_date : 1751000000 ,
160+ finished_date : 0 ,
161+ tod : [ 0 , 3000 , 4000 , 1400 ] ,
162+ dow : [ 0 , 0 , 1200 , 0 , 2000 , 3000 , 2200 ] ,
163+ } ;
164+
165+ /**
166+ * Seeds one document the way a device would: kosync progress (which also
167+ * records a position sample), metadata, a bookmark, a clipping and per-book
168+ * reading stats - i.e. a row in every table a removal has to clear.
169+ */
170+ async function seedBook (
171+ { app, db } : ReturnType < typeof makeTestApp > ,
172+ headers : Record < string , string > ,
173+ document : string
174+ ) {
175+ await app . request ( '/syncs/progress' , {
176+ method : 'PUT' ,
177+ headers,
178+ body : JSON . stringify ( {
179+ document,
180+ progress : POSITION . xpath ,
181+ percentage : 0.4867 ,
182+ device : 'CrossPoint' ,
183+ device_id : 'aaaa' ,
184+ position : POSITION ,
185+ } ) ,
186+ } ) ;
187+ await app . request ( '/api/v1/documents' , {
188+ method : 'PUT' ,
189+ headers,
190+ body : JSON . stringify ( { items : [ { document, title : 'Foundryside' , author : 'RJB' } ] } ) ,
191+ } ) ;
192+ await app . request ( `/api/v1/bookmarks/${ document } ` , {
193+ method : 'PUT' ,
194+ headers,
195+ body : JSON . stringify ( {
196+ items : [ { id : '0123456789abcdef' , xpath : '/body/p[1]' , percentage : 0.1 , summary : 'note' } ] ,
197+ } ) ,
198+ } ) ;
199+ await app . request ( `/api/v1/clippings/${ document } ` , {
200+ method : 'PUT' ,
201+ headers,
202+ body : JSON . stringify ( { items : [ { id : 'fedcba9876543210' , spine : 3 , text : 'a highlight' } ] } ) ,
203+ } ) ;
204+ await app . request ( '/api/v1/stats/books' , {
205+ method : 'PUT' ,
206+ headers,
207+ body : JSON . stringify ( { device_id : 'aaaa' , items : [ { document, ...BOOK_STATS } ] } ) ,
208+ } ) ;
209+ // Connector rows have no test-friendly HTTP path (linking needs a live
210+ // service), so seed the two document-keyed tables directly.
211+ const userId = ( db . prepare ( 'SELECT id FROM users WHERE username = ?' ) . get ( headers [ 'x-auth-user' ] ) as { id : number } ) . id ;
212+ db . prepare (
213+ `INSERT INTO connector_matches (user_id, connector_id, document, external_id, confidence, source, updated_at)
214+ VALUES (?, 'hardcover', ?, '42', 1, 'auto', 1)`
215+ ) . run ( userId , document ) ;
216+ db . prepare (
217+ `INSERT INTO connector_queue (user_id, connector_id, document, kind, payload, next_try_at, created_at, updated_at)
218+ VALUES (?, 'hardcover', ?, 'progress', '{}', 0, 1, 1)`
219+ ) . run ( userId , document ) ;
220+ }
221+
222+ it ( 'DELETE clears the kosync progress and the rest of that book, leaving others alone' , async ( ) => {
223+ const server = makeTestApp ( ) ;
224+ const { app, db } = server ;
225+ const { headers } = await registerUser ( app ) ;
226+ await seedBook ( server , headers , DOC ) ;
227+ await seedBook ( server , headers , OTHER_DOC ) ;
228+
229+ const res = await app . request ( `/api/v1/progress/${ DOC } ` , { method : 'DELETE' , headers } ) ;
230+ expect ( res . status ) . toBe ( 200 ) ;
231+ const body = await res . json ( ) ;
232+ expect ( body ) . toMatchObject ( { document : DOC , deleted : true } ) ;
233+ expect ( body . rows ) . toBeGreaterThan ( 0 ) ;
234+
235+ // The book is gone from the dashboard list and from kosync itself.
236+ const list = await ( await app . request ( '/api/v1/progress' , { headers } ) ) . json ( ) ;
237+ expect ( list . items . map ( ( i : { document : string } ) => i . document ) ) . toEqual ( [ OTHER_DOC ] ) ;
238+ const kosync = await app . request ( `/syncs/progress/${ DOC } ` , { headers } ) ;
239+ expect ( kosync . status ) . toBe ( 200 ) ;
240+ expect ( await kosync . json ( ) ) . toEqual ( { } ) ;
241+ const devices = await ( await app . request ( `/api/v1/progress/${ DOC } ` , { headers } ) ) . json ( ) ;
242+ expect ( devices . devices ) . toEqual ( [ ] ) ;
243+
244+ // ...along with its metadata, highlights, bookmarks, samples and stats.
245+ for ( const table of [
246+ 'documents' ,
247+ 'bookmarks' ,
248+ 'clippings' ,
249+ 'progress' ,
250+ 'progress_samples' ,
251+ 'stats_device_book' ,
252+ 'connector_matches' ,
253+ 'connector_queue' ,
254+ ] ) {
255+ const left = db
256+ . prepare ( `SELECT document FROM ${ table } WHERE document = ?` )
257+ . all ( DOC ) as unknown [ ] ;
258+ expect ( left , `${ table } still has rows for the removed book` ) . toEqual ( [ ] ) ;
259+ const kept = db
260+ . prepare ( `SELECT document FROM ${ table } WHERE document = ?` )
261+ . all ( OTHER_DOC ) as unknown [ ] ;
262+ expect ( kept . length , `${ table } lost rows for the other book` ) . toBeGreaterThan ( 0 ) ;
263+ }
264+
265+ // The other book still reads back intact.
266+ const other = await ( await app . request ( `/syncs/progress/${ OTHER_DOC } ` , { headers } ) ) . json ( ) ;
267+ expect ( other . document ) . toBe ( OTHER_DOC ) ;
268+ } ) ;
269+
270+ it ( 'DELETE only touches the caller, and 404s on a document with no data' , async ( ) => {
271+ const server = makeTestApp ( ) ;
272+ const { app } = server ;
273+ const a = await registerUser ( app ) ;
274+ const b = await registerUser ( app ) ;
275+ await seedBook ( server , a . headers , DOC ) ;
276+ await seedBook ( server , b . headers , DOC ) ;
277+
278+ // Same document hash, different user: B's copy must survive A's removal.
279+ expect ( ( await app . request ( `/api/v1/progress/${ DOC } ` , { method : 'DELETE' , headers : a . headers } ) ) . status ) . toBe ( 200 ) ;
280+ const bList = await ( await app . request ( '/api/v1/progress' , { headers : b . headers } ) ) . json ( ) ;
281+ expect ( bList . items ) . toHaveLength ( 1 ) ;
282+
283+ // Already removed for A - nothing left to delete.
284+ const again = await app . request ( `/api/v1/progress/${ DOC } ` , { method : 'DELETE' , headers : a . headers } ) ;
285+ expect ( again . status ) . toBe ( 404 ) ;
286+ expect ( ( await again . json ( ) ) . message ) . toBe ( 'Unknown document' ) ;
287+ } ) ;
288+
289+ it ( 'DELETE rejects a malformed document id' , async ( ) => {
290+ const { app } = makeTestApp ( ) ;
291+ const { headers } = await registerUser ( app ) ;
292+ const res = await app . request ( '/api/v1/progress/not%20a%20hash!' , { method : 'DELETE' , headers } ) ;
293+ expect ( res . status ) . toBe ( 403 ) ;
294+ } ) ;
295+ } ) ;
0 commit comments