📄 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