📄 src/models/test/trash.test.ts
D-OPEN SOVEREIGN
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library — The World Library
4 *
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 *
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
16import { addRecord, clear as clearDb, dbReactivity } from '../db.ts';
17import { scanTrash, trashStore, _resetForTest } from '../trash.ts';
18
19// Node has no localStorage global — minimal in-memory polyfill, same shape
20// trash.ts actually calls (getItem/setItem/removeItem).
21class MemoryStorage {
22    private store = new Map<string, string>();
23    getItem(key: string): string | null { return this.store.has(key) ? this.store.get(key)! : null; }
24    setItem(key: string, value: string): void { this.store.set(key, value); }
25    removeItem(key: string): void { this.store.delete(key); }
26    clear(): void { this.store.clear(); }
27}
28
29// applyMembership() only reads these fields — a plain object is enough, no
30// real Orm instance needed. Defaults mirror Orm's actual constructor defaults
31// (orm.ts ~L235-241): safe:true, deleted:false — a never-reviewed record must
32// not read as trashed.
33function fakeRecord(objectType: number, id: number, opts: {
34    creatorUsername?: string; deleted?: boolean; safe?: boolean;
35} = {}) {
36    return {
37        schema: { objectId: objectType },
38        id,
39        creatorUsername: opts.creatorUsername ?? 'canonical',
40        deleted: opts.deleted ?? false,
41        safe: opts.safe ?? true,
42    };
43}
44
45beforeEach(() => {
46    // @ts-expect-error test-only global
47    globalThis.localStorage = new MemoryStorage();
48    clearDb();
49    _resetForTest();
50});
51
52afterEach(() => {
53    vi.useRealTimers();
54});
55
56describe('trash — applyMembership via scanTrash (deleted/safe membership derivation)', () => {
57    it('trashes a record whose deleted flag is true', () => {
58        const type = 101;
59        addRecord(type, fakeRecord(type, 1, { deleted: true }), 'canonical');
60        scanTrash();
61        expect(trashStore.list(type)).toEqual([1]);
62    });
63
64    it('removes a record from trash once it is no longer deleted/unsafe', () => {
65        const type = 102;
66        const rec = fakeRecord(type, 1, { deleted: true });
67        addRecord(type, rec, 'canonical');
68        scanTrash();
69        expect(trashStore.list(type)).toEqual([1]);
70
71        rec.deleted = false;
72        scanTrash();
73        expect(trashStore.list(type)).toEqual([]);
74    });
75
76    it('does not trash a record that is explicitly safe and not deleted', () => {
77        const type = 103;
78        addRecord(type, fakeRecord(type, 1, { safe: true, deleted: false }), 'canonical');
79        scanTrash();
80        expect(trashStore.list(type)).toEqual([]);
81    });
82
83    // Regression test for a real bug: Orm's constructor used to default `safe`
84    // to false, so trash.ts's `record.safe === false` branch (trash.ts:120)
85    // caught every never-reviewed record, not just ones explicitly flagged
86    // unsafe — a brand-new draft landed in its own creator's trash the moment
87    // it was created. Fixed by defaulting `safe` to true in orm.ts.
88    it('does not trash a freshly created record that was never explicitly deleted or flagged unsafe', () => {
89        const type = 104;
90        addRecord(type, fakeRecord(type, 1, { creatorUsername: 'alice' }), 'alice');
91        scanTrash();
92        expect(trashStore.list(type, ['alice'])).toEqual([]);
93    });
94
95    it('ignores a record missing schema/id (defensive no-op, does not throw)', () => {
96        const type = 105;
97        expect(() => {
98            addRecord(type, { id: 1 } as any, 'canonical'); // no .schema
99            scanTrash();
100        }).not.toThrow();
101        expect(trashStore.list(type)).toEqual([]);
102    });
103
104    it('buckets the same numeric id separately per creatorUsername', () => {
105        // High bit set (isUserCreated) so db.ts's cache key includes creatorUsername
106        // (`${creatorUsername}:${wireId}`) — otherwise both records collapse onto
107        // the same `canonical:<id>` cache slot and the second addRecord() would
108        // silently overwrite the first.
109        const type = 106;
110        const userCreatedId = 1 | 0x80000000;
111        addRecord(type, fakeRecord(type, userCreatedId, { deleted: true, creatorUsername: 'alice' }), 'alice');
112        addRecord(type, fakeRecord(type, userCreatedId, { deleted: true, creatorUsername: 'bob' }), 'bob');
113        scanTrash();
114        expect(trashStore.list(type, ['alice'])).toEqual([userCreatedId]);
115        expect(trashStore.list(type, ['bob'])).toEqual([userCreatedId]);
116        expect(trashStore.usernames(type).sort()).toEqual(['alice', 'bob']);
117    });
118
119    it('scanTrash is idempotent — running it again with no changes does not re-notify', () => {
120        const type = 107;
121        addRecord(type, fakeRecord(type, 1, { deleted: true }), 'canonical');
122        scanTrash();
123
124        const listener = vi.fn();
125        trashStore.subscribe(type, listener);
126        scanTrash();
127        expect(listener).not.toHaveBeenCalled();
128    });
129});
130
131describe('trash — live path (dbReactivity.trigger → syncRecord, debounced)', () => {
132    it('applies membership synchronously, but only notifies subscribers after the debounce delay', () => {
133        vi.useFakeTimers();
134        const type = 201;
135        const rec = fakeRecord(type, 1, { deleted: true, creatorUsername: 'alice' });
136        const listener = vi.fn();
137        trashStore.subscribe(type, listener);
138
139        dbReactivity.trigger(rec);
140        // list()/count() are plain in-memory reads — membership applies
141        // synchronously in syncRecord(), only persist()+notify() are debounced.
142        expect(trashStore.list(type, ['alice'])).toEqual([1]);
143        expect(listener).not.toHaveBeenCalled();
144
145        vi.advanceTimersByTime(50);
146        expect(listener).toHaveBeenCalledTimes(1);
147    });
148
149    it('collapses a burst of live triggers into a single notify (debounce batching)', () => {
150        vi.useFakeTimers();
151        const type = 202;
152        const listener = vi.fn();
153        trashStore.subscribe(type, listener);
154
155        const recA = fakeRecord(type, 1, { deleted: true });
156        const recB = fakeRecord(type, 2, { deleted: true });
157        const recC = fakeRecord(type, 3, { deleted: true });
158        dbReactivity.trigger(recA);
159        dbReactivity.trigger(recB);
160        dbReactivity.trigger(recC);
161
162        vi.advanceTimersByTime(50);
163        expect(listener).toHaveBeenCalledTimes(1);
164        expect(trashStore.list(type).sort()).toEqual([1, 2, 3]);
165    });
166
167    it('does not notify subscribers of an unrelated objectType', () => {
168        vi.useFakeTimers();
169        const typeA = 203, typeB = 204;
170        const listenerA = vi.fn();
171        const listenerB = vi.fn();
172        trashStore.subscribe(typeA, listenerA);
173        trashStore.subscribe(typeB, listenerB);
174
175        dbReactivity.trigger(fakeRecord(typeA, 1, { deleted: true }));
176        vi.advanceTimersByTime(50);
177
178        expect(listenerA).toHaveBeenCalledTimes(1);
179        expect(listenerB).not.toHaveBeenCalled();
180    });
181
182    it('a no-op trigger (state unchanged) does not schedule a flush at all', () => {
183        vi.useFakeTimers();
184        const type = 205;
185        // deleted:false, safe:true → not trashed, matches the default `has=false` state → no membership change.
186        dbReactivity.trigger(fakeRecord(type, 1, { safe: true }));
187        const listener = vi.fn();
188        trashStore.subscribe(type, listener);
189
190        vi.advanceTimersByTime(1000);
191        expect(listener).not.toHaveBeenCalled();
192    });
193
194    it('unsubscribe stops further notifications', () => {
195        vi.useFakeTimers();
196        const type = 206;
197        const listener = vi.fn();
198        const unsubscribe = trashStore.subscribe(type, listener);
199        unsubscribe();
200
201        dbReactivity.trigger(fakeRecord(type, 1, { deleted: true }));
202        vi.advanceTimersByTime(50);
203        expect(listener).not.toHaveBeenCalled();
204    });
205
206    it('a listener throwing does not prevent other listeners for the same objectType from firing', () => {
207        vi.useFakeTimers();
208        const type = 207;
209        const good = vi.fn();
210        trashStore.subscribe(type, () => { throw new Error('boom'); });
211        trashStore.subscribe(type, good);
212
213        dbReactivity.trigger(fakeRecord(type, 1, { deleted: true }));
214        expect(() => vi.advanceTimersByTime(50)).not.toThrow();
215        expect(good).toHaveBeenCalledTimes(1);
216    });
217});
218
219describe('trash — trashStore.list/count/usernames merge semantics', () => {
220    beforeEach(() => {
221        const type = 301;
222        addRecord(type, fakeRecord(type, 1, { deleted: true, creatorUsername: 'alice' }), 'alice');
223        addRecord(type, fakeRecord(type, 2, { deleted: true, creatorUsername: 'alice' }), 'alice');
224        addRecord(type, fakeRecord(type, 3, { deleted: true, creatorUsername: 'bob' }), 'bob');
225        scanTrash();
226    });
227
228    it('list() with no usernames merges every bucketed username', () => {
229        expect(trashStore.list(301).sort()).toEqual([1, 2, 3]);
230    });
231
232    it('list() with an explicit usernames array only merges those buckets', () => {
233        expect(trashStore.list(301, ['alice']).sort()).toEqual([1, 2]);
234        expect(trashStore.list(301, ['bob'])).toEqual([3]);
235    });
236
237    it('list() de-dupes ids that happen to collide across usernames', () => {
238        // alice and bob both trash id 3 independently — merged list must not double-count.
239        addRecord(301, fakeRecord(301, 3, { deleted: true, creatorUsername: 'alice' }), 'alice');
240        scanTrash();
241        expect(trashStore.list(301, ['alice', 'bob'])).toEqual(expect.arrayContaining([3]));
242        expect(trashStore.list(301, ['alice', 'bob']).filter(id => id === 3)).toHaveLength(1);
243    });
244
245    it('list() returns empty for an untracked objectType', () => {
246        expect(trashStore.list(999999)).toEqual([]);
247    });
248
249    it('list() returns empty for a username with no bucket', () => {
250        expect(trashStore.list(301, ['nobody'])).toEqual([]);
251    });
252
253    it('count() matches list().length', () => {
254        expect(trashStore.count(301)).toBe(3);
255        expect(trashStore.count(301, ['alice'])).toBe(2);
256    });
257
258    it('usernames() lists exactly the bucketed usernames for that objectType', () => {
259        expect(trashStore.usernames(301).sort()).toEqual(['alice', 'bob']);
260    });
261
262    it('usernames() returns empty for an untracked objectType', () => {
263        expect(trashStore.usernames(999999)).toEqual([]);
264    });
265});
266
267describe('trash — localStorage persistence', () => {
268    it('persists only non-empty username buckets after scanTrash', () => {
269        const type = 401;
270        addRecord(type, fakeRecord(type, 1, { deleted: true, creatorUsername: 'alice' }), 'alice');
271        scanTrash();
272
273        const raw = localStorage.getItem('the_library:trash:v1');
274        expect(raw).toBeTruthy();
275        const parsed = JSON.parse(raw!);
276        expect(parsed[String(type)]).toEqual({ alice: [1] });
277    });
278
279    it('a fresh module import seeds in-memory state synchronously from localStorage', async () => {
280        const type = 402;
281        localStorage.setItem('the_library:trash:v1', JSON.stringify({ [type]: { alice: [7, 8] } }));
282
283        vi.resetModules();
284        const freshTrash = await import('../trash.ts');
285
286        // No scanTrash() call — the seed must be visible immediately at import time.
287        expect(freshTrash.trashStore.list(type, ['alice']).sort()).toEqual([7, 8]);
288    });
289
290    it('removing the last id from a bucket drops that username from the persisted snapshot', () => {
291        const type = 403;
292        const rec = fakeRecord(type, 1, { deleted: true, creatorUsername: 'alice' });
293        addRecord(type, rec, 'alice');
294        scanTrash();
295        expect(JSON.parse(localStorage.getItem('the_library:trash:v1')!)[String(type)]).toEqual({ alice: [1] });
296
297        rec.deleted = false;
298        rec.safe = true;
299        scanTrash();
300
301        const raw = localStorage.getItem('the_library:trash:v1');
302        const parsed = raw ? JSON.parse(raw) : {};
303        expect(parsed[String(type)]).toBeUndefined();
304    });
305
306    it('a fresh import falls back to empty state (without throwing) when the localStorage seed is corrupt JSON', async () => {
307        localStorage.setItem('the_library:trash:v1', '{not valid json');
308        const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
309
310        vi.resetModules();
311        const freshTrash = await import('../trash.ts');
312
313        expect(freshTrash.trashStore.list(999)).toEqual([]);
314        expect(warn).toHaveBeenCalledWith('[trash] failed to load localStorage seed', expect.anything());
315        warn.mockRestore();
316    });
317
318    it('scanTrash does not throw if localStorage.setItem throws (e.g. quota exceeded)', () => {
319        const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
320        vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError'); });
321
322        const type = 404;
323        addRecord(type, fakeRecord(type, 1, { deleted: true }), 'canonical');
324        expect(() => scanTrash()).not.toThrow();
325        expect(warn).toHaveBeenCalledWith('[trash] failed to persist localStorage snapshot', expect.anything());
326
327        // In-memory membership still applied even though persistence failed.
328        expect(trashStore.list(type)).toEqual([1]);
329        warn.mockRestore();
330    });
331});
332