|
| 1 | +/** Real PostgreSQL coverage for workspace deletion and knowledge base ownership. */ |
| 2 | +import { db } from '@sim/db' |
| 3 | +import { |
| 4 | + document, |
| 5 | + knowledgeBase, |
| 6 | + knowledgeConnector, |
| 7 | + organization, |
| 8 | + user, |
| 9 | + workspace, |
| 10 | +} from '@sim/db/schema' |
| 11 | +import { generateId } from '@sim/utils/id' |
| 12 | +import { eq, inArray } from 'drizzle-orm' |
| 13 | +import { afterEach, describe, expect, it, vi } from 'vitest' |
| 14 | + |
| 15 | +vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: null })) |
| 16 | +vi.mock('@/lib/mcp/service', () => ({ |
| 17 | + mcpService: { clearCache: vi.fn().mockResolvedValue(undefined) }, |
| 18 | +})) |
| 19 | +vi.mock('@/lib/workflows/lifecycle', () => ({ |
| 20 | + archiveWorkflowsForWorkspace: vi.fn().mockResolvedValue(0), |
| 21 | +})) |
| 22 | + |
| 23 | +import { |
| 24 | + createKnowledgeAclFixtureIds, |
| 25 | + seedKnowledgeAclFixture, |
| 26 | +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' |
| 27 | +import { |
| 28 | + createAuthorizedKnowledgeBase, |
| 29 | + restoreKnowledgeBase, |
| 30 | + updateKnowledgeBase, |
| 31 | +} from '@/lib/knowledge/service' |
| 32 | +import { archiveWorkspace } from '@/lib/workspaces/lifecycle' |
| 33 | + |
| 34 | +describe('workspace knowledge lifecycle in PostgreSQL', () => { |
| 35 | + const fixtures: ReturnType<typeof createKnowledgeAclFixtureIds>[] = [] |
| 36 | + |
| 37 | + async function seed() { |
| 38 | + const ids = createKnowledgeAclFixtureIds() |
| 39 | + fixtures.push(ids) |
| 40 | + await seedKnowledgeAclFixture(ids) |
| 41 | + const documentId = generateId() |
| 42 | + await db.insert(document).values({ |
| 43 | + id: documentId, |
| 44 | + knowledgeBaseId: ids.knowledgeBaseId, |
| 45 | + filename: 'fixture.txt', |
| 46 | + fileUrl: 'data:text/plain,fixture', |
| 47 | + fileSize: 7, |
| 48 | + mimeType: 'text/plain', |
| 49 | + processingStatus: 'completed', |
| 50 | + }) |
| 51 | + return { ...ids, documentId } |
| 52 | + } |
| 53 | + |
| 54 | + afterEach(async () => { |
| 55 | + for (const ids of fixtures.splice(0)) { |
| 56 | + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) |
| 57 | + await db.delete(organization).where(eq(organization.id, ids.organizationId)) |
| 58 | + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) |
| 59 | + } |
| 60 | + }) |
| 61 | + |
| 62 | + it.each([false, true])( |
| 63 | + 'deletes child KBs when workspace was already archived: %s', |
| 64 | + async (alreadyArchived) => { |
| 65 | + const ids = await seed() |
| 66 | + const other = await seed() |
| 67 | + const previousArchive = new Date('2025-01-01T00:00:00.000Z') |
| 68 | + if (alreadyArchived) { |
| 69 | + await db |
| 70 | + .update(workspace) |
| 71 | + .set({ archivedAt: previousArchive }) |
| 72 | + .where(eq(workspace.id, ids.workspaceId)) |
| 73 | + } |
| 74 | + |
| 75 | + expect( |
| 76 | + await archiveWorkspace(ids.workspaceId, { requestId: 'lifecycle-fixture' }) |
| 77 | + ).toMatchObject({ |
| 78 | + archived: !alreadyArchived, |
| 79 | + }) |
| 80 | + |
| 81 | + const [ws] = await db.select().from(workspace).where(eq(workspace.id, ids.workspaceId)) |
| 82 | + const [kb] = await db |
| 83 | + .select() |
| 84 | + .from(knowledgeBase) |
| 85 | + .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) |
| 86 | + const [doc] = await db.select().from(document).where(eq(document.id, ids.documentId)) |
| 87 | + const [connector] = await db |
| 88 | + .select() |
| 89 | + .from(knowledgeConnector) |
| 90 | + .where(eq(knowledgeConnector.id, ids.connectorId)) |
| 91 | + expect(ws.archivedAt).toBeInstanceOf(Date) |
| 92 | + expect(kb).toMatchObject({ workspaceId: ids.workspaceId, deletedAt: ws.archivedAt }) |
| 93 | + expect(doc).toMatchObject({ archivedAt: ws.archivedAt, deletedAt: null }) |
| 94 | + expect(connector).toMatchObject({ archivedAt: ws.archivedAt, status: 'paused' }) |
| 95 | + if (alreadyArchived) expect(ws.archivedAt).toEqual(previousArchive) |
| 96 | + |
| 97 | + await expect( |
| 98 | + restoreKnowledgeBase(ids.knowledgeBaseId, 'lifecycle-fixture') |
| 99 | + ).rejects.toMatchObject({ |
| 100 | + code: 'conflict', |
| 101 | + message: 'Cannot restore knowledge base into an archived workspace', |
| 102 | + }) |
| 103 | + await archiveWorkspace(ids.workspaceId, { requestId: 'lifecycle-retry' }) |
| 104 | + const [unchanged] = await db |
| 105 | + .select() |
| 106 | + .from(knowledgeBase) |
| 107 | + .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) |
| 108 | + expect(unchanged.deletedAt).toEqual(kb.deletedAt) |
| 109 | + const [otherKb] = await db |
| 110 | + .select() |
| 111 | + .from(knowledgeBase) |
| 112 | + .where(eq(knowledgeBase.id, other.knowledgeBaseId)) |
| 113 | + expect(otherKb.deletedAt).toBeNull() |
| 114 | + const [otherDoc] = await db.select().from(document).where(eq(document.id, other.documentId)) |
| 115 | + expect(otherDoc.archivedAt).toBeNull() |
| 116 | + } |
| 117 | + ) |
| 118 | + |
| 119 | + it('hard deletion cascades to KBs, documents, and connectors', async () => { |
| 120 | + const ids = await seed() |
| 121 | + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) |
| 122 | + |
| 123 | + expect( |
| 124 | + await db |
| 125 | + .select({ id: knowledgeBase.id }) |
| 126 | + .from(knowledgeBase) |
| 127 | + .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) |
| 128 | + ).toEqual([]) |
| 129 | + expect( |
| 130 | + await db.select({ id: document.id }).from(document).where(eq(document.id, ids.documentId)) |
| 131 | + ).toEqual([]) |
| 132 | + expect( |
| 133 | + await db |
| 134 | + .select({ id: knowledgeConnector.id }) |
| 135 | + .from(knowledgeConnector) |
| 136 | + .where(eq(knowledgeConnector.id, ids.connectorId)) |
| 137 | + ).toEqual([]) |
| 138 | + }) |
| 139 | + |
| 140 | + it('refuses owner detachment without changing the KB or its documents', async () => { |
| 141 | + const ids = await seed() |
| 142 | + await expect( |
| 143 | + /** @ts-expect-error Exercise a caller bypassing the HTTP contract. */ |
| 144 | + updateKnowledgeBase(ids.knowledgeBaseId, { workspaceId: null }, 'lifecycle-fixture', { |
| 145 | + actorUserId: ids.aliceId, |
| 146 | + }) |
| 147 | + ).rejects.toMatchObject({ code: 'validation' }) |
| 148 | + const [kb] = await db |
| 149 | + .select() |
| 150 | + .from(knowledgeBase) |
| 151 | + .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) |
| 152 | + expect(kb).toMatchObject({ workspaceId: ids.workspaceId, deletedAt: null }) |
| 153 | + const [doc] = await db.select().from(document).where(eq(document.id, ids.documentId)) |
| 154 | + expect(doc.archivedAt).toBeNull() |
| 155 | + }) |
| 156 | + |
| 157 | + it('requires an owner for creation while allowing organization-owned indexes', async () => { |
| 158 | + const ids = await seed() |
| 159 | + const data = { |
| 160 | + name: 'Organization search', |
| 161 | + userId: ids.aliceId, |
| 162 | + embeddingModel: 'text-embedding-3-small', |
| 163 | + embeddingDimension: 1536 as const, |
| 164 | + chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 20 }, |
| 165 | + isSearchIndex: true, |
| 166 | + } |
| 167 | + await expect(createAuthorizedKnowledgeBase(data, 'lifecycle-fixture')).rejects.toThrow( |
| 168 | + 'Resource requires exactly one workspace or organization owner' |
| 169 | + ) |
| 170 | + const kb = await createAuthorizedKnowledgeBase( |
| 171 | + { ...data, organizationId: ids.organizationId }, |
| 172 | + 'lifecycle-fixture' |
| 173 | + ) |
| 174 | + expect(kb).toMatchObject({ workspaceId: null, organizationId: ids.organizationId }) |
| 175 | + }) |
| 176 | +}) |
0 commit comments