From 0f19847e4fc229594fbcee2c3cc8d89e29e1e7b3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 14:21:05 +0545 Subject: [PATCH 1/9] chore(OUT-3987): add fishery dev dependency for test factories Typed factory/seeder library backing the L0.3 scenario fixtures: native sequence counters, params-based variants, and a build()/create() split that maps onto pure factories vs DB seeders. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + pnpm-lock.yaml | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/package.json b/package.json index b17b98c..840ad15 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@types/react-linkify": "^1.0.4", "@types/unorm": "^1.3.31", "drizzle-kit": "^0.31.4", + "fishery": "^2.4.0", "husky": "^9.1.7", "lint-staged": "^16.1.5", "msw": "^2.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfaac39..74b1ccc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: drizzle-kit: specifier: ^0.31.4 version: 0.31.4 + fishery: + specifier: ^2.4.0 + version: 2.4.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -3164,6 +3167,9 @@ packages: resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} engines: {node: '>=18'} + fishery@2.4.0: + resolution: {integrity: sha512-QgeTlvgNhVGuMztrfAhlSIBs3rD3l9RMjl9I15yb/lnrx3njrOhvegr2L3LWdqvXwYfQjdQGpglyAfHH2J8DRA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -7950,6 +7956,10 @@ snapshots: path-exists: 5.0.0 unicorn-magic: 0.1.0 + fishery@2.4.0: + dependencies: + lodash.mergewith: 4.6.2 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 From 74de6b29112ce7bbea8f126de065ad94b44a81e7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 14:21:26 +0545 Subject: [PATCH 2/9] test(OUT-3987): add typed Dropbox + Copilot scenario factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure build() factories emitting typed remote-system fixtures for the test harness, no DB access: - sequence.ts: one shared monotonic counter (nextSeq/seqUuid) + resetFactories() so a logical entity carries one deterministic id across Dropbox/Copilot/DB. - dropbox.ts: dropboxEntryFactory (+ folder/deleted .params() variants) → DropboxFileListFolderSingleEntry. - copilot.ts: copilotFileFactory (+ folder/pending/downloadable/renamed variants) and copilotListPage() → CopilotFileRetrieve / CopilotFileList. Typed against the production Zod/TS schemas so fixtures fail to compile on drift. vitest.config.ts include widened to run test/**/*.test.ts in the fast unit project (integration files stay excluded). Co-Authored-By: Claude Opus 4.8 --- test/factories/copilot.test.ts | 52 +++++++++++++++++++++++++++++++++ test/factories/copilot.ts | 36 +++++++++++++++++++++++ test/factories/dropbox.test.ts | 39 +++++++++++++++++++++++++ test/factories/dropbox.ts | 27 +++++++++++++++++ test/factories/index.ts | 12 ++++++++ test/factories/sequence.test.ts | 24 +++++++++++++++ test/factories/sequence.ts | 17 +++++++++++ vitest.config.ts | 2 +- 8 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 test/factories/copilot.test.ts create mode 100644 test/factories/copilot.ts create mode 100644 test/factories/dropbox.test.ts create mode 100644 test/factories/dropbox.ts create mode 100644 test/factories/index.ts create mode 100644 test/factories/sequence.test.ts create mode 100644 test/factories/sequence.ts diff --git a/test/factories/copilot.test.ts b/test/factories/copilot.test.ts new file mode 100644 index 0000000..369d39c --- /dev/null +++ b/test/factories/copilot.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { CopilotFileListSchema, CopilotFileRetrieveSchema } from '@/lib/copilot/types' +import { + copilotDownloadableFactory, + copilotFileFactory, + copilotFolderFactory, + copilotListPage, + copilotPendingFactory, + copilotRenamedFactory, +} from './copilot' +import { resetFactories } from './index' + +beforeEach(() => resetFactories()) + +describe('copilotFileFactory', () => { + it('produces a schema-valid file with a sequential uuid', () => { + const file = copilotFileFactory.build() + expect(CopilotFileRetrieveSchema.parse(file)).toEqual(file) + expect(file.id).toBe('00000000-0000-4000-8000-000000000001') + expect(file.object).toBe('file') + }) + + it('folder trait sets object=folder', () => { + expect(copilotFolderFactory.build().object).toBe('folder') + }) + + it('pending trait sets status=pending', () => { + expect(copilotPendingFactory.build().status).toBe('pending') + }) + + it('downloadable trait sets a downloadUrl', () => { + expect(copilotDownloadableFactory.build().downloadUrl).toBeDefined() + }) + + // test required for update event from Assembly. update event has to have previousAttributes field in the payload body + it('renamed trait sets previousAttributes.name', () => { + expect(copilotRenamedFactory.build().previousAttributes?.name).toBeDefined() + }) +}) + +describe('copilotListPage', () => { + it('builds a schema-valid page without a nextToken', () => { + const page = copilotListPage([copilotFileFactory.build()]) + expect(CopilotFileListSchema.parse(page)).toEqual(page) + expect(page.nextToken).toBeUndefined() + }) + + it('includes a nextToken when given one', () => { + const page = copilotListPage([], { nextToken: 'token:1' }) + expect(page.nextToken).toBe('token:1') + }) +}) diff --git a/test/factories/copilot.ts b/test/factories/copilot.ts new file mode 100644 index 0000000..5824513 --- /dev/null +++ b/test/factories/copilot.ts @@ -0,0 +1,36 @@ +import { Factory } from 'fishery' +import { ObjectType } from '@/db/constants' +import type { CopilotFileList, CopilotFileRetrieve } from '@/lib/copilot/types' +import { nextSeq, seqUuid } from './sequence' + +export const copilotFileFactory = Factory.define(({ params }) => { + const n = nextSeq() + const isFolder = params.object === ObjectType.FOLDER + const name = isFolder ? `folder-${n}` : `file-${n}.txt` + return { + id: seqUuid(n), + channelId: 'ch-1', + name, + object: params.object ?? ObjectType.FILE, + path: `/${name}`, + } +}) + +export const copilotFolderFactory = copilotFileFactory.params({ object: ObjectType.FOLDER }) +export const copilotPendingFactory = copilotFileFactory.params({ status: 'pending' }) +export const copilotDownloadableFactory = copilotFileFactory.params({ + downloadUrl: 'https://content.example/download', +}) +export const copilotRenamedFactory = copilotFileFactory.params({ + previousAttributes: { name: 'previous-name.txt' }, +}) + +// Wraps built files into the { data, nextToken? } page shape the Copilot list +// endpoint returns. Compose with the existing paginateCopilotListFiles MSW +// helper for multi-page listings. +export function copilotListPage( + items: CopilotFileRetrieve[], + opts: { nextToken?: string } = {}, +): CopilotFileList { + return opts.nextToken ? { data: items, nextToken: opts.nextToken } : { data: items } +} diff --git a/test/factories/dropbox.test.ts b/test/factories/dropbox.test.ts new file mode 100644 index 0000000..4b320b8 --- /dev/null +++ b/test/factories/dropbox.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { DropboxFileListFolderSingleEntrySchema } from '@/features/sync/types' +import { dropboxDeletedFactory, dropboxEntryFactory, dropboxFolderFactory } from './dropbox' +import { resetFactories } from './index' + +beforeEach(() => resetFactories()) + +describe('dropboxEntryFactory', () => { + it('produces a schema-valid file entry with a content hash', () => { + const entry = dropboxEntryFactory.build() + expect(DropboxFileListFolderSingleEntrySchema.parse(entry)).toEqual(entry) + expect(entry['.tag']).toBe('file') + expect(entry.id).toBe('dbx:1') + expect(entry.content_hash).toBeDefined() + expect(entry.is_downloadable).toBeTruthy() + }) + + it('folder trait sets .tag=folder and drops the content hash', () => { + const folder = dropboxFolderFactory.build() + expect(folder['.tag']).toBe('folder') + expect(folder.content_hash).toBeUndefined() + expect(folder.is_downloadable).toBeUndefined() + }) + + it('deleted trait sets .tag=deleted', () => { + expect(dropboxDeletedFactory.build()['.tag']).toBe('deleted') + }) + + it('respects explicit overrides', () => { + const entry = dropboxEntryFactory.build({ path_display: '/root/custom.txt' }) + expect(entry.path_display).toBe('/root/custom.txt') + expect(entry.id).toBe('dbx:1') + }) + + it('sequences ids across builds', () => { + expect(dropboxEntryFactory.build().id).toBe('dbx:1') + expect(dropboxEntryFactory.build().id).toBe('dbx:2') + }) +}) diff --git a/test/factories/dropbox.ts b/test/factories/dropbox.ts new file mode 100644 index 0000000..80a04d8 --- /dev/null +++ b/test/factories/dropbox.ts @@ -0,0 +1,27 @@ +import { Factory } from 'fishery' +import type { DropboxFileListFolderSingleEntry } from '@/features/sync/types' +import { nextSeq } from './sequence' + +// Default is a file entry with a content hash; the folder/deleted variants +// below preset `.tag`, and the generator branches on it (folders/deletes carry +// no content_hash — matching what the Dropbox API returns). +export const dropboxEntryFactory = Factory.define( + ({ params }) => { + const n = nextSeq() + const tag = params['.tag'] ?? 'file' + const isFolder = tag === 'folder' + const name = isFolder ? `folder-${n}` : `file-${n}.txt` + const entry: DropboxFileListFolderSingleEntry = { + '.tag': tag, + id: `dbx:${n}`, + name, + path_display: `/root/${name}`, + ...(!isFolder && { is_downloadable: true }), + } + if (tag === 'file') entry.content_hash = `hash-${n}` + return entry + }, +) + +export const dropboxFolderFactory = dropboxEntryFactory.params({ '.tag': 'folder' }) +export const dropboxDeletedFactory = dropboxEntryFactory.params({ '.tag': 'deleted' }) diff --git a/test/factories/index.ts b/test/factories/index.ts new file mode 100644 index 0000000..a4715de --- /dev/null +++ b/test/factories/index.ts @@ -0,0 +1,12 @@ +import { resetSeq } from './sequence' + +export * from './copilot' +export * from './dropbox' +export { seqUuid } from './sequence' + +// Zeroes the shared sequence so ids (dbx:1, ...0001, /root/file-1.txt) are +// stable per test. Wired into the integration beforeEach in Task 6; call it +// manually in pure unit tests. +export function resetFactories(): void { + resetSeq() +} diff --git a/test/factories/sequence.test.ts b/test/factories/sequence.test.ts new file mode 100644 index 0000000..b27e70f --- /dev/null +++ b/test/factories/sequence.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { resetFactories, seqUuid } from './index' +import { nextSeq } from './sequence' + +beforeEach(() => resetFactories()) + +describe('shared sequence', () => { + it('increments from 1', () => { + expect(nextSeq()).toBe(1) + expect(nextSeq()).toBe(2) + }) + + it('resetFactories() zeroes the counter', () => { + nextSeq() + nextSeq() + resetFactories() + expect(nextSeq()).toBe(1) + }) + + it('seqUuid produces a valid, zero-padded uuid', () => { + expect(seqUuid(1)).toBe('00000000-0000-4000-8000-000000000001') + expect(seqUuid(42)).toBe('00000000-0000-4000-8000-000000000042') + }) +}) diff --git a/test/factories/sequence.ts b/test/factories/sequence.ts new file mode 100644 index 0000000..b27bcdc --- /dev/null +++ b/test/factories/sequence.ts @@ -0,0 +1,17 @@ +// One process-global counter shared by every factory AND seeder, so a given +// logical entity carries one number across its Dropbox / Copilot / DB forms. +// Reset between tests via resetFactories() (see ./index.ts). +let counter = 0 + +export function nextSeq(): number { + return ++counter +} + +export function resetSeq(): void { + counter = 0 +} + +// Deterministic, schema-valid UUID for id columns (z.uuid() / uuid()). +export function seqUuid(n: number): string { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` +} diff --git a/vitest.config.ts b/vitest.config.ts index e44e5b0..eac3593 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['src/**/*.{test,spec}.ts'], + include: ['src/**/*.{test,spec}.ts', 'test/**/*.test.ts'], // Integration tests run in a separate project (vitest.integration.config.ts) // against a real Postgres container — keep them out of the fast unit run. exclude: ['**/*.integration.test.ts', '**/node_modules/**'], From 461105b0fd0c693b362c17c53242a8ee83d9321e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 14:22:07 +0545 Subject: [PATCH 3/9] test(OUT-3987): add DB seeders for connection/channel/file-sync rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async create() seeders writing real rows through the db singleton against the Testcontainers Postgres harness (integration-only): - dropboxConnectionSeeder, channelSeeder, fileSyncSeeder — each returns the persisted row (InferSelectModel), typed from the Drizzle insert schemas. - Auto-parent wiring: seeding a child with no parent id creates the chain (file -> channel -> connection) with a consistent portalId. channelSeeder reuses an existing connection when only portalId is given, and throws clearly on an unresolvable/ nonexistent parent. - Trait helpers (Partial spread into create()): pendingCreate/ pendingDelete (respecting the pending_action_target_consistency CHECK), tombstone, synced (Assembly-side), and fromDropboxEntry to keep a seeded row consistent with its Dropbox fixture. - resetFactories() wired into the integration beforeEach alongside the existing TRUNCATE so the shared sequence is deterministic per test. Covered by seeders.integration.test.ts, incl. a round-trip through the real MapFilesService.getDbxMappedFileFromPath read path. Co-Authored-By: Claude Opus 4.8 --- test/integration/setup.ts | 5 + test/seeders/channel.ts | 59 ++++++++ test/seeders/connection.ts | 30 ++++ test/seeders/fileSync.ts | 101 ++++++++++++++ test/seeders/index.ts | 3 + test/seeders/seeders.integration.test.ts | 170 +++++++++++++++++++++++ 6 files changed, 368 insertions(+) create mode 100644 test/seeders/channel.ts create mode 100644 test/seeders/connection.ts create mode 100644 test/seeders/fileSync.ts create mode 100644 test/seeders/index.ts create mode 100644 test/seeders/seeders.integration.test.ts diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 346baa0..63acf58 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -1,5 +1,6 @@ import postgres from 'postgres' import { afterAll, afterEach, beforeAll, beforeEach, inject } from 'vitest' +import { resetFactories } from '../factories' import { server } from '../msw/server' import { applyPlaceholderServerEnv } from '../support/placeholder-env' @@ -47,6 +48,10 @@ beforeEach(async () => { } }) +// Zero the shared factory sequence between tests so ids are deterministic and +// don't leak across tests (mirrors the truncate isolation above). +beforeEach(() => resetFactories()) + afterAll(async () => { await sql.end() }) diff --git a/test/seeders/channel.ts b/test/seeders/channel.ts new file mode 100644 index 0000000..dd71fa2 --- /dev/null +++ b/test/seeders/channel.ts @@ -0,0 +1,59 @@ +import { eq, type InferSelectModel } from 'drizzle-orm' +import { Factory } from 'fishery' +import db from '@/db' +import { type ChannelSyncCreateType, channelSync } from '@/db/schema/channelSync.schema' +import { dropboxConnections } from '@/db/schema/dropboxConnections.schema' +import { nextSeq } from '../factories/sequence' +import { dropboxConnectionSeeder } from './connection' + +type ChannelRow = InferSelectModel + +// portalId/dbxAccountId are optional at the seed layer: when omitted we mint a +// connection and inherit its identity, so the channel and connection agree. +type ChannelSeed = Omit & { + portalId?: string + dbxAccountId?: string +} + +export const channelSeeder = Factory.define, ChannelRow>( + ({ onCreate }) => { + const n = nextSeq() + onCreate(async (values) => { + const { portalId: pid, dbxAccountId: aid, ...rest } = values + let portalId = pid + let dbxAccountId = aid + if (dbxAccountId && !portalId) { + throw new Error( + 'channelSeeder: supply portalId when specifying dbxAccountId (a dbxAccountId alone cannot resolve a connection)', + ) + } else if (!portalId) { + const conn = await dropboxConnectionSeeder.create() + portalId = conn.portalId + dbxAccountId = conn.accountId ?? undefined + } else if (!dbxAccountId) { + // portalId is globally unique, so find-or-create the connection for it. + const [existing] = await db + .select({ accountId: dropboxConnections.accountId }) + .from(dropboxConnections) + .where(eq(dropboxConnections.portalId, portalId)) + dbxAccountId = existing + ? (existing.accountId ?? `acc-${n}`) + : ((await dropboxConnectionSeeder.create({ portalId })).accountId ?? `acc-${n}`) + } + if (!dbxAccountId) { + // Unreachable: dropboxConnectionSeeder always defaults accountId. + throw new Error('channelSeeder: could not resolve dbxAccountId') + } + const [row] = await db + .insert(channelSync) + .values({ ...rest, portalId, dbxAccountId }) + .returning() + return row + }) + return { + assemblyChannelId: `ch-${n}`, + dbxRootPath: '/root', + status: true, + } + }, +) diff --git a/test/seeders/connection.ts b/test/seeders/connection.ts new file mode 100644 index 0000000..14c7863 --- /dev/null +++ b/test/seeders/connection.ts @@ -0,0 +1,30 @@ +import type { InferSelectModel } from 'drizzle-orm' +import { Factory } from 'fishery' +import db from '@/db' +import { + type DropboxConnectionInsertPayload, + dropboxConnections, +} from '@/db/schema/dropboxConnections.schema' +import { nextSeq, seqUuid } from '../factories/sequence' + +type ConnectionRow = InferSelectModel + +export const dropboxConnectionSeeder = Factory.define< + DropboxConnectionInsertPayload, + Record, + ConnectionRow +>(({ onCreate }) => { + const n = nextSeq() + onCreate(async (values) => { + const [row] = await db.insert(dropboxConnections).values(values).returning() + return row + }) + return { + portalId: `portal-${n}`, + accountId: `acc-${n}`, + refreshToken: `rt-${n}`, + rootNamespaceId: `ns-${n}`, + initiatedBy: seqUuid(n), + status: true, + } +}) diff --git a/test/seeders/fileSync.ts b/test/seeders/fileSync.ts new file mode 100644 index 0000000..8ef1a76 --- /dev/null +++ b/test/seeders/fileSync.ts @@ -0,0 +1,101 @@ +import { eq, type InferSelectModel } from 'drizzle-orm' +import { Factory } from 'fishery' +import db from '@/db' +import { ObjectType, PendingAction, type PendingActionTargetValue } from '@/db/constants' +import { channelSync } from '@/db/schema/channelSync.schema' +import { type FileSyncCreateType, fileFolderSync } from '@/db/schema/fileFolderSync.schema' +import type { DropboxFileListFolderSingleEntry } from '@/features/sync/types' +import { nextSeq, seqUuid } from '../factories/sequence' +import { channelSeeder } from './channel' + +type FileRow = InferSelectModel + +// portalId/channelSyncId optional at the seed layer: omit them to auto-create +// the parent channel (which auto-creates a connection). +export type FileSeed = Omit & { + portalId?: string + channelSyncId?: string +} + +export const fileSyncSeeder = Factory.define, FileRow>( + ({ onCreate }) => { + const n = nextSeq() + onCreate(async (values) => { + const { portalId: pid, channelSyncId: cid, ...rest } = values + let portalId = pid + let channelSyncId = cid + if (!channelSyncId) { + const channel = await channelSeeder.create(portalId ? { portalId } : {}) + channelSyncId = channel.id + portalId = portalId ?? channel.portalId + } else if (!portalId) { + const [ch] = await db + .select({ portalId: channelSync.portalId }) + .from(channelSync) + .where(eq(channelSync.id, channelSyncId)) + if (!ch) throw new Error(`fileSyncSeeder: channelSync ${channelSyncId} not found`) + portalId = ch.portalId + } + const [row] = await db + .insert(fileFolderSync) + .values({ ...rest, portalId, channelSyncId }) + .returning() + return row + }) + return { + itemPath: `/root/file-${n}.txt`, + object: ObjectType.FILE, + } + }, +) + +// --- Traits: partial overrides passed into .create(). Each keeps the +// pending_action_target_consistency CHECK satisfied (action+target together). --- + +export function pendingCreate(target: PendingActionTargetValue): Partial { + return { + pendingAction: PendingAction.CREATE, + pendingActionTarget: target, + pendingActionAttempts: 1, + pendingActionLastAttemptAt: new Date(), + } +} + +export function pendingDelete(target: PendingActionTargetValue): Partial { + return { + pendingAction: PendingAction.DELETE, + pendingActionTarget: target, + pendingActionAttempts: 1, + pendingActionLastAttemptAt: new Date(), + } +} + +export function tombstone(): Partial { + return { deletedAt: new Date() } +} + +// The Assembly side of a fully-synced row. Compose with fromDropboxEntry for the +// Dropbox side: fileSyncSeeder.create(fromDropboxEntry(entry, synced())). +export function synced(overrides: Partial = {}): Partial { + const n = nextSeq() + return { + assemblyFileId: seqUuid(n), + assemblyPath: `/root/file-${n}.txt`, + ...overrides, + } +} + +// Derive DB fields from a Dropbox entry factory result so a seeded row and the +// remote fixture it represents cannot disagree on id / path / hash. +export function fromDropboxEntry( + entry: DropboxFileListFolderSingleEntry, + overrides: Partial = {}, +): Partial { + return { + itemPath: entry.path_display, + dbxFileId: entry.id, + contentHash: entry.content_hash ?? null, + object: entry['.tag'] === 'folder' ? ObjectType.FOLDER : ObjectType.FILE, + ...overrides, + } +} diff --git a/test/seeders/index.ts b/test/seeders/index.ts new file mode 100644 index 0000000..a29dcea --- /dev/null +++ b/test/seeders/index.ts @@ -0,0 +1,3 @@ +export * from './channel' +export * from './connection' +export * from './fileSync' diff --git a/test/seeders/seeders.integration.test.ts b/test/seeders/seeders.integration.test.ts new file mode 100644 index 0000000..9e7981d --- /dev/null +++ b/test/seeders/seeders.integration.test.ts @@ -0,0 +1,170 @@ +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { ObjectType, PendingAction, PendingActionTarget } from '@/db/constants' +import { channelSync } from '@/db/schema/channelSync.schema' +import type { DropboxConnectionTokens } from '@/db/schema/dropboxConnections.schema' +import { dropboxConnections } from '@/db/schema/dropboxConnections.schema' +import { MapFilesService } from '@/features/sync/lib/MapFiles.service' +import type User from '@/lib/copilot/models/User.model' +import { dropboxEntryFactory } from '../factories' +import { + channelSeeder, + dropboxConnectionSeeder, + fileSyncSeeder, + fromDropboxEntry, + pendingCreate, + pendingDelete, + synced, + tombstone, +} from './index' + +describe('dropboxConnectionSeeder', () => { + it('persists a connection row with sane defaults', async () => { + const conn = await dropboxConnectionSeeder.create() + const [found] = await db + .select() + .from(dropboxConnections) + .where(eq(dropboxConnections.id, conn.id)) + expect(found.portalId).toBe(conn.portalId) + expect(found.status).toBe(true) + expect(found.initiatedBy).toBe(conn.initiatedBy) + }) + + it('applies overrides', async () => { + const conn = await dropboxConnectionSeeder.create({ portalId: 'portal-custom' }) + expect(conn.portalId).toBe('portal-custom') + }) +}) + +describe('channelSeeder', () => { + it('auto-creates a connection and shares its portal/account when none is given', async () => { + const channel = await channelSeeder.create() + const [conn] = await db + .select() + .from(dropboxConnections) + .where(eq(dropboxConnections.portalId, channel.portalId)) + expect(conn).toBeDefined() + expect(conn.accountId).toBe(channel.dbxAccountId) + }) + + it('does not create a connection when portalId + dbxAccountId are supplied', async () => { + const channel = await channelSeeder.create({ portalId: 'portal-x', dbxAccountId: 'acc-x' }) + const [row] = await db.select().from(channelSync).where(eq(channelSync.id, channel.id)) + expect(row.portalId).toBe('portal-x') + const conns = await db + .select() + .from(dropboxConnections) + .where(eq(dropboxConnections.portalId, 'portal-x')) + expect(conns).toHaveLength(0) + }) + + it('reuses an existing connection when only portalId is supplied', async () => { + const conn = await dropboxConnectionSeeder.create() + const channel = await channelSeeder.create({ portalId: conn.portalId }) + expect(channel.dbxAccountId).toBe(conn.accountId) + const conns = await db + .select() + .from(dropboxConnections) + .where(eq(dropboxConnections.portalId, conn.portalId)) + expect(conns).toHaveLength(1) + }) + + it('throws when dbxAccountId is supplied without portalId', async () => { + await expect(channelSeeder.create({ dbxAccountId: 'acc-orphan' })).rejects.toThrow(/portalId/) + }) +}) + +describe('fileSyncSeeder', () => { + it('auto-creates the full connection -> channel -> file chain when given no parent', async () => { + const row = await fileSyncSeeder.create() + expect(row.channelSyncId).toBeTypeOf('string') + const [channel] = await db + .select() + .from(channelSync) + .where(eq(channelSync.id, row.channelSyncId)) + expect(channel).toBeDefined() + expect(row.portalId).toBe(channel.portalId) + // GENERATED column is populated by the DB. + expect(row.itemPathLower).toBe(row.itemPath?.toLowerCase()) + }) + + it('reuses an explicit channelSyncId and inherits its portalId', async () => { + const channel = await channelSeeder.create() + const row = await fileSyncSeeder.create({ channelSyncId: channel.id }) + expect(row.channelSyncId).toBe(channel.id) + expect(row.portalId).toBe(channel.portalId) + }) + + it('pendingCreate trait satisfies the action/target CHECK constraint', async () => { + const row = await fileSyncSeeder.create(pendingCreate(PendingActionTarget.DROPBOX)) + expect(row.pendingAction).toBe(PendingAction.CREATE) + expect(row.pendingActionTarget).toBe(PendingActionTarget.DROPBOX) + expect(row.pendingActionAttempts).toBe(1) + }) + + it('pendingDelete trait satisfies the action/target CHECK constraint', async () => { + const row = await fileSyncSeeder.create(pendingDelete(PendingActionTarget.ASSEMBLY)) + expect(row.pendingAction).toBe(PendingAction.DELETE) + expect(row.pendingActionTarget).toBe(PendingActionTarget.ASSEMBLY) + expect(row.pendingActionAttempts).toBe(1) + }) + + it('tombstone frees the path for a new live insert (partial unique index)', async () => { + const channel = await channelSeeder.create() + await fileSyncSeeder.create({ + channelSyncId: channel.id, + itemPath: '/root/dup.txt', + ...tombstone(), + }) + // A second LIVE row at the same path must be allowed because the first is soft-deleted. + const live = await fileSyncSeeder.create({ + channelSyncId: channel.id, + itemPath: '/root/dup.txt', + }) + expect(live.deletedAt).toBeNull() + }) + + it('fromDropboxEntry keeps the row consistent with the remote fixture', async () => { + const entry = dropboxEntryFactory.build() + const row = await fileSyncSeeder.create(fromDropboxEntry(entry)) + expect(row.itemPath).toBe(entry.path_display) + expect(row.dbxFileId).toBe(entry.id) + expect(row.contentHash).toBe(entry.content_hash) + expect(row.object).toBe(ObjectType.FILE) + }) + + it('throws a clear error when given a nonexistent channelSyncId', async () => { + await expect( + fileSyncSeeder.create({ channelSyncId: '00000000-0000-4000-8000-0000000000ff' }), + ).rejects.toThrow(/channelSync .* not found/) + }) +}) + +describe('seeded scenario round-trips through the real read path', () => { + it('a synced row seeded from a Dropbox entry is found by getDbxMappedFileFromPath', async () => { + const channel = await channelSeeder.create() + const entry = dropboxEntryFactory.build() + await fileSyncSeeder.create( + fromDropboxEntry(entry, { + channelSyncId: channel.id, + itemPath: '/nested.txt', + ...synced({ assemblyPath: '/nested.txt' }), + }), + ) + + // Constructed like the existing unit test (MapFiles.tombstone.test.ts): the + // real constructor is (user: User, connectionToken: DropboxConnectionTokens), + // not `new MapFilesService(portalId)`. + const user = { portalId: channel.portalId, token: 'test-token' } as unknown as User + const connectionToken = { + refreshToken: 'rt', + accountId: 'acc', + rootNamespaceId: null, + } as DropboxConnectionTokens + const mapFiles = new MapFilesService(user, connectionToken) + + const found = await mapFiles.getDbxMappedFileFromPath('/nested.txt', channel.id) + expect(found?.dbxFileId).toBe(entry.id) + }) +}) From e84559a7761b76f4449b9d3035bc46c1a8a36376 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 14:34:20 +0545 Subject: [PATCH 4/9] test(OUT-3987): reject mismatched portal/channel in fileSyncSeeder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both portalId and channelSyncId were supplied, the seeder inserted them without verifying the channel belongs to that portal — the FK only proves the channel exists. That let a test seed a file under portal-A pointing at a portal-B channel: an impossible tenant/channel pairing that production reads and the (portalId, channelSyncId, ...) unique indexes could never produce. Now the channel-provided path always derives portalId from the channel row (its portalId is the ownership source of truth) and, when the caller also passes portalId, throws on a mismatch instead of seeding the drift. Adds a test covering the rejection. Co-Authored-By: Claude Opus 4.8 --- test/seeders/fileSync.ts | 11 ++++++++++- test/seeders/seeders.integration.test.ts | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/test/seeders/fileSync.ts b/test/seeders/fileSync.ts index 8ef1a76..3a08341 100644 --- a/test/seeders/fileSync.ts +++ b/test/seeders/fileSync.ts @@ -28,12 +28,21 @@ export const fileSyncSeeder = Factory.define, Fi const channel = await channelSeeder.create(portalId ? { portalId } : {}) channelSyncId = channel.id portalId = portalId ?? channel.portalId - } else if (!portalId) { + } else { + // The channel row's portalId is the source of truth for tenant ownership; + // the FK only proves the channel exists. Derive portalId from it, and if + // the caller also supplied one, reject a mismatch rather than seeding an + // impossible tenant/channel pairing production could never produce. const [ch] = await db .select({ portalId: channelSync.portalId }) .from(channelSync) .where(eq(channelSync.id, channelSyncId)) if (!ch) throw new Error(`fileSyncSeeder: channelSync ${channelSyncId} not found`) + if (portalId && portalId !== ch.portalId) { + throw new Error( + `fileSyncSeeder: portalId ${portalId} does not own channelSync ${channelSyncId} (owned by ${ch.portalId})`, + ) + } portalId = ch.portalId } const [row] = await db diff --git a/test/seeders/seeders.integration.test.ts b/test/seeders/seeders.integration.test.ts index 9e7981d..cc5658d 100644 --- a/test/seeders/seeders.integration.test.ts +++ b/test/seeders/seeders.integration.test.ts @@ -139,6 +139,13 @@ describe('fileSyncSeeder', () => { fileSyncSeeder.create({ channelSyncId: '00000000-0000-4000-8000-0000000000ff' }), ).rejects.toThrow(/channelSync .* not found/) }) + + it('rejects a portalId that does not own the supplied channelSyncId', async () => { + const channel = await channelSeeder.create() + await expect( + fileSyncSeeder.create({ channelSyncId: channel.id, portalId: 'portal-foreign' }), + ).rejects.toThrow(/does not own/) + }) }) describe('seeded scenario round-trips through the real read path', () => { From 7be62bdefb633d2e332ce24e7a24b88ebc8438b3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 14:41:43 +0545 Subject: [PATCH 5/9] test(OUT-3987): stop channelSeeder inventing a synthetic dbxAccountId The portalId-only path fell back to acc-${n} when the existing connection had a null accountId (the legitimate pre-OAuth state). That synthetic account matches neither the connection nor the real Dropbox account OAuth later records, so webhook/update paths filtered by dbxAccountId would miss the seeded channel. Now the channel always adopts the connection's own accountId; if the existing connection has none yet, it throws (set the connection's accountId or pass dbxAccountId explicitly) instead of forging a mismatched link. Also drops the dead acc-${n} fallback on the freshly-created-connection branch (its accountId is always defaulted). Adds a test for the rejection. Co-Authored-By: Claude Opus 4.8 --- test/seeders/channel.ts | 21 ++++++++++++++++----- test/seeders/seeders.integration.test.ts | 5 +++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/test/seeders/channel.ts b/test/seeders/channel.ts index dd71fa2..e4ecb8c 100644 --- a/test/seeders/channel.ts +++ b/test/seeders/channel.ts @@ -31,17 +31,28 @@ export const channelSeeder = Factory.define, portalId = conn.portalId dbxAccountId = conn.accountId ?? undefined } else if (!dbxAccountId) { - // portalId is globally unique, so find-or-create the connection for it. + // portalId is globally unique, so find-or-create the connection for it, + // then adopt ITS accountId — never invent one. A fabricated account + // would match neither the connection nor the real Dropbox account OAuth + // later records, so webhook/update paths filtered by dbxAccountId would + // miss this channel. If the existing connection has no accountId yet + // (pre-OAuth), fail loudly instead of seeding that drift. const [existing] = await db .select({ accountId: dropboxConnections.accountId }) .from(dropboxConnections) .where(eq(dropboxConnections.portalId, portalId)) - dbxAccountId = existing - ? (existing.accountId ?? `acc-${n}`) - : ((await dropboxConnectionSeeder.create({ portalId })).accountId ?? `acc-${n}`) + const conn = existing ?? (await dropboxConnectionSeeder.create({ portalId })) + if (!conn.accountId) { + throw new Error( + `channelSeeder: connection for portal ${portalId} has no accountId; set the connection's accountId or pass dbxAccountId explicitly`, + ) + } + dbxAccountId = conn.accountId } if (!dbxAccountId) { - // Unreachable: dropboxConnectionSeeder always defaults accountId. + // Reachable only if the neither-branch minted a connection whose + // accountId was null; dropboxConnectionSeeder defaults it, so in + // practice this never fires. throw new Error('channelSeeder: could not resolve dbxAccountId') } const [row] = await db diff --git a/test/seeders/seeders.integration.test.ts b/test/seeders/seeders.integration.test.ts index cc5658d..355c3be 100644 --- a/test/seeders/seeders.integration.test.ts +++ b/test/seeders/seeders.integration.test.ts @@ -73,6 +73,11 @@ describe('channelSeeder', () => { it('throws when dbxAccountId is supplied without portalId', async () => { await expect(channelSeeder.create({ dbxAccountId: 'acc-orphan' })).rejects.toThrow(/portalId/) }) + + it('rejects when the existing connection for the portal has no accountId', async () => { + const conn = await dropboxConnectionSeeder.create({ accountId: null }) + await expect(channelSeeder.create({ portalId: conn.portalId })).rejects.toThrow(/no accountId/) + }) }) describe('fileSyncSeeder', () => { From 90bc2c985993317471d9802a269a2482f492412f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 14:46:38 +0545 Subject: [PATCH 6/9] test(OUT-3987): reject deleted Dropbox entries in fromDropboxEntry fromDropboxEntry mapped a .tag==='deleted' entry to ObjectType.FILE with a live path and null content hash, seeding a live DB row for an already-deleted remote item. Deleted entries are handled by the deletion/delta flow, so this could hide deletion or resync bugs in a scenario. Now it throws on a deleted entry, pointing the author at tombstone() or the deletion flow to model a gone item. Adds a test for the rejection. Co-Authored-By: Claude Opus 4.8 --- test/seeders/fileSync.ts | 8 ++++++++ test/seeders/seeders.integration.test.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/test/seeders/fileSync.ts b/test/seeders/fileSync.ts index 3a08341..2d5321b 100644 --- a/test/seeders/fileSync.ts +++ b/test/seeders/fileSync.ts @@ -100,6 +100,14 @@ export function fromDropboxEntry( entry: DropboxFileListFolderSingleEntry, overrides: Partial = {}, ): Partial { + // A deleted entry is a gone remote item handled by the deletion/delta flow, not + // a live file. Deriving a live row from it would seed an incoherent fixture that + // could mask deletion/resync bugs — model a gone item with tombstone() instead. + if (entry['.tag'] === 'deleted') { + throw new Error( + 'fromDropboxEntry: cannot derive a live row from a deleted Dropbox entry; use tombstone() or drive the deletion flow', + ) + } return { itemPath: entry.path_display, dbxFileId: entry.id, diff --git a/test/seeders/seeders.integration.test.ts b/test/seeders/seeders.integration.test.ts index 355c3be..6d59ff7 100644 --- a/test/seeders/seeders.integration.test.ts +++ b/test/seeders/seeders.integration.test.ts @@ -7,7 +7,7 @@ import type { DropboxConnectionTokens } from '@/db/schema/dropboxConnections.sch import { dropboxConnections } from '@/db/schema/dropboxConnections.schema' import { MapFilesService } from '@/features/sync/lib/MapFiles.service' import type User from '@/lib/copilot/models/User.model' -import { dropboxEntryFactory } from '../factories' +import { dropboxDeletedFactory, dropboxEntryFactory } from '../factories' import { channelSeeder, dropboxConnectionSeeder, @@ -151,6 +151,10 @@ describe('fileSyncSeeder', () => { fileSyncSeeder.create({ channelSyncId: channel.id, portalId: 'portal-foreign' }), ).rejects.toThrow(/does not own/) }) + + it('fromDropboxEntry refuses to derive a live row from a deleted entry', () => { + expect(() => fromDropboxEntry(dropboxDeletedFactory.build())).toThrow(/deleted/) + }) }) describe('seeded scenario round-trips through the real read path', () => { From 78e03071517545e110139451efc8097e28bd9c7d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 16:51:44 +0545 Subject: [PATCH 7/9] test(OUT-3987): reject explicit account drift in channelSeeder When both portalId and dbxAccountId were supplied, the channel was inserted without checking the portal's existing connection. A test could create a connection for portal-x (accountId acc-real) then seed channelSeeder.create({ portalId: 'portal-x', dbxAccountId: 'acc-other' }), producing a channel detached from the real Dropbox account so account-filtered webhook/update reads would miss it. Now, when a connection exists for the portal, the explicit dbxAccountId is validated against it and a mismatch throws. With no connection for the portal the channel is still inserted as-is (minting nothing), preserving existing behavior. Adds a test for the rejection. Co-Authored-By: Claude Opus 4.8 --- test/seeders/channel.ts | 15 +++++++++++++++ test/seeders/seeders.integration.test.ts | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/test/seeders/channel.ts b/test/seeders/channel.ts index e4ecb8c..eca76af 100644 --- a/test/seeders/channel.ts +++ b/test/seeders/channel.ts @@ -48,6 +48,21 @@ export const channelSeeder = Factory.define, ) } dbxAccountId = conn.accountId + } else { + // Both portalId and dbxAccountId supplied: if a connection already exists + // for this portal, the channel must use that connection's account. A + // different explicit account detaches the channel from the real Dropbox + // account, so account-filtered webhook/update reads would miss it. (No + // connection for the portal → insert as-is, minting nothing.) + const [existing] = await db + .select({ accountId: dropboxConnections.accountId }) + .from(dropboxConnections) + .where(eq(dropboxConnections.portalId, portalId)) + if (existing?.accountId && existing.accountId !== dbxAccountId) { + throw new Error( + `channelSeeder: dbxAccountId ${dbxAccountId} does not match the connection for portal ${portalId} (account ${existing.accountId})`, + ) + } } if (!dbxAccountId) { // Reachable only if the neither-branch minted a connection whose diff --git a/test/seeders/seeders.integration.test.ts b/test/seeders/seeders.integration.test.ts index 6d59ff7..845a87c 100644 --- a/test/seeders/seeders.integration.test.ts +++ b/test/seeders/seeders.integration.test.ts @@ -78,6 +78,13 @@ describe('channelSeeder', () => { const conn = await dropboxConnectionSeeder.create({ accountId: null }) await expect(channelSeeder.create({ portalId: conn.portalId })).rejects.toThrow(/no accountId/) }) + + it('rejects an explicit dbxAccountId that conflicts with the portal connection', async () => { + const conn = await dropboxConnectionSeeder.create() + await expect( + channelSeeder.create({ portalId: conn.portalId, dbxAccountId: 'acc-other' }), + ).rejects.toThrow(/does not match/) + }) }) describe('fileSyncSeeder', () => { From dc4ab95da083f0d8d4608ba596090f281112cb56 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 17:44:24 +0545 Subject: [PATCH 8/9] test(OUT-3987): reject explicit account against pre-OAuth connection channelSeeder's both-supplied check only caught a non-null account mismatch, so a null-account (pre-OAuth) connection let a caller pin a dbxAccountId that may not match the account OAuth later records. Now an existing connection's account must be set and equal the explicit one, else it throws. Also trims verbose comments in the seeders. Co-Authored-By: Claude Opus 4.8 --- test/seeders/channel.ts | 25 +++++++++--------------- test/seeders/fileSync.ts | 10 +++------- test/seeders/seeders.integration.test.ts | 7 +++++++ 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/test/seeders/channel.ts b/test/seeders/channel.ts index eca76af..dab10fe 100644 --- a/test/seeders/channel.ts +++ b/test/seeders/channel.ts @@ -31,12 +31,8 @@ export const channelSeeder = Factory.define, portalId = conn.portalId dbxAccountId = conn.accountId ?? undefined } else if (!dbxAccountId) { - // portalId is globally unique, so find-or-create the connection for it, - // then adopt ITS accountId — never invent one. A fabricated account - // would match neither the connection nor the real Dropbox account OAuth - // later records, so webhook/update paths filtered by dbxAccountId would - // miss this channel. If the existing connection has no accountId yet - // (pre-OAuth), fail loudly instead of seeding that drift. + // Find-or-create the connection for this portal and adopt its accountId + // (never invent one); throw if it has none yet (pre-OAuth). const [existing] = await db .select({ accountId: dropboxConnections.accountId }) .from(dropboxConnections) @@ -49,25 +45,22 @@ export const channelSeeder = Factory.define, } dbxAccountId = conn.accountId } else { - // Both portalId and dbxAccountId supplied: if a connection already exists - // for this portal, the channel must use that connection's account. A - // different explicit account detaches the channel from the real Dropbox - // account, so account-filtered webhook/update reads would miss it. (No - // connection for the portal → insert as-is, minting nothing.) + // Both supplied: any existing connection's account must be set and equal + // the explicit one, else the channel detaches from the real account. const [existing] = await db .select({ accountId: dropboxConnections.accountId }) .from(dropboxConnections) .where(eq(dropboxConnections.portalId, portalId)) - if (existing?.accountId && existing.accountId !== dbxAccountId) { + if (existing && existing.accountId !== dbxAccountId) { throw new Error( - `channelSeeder: dbxAccountId ${dbxAccountId} does not match the connection for portal ${portalId} (account ${existing.accountId})`, + existing.accountId + ? `channelSeeder: dbxAccountId ${dbxAccountId} does not match the connection for portal ${portalId} (account ${existing.accountId})` + : `channelSeeder: connection for portal ${portalId} has no accountId yet; set it before seeding a channel with an explicit dbxAccountId`, ) } } + // Defensive: dropboxConnectionSeeder always defaults accountId, so unreached. if (!dbxAccountId) { - // Reachable only if the neither-branch minted a connection whose - // accountId was null; dropboxConnectionSeeder defaults it, so in - // practice this never fires. throw new Error('channelSeeder: could not resolve dbxAccountId') } const [row] = await db diff --git a/test/seeders/fileSync.ts b/test/seeders/fileSync.ts index 2d5321b..66bc868 100644 --- a/test/seeders/fileSync.ts +++ b/test/seeders/fileSync.ts @@ -29,10 +29,8 @@ export const fileSyncSeeder = Factory.define, Fi channelSyncId = channel.id portalId = portalId ?? channel.portalId } else { - // The channel row's portalId is the source of truth for tenant ownership; - // the FK only proves the channel exists. Derive portalId from it, and if - // the caller also supplied one, reject a mismatch rather than seeding an - // impossible tenant/channel pairing production could never produce. + // The channel's portalId owns the row; derive it, and reject a caller + // portalId that disagrees rather than seeding an impossible pairing. const [ch] = await db .select({ portalId: channelSync.portalId }) .from(channelSync) @@ -100,9 +98,7 @@ export function fromDropboxEntry( entry: DropboxFileListFolderSingleEntry, overrides: Partial = {}, ): Partial { - // A deleted entry is a gone remote item handled by the deletion/delta flow, not - // a live file. Deriving a live row from it would seed an incoherent fixture that - // could mask deletion/resync bugs — model a gone item with tombstone() instead. + // A deleted entry is a gone item (deletion flow's job), not a live row. if (entry['.tag'] === 'deleted') { throw new Error( 'fromDropboxEntry: cannot derive a live row from a deleted Dropbox entry; use tombstone() or drive the deletion flow', diff --git a/test/seeders/seeders.integration.test.ts b/test/seeders/seeders.integration.test.ts index 845a87c..4c16e11 100644 --- a/test/seeders/seeders.integration.test.ts +++ b/test/seeders/seeders.integration.test.ts @@ -85,6 +85,13 @@ describe('channelSeeder', () => { channelSeeder.create({ portalId: conn.portalId, dbxAccountId: 'acc-other' }), ).rejects.toThrow(/does not match/) }) + + it('rejects an explicit dbxAccountId while the portal connection has no account yet', async () => { + const conn = await dropboxConnectionSeeder.create({ accountId: null }) + await expect( + channelSeeder.create({ portalId: conn.portalId, dbxAccountId: 'acc-other' }), + ).rejects.toThrow(/no accountId yet/) + }) }) describe('fileSyncSeeder', () => { From 5f87ffe40088564e4770018327d9337eb031bb4c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Jul 2026 17:50:30 +0545 Subject: [PATCH 9/9] refactor(OUT-3987): centralize channel account invariant Replace channelSeeder's four inline reconciliation branches with a single resolveChannelAccount(portalId, dbxAccountId?) helper enforcing one rule: a channel's dbxAccountId must equal its portal connection's account, and that account must exist. Behavior unchanged (all channelSeeder tests pass); the account/ownership edge cases are now proven exhaustive in one place. Co-Authored-By: Claude Opus 4.8 --- test/seeders/channel.ts | 99 ++++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/test/seeders/channel.ts b/test/seeders/channel.ts index dab10fe..c1ef6d1 100644 --- a/test/seeders/channel.ts +++ b/test/seeders/channel.ts @@ -8,61 +8,70 @@ import { dropboxConnectionSeeder } from './connection' type ChannelRow = InferSelectModel -// portalId/dbxAccountId are optional at the seed layer: when omitted we mint a -// connection and inherit its identity, so the channel and connection agree. +// portalId/dbxAccountId optional: omit them to mint/adopt a connection so the +// channel and its connection agree on account. type ChannelSeed = Omit & { portalId?: string dbxAccountId?: string } +// Single source of the invariant: a channel's dbxAccountId must equal its portal +// connection's account, and that account must exist. Resolves the (portalId, +// dbxAccountId) pair or throws — never seeds a channel detached from its account. +async function resolveChannelAccount( + portalId: string | undefined, + dbxAccountId: string | undefined, +): Promise<{ portalId: string; dbxAccountId: string }> { + // A dbxAccountId alone can't resolve which connection/portal it belongs to. + if (dbxAccountId && !portalId) { + throw new Error( + 'channelSeeder: supply portalId when specifying dbxAccountId (a dbxAccountId alone cannot resolve a connection)', + ) + } + + // Neither given: mint a fresh connection and adopt its identity. + if (!portalId) { + const conn = await dropboxConnectionSeeder.create() + if (!conn.accountId) throw new Error('channelSeeder: minted connection has no accountId') + return { portalId: conn.portalId, dbxAccountId: conn.accountId } + } + + // portalId known: its connection (portalId is globally unique) is the account + // source of truth. + const [existing] = await db + .select({ accountId: dropboxConnections.accountId }) + .from(dropboxConnections) + .where(eq(dropboxConnections.portalId, portalId)) + + // portalId alone: adopt the connection's account (find-or-create); throw if none. + if (!dbxAccountId) { + const conn = existing ?? (await dropboxConnectionSeeder.create({ portalId })) + if (!conn.accountId) { + throw new Error( + `channelSeeder: connection for portal ${portalId} has no accountId; set the connection's accountId or pass dbxAccountId explicitly`, + ) + } + return { portalId, dbxAccountId: conn.accountId } + } + + // Both given: any existing connection's account must be set and equal. No + // connection for the portal → accept the explicit pair as-is (mint nothing). + if (existing && existing.accountId !== dbxAccountId) { + throw new Error( + existing.accountId + ? `channelSeeder: dbxAccountId ${dbxAccountId} does not match the connection for portal ${portalId} (account ${existing.accountId})` + : `channelSeeder: connection for portal ${portalId} has no accountId yet; set it before seeding a channel with an explicit dbxAccountId`, + ) + } + return { portalId, dbxAccountId } +} + export const channelSeeder = Factory.define, ChannelRow>( ({ onCreate }) => { const n = nextSeq() onCreate(async (values) => { const { portalId: pid, dbxAccountId: aid, ...rest } = values - let portalId = pid - let dbxAccountId = aid - if (dbxAccountId && !portalId) { - throw new Error( - 'channelSeeder: supply portalId when specifying dbxAccountId (a dbxAccountId alone cannot resolve a connection)', - ) - } else if (!portalId) { - const conn = await dropboxConnectionSeeder.create() - portalId = conn.portalId - dbxAccountId = conn.accountId ?? undefined - } else if (!dbxAccountId) { - // Find-or-create the connection for this portal and adopt its accountId - // (never invent one); throw if it has none yet (pre-OAuth). - const [existing] = await db - .select({ accountId: dropboxConnections.accountId }) - .from(dropboxConnections) - .where(eq(dropboxConnections.portalId, portalId)) - const conn = existing ?? (await dropboxConnectionSeeder.create({ portalId })) - if (!conn.accountId) { - throw new Error( - `channelSeeder: connection for portal ${portalId} has no accountId; set the connection's accountId or pass dbxAccountId explicitly`, - ) - } - dbxAccountId = conn.accountId - } else { - // Both supplied: any existing connection's account must be set and equal - // the explicit one, else the channel detaches from the real account. - const [existing] = await db - .select({ accountId: dropboxConnections.accountId }) - .from(dropboxConnections) - .where(eq(dropboxConnections.portalId, portalId)) - if (existing && existing.accountId !== dbxAccountId) { - throw new Error( - existing.accountId - ? `channelSeeder: dbxAccountId ${dbxAccountId} does not match the connection for portal ${portalId} (account ${existing.accountId})` - : `channelSeeder: connection for portal ${portalId} has no accountId yet; set it before seeding a channel with an explicit dbxAccountId`, - ) - } - } - // Defensive: dropboxConnectionSeeder always defaults accountId, so unreached. - if (!dbxAccountId) { - throw new Error('channelSeeder: could not resolve dbxAccountId') - } + const { portalId, dbxAccountId } = await resolveChannelAccount(pid, aid) const [row] = await db .insert(channelSync) .values({ ...rest, portalId, dbxAccountId })