diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 39d9e150fe..292a036a64 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1068,17 +1068,30 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { .where(and(inArray(chatConversations.companyId, companyIds), inArray(chatConversations.state, ["active", "waiting"]))); } - async function seedCompany() { + const companyPrefixAttempts = 8; + const freshCompanyPrefix = () => `C${randomUUID().replaceAll("-", "").slice(0, 7).toUpperCase()}`; + + async function seedCompany(nextIssuePrefix = freshCompanyPrefix) { const companyId = randomUUID(); - fixtureCompanies.add(companyId); const assignedAgentId = randomUUID(); const replacementAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: `Chat Test ${companyId.slice(0, 8)}`, - issuePrefix: `C${companyId.replaceAll("-", "").slice(0, 7).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + let inserted = false; + // Retired fixtures retain company rows. Retry only the short-prefix unique + // conflict; every other database error must still fail the test immediately. + for (let attempt = 0; attempt < companyPrefixAttempts; attempt += 1) { + const [company] = await db.insert(companies).values({ + id: companyId, + name: `Chat Test ${companyId.slice(0, 8)}`, + issuePrefix: nextIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }).onConflictDoNothing({ target: companies.issuePrefix }).returning({ id: companies.id }); + if (company) { + inserted = true; + fixtureCompanies.add(companyId); + break; + } + } + if (!inserted) throw new Error(`Could not allocate a chat fixture company prefix after ${companyPrefixAttempts} attempts`); const now = new Date(); await db .insert(authUsers) @@ -1133,6 +1146,46 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { return { companyId, assignedAgentId, replacementAgentId }; } + it("retries a colliding company fixture prefix and creates a distinct complete fixture", async () => { + const existing = await seedCompany(); + const [original] = await db.select().from(companies).where(eq(companies.id, existing.companyId)); + const candidates: string[] = []; + const nextPrefix = vi.fn(() => { + const prefix = candidates.length === 0 ? original!.issuePrefix : freshCompanyPrefix(); + candidates.push(prefix); + return prefix; + }); + const fixture = await seedCompany(nextPrefix); + const [allocated] = await db.select().from(companies).where(eq(companies.id, fixture.companyId)); + expect(nextPrefix.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(allocated!.issuePrefix).toBe(candidates.at(-1)); + expect(allocated!.issuePrefix).toMatch(/^C[A-F0-9]{7}$/); + expect(allocated!.issuePrefix).not.toBe(original!.issuePrefix); + expect(fixture.companyId).not.toBe(existing.companyId); + expect(await db.select().from(companies).where(eq(companies.id, existing.companyId))).toEqual([original]); + expect((await db.select().from(agents).where(eq(agents.companyId, fixture.companyId))).map(agent => agent.id).sort()) + .toEqual([fixture.assignedAgentId, fixture.replacementAgentId].sort()); + expect(await db.select().from(companyMemberships).where(eq(companyMemberships.companyId, fixture.companyId))) + .toEqual([expect.objectContaining({ principalId: "owner-user", membershipRole: "operator" })]); + }); + + it("bounds company fixture prefix collision retries without creating a partial fixture", async () => { + const fixture = await seedCompany(); + const [company] = await db.select().from(companies).where(eq(companies.id, fixture.companyId)); + const before = await db.select({ id: companies.id }).from(companies); + const nextPrefix = vi.fn(() => company!.issuePrefix); + await expect(seedCompany(nextPrefix)).rejects.toThrow(`after ${companyPrefixAttempts} attempts`); + expect(nextPrefix).toHaveBeenCalledTimes(companyPrefixAttempts); + expect((await db.select({ id: companies.id }).from(companies)).map(row => row.id).sort()) + .toEqual(before.map(row => row.id).sort()); + }); + + it("does not retry unrelated company fixture database errors", async () => { + const nextPrefix = vi.fn(() => "C\0INVALID"); + await expect(seedCompany(nextPrefix)).rejects.toMatchObject({ cause: { code: "22021" } }); + expect(nextPrefix).toHaveBeenCalledOnce(); + }); + // A truthy return is not a durable scheduler receipt. These transport tests // record the same exact receipt identity; real scheduling/coalescing is // separately exercised by durable-chat-wakeup.test.ts against heartbeat.