Skip to content

Commit 7416a7f

Browse files
committed
test(mothership): prove offboarding queues memory erasure with a real database
The unit tests only checked mocked outbox calls. The integration test runs member removal, external removal and organization cleanup against Postgres: each committed change queues its erasure, a rolled-back one queues nothing.
1 parent 5446713 commit 7416a7f

3 files changed

Lines changed: 126 additions & 70 deletions

File tree

‎apps/sim/lib/billing/organizations/membership-external-removal.test.ts‎

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,23 @@
11
import { member } from '@sim/db/schema'
22
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
3-
import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock'
43
import {
54
organizationMemberLimitsMock,
65
organizationMemberLimitsMockFns,
76
} from '@sim/testing/mocks/organization-member-limits.mock'
8-
import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock'
9-
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
108

119
vi.mock('@/lib/billing/organizations/member-limits', () => organizationMemberLimitsMock)
12-
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
1310

1411
import {
1512
removeExternalUserFromOrganizationWorkspaces,
1613
removeUserFromOrganization,
1714
} from '@/lib/billing/organizations/membership'
18-
import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event'
1915

2016
const mockSetOrgMemberUsageLimit = organizationMemberLimitsMockFns.mockSetOrgMemberUsageLimit
21-
const { mockEnqueueOutboxEvents } = outboxServiceMockFns
22-
23-
/** The private-memory erasure a removal queues for the departing user (D106 in Mothership). */
24-
function memberErasure(userId: string) {
25-
return [
26-
expect.anything(),
27-
MEMORY_CLEANUP_EVENT,
28-
[
29-
expect.objectContaining({
30-
request: {
31-
version: 1,
32-
target: { kind: 'organization_member', organizationId: 'org-1', userId },
33-
},
34-
}),
35-
],
36-
]
37-
}
3817

3918
describe('external organization access removal', () => {
4019
beforeEach(() => {
4120
resetDbChainMock()
42-
setEnv({ COPILOT_API_KEY: 'test-key' })
43-
})
44-
45-
afterAll(() => {
46-
resetEnvMock()
47-
})
48-
49-
it("erases the departing user's private Mothership memory in the organization", async () => {
50-
await removeExternalUserFromOrganizationWorkspaces({
51-
organizationId: 'org-1',
52-
userId: 'external-user',
53-
})
54-
expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('external-user'))
55-
56-
queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }])
57-
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'membership' }])
58-
await removeUserFromOrganization({
59-
organizationId: 'org-1',
60-
userId: 'target',
61-
memberId: 'membership',
62-
skipBillingLogic: true,
63-
onError: 'throw',
64-
})
65-
expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('target'))
66-
})
67-
68-
it('erases nothing when a removal does not commit', async () => {
69-
queueTableRows(member, [])
70-
queueTableRows(member, [{ id: 'new-membership' }])
71-
await expect(
72-
removeExternalUserFromOrganizationWorkspaces({ organizationId: 'org-1', userId: 'external' })
73-
).rejects.toMatchObject({ code: 'conflict' })
74-
expect(mockEnqueueOutboxEvents).not.toHaveBeenCalled()
7521
})
7622

