Skip to content

Commit fdf084f

Browse files
committed
test: unit tests for saveRevision pruning, listRevisions, getRevision, restoreConfigToRevision
1 parent 5473f0d commit fdf084f

2 files changed

Lines changed: 260 additions & 1 deletion

File tree

src/lib/server/db/configs.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/**
2+
* Unit tests for revision-related db functions in configs.ts.
3+
*
4+
* Uses an in-memory mock DB. The mock's DELETE handler has been extended in
5+
* db-mock.ts to support the NOT IN subquery used by the pruning step inside
6+
* saveRevision — see executeQuery's "NOT IN subquery" branch.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { saveRevision, listRevisions, getRevision, restoreConfigToRevision } from './configs';
11+
import { createMockDB } from '$lib/test/db-mock';
12+
import { mockConfig, mockRevision, mockRevisionOlder } from '$lib/test/fixtures';
13+
14+
// ── saveRevision ─────────────────────────────────────────────────────────────
15+
16+
describe('saveRevision', () => {
17+
it('inserts a new revision into config_revisions', async () => {
18+
const db = createMockDB({ configs: [mockConfig] });
19+
const packages = JSON.stringify([{ name: 'git', type: 'formula' }]);
20+
21+
await saveRevision(db, mockConfig.id, packages, null);
22+
23+
const revisions = db.data.config_revisions;
24+
expect(revisions).toHaveLength(1);
25+
expect(revisions[0].config_id).toBe(mockConfig.id);
26+
expect(revisions[0].packages).toBe(packages);
27+
expect(revisions[0].message).toBeNull();
28+
expect(revisions[0].id).toMatch(/^rev_/);
29+
});
30+
31+
it('stores message when provided', async () => {
32+
const db = createMockDB({ configs: [mockConfig] });
33+
34+
await saveRevision(
35+
db,
36+
mockConfig.id,
37+
JSON.stringify([{ name: 'git', type: 'formula' }]),
38+
'before adding rust'
39+
);
40+
41+
expect(db.data.config_revisions[0].message).toBe('before adding rust');
42+
});
43+
44+
it('generates a unique id for each revision', async () => {
45+
const db = createMockDB({ configs: [mockConfig] });
46+
const pkgs = JSON.stringify([]);
47+
48+
await saveRevision(db, mockConfig.id, pkgs, null);
49+
await saveRevision(db, mockConfig.id, pkgs, null);
50+
51+
const ids = db.data.config_revisions.map((r: any) => r.id);
52+
expect(new Set(ids).size).toBe(2);
53+
});
54+
55+
it('keeps at most 10 revisions per config (prunes oldest)', async () => {
56+
// Seed 10 existing revisions with distinct created_at timestamps
57+
const existing = Array.from({ length: 10 }, (_, i) => ({
58+
id: `rev_existing${String(i).padStart(2, '0')}`,
59+
config_id: mockConfig.id,
60+
packages: JSON.stringify([]),
61+
message: null,
62+
created_at: `2026-01-${String(i + 1).padStart(2, '0')} 00:00:00`
63+
}));
64+
65+
const db = createMockDB({ configs: [mockConfig], config_revisions: existing });
66+
67+
// The 11th save should trigger pruning
68+
await saveRevision(db, mockConfig.id, JSON.stringify([{ name: 'new', type: 'formula' }]), 'eleventh');
69+
70+
expect(db.data.config_revisions.length).toBeLessThanOrEqual(10);
71+
});
72+
73+
it('does not prune revisions belonging to other configs', async () => {
74+
const otherConfig = { ...mockConfig, id: 'cfg_other' };
75+
76+
const existing = Array.from({ length: 10 }, (_, i) => ({
77+
id: `rev_mine${String(i).padStart(2, '0')}`,
78+
config_id: mockConfig.id,
79+
packages: JSON.stringify([]),
80+
message: null,
81+
created_at: `2026-01-${String(i + 1).padStart(2, '0')} 00:00:00`
82+
}));
83+
84+
const other = {
85+
id: 'rev_other01',
86+
config_id: otherConfig.id,
87+
packages: JSON.stringify([]),
88+
message: null,
89+
created_at: '2026-01-01 00:00:00'
90+
};
91+
92+
const db = createMockDB({
93+
configs: [mockConfig, otherConfig],
94+
config_revisions: [...existing, other]
95+
});
96+
97+
await saveRevision(db, mockConfig.id, JSON.stringify([]), 'overflow');
98+
99+
// The other config's revision must survive
100+
const otherStillPresent = db.data.config_revisions.some(
101+
(r: any) => r.id === 'rev_other01'
102+
);
103+
expect(otherStillPresent).toBe(true);
104+
});
105+
});
106+
107+
// ── listRevisions ─────────────────────────────────────────────────────────────
108+
109+
describe('listRevisions', () => {
110+
it('returns revisions for the given config', async () => {
111+
const db = createMockDB({
112+
configs: [mockConfig],
113+
config_revisions: [mockRevision, mockRevisionOlder]
114+
});
115+
116+
const result = await listRevisions(db, mockConfig.id);
117+
118+
expect(result.length).toBe(2);
119+
});
120+
121+
it('returns an empty array when there are no revisions', async () => {
122+
const db = createMockDB({ configs: [mockConfig] });
123+
124+
const result = await listRevisions(db, mockConfig.id);
125+
126+
expect(result).toEqual([]);
127+
});
128+
129+
it('does not return revisions from other configs', async () => {
130+
const otherRevision = { ...mockRevision, id: 'rev_other', config_id: 'cfg_other' };
131+
const db = createMockDB({
132+
configs: [mockConfig],
133+
config_revisions: [mockRevision, otherRevision]
134+
});
135+
136+
const result = await listRevisions(db, mockConfig.id);
137+
138+
expect(result.every((r) => r.id !== 'rev_other')).toBe(true);
139+
});
140+
141+
it('includes id, message, and created_at fields', async () => {
142+
const db = createMockDB({
143+
configs: [mockConfig],
144+
config_revisions: [mockRevision]
145+
});
146+
147+
const result = await listRevisions(db, mockConfig.id);
148+
const rev = result[0];
149+
150+
expect(rev.id).toBe(mockRevision.id);
151+
expect(rev.message).toBe(mockRevision.message);
152+
expect(rev.created_at).toBe(mockRevision.created_at);
153+
});
154+
});
155+
156+
// ── getRevision ───────────────────────────────────────────────────────────────
157+
158+
describe('getRevision', () => {
159+
it('returns the revision when it exists and belongs to the config', async () => {
160+
const db = createMockDB({
161+
configs: [mockConfig],
162+
config_revisions: [mockRevision]
163+
});
164+
165+
// getRevision signature: (db, revisionId, configId)
166+
const result = await getRevision(db, mockRevision.id, mockConfig.id);
167+
168+
expect(result).not.toBeNull();
169+
expect(result!.id).toBe(mockRevision.id);
170+
});
171+
172+
it('returns null when the revision id does not exist', async () => {
173+
const db = createMockDB({ configs: [mockConfig] });
174+
175+
const result = await getRevision(db, 'rev_nonexistent', mockConfig.id);
176+
177+
expect(result).toBeNull();
178+
});
179+
180+
it('returns null when revision belongs to a different config', async () => {
181+
const wrongConfigRevision = { ...mockRevision, config_id: 'cfg_other' };
182+
const db = createMockDB({
183+
config_revisions: [wrongConfigRevision]
184+
});
185+
186+
const result = await getRevision(db, mockRevision.id, mockConfig.id);
187+
188+
expect(result).toBeNull();
189+
});
190+
});
191+
192+
// ── restoreConfigToRevision ───────────────────────────────────────────────────
193+
194+
describe('restoreConfigToRevision', () => {
195+
it('updates the config packages to the revision packages', async () => {
196+
const db = createMockDB({
197+
configs: [mockConfig],
198+
config_revisions: [mockRevision]
199+
});
200+
201+
await restoreConfigToRevision(db, mockConfig.id, mockRevision.id);
202+
203+
const updated = db.data.configs.find((c: any) => c.id === mockConfig.id);
204+
expect(updated.packages).toBe(mockRevision.packages);
205+
});
206+
207+
it('saves the current packages as a "before restore" revision before overwriting', async () => {
208+
const db = createMockDB({
209+
configs: [mockConfig],
210+
config_revisions: [mockRevision]
211+
});
212+
213+
const packagesBefore = mockConfig.packages;
214+
await restoreConfigToRevision(db, mockConfig.id, mockRevision.id);
215+
216+
const beforeRevision = db.data.config_revisions.find((r: any) =>
217+
r.message?.includes('before restore')
218+
);
219+
expect(beforeRevision).toBeDefined();
220+
expect(beforeRevision.packages).toBe(packagesBefore);
221+
});
222+
223+
it('returns null when the revision does not exist', async () => {
224+
const db = createMockDB({ configs: [mockConfig] });
225+
226+
const result = await restoreConfigToRevision(db, mockConfig.id, 'rev_nonexistent');
227+
228+
expect(result).toBeNull();
229+
});
230+
});

src/lib/test/db-mock.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,37 @@ function executeQuery<T>(sql: string, bindings: any[], tables: Record<string, an
344344
if (!tableMatch) return [] as T[];
345345

346346
const tableName = tableMatch[1];
347-
const whereMatch = sql.match(/where\s+(\w+)\s*=\s*\?/i);
348347

348+
// NOT IN subquery (revision pruning pattern):
349+
// DELETE FROM t WHERE filterField = ? AND idField NOT IN (SELECT idField FROM t WHERE filterField = ? ORDER BY orderField DESC LIMIT ?)
350+
if (sqlLower.includes('not in')) {
351+
const filterFieldMatch = sql.match(/where\s+(\w+)\s*=\s*\?/i);
352+
const idFieldMatch = sql.match(/and\s+(\w+)\s+not\s+in/i);
353+
const orderFieldMatch = sql.match(/order\s+by\s+(\w+)\s+desc/i);
354+
355+
if (filterFieldMatch && idFieldMatch && orderFieldMatch && bindings.length >= 3) {
356+
const filterField = filterFieldMatch[1];
357+
const idField = idFieldMatch[1];
358+
const orderField = orderFieldMatch[1];
359+
const filterValue = bindings[0];
360+
const limit = Number(bindings[2]);
361+
362+
// Sort matching rows newest-first and keep top `limit` ids
363+
const allForFilter = tables[tableName].filter((row: any) => row[filterField] === filterValue);
364+
allForFilter.sort((a: any, b: any) => (b[orderField] > a[orderField] ? 1 : -1));
365+
const keepIds = new Set(allForFilter.slice(0, limit).map((r: any) => r[idField]));
366+
367+
const before = tables[tableName].length;
368+
tables[tableName] = tables[tableName].filter(
369+
(row: any) => row[filterField] !== filterValue || keepIds.has(row[idField])
370+
);
371+
const deleted = before - tables[tableName].length;
372+
return new Array(deleted).fill({}) as T[];
373+
}
374+
}
375+
376+
// Simple WHERE field = ? pattern
377+
const whereMatch = sql.match(/where\s+(\w+)\s*=\s*\?/i);
349378
if (whereMatch && bindings.length > 0) {
350379
const fieldName = whereMatch[1];
351380
const value = bindings[0];

0 commit comments

Comments
 (0)