Files
email/sidecar/sync-meta.test.ts
Claude Opus 5 980a9d3c38 email, extracted from the officer platform
IMAP and Gmail sync into a per-account SQLite store, with the mail read and
composed in the browser.

moved whole from src/servers/sidecar/email (14 files), src/servers/api/email,
officer_db/src/email, and the Screens/Dashboard/Email tree. what changed on the
way out:

- credentials are encrypted. the column was plaintext jsonb holding an IMAP app
  password that the SMTP transport read straight out of the row; it is now
  AES-256-GCM under this plugin's own 'email' secret-store purpose, sealed and
  opened in queries.ts alone so nothing above it can hold ciphertext by
  accident. no plaintext fallback on read - a value that does not verify is an
  error.
- the router derives its prefix from mountPrefix() instead of the literal
  '/api/email', which was only correct for a first-party publisher.
- the frontend became panels.ts + layout.ts. the two panel appTypes that were
  null are now declared, because the platform screen used to override them by
  panel id and a plugin has no such hook.
- account management became a modal. it was a section in the platform's
  Settings, which a plugin cannot contribute to, and a panel does not work
  either: PluginScreen renders the workspace locked, so a panel outside the
  default layout is unreachable. it follows Compose, which had already solved
  this.
- 17 palette classes replaced with semantic tokens.

not verified: this has never run. it was switched off in the platform on
2026-08-13 and its table was never created, so nothing has exercised sync,
sending or the UI since. the first install is the first test.
2026-08-18 00:29:50 +00:00

125 lines
5.7 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import { Database } from 'bun:sqlite';
import { readSyncMeta, writeSyncMeta, hasSyncedBefore } from './sync-meta';
// Moving an account's sync position out of Postgres and into its own emails.db. Both failure modes are
// expensive and neither is loud, so they are pinned here:
//
// migrate when we should not -> a restored older mailbox inherits a newer position and SKIPS mail
// fail to migrate -> an existing account looks brand new and re-downloads everything
const freshDb = () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)');
return db;
};
describe('readSyncMeta', () => {
it('reads what the file already knows', () => {
const db = freshDb();
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
});
it('migrates keys the file is MISSING, even when it already has others', () => {
// The case that broke a live 6.9 GB mailbox. The older Gmail backfill wrote last_sync_at and the
// uidvalidity set into SQLite and never the imap_lastuid ones, so the file was non-empty and
// half-migrated at once. All-or-nothing skipped it, leaving no lastuid for any folder — and a
// missing lastuid refetches that folder from UID 1.
const db = freshDb();
writeSyncMeta(db, { last_sync_at: '2026-07-23T00:00:00Z', 'imap_uidvalidity:INBOX': '2' });
const meta = readSyncMeta(db, {
last_sync_at: '2026-08-10T00:00:00Z', // newer, but the file's own answer stands
'imap_uidvalidity:INBOX': '2',
'imap_lastuid:INBOX': '4200', // absent from the file — this is what must arrive
});
expect(meta['imap_lastuid:INBOX']).toBe('4200');
expect(meta.last_sync_at).toBe('2026-07-23T00:00:00Z');
// Persisted, so the fallback is not needed next time.
expect(readSyncMeta(db)['imap_lastuid:INBOX']).toBe('4200');
});
it('migrates from Postgres exactly once, when the file has nothing', () => {
// The account existed before the move. Without this it looks like a first sync and re-downloads
// the entire mailbox.
const db = freshDb();
const pg = { last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' };
expect(readSyncMeta(db, pg)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
// Backfilled, so the fallback is never consulted again.
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
});
it('NEVER lets Postgres override a position the file already has', () => {
// The dangerous direction. Restore an older emails.db onto a server whose Postgres row is newer,
// and topping up from Postgres would advance past mail this file does not contain — skipped
// permanently, because nothing looks below lastuid again. The file wins, always.
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': '100' });
const meta = readSyncMeta(db, { 'imap_lastuid:INBOX': '9999' });
// The key the file has is never overwritten — that is what stops a restored older mailbox being
// advanced past mail it does not contain.
expect(meta['imap_lastuid:INBOX']).toBe('100');
});
it('ignores non-string values rather than stringifying them', () => {
// The Postgres column is free-form jsonb. An object coerced to "[object Object]" would be a
// position that parses as a number nowhere and compares as garbage.
const db = freshDb();
expect(readSyncMeta(db, { good: 'yes', bad: { nested: true }, alsoBad: 42 } as never)).toEqual({ good: 'yes' });
});
it('is empty for a genuinely new account', () => {
expect(readSyncMeta(freshDb(), {})).toEqual({});
expect(readSyncMeta(freshDb(), null)).toEqual({});
});
});
describe('writeSyncMeta', () => {
it('upserts rather than duplicating, so a checkpoint mid-sync is safe to repeat', () => {
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': '10' });
writeSyncMeta(db, { 'imap_lastuid:INBOX': '20' });
expect(readSyncMeta(db)).toEqual({ 'imap_lastuid:INBOX': '20' });
});
it('skips null and undefined instead of writing them as text', () => {
// "null" as a stored lastuid would parse to NaN and compare false against every real UID.
const db = freshDb();
writeSyncMeta(db, { a: '1', b: null, c: undefined });
expect(readSyncMeta(db)).toEqual({ a: '1' });
});
it('stores numbers as their text form, since UIDs arrive both ways', () => {
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': 4200 });
expect(readSyncMeta(db)['imap_lastuid:INBOX']).toBe('4200');
});
});
describe('hasSyncedBefore', () => {
it('is false for a new account and true once the file records a sync', () => {
const db = freshDb();
expect(hasSyncedBefore(db)).toBe(false);
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z' });
expect(hasSyncedBefore(db)).toBe(true);
});
it('is true for an account that synced before the move but has not migrated yet', () => {
// The whole point: an unmigrated account must not be treated as new. This is the check that stands
// between an existing user and an unnecessary full re-sync of their mailbox.
expect(hasSyncedBefore(freshDb(), { last_sync_at: '2026-01-01T00:00:00Z' })).toBe(true);
});
it('is false when Postgres holds a position but no completed sync', () => {
// Folder UIDs without last_sync_at means a sync started and never finished.
expect(hasSyncedBefore(freshDb(), { 'imap_uidvalidity:INBOX': '99' })).toBe(false);
});
});