mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
544 lines
20 KiB
TypeScript
544 lines
20 KiB
TypeScript
/**
|
|
* Integration tests for name-conflict resolution across all matter service
|
|
* entry points: createMatter, updateMatter, confirmUpload, copyMatter,
|
|
* restoreMatter.
|
|
*/
|
|
import { sql } from 'drizzle-orm'
|
|
import { nanoid } from 'nanoid'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { ObjectStatus } from '../../shared/constants'
|
|
import { createTestApp } from '../test/setup.js'
|
|
import { confirmUpload, copyMatter, createMatter, restoreMatter, updateMatter } from './matter.js'
|
|
import { NameConflictError } from './matter-name-conflict.js'
|
|
|
|
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
|
|
|
const STORAGE_ID = 'st-conflict'
|
|
|
|
async function insertStorage(db: TestDb) {
|
|
const now = Date.now()
|
|
await db.run(sql`
|
|
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
|
VALUES (${STORAGE_ID}, 'Test', 'private', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
|
`)
|
|
}
|
|
|
|
async function makeFolder(db: TestDb, orgId: string, name: string, parent = '', id = nanoid()) {
|
|
const now = Date.now()
|
|
await db.run(sql`
|
|
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
|
VALUES (${id}, ${orgId}, ${`${id}-alias`}, ${name}, 'folder', 0, 1, ${parent}, '', ${STORAGE_ID}, 'active', ${now}, ${now})
|
|
`)
|
|
return id
|
|
}
|
|
|
|
async function makeFile(
|
|
db: TestDb,
|
|
orgId: string,
|
|
name: string,
|
|
opts: { parent?: string; status?: string; id?: string } = {},
|
|
) {
|
|
const id = opts.id ?? nanoid()
|
|
const now = Date.now()
|
|
const status = opts.status ?? ObjectStatus.ACTIVE
|
|
await db.run(sql`
|
|
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
|
VALUES (${id}, ${orgId}, ${`${id}-alias`}, ${name}, 'text/plain', 100, 0, ${opts.parent ?? ''}, 'key.txt', ${STORAGE_ID}, ${status}, ${now}, ${now})
|
|
`)
|
|
return id
|
|
}
|
|
|
|
// ─── createMatter ─────────────────────────────────────────────────────────────
|
|
|
|
describe('createMatter — name conflict', () => {
|
|
it('throws NameConflictError when creating a folder with a duplicate name (default fail)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
await makeFolder(db, orgId, 'Docs')
|
|
|
|
await expect(
|
|
createMatter(db, {
|
|
orgId,
|
|
name: 'Docs',
|
|
type: 'folder',
|
|
dirtype: 1,
|
|
object: '',
|
|
storageId: STORAGE_ID,
|
|
status: 'active',
|
|
}),
|
|
).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('auto-renames folder with onConflict: rename', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
await makeFolder(db, orgId, 'Docs')
|
|
|
|
const matter = await createMatter(db, {
|
|
orgId,
|
|
name: 'Docs',
|
|
type: 'folder',
|
|
dirtype: 1,
|
|
object: '',
|
|
storageId: STORAGE_ID,
|
|
status: 'active',
|
|
onConflict: 'rename',
|
|
})
|
|
|
|
expect(matter.name).toBe('Docs (1)')
|
|
})
|
|
|
|
it('throws NameConflictError when onConflict: replace and incoming is a folder', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
await makeFolder(db, orgId, 'Assets')
|
|
|
|
await expect(
|
|
createMatter(db, {
|
|
orgId,
|
|
name: 'Assets',
|
|
type: 'folder',
|
|
dirtype: 1,
|
|
object: '',
|
|
storageId: STORAGE_ID,
|
|
status: 'active',
|
|
onConflict: 'replace',
|
|
}),
|
|
).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('throws NameConflictError for duplicate active file name (default fail)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
await makeFile(db, orgId, 'report.pdf')
|
|
|
|
await expect(
|
|
createMatter(db, {
|
|
orgId,
|
|
name: 'report.pdf',
|
|
type: 'application/pdf',
|
|
object: 'some/key.pdf',
|
|
storageId: STORAGE_ID,
|
|
status: 'draft',
|
|
}),
|
|
).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('renames duplicate file with onConflict: rename', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
await makeFile(db, orgId, 'photo.jpg')
|
|
|
|
const matter = await createMatter(db, {
|
|
orgId,
|
|
name: 'photo.jpg',
|
|
type: 'image/jpeg',
|
|
object: 'some/key.jpg',
|
|
storageId: STORAGE_ID,
|
|
status: 'draft',
|
|
onConflict: 'rename',
|
|
})
|
|
|
|
expect(matter.name).toBe('photo (1).jpg')
|
|
})
|
|
|
|
it('replaces existing file with onConflict: replace (existing becomes trashed)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const existingId = await makeFile(db, orgId, 'data.csv')
|
|
|
|
const matter = await createMatter(db, {
|
|
orgId,
|
|
name: 'data.csv',
|
|
type: 'text/csv',
|
|
object: 'some/data.csv',
|
|
storageId: STORAGE_ID,
|
|
status: 'draft',
|
|
onConflict: 'replace',
|
|
})
|
|
|
|
expect(matter.name).toBe('data.csv')
|
|
const rows = await db.all<{ status: string }>(sql`SELECT status FROM matters WHERE id = ${existingId}`)
|
|
expect(rows[0].status).toBe(ObjectStatus.TRASHED)
|
|
})
|
|
})
|
|
|
|
// ─── confirmUpload ─────────────────────────────────────────────────────────────
|
|
|
|
describe('confirmUpload — name conflict', () => {
|
|
it('throws NameConflictError when an active sibling was created during upload (default fail)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
// Simulate a draft created before another user created the same active name
|
|
const draftId = await makeFile(db, orgId, 'collision.txt', { status: 'draft' })
|
|
await makeFile(db, orgId, 'collision.txt') // active sibling added during upload window
|
|
|
|
await expect(confirmUpload(db, draftId, orgId, { onConflict: 'fail' })).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('renames the draft file when an active sibling was created during upload (rename)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const draftId = await makeFile(db, orgId, 'collision.txt', { status: 'draft' })
|
|
await makeFile(db, orgId, 'collision.txt') // active sibling
|
|
|
|
const { matter } = await confirmUpload(db, draftId, orgId, { onConflict: 'rename' })
|
|
expect(matter).not.toBeNull()
|
|
expect(matter?.name).toBe('collision (1).txt')
|
|
expect(matter?.status).toBe('active')
|
|
})
|
|
})
|
|
|
|
// ─── updateMatter (rename / move) ─────────────────────────────────────────────
|
|
|
|
describe('updateMatter — name conflict on rename', () => {
|
|
it('throws NameConflictError when renaming to an existing name (default fail)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'a.txt')
|
|
await makeFile(db, orgId, 'b.txt')
|
|
|
|
await expect(updateMatter(db, id, orgId, { name: 'b.txt' })).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('auto-renames when onConflict: rename during rename', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'a.txt')
|
|
await makeFile(db, orgId, 'b.txt')
|
|
|
|
const result = await updateMatter(db, id, orgId, { name: 'b.txt', onConflict: 'rename' })
|
|
expect(result?.name).toBe('b (1).txt')
|
|
})
|
|
|
|
it('replaces existing file when onConflict: replace during rename', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'a.txt')
|
|
const targetId = await makeFile(db, orgId, 'b.txt')
|
|
|
|
const result = await updateMatter(db, id, orgId, { name: 'b.txt', onConflict: 'replace' })
|
|
expect(result?.name).toBe('b.txt')
|
|
const rows = await db.all<{ status: string }>(sql`SELECT status FROM matters WHERE id = ${targetId}`)
|
|
expect(rows[0].status).toBe(ObjectStatus.TRASHED)
|
|
})
|
|
|
|
it('allows rename-in-place (A → A) without conflict even with fail strategy', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'same.txt')
|
|
|
|
const result = await updateMatter(db, id, orgId, { name: 'same.txt', onConflict: 'fail' })
|
|
expect(result?.name).toBe('same.txt')
|
|
})
|
|
})
|
|
|
|
describe('updateMatter — name conflict on move', () => {
|
|
it('throws NameConflictError when moving a file into a folder that already has same-named item', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'file.txt')
|
|
await makeFolder(db, orgId, 'Dest')
|
|
await makeFile(db, orgId, 'file.txt', { parent: 'Dest' })
|
|
|
|
await expect(updateMatter(db, id, orgId, { parent: 'Dest' })).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('auto-renames on move when onConflict: rename', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'file.txt')
|
|
await makeFile(db, orgId, 'file.txt', { parent: 'Dest' })
|
|
|
|
const result = await updateMatter(db, id, orgId, { parent: 'Dest', onConflict: 'rename' })
|
|
expect(result?.name).toBe('file (1).txt')
|
|
expect(result?.parent).toBe('Dest')
|
|
})
|
|
})
|
|
|
|
// ─── move ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('move — name conflict', () => {
|
|
it('throws NameConflictError when moving into a parent with a name collision (default fail)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'report.txt')
|
|
await makeFile(db, orgId, 'report.txt', { parent: 'Dest' })
|
|
|
|
await expect(updateMatter(db, id, orgId, { parent: 'Dest', onConflict: 'fail' })).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('renames when onConflict: rename during move', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'report.txt')
|
|
await makeFile(db, orgId, 'report.txt', { parent: 'Dest' })
|
|
|
|
const result = await updateMatter(db, id, orgId, { parent: 'Dest', onConflict: 'rename' })
|
|
expect(result?.name).toBe('report (1).txt')
|
|
expect(result?.parent).toBe('Dest')
|
|
})
|
|
|
|
it('does not apply conflict resolution for in-place moves (same parent)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'file.txt', { parent: 'SameFolder' })
|
|
|
|
// Moving to the same parent should not trigger conflict even if fail strategy
|
|
const result = await updateMatter(db, id, orgId, { parent: 'SameFolder', onConflict: 'fail' })
|
|
expect(result?.name).toBe('file.txt')
|
|
expect(result?.parent).toBe('SameFolder')
|
|
})
|
|
})
|
|
|
|
// ─── copyMatter ───────────────────────────────────────────────────────────────
|
|
|
|
describe('copyMatter — name conflict', () => {
|
|
it('defaults to rename when copying into a parent that already has the same name', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const source = await createMatter(db, {
|
|
orgId,
|
|
name: 'doc.txt',
|
|
type: 'text/plain',
|
|
object: 'orig/key',
|
|
storageId: STORAGE_ID,
|
|
status: 'active',
|
|
})
|
|
// Another file with same name in target parent
|
|
await makeFile(db, orgId, 'doc.txt', { parent: 'Target' })
|
|
|
|
const copy = await copyMatter(db, source, 'Target', 'copy/key')
|
|
expect(copy.name).toBe('doc (1).txt')
|
|
expect(copy.parent).toBe('Target')
|
|
})
|
|
|
|
it('returns original name when target parent has no conflict', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const source = await createMatter(db, {
|
|
orgId,
|
|
name: 'doc.txt',
|
|
type: 'text/plain',
|
|
object: 'orig/key',
|
|
storageId: STORAGE_ID,
|
|
status: 'active',
|
|
})
|
|
|
|
const copy = await copyMatter(db, source, 'EmptyFolder', 'copy/key')
|
|
expect(copy.name).toBe('doc.txt')
|
|
})
|
|
|
|
it('throws NameConflictError when onConflict: fail and target already has same name', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const source = await createMatter(db, {
|
|
orgId,
|
|
name: 'doc.txt',
|
|
type: 'text/plain',
|
|
object: 'orig/key',
|
|
storageId: STORAGE_ID,
|
|
status: 'active',
|
|
})
|
|
await makeFile(db, orgId, 'doc.txt', { parent: 'Target' })
|
|
|
|
await expect(copyMatter(db, source, 'Target', 'copy/key', { onConflict: 'fail' })).rejects.toThrow(
|
|
NameConflictError,
|
|
)
|
|
})
|
|
})
|
|
|
|
// ─── restoreMatter ────────────────────────────────────────────────────────────
|
|
|
|
describe('restoreMatter — name conflict', () => {
|
|
it('throws NameConflictError when restoring to a parent where same name is already active (default fail)', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const trashedId = await makeFile(db, orgId, 'note.txt', { status: 'trashed' })
|
|
await makeFile(db, orgId, 'note.txt') // active sibling created while original was in trash
|
|
|
|
await expect(restoreMatter(db, orgId, trashedId)).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('restores with an auto-renamed name when onConflict: rename', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const trashedId = await makeFile(db, orgId, 'note.txt', { status: 'trashed' })
|
|
await makeFile(db, orgId, 'note.txt') // active sibling
|
|
|
|
const result = await restoreMatter(db, orgId, trashedId, undefined, 'rename')
|
|
expect(result?.name).toBe('note (1).txt')
|
|
expect(result?.status).toBe(ObjectStatus.ACTIVE)
|
|
})
|
|
|
|
it('restores without conflict when original name is still free', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const trashedId = await makeFile(db, orgId, 'safe.txt', { status: 'trashed' })
|
|
|
|
const result = await restoreMatter(db, orgId, trashedId)
|
|
expect(result?.name).toBe('safe.txt')
|
|
expect(result?.status).toBe(ObjectStatus.ACTIVE)
|
|
})
|
|
|
|
it('returns the item unchanged when it is not trashed', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
const id = await makeFile(db, orgId, 'active.txt')
|
|
|
|
const result = await restoreMatter(db, orgId, id)
|
|
expect(result?.status).toBe(ObjectStatus.ACTIVE)
|
|
expect(result?.name).toBe('active.txt')
|
|
})
|
|
|
|
it('returns null for missing id', async () => {
|
|
const { db } = await createTestApp()
|
|
const result = await restoreMatter(db, 'org-x', 'nonexistent')
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ─── case-insensitive conflict across service calls ───────────────────────────
|
|
|
|
describe('case-insensitive name conflict across service layer', () => {
|
|
it('createMatter with onConflict: fail rejects incoming "README.MD" when "readme.md" is active', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
await makeFile(db, orgId, 'readme.md')
|
|
|
|
await expect(
|
|
createMatter(db, {
|
|
orgId,
|
|
name: 'README.MD',
|
|
type: 'text/markdown',
|
|
object: 'key.md',
|
|
storageId: STORAGE_ID,
|
|
status: 'active',
|
|
}),
|
|
).rejects.toThrow(NameConflictError)
|
|
})
|
|
|
|
it('updateMatter rename rejects "Report.PDF" when "report.pdf" is active in same folder', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
const id = await makeFile(db, orgId, 'other.txt')
|
|
await makeFile(db, orgId, 'report.pdf')
|
|
|
|
await expect(updateMatter(db, id, orgId, { name: 'Report.PDF' })).rejects.toThrow(NameConflictError)
|
|
})
|
|
})
|
|
|
|
// ─── confirmUpload quota-then-replace atomicity ───────────────────────────────
|
|
|
|
describe('confirmUpload — quota-then-replace atomicity', () => {
|
|
it('returns quotaExceeded and leaves incumbent active when quota is too tight', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
// Insert a tight quota that allows zero growth
|
|
const quotaId = nanoid()
|
|
await db.run(sql`
|
|
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
|
|
VALUES (${quotaId}, ${orgId}, 0, 100, 0, 0, '2026-05')
|
|
`)
|
|
await db.run(sql`
|
|
INSERT INTO org_quota_entitlements
|
|
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
|
|
VALUES
|
|
(${nanoid()}, ${orgId}, 'storage', 'plan', 'test', ${`test-storage-plan:${orgId}:${nanoid()}`}, 100, ${Date.now()}, NULL, 'active', '{"packageName":"Test Plan"}', ${Date.now()}, ${Date.now()})
|
|
`)
|
|
|
|
// Incumbent active file that 'replace' would trash
|
|
const incumbentId = await makeFile(db, orgId, 'report.txt')
|
|
|
|
// Draft file (size > 0 so quota check fires) with same name
|
|
const draftId = nanoid()
|
|
const now = Date.now()
|
|
await db.run(sql`
|
|
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
|
VALUES (${draftId}, ${orgId}, ${`${draftId}-alias`}, 'report.txt', 'text/plain', 50, 0, '', 'key.txt', ${STORAGE_ID}, 'draft', ${now}, ${now})
|
|
`)
|
|
|
|
const result = await confirmUpload(db, draftId, orgId, { onConflict: 'replace' })
|
|
|
|
expect(result.quotaExceeded).toBe(true)
|
|
const rows = await db.all<{ status: string }>(sql`SELECT status FROM matters WHERE id = ${incumbentId}`)
|
|
expect(rows[0].status).toBe(ObjectStatus.ACTIVE)
|
|
})
|
|
})
|
|
|
|
// ─── restoreMatter rename-before-activate ordering ───────────────────────────
|
|
|
|
describe('restoreMatter — rename-before-activate ordering', () => {
|
|
it('cascades updated parent path to descendants before activation', async () => {
|
|
const { db } = await createTestApp()
|
|
await insertStorage(db)
|
|
const orgId = nanoid()
|
|
|
|
// Trashed folder "A" with a trashed child
|
|
const folderId = nanoid()
|
|
const folderNow = Date.now()
|
|
await db.run(sql`
|
|
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
|
|
VALUES (${folderId}, ${orgId}, ${`${folderId}-alias`}, 'A', 'folder', 0, 1, '', '', ${STORAGE_ID}, 'trashed', ${folderNow}, ${folderNow}, ${folderNow})
|
|
`)
|
|
|
|
const childId = nanoid()
|
|
await db.run(sql`
|
|
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
|
|
VALUES (${childId}, ${orgId}, ${`${childId}-alias`}, 'child.txt', 'text/plain', 0, 0, 'A', 'child.txt', ${STORAGE_ID}, 'trashed', ${folderNow}, ${folderNow}, ${folderNow})
|
|
`)
|
|
|
|
// Active folder also named "A" — restore will rename the trashed one to "A (1)"
|
|
await makeFolder(db, orgId, 'A')
|
|
|
|
const result = await restoreMatter(db, orgId, folderId, undefined, 'rename')
|
|
|
|
expect(result?.name).toBe('A (1)')
|
|
expect(result?.status).toBe(ObjectStatus.ACTIVE)
|
|
|
|
// Descendant must have the updated parent path and be active
|
|
const childRows = await db.all<{ parent: string; status: string }>(
|
|
sql`SELECT parent, status FROM matters WHERE id = ${childId}`,
|
|
)
|
|
expect(childRows[0].parent).toBe('A (1)')
|
|
expect(childRows[0].status).toBe(ObjectStatus.ACTIVE)
|
|
})
|
|
})
|