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 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/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..c1ef6d1 --- /dev/null +++ b/test/seeders/channel.ts @@ -0,0 +1,87 @@ +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 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 + const { portalId, dbxAccountId } = await resolveChannelAccount(pid, aid) + 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..66bc868 --- /dev/null +++ b/test/seeders/fileSync.ts @@ -0,0 +1,114 @@ +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 { + // 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) + .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 + .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 { + // 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', + ) + } + 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..4c16e11 --- /dev/null +++ b/test/seeders/seeders.integration.test.ts @@ -0,0 +1,200 @@ +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 { dropboxDeletedFactory, 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/) + }) + + 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/) + }) + + 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/) + }) + + 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', () => { + 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/) + }) + + 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/) + }) + + 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', () => { + 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) + }) +}) 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/**'],