7723
it('clears a stale per-user organization usage cap in the removal transaction', async () => {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { db } from '@sim/db'
2+
import { member, organization, outboxEvent, user } from '@sim/db/schema'
3+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
4+
import { generateId } from '@sim/utils/id'
5+
import { and, eq, inArray, sql } from 'drizzle-orm'
6+
import { afterAll, beforeAll, expect, it } from 'vitest'
7+
8+
readTestDatabaseUrl()
9+
10+
const ownerId = generateId()
11+
const memberId = generateId()
12+
const externalId = generateId()
13+
const organizationId = generateId()
14+
const membershipId = generateId()
15+
let runtime: {
16+
membership: typeof import('@/lib/billing/organizations/membership')
17+
resources: typeof import('@/lib/organizations/resource-cleanup')
18+
event: typeof import('@/lib/mothership/memories/cleanup-event')
19+
}
20+
21+
beforeAll(async () => {
22+
// The cleanup outbox reads its worker credential and destination when it loads.
23+
process.env.SIM_AGENT_API_URL = 'http://127.0.0.1:9'
24+
process.env.COPILOT_API_KEY = 'memory-offboarding-fixture-key'
25+
const [membership, resources, event] = await Promise.all([
26+
import('@/lib/billing/organizations/membership'),
27+
import('@/lib/organizations/resource-cleanup'),
28+
import('@/lib/mothership/memories/cleanup-event'),
29+
])
30+
runtime = { membership, resources, event }
31+
await db.insert(user).values(
32+
[ownerId, memberId, externalId].map((id) => ({
33+
id,
34+
name: 'Offboarding fixture',
35+
email: `${id}@memory.test`,
36+
emailVerified: true,
37+
createdAt: new Date(),
38+
updatedAt: new Date(),
39+
}))
40+
)
41+
await db
42+
.insert(organization)
43+
.values({ id: organizationId, name: 'Offboarding', slug: organizationId })
44+
await db.insert(member).values([
45+
{ id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() },
46+
{ id: membershipId, organizationId, userId: memberId, role: 'member', createdAt: new Date() },
47+
])
48+
}, 60_000)
49+
50+
afterAll(async () => {
51+
await db
52+
.delete(outboxEvent)
53+
.where(
54+
and(
55+
eq(outboxEvent.eventType, runtime.event.MEMORY_CLEANUP_EVENT),
56+
sql`${outboxEvent.payload}->'request'->'target'->>'organizationId' = ${organizationId}`
57+
)
58+
)
59+
await db.delete(organization).where(eq(organization.id, organizationId))
60+
await db.delete(user).where(inArray(user.id, [ownerId, memberId, externalId]))
61+
})
62+
63+
/** The memory erasures queued for this organization, as `kind:userId` (or `kind`). */
64+
async function queued(): Promise<string[]> {
65+
const rows = await db
66+
.select({ payload: outboxEvent.payload })
67+
.from(outboxEvent)
68+
.where(
69+
and(
70+
eq(outboxEvent.eventType, runtime.event.MEMORY_CLEANUP_EVENT),
71+
sql`${outboxEvent.payload}->'request'->'target'->>'organizationId' = ${organizationId}`
72+
)
73+
)
74+
return rows
75+
.map(({ payload }) => {
76+
const target = (payload as { request: { target: { kind: string; userId?: string } } }).request
77+
.target
78+
return target.userId ? `${target.kind}:${target.userId}` : target.kind
79+
})
80+
.sort()
81+
}
82+
83+
it('queues private memory erasure with each committed departure and organization deletion, never on rollback', async () => {
84+
// A removal whose actor lacks authority rolls back with nothing queued.
85+
await expect(
86+
runtime.membership.removeUserFromOrganization({
87+
organizationId,
88+
userId: memberId,
89+
memberId: membershipId,
90+
actorUserId: externalId,
91+
skipBillingLogic: true,
92+
onError: 'throw',
93+
})
94+
).rejects.toThrow()
95+
expect(await queued()).toEqual([])
96+
97+
await runtime.membership.removeUserFromOrganization({
98+
organizationId,
99+
userId: memberId,
100+
memberId: membershipId,
101+
skipBillingLogic: true,
102+
onError: 'throw',
103+
})
104+
await runtime.membership.removeExternalUserFromOrganizationWorkspaces({
105+
organizationId,
106+
userId: externalId,
107+
})
108+
expect(await queued()).toEqual(
109+
[`organization_member:${externalId}`, `organization_member:${memberId}`].sort()
110+
)
111+
112+
// Organization deletion queues the erasure in the delete transaction: a rollback drops it.
113+
await expect(
114+
db.transaction(async (tx) => {
115+
await runtime.resources.enqueueOrganizationResourceCleanup(tx, organizationId)
116+
throw new Error('rollback fixture')
117+
})
118+
).rejects.toThrow('rollback fixture')
119+
expect(await queued()).not.toContain('organization')
120+
await db.transaction(async (tx) => {
121+
await runtime.resources.enqueueOrganizationResourceCleanup(tx, organizationId)
122+
})
123+
expect(await queued()).toContain('organization')
124+
}, 30_000)

‎apps/sim/lib/organizations/resource-cleanup.test.ts‎

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ vi.mock('@/lib/cleanup/chat-cleanup', () => chatCleanupMock)
1212
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
1313

1414
import type { OutboxEventContext } from '@/lib/core/outbox/service'
15-
import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event'
1615
import {
1716
enqueueOrganizationResourceCleanup,
1817
organizationResourceCleanupOutboxHandlers,
@@ -21,7 +20,7 @@ import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resourc
2120

2221
const { mockDeleteFile } = storageServiceMockFns
2322
const { mockCleanupCopilotBackend } = chatCleanupMockFns
24-
const { mockEnqueueOutboxEvent, mockEnqueueOutboxEvents } = outboxServiceMockFns
23+
const { mockEnqueueOutboxEvent } = outboxServiceMockFns
2524

2625
const ORG_ID = 'org-1'
2726
const CHAT_ID = '10000000-0000-4000-8000-000000000001'
@@ -61,19 +60,6 @@ afterAll(() => {
6160
})
6261

6362
describe('enqueueOrganizationResourceCleanup', () => {
64-
it("erases the organization's private Mothership memory in the delete transaction", async () => {
65-
queueTableRows(organization, [{ id: ORG_ID }])
66-
67-
await db.transaction(async (tx) => {
68-
await enqueueOrganizationResourceCleanup(tx, ORG_ID)
69-
expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(tx, MEMORY_CLEANUP_EVENT, [
70-
expect.objectContaining({
71-
request: { version: 1, target: { kind: 'organization', organizationId: ORG_ID } },
72-
}),
73-
])
74-
})
75-
})
76-
7763
it('locks the organization and snapshots only canonical direct org resources inside the caller transaction', async () => {
7864
queueTableRows(organization, [{ id: ORG_ID }])
7965
queueTableRows(workspaceFiles, [

0 commit comments

Comments
 (0)