Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions test/factories/copilot.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
36 changes: 36 additions & 0 deletions test/factories/copilot.ts
Original file line number Diff line number Diff line change
@@ -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<CopilotFileRetrieve>(({ 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 }
}
39 changes: 39 additions & 0 deletions test/factories/dropbox.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
27 changes: 27 additions & 0 deletions test/factories/dropbox.ts
Original file line number Diff line number Diff line change
@@ -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<DropboxFileListFolderSingleEntry>(
({ 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' })
12 changes: 12 additions & 0 deletions test/factories/index.ts
Original file line number Diff line number Diff line change
@@ -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()
}
24 changes: 24 additions & 0 deletions test/factories/sequence.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
17 changes: 17 additions & 0 deletions test/factories/sequence.ts
Original file line number Diff line number Diff line change
@@ -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')}`
}
5 changes: 5 additions & 0 deletions test/integration/setup.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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()
})
87 changes: 87 additions & 0 deletions test/seeders/channel.ts
Original file line number Diff line number Diff line change
@@ -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<typeof channelSync>

// portalId/dbxAccountId optional: omit them to mint/adopt a connection so the
// channel and its connection agree on account.
type ChannelSeed = Omit<ChannelSyncCreateType, 'portalId' | 'dbxAccountId'> & {
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<ChannelSeed, Record<string, never>, 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 })
Comment thread
greptile-apps[bot] marked this conversation as resolved.
.returning()
return row
})
return {
assemblyChannelId: `ch-${n}`,
dbxRootPath: '/root',
status: true,
}
},
)
30 changes: 30 additions & 0 deletions test/seeders/connection.ts
Original file line number Diff line number Diff line change
@@ -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<typeof dropboxConnections>

export const dropboxConnectionSeeder = Factory.define<
DropboxConnectionInsertPayload,
Record<string, never>,
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,
}
})
Loading
Loading