The cache is a rebuildable index over gitea, never a source of truth (D4). Two tests lock that invariant where the durable cache actually lives: - packages/core: cache-purity-v0.test.ts — file-backed node:sqlite. Build the SQLite mirror from a gitea snapshot, capture every re-derived field, delete the .sqlite file, rebuild from the same snapshot, assert byte-identical. Plus a structural D4 guard: every issues-table column must map to a gitea field, so a future user-authored column can't silently break rebuild-ability. - apps/desktop: snapshot-store.test.ts — the shipped durable cache is the JSON snapshot-store. Delete the file → loadSnapshot returns null (degrades to no-cache, never throws), which is what forces the next getSnapshot to reconcile fresh from gitea. Corrupt/partial files are likewise treated as no-cache. Stands up vitest for the desktop main process (first unit tests there); electron is mocked, snapshot path is injected. No native better-sqlite3 shipped: the SQLite mirror has no consumer on any hot path yet, so wiring it into main (native module + asarUnpack + dmg re-verify) would add packaging risk for no runtime benefit. The purity invariant is proven at the seam for both caches; the native driver migration is deferred until SQLite becomes load-bearing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
/**
|
|
* Purity / rebuild guarantee for the *shipped* durable cache (#30, D4). The app's
|
|
* on-disk reconcile cache is this JSON snapshot-store. The invariant: delete it
|
|
* and lose nothing — the durable truth is in gitea, the file is only a boot/offline
|
|
* mirror. This test deletes the real file and asserts the store degrades to
|
|
* "no cache" (null), which is what forces the next `getSnapshot` to reconcile
|
|
* fresh from gitea rather than serve stale or missing data.
|
|
*/
|
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// electron can't be imported outside the Electron runtime; the store only needs
|
|
// app.getPath for its default path, which every test overrides with an injected path.
|
|
vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } }))
|
|
|
|
import { loadSnapshot, type PersistedSnapshot, saveSnapshot } from './snapshot-store.js'
|
|
|
|
const SNAP: Omit<PersistedSnapshot, 'savedAt'> = {
|
|
issues: [{ number: 1, title: 'An issue', state: 'open', labels: ['est/5d', 'p/1'] }],
|
|
milestones: [{ id: 7, title: 'P2' }],
|
|
deps: [{ issue: 1, dependsOn: 2 }],
|
|
timelines: { 1: [{ type: 'opened', at: '2026-07-01T00:00:00Z' }] },
|
|
}
|
|
|
|
describe('snapshot-store purity (#30)', () => {
|
|
let dir: string | null = null
|
|
const path = () => {
|
|
if (!dir) dir = mkdtempSync(join(tmpdir(), 'commitea-snap-'))
|
|
return join(dir, 'commitea-snapshot.json')
|
|
}
|
|
afterEach(() => {
|
|
if (dir) rmSync(dir, { recursive: true, force: true })
|
|
dir = null
|
|
})
|
|
|
|
it('round-trips a saved snapshot', () => {
|
|
const p = path()
|
|
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
|
|
const back = loadSnapshot(p)
|
|
expect(back).toEqual({ ...SNAP, savedAt: '2026-07-09T00:00:00Z' })
|
|
})
|
|
|
|
it('delete the cache file → load returns null (degrades to no-cache, not a crash)', () => {
|
|
const p = path()
|
|
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
|
|
expect(loadSnapshot(p)).not.toBeNull()
|
|
|
|
rmSync(p) // delete the durable cache
|
|
expect(existsSync(p)).toBe(false)
|
|
|
|
// The store must NOT throw and must report "no cache" so the next reconcile
|
|
// rebuilds from gitea. If this ever returned stale data or threw, D4 breaks.
|
|
expect(loadSnapshot(p)).toBeNull()
|
|
})
|
|
|
|
it('a corrupt / partial file is treated as no-cache, never a crash', () => {
|
|
const p = path()
|
|
writeFileSync(p, '{ this is not json', 'utf8')
|
|
expect(loadSnapshot(p)).toBeNull()
|
|
// valid JSON but wrong shape (no issues array) is also rejected
|
|
writeFileSync(p, JSON.stringify({ savedAt: 'x' }), 'utf8')
|
|
expect(loadSnapshot(p)).toBeNull()
|
|
})
|
|
|
|
it('re-saving after a delete restores the cache — the rebuild is lossless', () => {
|
|
const p = path()
|
|
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
|
|
const before = loadSnapshot(p)
|
|
|
|
rmSync(p)
|
|
expect(loadSnapshot(p)).toBeNull()
|
|
|
|
// a resync would call saveSnapshot again with the freshly reconciled data
|
|
saveSnapshot(SNAP, '2026-07-09T01:00:00Z', p)
|
|
const after = loadSnapshot(p)
|
|
|
|
// same durable payload, only the savedAt marker differs
|
|
expect(after!.issues).toEqual(before!.issues)
|
|
expect(after!.milestones).toEqual(before!.milestones)
|
|
expect(after!.deps).toEqual(before!.deps)
|
|
expect(after!.timelines).toEqual(before!.timelines)
|
|
})
|
|
})
|