@open-mercato/ai-assistant 0.6.5-develop.4498.1.55dc06a57c → 0.6.5-develop.4534.1.b459babe6d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js +146 -0
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js.map +7 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js +119 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js.map +7 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js +174 -0
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js.map +7 -0
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js +132 -0
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js.map +7 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js +68 -0
  11. package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js.map +7 -0
  12. package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js +74 -0
  13. package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js.map +7 -0
  14. package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js +57 -0
  15. package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js.map +7 -0
  16. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +127 -0
  17. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +7 -0
  18. package/package.json +6 -6
  19. package/src/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.ts +209 -0
  20. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.ts +176 -0
  21. package/src/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.ts +222 -0
  22. package/src/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.ts +184 -0
  23. package/src/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.ts +95 -0
  24. package/src/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.ts +115 -0
  25. package/src/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.ts +97 -0
  26. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +198 -0
@@ -1,2 +1,2 @@
1
- [build:ai-assistant] found 203 entry points
1
+ [build:ai-assistant] found 211 entry points
2
2
  [build:ai-assistant] built successfully
@@ -0,0 +1,146 @@
1
+ import { test, expect, request as playwrightRequest } from "@playwright/test";
2
+ import { randomUUID } from "node:crypto";
3
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
4
+ import {
5
+ createRoleFixture,
6
+ deleteRoleIfExists,
7
+ createUserFixture,
8
+ deleteUserIfExists,
9
+ setUserAclVisibility
10
+ } from "@open-mercato/core/helpers/integration/authFixtures";
11
+ import { deleteUserAclInDb } from "@open-mercato/core/helpers/integration/dbFixtures";
12
+ import { getTokenScope, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
13
+ import {
14
+ seedPendingActionInDb,
15
+ deletePendingActionInDb
16
+ } from "./helpers/aiAssistantFixtures.js";
17
+ const ACTIONS = "/api/ai_assistant/ai/actions";
18
+ test.describe("TC-AI-ACTIONS-PENDING-004: Pending action confirm/cancel", () => {
19
+ test("GET serialization, cancel + confirm idempotency, and state-machine guards", async ({ request }) => {
20
+ test.slow();
21
+ const adminToken = await getAuthToken(request, "admin");
22
+ const { tenantId, organizationId, userId: adminId } = getTokenScope(adminToken);
23
+ const seededIds = [];
24
+ const seed = async (overrides) => {
25
+ const row = await seedPendingActionInDb({
26
+ tenantId,
27
+ organizationId: organizationId || null,
28
+ createdByUserId: adminId,
29
+ ...overrides
30
+ });
31
+ seededIds.push(row.id);
32
+ return row;
33
+ };
34
+ try {
35
+ const pending = await seed({ status: "pending" });
36
+ const getRes = await apiRequest(request, "GET", `${ACTIONS}/${pending.id}`, { token: adminToken });
37
+ expect(getRes.status()).toBe(200);
38
+ const body = await readJsonSafe(getRes);
39
+ expect(body?.id).toBe(pending.id);
40
+ expect(body?.status).toBe("pending");
41
+ expect(typeof body?.agentId).toBe("string");
42
+ expect(typeof body?.expiresAt).toBe("string");
43
+ expect(body?.normalizedInput, "normalizedInput is stripped").toBeUndefined();
44
+ expect(body?.createdByUserId, "createdByUserId is stripped").toBeUndefined();
45
+ expect(body?.idempotencyKey, "idempotencyKey is stripped").toBeUndefined();
46
+ expect(body?.tenantId, "tenantId is stripped").toBeUndefined();
47
+ expect(body?.organizationId, "organizationId is stripped").toBeUndefined();
48
+ const cancelable = await seed({ status: "pending" });
49
+ const cancel = await apiRequest(request, "POST", `${ACTIONS}/${cancelable.id}/cancel`, {
50
+ token: adminToken,
51
+ data: { reason: "user_rejected" }
52
+ });
53
+ expect(cancel.status()).toBe(200);
54
+ const cancelBody = await readJsonSafe(cancel);
55
+ expect(cancelBody?.ok).toBe(true);
56
+ expect(cancelBody?.pendingAction.status).toBe("cancelled");
57
+ const cancelAgain = await apiRequest(request, "POST", `${ACTIONS}/${cancelable.id}/cancel`, {
58
+ token: adminToken,
59
+ data: {}
60
+ });
61
+ expect(cancelAgain.status(), "cancel is idempotent").toBe(200);
62
+ expect((await readJsonSafe(cancelAgain))?.pendingAction.status).toBe(
63
+ "cancelled"
64
+ );
65
+ const confirmed = await seed({ status: "confirmed", executionResult: { recordId: "seeded-record" } });
66
+ const confirm = await apiRequest(request, "POST", `${ACTIONS}/${confirmed.id}/confirm`, {
67
+ token: adminToken,
68
+ data: {}
69
+ });
70
+ expect(confirm.status()).toBe(200);
71
+ const confirmBody = await readJsonSafe(
72
+ confirm
73
+ );
74
+ expect(confirmBody?.ok).toBe(true);
75
+ expect(confirmBody?.pendingAction.status).toBe("confirmed");
76
+ expect(confirmBody?.mutationResult).toEqual({ recordId: "seeded-record" });
77
+ const cancelledRow = await seed({ status: "cancelled" });
78
+ const confirmCancelled = await apiRequest(request, "POST", `${ACTIONS}/${cancelledRow.id}/confirm`, {
79
+ token: adminToken,
80
+ data: {}
81
+ });
82
+ expect(confirmCancelled.status()).toBe(409);
83
+ expect((await readJsonSafe(confirmCancelled))?.code).toBe("invalid_status");
84
+ const expiredRow = await seed({ status: "pending", expiresInMinutes: -10 });
85
+ const cancelExpired = await apiRequest(request, "POST", `${ACTIONS}/${expiredRow.id}/cancel`, {
86
+ token: adminToken,
87
+ data: {}
88
+ });
89
+ expect(cancelExpired.status()).toBe(409);
90
+ expect((await readJsonSafe(cancelExpired))?.code).toBe("expired");
91
+ const notFound = await apiRequest(request, "GET", `${ACTIONS}/${randomUUID()}`, { token: adminToken });
92
+ expect(notFound.status()).toBe(404);
93
+ expect((await readJsonSafe(notFound))?.code).toBe("pending_action_not_found");
94
+ const tooLong = await apiRequest(request, "GET", `${ACTIONS}/${"a".repeat(200)}`, { token: adminToken });
95
+ expect(tooLong.status()).toBe(400);
96
+ expect((await readJsonSafe(tooLong))?.code).toBe("validation_error");
97
+ } finally {
98
+ for (const id of seededIds) {
99
+ await deletePendingActionInDb(id).catch(() => void 0);
100
+ }
101
+ }
102
+ });
103
+ test("auth gates: unauthenticated 401 and missing ai_assistant.view 403", async ({ request, baseURL }) => {
104
+ test.slow();
105
+ const adminToken = await getAuthToken(request, "admin");
106
+ const { tenantId, organizationId, userId: adminId } = getTokenScope(adminToken);
107
+ const stamp = randomUUID().slice(0, 8);
108
+ const password = "Secret123!";
109
+ let seededId = null;
110
+ let roleId = null;
111
+ let userId = null;
112
+ try {
113
+ const row = await seedPendingActionInDb({
114
+ tenantId,
115
+ organizationId: organizationId || null,
116
+ createdByUserId: adminId,
117
+ status: "pending"
118
+ });
119
+ seededId = row.id;
120
+ const anon = await playwrightRequest.newContext({ baseURL });
121
+ try {
122
+ const res = await anon.fetch(`${ACTIONS}/${seededId}`, { method: "GET" });
123
+ expect(res.status()).toBe(401);
124
+ } finally {
125
+ await anon.dispose();
126
+ }
127
+ roleId = await createRoleFixture(request, adminToken, { name: `IT Pending Role ${stamp}` });
128
+ userId = await createUserFixture(request, adminToken, {
129
+ email: `it-pending-${stamp}@example.com`,
130
+ password,
131
+ organizationId,
132
+ roles: [roleId]
133
+ });
134
+ await setUserAclVisibility(request, adminToken, { userId, features: [], organizations: null });
135
+ const viewlessToken = await getAuthToken(request, `it-pending-${stamp}@example.com`, password);
136
+ const denied = await apiRequest(request, "GET", `${ACTIONS}/${seededId}`, { token: viewlessToken });
137
+ expect(denied.status(), "caller without ai_assistant.view is 403").toBe(403);
138
+ } finally {
139
+ await deletePendingActionInDb(seededId).catch(() => void 0);
140
+ await deleteUserAclInDb(userId ?? "").catch(() => void 0);
141
+ await deleteUserIfExists(request, adminToken, userId);
142
+ await deleteRoleIfExists(request, adminToken, roleId);
143
+ }
144
+ });
145
+ });
146
+ //# sourceMappingURL=TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.ts"],
4
+ "sourcesContent": ["import { test, expect, request as playwrightRequest } from '@playwright/test';\nimport { randomUUID } from 'node:crypto';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n createRoleFixture,\n deleteRoleIfExists,\n createUserFixture,\n deleteUserIfExists,\n setUserAclVisibility,\n} from '@open-mercato/core/helpers/integration/authFixtures';\nimport { deleteUserAclInDb } from '@open-mercato/core/helpers/integration/dbFixtures';\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n seedPendingActionInDb,\n deletePendingActionInDb,\n type SeedPendingActionInput,\n} from './helpers/aiAssistantFixtures';\n\n/**\n * TC-AI-ACTIONS-PENDING-004 \u2014 Pending action confirm/cancel (mutation-approval gate).\n * Source: GitHub issue #2495.\n *\n * Surfaces under test:\n * - /api/ai_assistant/ai/actions/{id} (GET)\n * - /api/ai_assistant/ai/actions/{id}/confirm (POST)\n * - /api/ai_assistant/ai/actions/{id}/cancel (POST)\n *\n * Contract notes verified against the route handlers:\n * - there is NO public route to CREATE a pending action (it is born only from\n * the internal `prepareMutation` path), so rows are seeded directly via SQL.\n * - GET strips `normalizedInput`, `createdByUserId`, `idempotencyKey`,\n * `tenantId`, `organizationId` from the client serialization.\n * - confirm/cancel are idempotent at the route layer; an already-terminal row\n * short-circuits (200) rather than throwing.\n * - cancelling an expired row -> 409 `expired`; confirming a cancelled row ->\n * 409 `invalid_status`; unknown id -> 404 `pending_action_not_found`.\n * - all three require `ai_assistant.view`.\n *\n * The confirm HAPPY path is exercised via the terminal short-circuit (a seeded\n * `confirmed` row) so the test stays deterministic and provider-free \u2014 driving a\n * real `pending -> confirmed` transition would execute a live tool mutation that\n * depends on the agent/tool registry and an LLM-proposed payload.\n */\n\nconst ACTIONS = '/api/ai_assistant/ai/actions';\n\ninterface SerializedPendingAction {\n id: string;\n agentId: string;\n toolName: string;\n status: string;\n createdAt: string;\n expiresAt: string;\n executionResult: unknown;\n normalizedInput?: unknown;\n createdByUserId?: unknown;\n idempotencyKey?: unknown;\n tenantId?: unknown;\n organizationId?: unknown;\n}\n\ntest.describe('TC-AI-ACTIONS-PENDING-004: Pending action confirm/cancel', () => {\n test('GET serialization, cancel + confirm idempotency, and state-machine guards', async ({ request }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId, organizationId, userId: adminId } = getTokenScope(adminToken);\n const seededIds: string[] = [];\n const seed = async (overrides: Partial<SeedPendingActionInput>) => {\n const row = await seedPendingActionInDb({\n tenantId,\n organizationId: organizationId || null,\n createdByUserId: adminId,\n ...overrides,\n });\n seededIds.push(row.id);\n return row;\n };\n\n try {\n // GET returns the client serialization with privileged fields stripped.\n const pending = await seed({ status: 'pending' });\n const getRes = await apiRequest(request, 'GET', `${ACTIONS}/${pending.id}`, { token: adminToken });\n expect(getRes.status()).toBe(200);\n const body = await readJsonSafe<SerializedPendingAction>(getRes);\n expect(body?.id).toBe(pending.id);\n expect(body?.status).toBe('pending');\n expect(typeof body?.agentId).toBe('string');\n expect(typeof body?.expiresAt).toBe('string');\n expect(body?.normalizedInput, 'normalizedInput is stripped').toBeUndefined();\n expect(body?.createdByUserId, 'createdByUserId is stripped').toBeUndefined();\n expect(body?.idempotencyKey, 'idempotencyKey is stripped').toBeUndefined();\n expect(body?.tenantId, 'tenantId is stripped').toBeUndefined();\n expect(body?.organizationId, 'organizationId is stripped').toBeUndefined();\n\n // Cancel happy path + idempotency.\n const cancelable = await seed({ status: 'pending' });\n const cancel = await apiRequest(request, 'POST', `${ACTIONS}/${cancelable.id}/cancel`, {\n token: adminToken,\n data: { reason: 'user_rejected' },\n });\n expect(cancel.status()).toBe(200);\n const cancelBody = await readJsonSafe<{ ok: boolean; pendingAction: SerializedPendingAction }>(cancel);\n expect(cancelBody?.ok).toBe(true);\n expect(cancelBody?.pendingAction.status).toBe('cancelled');\n\n const cancelAgain = await apiRequest(request, 'POST', `${ACTIONS}/${cancelable.id}/cancel`, {\n token: adminToken,\n data: {},\n });\n expect(cancelAgain.status(), 'cancel is idempotent').toBe(200);\n expect((await readJsonSafe<{ pendingAction: SerializedPendingAction }>(cancelAgain))?.pendingAction.status).toBe(\n 'cancelled',\n );\n\n // Confirm happy path via the terminal short-circuit (seeded confirmed row).\n const confirmed = await seed({ status: 'confirmed', executionResult: { recordId: 'seeded-record' } });\n const confirm = await apiRequest(request, 'POST', `${ACTIONS}/${confirmed.id}/confirm`, {\n token: adminToken,\n data: {},\n });\n expect(confirm.status()).toBe(200);\n const confirmBody = await readJsonSafe<{ ok: boolean; pendingAction: SerializedPendingAction; mutationResult: unknown }>(\n confirm,\n );\n expect(confirmBody?.ok).toBe(true);\n expect(confirmBody?.pendingAction.status).toBe('confirmed');\n expect(confirmBody?.mutationResult).toEqual({ recordId: 'seeded-record' });\n\n // Confirming a cancelled row -> 409 invalid_status.\n const cancelledRow = await seed({ status: 'cancelled' });\n const confirmCancelled = await apiRequest(request, 'POST', `${ACTIONS}/${cancelledRow.id}/confirm`, {\n token: adminToken,\n data: {},\n });\n expect(confirmCancelled.status()).toBe(409);\n expect((await readJsonSafe<{ code?: string }>(confirmCancelled))?.code).toBe('invalid_status');\n\n // Cancelling an expired row -> 409 expired.\n const expiredRow = await seed({ status: 'pending', expiresInMinutes: -10 });\n const cancelExpired = await apiRequest(request, 'POST', `${ACTIONS}/${expiredRow.id}/cancel`, {\n token: adminToken,\n data: {},\n });\n expect(cancelExpired.status()).toBe(409);\n expect((await readJsonSafe<{ code?: string }>(cancelExpired))?.code).toBe('expired');\n\n // Unknown id -> 404; over-long id -> 400 validation_error.\n const notFound = await apiRequest(request, 'GET', `${ACTIONS}/${randomUUID()}`, { token: adminToken });\n expect(notFound.status()).toBe(404);\n expect((await readJsonSafe<{ code?: string }>(notFound))?.code).toBe('pending_action_not_found');\n\n const tooLong = await apiRequest(request, 'GET', `${ACTIONS}/${'a'.repeat(200)}`, { token: adminToken });\n expect(tooLong.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(tooLong))?.code).toBe('validation_error');\n } finally {\n for (const id of seededIds) {\n await deletePendingActionInDb(id).catch(() => undefined);\n }\n }\n });\n\n test('auth gates: unauthenticated 401 and missing ai_assistant.view 403', async ({ request, baseURL }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId, organizationId, userId: adminId } = getTokenScope(adminToken);\n const stamp = randomUUID().slice(0, 8);\n const password = 'Secret123!';\n\n let seededId: string | null = null;\n let roleId: string | null = null;\n let userId: string | null = null;\n try {\n const row = await seedPendingActionInDb({\n tenantId,\n organizationId: organizationId || null,\n createdByUserId: adminId,\n status: 'pending',\n });\n seededId = row.id;\n\n // Unauthenticated GET -> 401 (fresh context, no session cookie).\n const anon = await playwrightRequest.newContext({ baseURL });\n try {\n const res = await anon.fetch(`${ACTIONS}/${seededId}`, { method: 'GET' });\n expect(res.status()).toBe(401);\n } finally {\n await anon.dispose();\n }\n\n // Authenticated user lacking ai_assistant.view -> 403.\n roleId = await createRoleFixture(request, adminToken, { name: `IT Pending Role ${stamp}` });\n userId = await createUserFixture(request, adminToken, {\n email: `it-pending-${stamp}@example.com`,\n password,\n organizationId,\n roles: [roleId],\n });\n await setUserAclVisibility(request, adminToken, { userId, features: [], organizations: null });\n const viewlessToken = await getAuthToken(request, `it-pending-${stamp}@example.com`, password);\n const denied = await apiRequest(request, 'GET', `${ACTIONS}/${seededId}`, { token: viewlessToken });\n expect(denied.status(), 'caller without ai_assistant.view is 403').toBe(403);\n } finally {\n await deletePendingActionInDb(seededId).catch(() => undefined);\n await deleteUserAclInDb(userId ?? '').catch(() => undefined);\n await deleteUserIfExists(request, adminToken, userId);\n await deleteRoleIfExists(request, adminToken, roleId);\n }\n });\n});\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,119 @@
1
+ import { test, expect, request as playwrightRequest } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { getTokenScope, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import { deleteAgentOverridesInDb } from "./helpers/aiAssistantFixtures.js";
5
+ const AGENTS = "/api/ai_assistant/ai/agents";
6
+ const POLICY_RANK = {
7
+ "read-only": 0,
8
+ "destructive-confirm-required": 1,
9
+ "confirm-required": 2
10
+ };
11
+ test.describe("TC-AI-AGENT-OVERRIDES-005: Per-agent runtime overrides", () => {
12
+ test("prompt + mutation-policy + loop override CRUD, validation, escalation, RBAC", async ({
13
+ request,
14
+ baseURL
15
+ }) => {
16
+ test.slow();
17
+ const adminToken = await getAuthToken(request, "admin");
18
+ const { tenantId } = getTokenScope(adminToken);
19
+ const agentsRes = await apiRequest(request, "GET", AGENTS, { token: adminToken });
20
+ expect(agentsRes.status()).toBe(200);
21
+ const agentsBody = await readJsonSafe(agentsRes);
22
+ expect(Array.isArray(agentsBody?.agents) && (agentsBody?.agents.length ?? 0) > 0, "at least one agent is registered").toBe(
23
+ true
24
+ );
25
+ const agentId = agentsBody.agents[0].id;
26
+ const promptOverride = `${AGENTS}/${encodeURIComponent(agentId)}/prompt-override`;
27
+ const mutationPolicy = `${AGENTS}/${encodeURIComponent(agentId)}/mutation-policy`;
28
+ const loopOverride = `${AGENTS}/${encodeURIComponent(agentId)}/loop-override`;
29
+ try {
30
+ const savePrompt = await apiRequest(request, "POST", promptOverride, {
31
+ token: adminToken,
32
+ data: { sections: { tone: "Be concise and helpful." } }
33
+ });
34
+ expect(savePrompt.status(), "POST prompt-override returns 200").toBe(200);
35
+ const savedPrompt = await readJsonSafe(savePrompt);
36
+ expect(savedPrompt?.ok).toBe(true);
37
+ expect(typeof savedPrompt?.version).toBe("number");
38
+ const getPrompt = await apiRequest(request, "GET", promptOverride, { token: adminToken });
39
+ expect(getPrompt.status()).toBe(200);
40
+ const promptBody = await readJsonSafe(getPrompt);
41
+ expect(promptBody?.override?.sections?.tone).toBe("Be concise and helpful.");
42
+ const reserved = await apiRequest(request, "POST", promptOverride, {
43
+ token: adminToken,
44
+ data: { sections: { mutationPolicy: "confirm-required" } }
45
+ });
46
+ expect(reserved.status()).toBe(400);
47
+ expect((await readJsonSafe(reserved))?.code).toBe("reserved_key");
48
+ const getPolicy = await apiRequest(request, "GET", mutationPolicy, { token: adminToken });
49
+ expect(getPolicy.status()).toBe(200);
50
+ const policyBody = await readJsonSafe(getPolicy);
51
+ const codeDeclared = policyBody?.codeDeclared ?? "read-only";
52
+ const savePolicy = await apiRequest(request, "POST", mutationPolicy, {
53
+ token: adminToken,
54
+ data: { mutationPolicy: "read-only" }
55
+ });
56
+ expect(savePolicy.status(), "saving a non-escalating policy returns 200").toBe(200);
57
+ const getPolicyAfter = await apiRequest(request, "GET", mutationPolicy, { token: adminToken });
58
+ expect((await readJsonSafe(getPolicyAfter))?.override?.mutationPolicy).toBe(
59
+ "read-only"
60
+ );
61
+ const invalidPolicy = await apiRequest(request, "POST", mutationPolicy, {
62
+ token: adminToken,
63
+ data: { mutationPolicy: "not-a-valid-policy" }
64
+ });
65
+ expect(invalidPolicy.status()).toBe(400);
66
+ expect((await readJsonSafe(invalidPolicy))?.code).toBe("validation_error");
67
+ if ((POLICY_RANK[codeDeclared] ?? 0) < POLICY_RANK["confirm-required"]) {
68
+ const escalation = await apiRequest(request, "POST", mutationPolicy, {
69
+ token: adminToken,
70
+ data: { mutationPolicy: "confirm-required" }
71
+ });
72
+ expect(escalation.status()).toBe(400);
73
+ expect((await readJsonSafe(escalation))?.code).toBe("escalation_not_allowed");
74
+ }
75
+ const deletePolicy = await apiRequest(request, "DELETE", mutationPolicy, { token: adminToken });
76
+ expect(deletePolicy.status()).toBe(200);
77
+ const getPolicyCleared = await apiRequest(request, "GET", mutationPolicy, { token: adminToken });
78
+ expect((await readJsonSafe(getPolicyCleared))?.override, "override cleared (null, not 404)").toBeNull();
79
+ const putLoop = await apiRequest(request, "PUT", loopOverride, {
80
+ token: adminToken,
81
+ data: { loopMaxSteps: 5 }
82
+ });
83
+ expect(putLoop.status(), "PUT loop-override returns 200").toBe(200);
84
+ const getLoop = await apiRequest(request, "GET", loopOverride, { token: adminToken });
85
+ expect(getLoop.status()).toBe(200);
86
+ expect((await readJsonSafe(getLoop))?.override?.loopMaxSteps).toBe(5);
87
+ const deleteLoop = await apiRequest(request, "DELETE", loopOverride, { token: adminToken });
88
+ expect(deleteLoop.status()).toBe(200);
89
+ const getLoopCleared = await apiRequest(request, "GET", loopOverride, { token: adminToken });
90
+ expect((await readJsonSafe(getLoopCleared))?.override).toBeNull();
91
+ const malformed = await apiRequest(request, "GET", `${AGENTS}/BadAgentId/prompt-override`, { token: adminToken });
92
+ expect(malformed.status()).toBe(400);
93
+ expect((await readJsonSafe(malformed))?.code).toBe("validation_error");
94
+ const unknown = await apiRequest(request, "GET", `${AGENTS}/does.not_exist/prompt-override`, { token: adminToken });
95
+ expect(unknown.status()).toBe(404);
96
+ expect((await readJsonSafe(unknown))?.code).toBe("agent_unknown");
97
+ const employeeToken = await getAuthToken(request, "employee");
98
+ const denied = await apiRequest(request, "POST", promptOverride, {
99
+ token: employeeToken,
100
+ data: { sections: { tone: "nope" } }
101
+ });
102
+ expect(denied.status(), "employee lacks settings.manage -> 403").toBe(403);
103
+ const anon = await playwrightRequest.newContext({ baseURL });
104
+ try {
105
+ const res = await anon.fetch(promptOverride, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ data: JSON.stringify({ sections: { tone: "nope" } })
109
+ });
110
+ expect(res.status(), "unauthenticated POST is 401").toBe(401);
111
+ } finally {
112
+ await anon.dispose();
113
+ }
114
+ } finally {
115
+ await deleteAgentOverridesInDb({ tenantId, agentId }).catch(() => void 0);
116
+ }
117
+ });
118
+ });
119
+ //# sourceMappingURL=TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.ts"],
4
+ "sourcesContent": ["import { test, expect, request as playwrightRequest } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { deleteAgentOverridesInDb } from './helpers/aiAssistantFixtures';\n\n/**\n * TC-AI-AGENT-OVERRIDES-005 \u2014 Per-agent runtime overrides (prompt / mutation-policy / loop).\n * Source: GitHub issue #2495.\n *\n * Surfaces under test:\n * - /api/ai_assistant/ai/agents/{agentId}/prompt-override (GET, POST)\n * - /api/ai_assistant/ai/agents/{agentId}/mutation-policy (GET, POST, DELETE)\n * - /api/ai_assistant/ai/agents/{agentId}/loop-override (GET, PUT, DELETE)\n *\n * Contract notes verified against the route handlers (the issue's guesses were wrong):\n * - prompt-override + mutation-policy use POST (NOT PUT); loop-override uses PUT.\n * - prompt-override has no DELETE route (versioned) -> swept via SQL in teardown.\n * - mutation-policy escalation beyond the agent's declared ceiling -> 400\n * `escalation_not_allowed` (NOT `policy_escalation_not_allowed`); an invalid\n * policy value -> 400 `validation_error` (NOT `invalid_mutation_policy`).\n * - a malformed agentId -> 400 `validation_error`; an unknown one -> 404 `agent_unknown`.\n * - writes require `ai_assistant.settings.manage`.\n *\n * The agent id is discovered dynamically from GET /ai/agents \u2014 never hard-coded.\n */\n\nconst AGENTS = '/api/ai_assistant/ai/agents';\n\ninterface AgentSummary {\n id: string;\n mutationPolicy: string;\n}\n\nconst POLICY_RANK: Record<string, number> = {\n 'read-only': 0,\n 'destructive-confirm-required': 1,\n 'confirm-required': 2,\n};\n\ntest.describe('TC-AI-AGENT-OVERRIDES-005: Per-agent runtime overrides', () => {\n test('prompt + mutation-policy + loop override CRUD, validation, escalation, RBAC', async ({\n request,\n baseURL,\n }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId } = getTokenScope(adminToken);\n\n const agentsRes = await apiRequest(request, 'GET', AGENTS, { token: adminToken });\n expect(agentsRes.status()).toBe(200);\n const agentsBody = await readJsonSafe<{ agents: AgentSummary[] }>(agentsRes);\n expect(Array.isArray(agentsBody?.agents) && (agentsBody?.agents.length ?? 0) > 0, 'at least one agent is registered').toBe(\n true,\n );\n // Intentionally targets the first registered agent (whichever modules are\n // enabled). Policy assertions are computed relative to that agent's declared\n // ceiling (`codeDeclared`) so the test is correct for any agent.\n const agentId = agentsBody!.agents[0].id;\n\n const promptOverride = `${AGENTS}/${encodeURIComponent(agentId)}/prompt-override`;\n const mutationPolicy = `${AGENTS}/${encodeURIComponent(agentId)}/mutation-policy`;\n const loopOverride = `${AGENTS}/${encodeURIComponent(agentId)}/loop-override`;\n\n try {\n // --- prompt-override (GET + POST; reserved-key validation) ---\n const savePrompt = await apiRequest(request, 'POST', promptOverride, {\n token: adminToken,\n data: { sections: { tone: 'Be concise and helpful.' } },\n });\n expect(savePrompt.status(), 'POST prompt-override returns 200').toBe(200);\n const savedPrompt = await readJsonSafe<{ ok: boolean; version: number }>(savePrompt);\n expect(savedPrompt?.ok).toBe(true);\n expect(typeof savedPrompt?.version).toBe('number');\n\n const getPrompt = await apiRequest(request, 'GET', promptOverride, { token: adminToken });\n expect(getPrompt.status()).toBe(200);\n const promptBody = await readJsonSafe<{ override: { sections: Record<string, string> } | null }>(getPrompt);\n expect(promptBody?.override?.sections?.tone).toBe('Be concise and helpful.');\n\n const reserved = await apiRequest(request, 'POST', promptOverride, {\n token: adminToken,\n data: { sections: { mutationPolicy: 'confirm-required' } },\n });\n expect(reserved.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(reserved))?.code).toBe('reserved_key');\n\n // --- mutation-policy (GET + POST + DELETE; escalation + invalid value) ---\n const getPolicy = await apiRequest(request, 'GET', mutationPolicy, { token: adminToken });\n expect(getPolicy.status()).toBe(200);\n const policyBody = await readJsonSafe<{ codeDeclared: string }>(getPolicy);\n const codeDeclared = policyBody?.codeDeclared ?? 'read-only';\n\n // 'read-only' is the most restrictive policy, so saving it is never an escalation.\n const savePolicy = await apiRequest(request, 'POST', mutationPolicy, {\n token: adminToken,\n data: { mutationPolicy: 'read-only' },\n });\n expect(savePolicy.status(), 'saving a non-escalating policy returns 200').toBe(200);\n\n const getPolicyAfter = await apiRequest(request, 'GET', mutationPolicy, { token: adminToken });\n expect((await readJsonSafe<{ override: { mutationPolicy: string } | null }>(getPolicyAfter))?.override?.mutationPolicy).toBe(\n 'read-only',\n );\n\n const invalidPolicy = await apiRequest(request, 'POST', mutationPolicy, {\n token: adminToken,\n data: { mutationPolicy: 'not-a-valid-policy' },\n });\n expect(invalidPolicy.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(invalidPolicy))?.code).toBe('validation_error');\n\n // Escalation (widening the agent's declared ceiling) is rejected. Only\n // assert when 'confirm-required' is strictly less restrictive than declared.\n if ((POLICY_RANK[codeDeclared] ?? 0) < POLICY_RANK['confirm-required']) {\n const escalation = await apiRequest(request, 'POST', mutationPolicy, {\n token: adminToken,\n data: { mutationPolicy: 'confirm-required' },\n });\n expect(escalation.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(escalation))?.code).toBe('escalation_not_allowed');\n }\n\n const deletePolicy = await apiRequest(request, 'DELETE', mutationPolicy, { token: adminToken });\n expect(deletePolicy.status()).toBe(200);\n const getPolicyCleared = await apiRequest(request, 'GET', mutationPolicy, { token: adminToken });\n expect((await readJsonSafe<{ override: unknown }>(getPolicyCleared))?.override, 'override cleared (null, not 404)').toBeNull();\n\n // --- loop-override (GET + PUT + DELETE) ---\n const putLoop = await apiRequest(request, 'PUT', loopOverride, {\n token: adminToken,\n data: { loopMaxSteps: 5 },\n });\n expect(putLoop.status(), 'PUT loop-override returns 200').toBe(200);\n\n const getLoop = await apiRequest(request, 'GET', loopOverride, { token: adminToken });\n expect(getLoop.status()).toBe(200);\n expect((await readJsonSafe<{ override: { loopMaxSteps: number } | null }>(getLoop))?.override?.loopMaxSteps).toBe(5);\n\n const deleteLoop = await apiRequest(request, 'DELETE', loopOverride, { token: adminToken });\n expect(deleteLoop.status()).toBe(200);\n const getLoopCleared = await apiRequest(request, 'GET', loopOverride, { token: adminToken });\n expect((await readJsonSafe<{ override: unknown }>(getLoopCleared))?.override).toBeNull();\n\n // --- agent-id validation ---\n const malformed = await apiRequest(request, 'GET', `${AGENTS}/BadAgentId/prompt-override`, { token: adminToken });\n expect(malformed.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(malformed))?.code).toBe('validation_error');\n\n const unknown = await apiRequest(request, 'GET', `${AGENTS}/does.not_exist/prompt-override`, { token: adminToken });\n expect(unknown.status()).toBe(404);\n expect((await readJsonSafe<{ code?: string }>(unknown))?.code).toBe('agent_unknown');\n\n // --- RBAC: employee lacks settings.manage; unauthenticated is rejected ---\n const employeeToken = await getAuthToken(request, 'employee');\n const denied = await apiRequest(request, 'POST', promptOverride, {\n token: employeeToken,\n data: { sections: { tone: 'nope' } },\n });\n expect(denied.status(), 'employee lacks settings.manage -> 403').toBe(403);\n\n const anon = await playwrightRequest.newContext({ baseURL });\n try {\n const res = await anon.fetch(promptOverride, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n data: JSON.stringify({ sections: { tone: 'nope' } }),\n });\n expect(res.status(), 'unauthenticated POST is 401').toBe(401);\n } finally {\n await anon.dispose();\n }\n } finally {\n await deleteAgentOverridesInDb({ tenantId, agentId }).catch(() => undefined);\n }\n });\n});\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,174 @@
1
+ import { test, expect, request as playwrightRequest } from "@playwright/test";
2
+ import { randomUUID } from "node:crypto";
3
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
4
+ import {
5
+ createRoleFixture,
6
+ deleteRoleIfExists,
7
+ createUserFixture,
8
+ deleteUserIfExists,
9
+ setUserAclVisibility
10
+ } from "@open-mercato/core/helpers/integration/authFixtures";
11
+ import {
12
+ createOrganizationInDb,
13
+ deleteOrganizationInDb,
14
+ deleteUserAclInDb
15
+ } from "@open-mercato/core/helpers/integration/dbFixtures";
16
+ import { getTokenScope, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
17
+ import { deleteConversationCascadeInDb } from "./helpers/aiAssistantFixtures.js";
18
+ const CONVERSATIONS = "/api/ai_assistant/ai/conversations";
19
+ test.describe("TC-AI-CONVERSATIONS-001: Conversation CRUD lifecycle + org scoping", () => {
20
+ test("create (idempotent) -> list -> patch -> soft-delete hides from list", async ({ request }) => {
21
+ test.slow();
22
+ const adminToken = await getAuthToken(request, "admin");
23
+ const { tenantId } = getTokenScope(adminToken);
24
+ const agentId = `it_conv.agent_${randomUUID().slice(0, 8)}`;
25
+ const conversationId = `it-conv-${randomUUID()}`;
26
+ try {
27
+ const createRes = await apiRequest(request, "POST", CONVERSATIONS, {
28
+ token: adminToken,
29
+ data: { agentId, conversationId, title: "Original title" }
30
+ });
31
+ expect(createRes.status(), "create returns 201").toBe(201);
32
+ const created = await readJsonSafe(createRes);
33
+ expect(created?.conversationId).toBe(conversationId);
34
+ expect(created?.agentId).toBe(agentId);
35
+ expect(created?.title).toBe("Original title");
36
+ expect(created?.status).toBe("open");
37
+ expect(created?.visibility).toBe("private");
38
+ expect(created?.isOwner).toBeNull();
39
+ const recreate = await apiRequest(request, "POST", CONVERSATIONS, {
40
+ token: adminToken,
41
+ data: { agentId, conversationId, title: "Original title" }
42
+ });
43
+ expect(recreate.status(), "re-create with same id is idempotent (200)").toBe(200);
44
+ const getItem = await apiRequest(
45
+ request,
46
+ "GET",
47
+ `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
48
+ { token: adminToken }
49
+ );
50
+ expect(getItem.status()).toBe(200);
51
+ const item = await readJsonSafe(getItem);
52
+ expect(item?.conversation.conversationId).toBe(conversationId);
53
+ expect(item?.conversation.isOwner).toBe(true);
54
+ const listRes = await apiRequest(
55
+ request,
56
+ "GET",
57
+ `${CONVERSATIONS}?agent=${encodeURIComponent(agentId)}`,
58
+ { token: adminToken }
59
+ );
60
+ expect(listRes.status()).toBe(200);
61
+ const list = await readJsonSafe(listRes);
62
+ expect(Array.isArray(list?.items)).toBe(true);
63
+ expect(list?.items.some((c) => c.conversationId === conversationId)).toBe(true);
64
+ const patchRes = await apiRequest(
65
+ request,
66
+ "PATCH",
67
+ `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
68
+ { token: adminToken, data: { title: "Renamed title", status: "closed" } }
69
+ );
70
+ expect(patchRes.status()).toBe(200);
71
+ const patched = await readJsonSafe(patchRes);
72
+ expect(patched?.title).toBe("Renamed title");
73
+ expect(patched?.status).toBe("closed");
74
+ const delRes = await apiRequest(
75
+ request,
76
+ "DELETE",
77
+ `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
78
+ { token: adminToken }
79
+ );
80
+ expect(delRes.status(), "delete returns 200 (soft delete)").toBe(200);
81
+ expect((await readJsonSafe(delRes))?.ok).toBe(true);
82
+ const listAfter = await apiRequest(
83
+ request,
84
+ "GET",
85
+ `${CONVERSATIONS}?agent=${encodeURIComponent(agentId)}`,
86
+ { token: adminToken }
87
+ );
88
+ expect(listAfter.status()).toBe(200);
89
+ const listAfterBody = await readJsonSafe(listAfter);
90
+ expect(listAfterBody?.items.some((c) => c.conversationId === conversationId)).toBe(false);
91
+ } finally {
92
+ await deleteConversationCascadeInDb({ tenantId, conversationId }).catch(() => void 0);
93
+ }
94
+ });
95
+ test("validation + auth gates: missing agentId -> 400, unauthenticated -> 401", async ({ request, baseURL }) => {
96
+ const adminToken = await getAuthToken(request, "admin");
97
+ const badCreate = await apiRequest(request, "POST", CONVERSATIONS, {
98
+ token: adminToken,
99
+ data: { title: "no agent id" }
100
+ });
101
+ expect(badCreate.status()).toBe(400);
102
+ expect((await readJsonSafe(badCreate))?.code).toBe("validation_error");
103
+ const anon = await playwrightRequest.newContext({ baseURL });
104
+ try {
105
+ const res = await anon.fetch(CONVERSATIONS, { method: "GET" });
106
+ expect(res.status(), "unauthenticated list is 401").toBe(401);
107
+ } finally {
108
+ await anon.dispose();
109
+ }
110
+ });
111
+ test("cross-org caller cannot GET/PATCH/DELETE another org conversation (404)", async ({ request }) => {
112
+ test.slow();
113
+ const adminToken = await getAuthToken(request, "admin");
114
+ const { tenantId } = getTokenScope(adminToken);
115
+ const stamp = randomUUID().slice(0, 8);
116
+ const password = "Secret123!";
117
+ const agentId = `it_conv.xorg_${stamp}`;
118
+ const conversationId = `it-conv-xorg-${randomUUID()}`;
119
+ let orgBId = null;
120
+ let roleId = null;
121
+ let userId = null;
122
+ try {
123
+ orgBId = await createOrganizationInDb({ name: `IT Conv OrgB ${stamp}`, tenantId });
124
+ roleId = await createRoleFixture(request, adminToken, { name: `IT Conv Role ${stamp}` });
125
+ userId = await createUserFixture(request, adminToken, {
126
+ email: `it-conv-${stamp}@example.com`,
127
+ password,
128
+ organizationId: orgBId,
129
+ roles: [roleId]
130
+ });
131
+ await setUserAclVisibility(request, adminToken, {
132
+ userId,
133
+ features: ["ai_assistant.view", "ai_assistant.conversations.share"],
134
+ organizations: [orgBId]
135
+ });
136
+ const otherToken = await getAuthToken(request, `it-conv-${stamp}@example.com`, password);
137
+ expect(getTokenScope(otherToken).organizationId, "fixture user is homed in org B").toBe(orgBId);
138
+ const createRes = await apiRequest(request, "POST", CONVERSATIONS, {
139
+ token: adminToken,
140
+ data: { agentId, conversationId, title: "Org A private" }
141
+ });
142
+ expect(createRes.status()).toBe(201);
143
+ const getRes = await apiRequest(
144
+ request,
145
+ "GET",
146
+ `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
147
+ { token: otherToken }
148
+ );
149
+ expect(getRes.status(), "cross-org GET is 404 (not 403)").toBe(404);
150
+ expect((await readJsonSafe(getRes))?.code).toBe("conversation_not_found");
151
+ const patchRes = await apiRequest(
152
+ request,
153
+ "PATCH",
154
+ `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
155
+ { token: otherToken, data: { title: "hijack" } }
156
+ );
157
+ expect(patchRes.status(), "cross-org PATCH is 404").toBe(404);
158
+ const delRes = await apiRequest(
159
+ request,
160
+ "DELETE",
161
+ `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
162
+ { token: otherToken }
163
+ );
164
+ expect(delRes.status(), "cross-org DELETE is 404").toBe(404);
165
+ } finally {
166
+ await deleteConversationCascadeInDb({ tenantId, conversationId }).catch(() => void 0);
167
+ await deleteUserAclInDb(userId ?? "").catch(() => void 0);
168
+ await deleteUserIfExists(request, adminToken, userId);
169
+ await deleteRoleIfExists(request, adminToken, roleId);
170
+ await deleteOrganizationInDb(orgBId).catch(() => void 0);
171
+ }
172
+ });
173
+ });
174
+ //# sourceMappingURL=TC-AI-CONVERSATIONS-001-create-list-delete.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.ts"],
4
+ "sourcesContent": ["import { test, expect, request as playwrightRequest } from '@playwright/test';\nimport { randomUUID } from 'node:crypto';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n createRoleFixture,\n deleteRoleIfExists,\n createUserFixture,\n deleteUserIfExists,\n setUserAclVisibility,\n} from '@open-mercato/core/helpers/integration/authFixtures';\nimport {\n createOrganizationInDb,\n deleteOrganizationInDb,\n deleteUserAclInDb,\n} from '@open-mercato/core/helpers/integration/dbFixtures';\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { deleteConversationCascadeInDb } from './helpers/aiAssistantFixtures';\n\n/**\n * TC-AI-CONVERSATIONS-001 \u2014 Conversation CRUD lifecycle (create, list, patch, delete).\n * Source: GitHub issue #2495.\n *\n * Surfaces under test:\n * - /api/ai_assistant/ai/conversations (POST, GET)\n * - /api/ai_assistant/ai/conversations/{id} (PATCH, DELETE)\n *\n * Contract notes verified against the route handlers (not the issue's guesses):\n * - create body field is `agentId` (required); response is a single serialized\n * conversation with `conversationId/agentId/title/status/visibility/isOwner`.\n * - re-creating the same `conversationId` is idempotent -> 200 (vs 201 first time).\n * - DELETE is a SOFT delete returning 200 `{ ok: true }` (NOT 204).\n * - item routes are scoped by tenant+organization+ownership; a caller in a\n * different organization sees the row as absent -> 404 `conversation_not_found`\n * (NOT 403).\n */\n\nconst CONVERSATIONS = '/api/ai_assistant/ai/conversations';\n\ninterface SerializedConversation {\n conversationId: string;\n agentId: string;\n title: string | null;\n status: string;\n visibility: string;\n isOwner: boolean | null;\n participantCount: number;\n}\n\ntest.describe('TC-AI-CONVERSATIONS-001: Conversation CRUD lifecycle + org scoping', () => {\n test('create (idempotent) -> list -> patch -> soft-delete hides from list', async ({ request }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId } = getTokenScope(adminToken);\n const agentId = `it_conv.agent_${randomUUID().slice(0, 8)}`;\n const conversationId = `it-conv-${randomUUID()}`;\n\n try {\n const createRes = await apiRequest(request, 'POST', CONVERSATIONS, {\n token: adminToken,\n data: { agentId, conversationId, title: 'Original title' },\n });\n expect(createRes.status(), 'create returns 201').toBe(201);\n const created = await readJsonSafe<SerializedConversation>(createRes);\n expect(created?.conversationId).toBe(conversationId);\n expect(created?.agentId).toBe(agentId);\n expect(created?.title).toBe('Original title');\n expect(created?.status).toBe('open');\n expect(created?.visibility).toBe('private');\n // `isOwner` is only enriched on the item GET (which passes callerUserId);\n // on the create/list responses it is intentionally null.\n expect(created?.isOwner).toBeNull();\n\n const recreate = await apiRequest(request, 'POST', CONVERSATIONS, {\n token: adminToken,\n data: { agentId, conversationId, title: 'Original title' },\n });\n expect(recreate.status(), 're-create with same id is idempotent (200)').toBe(200);\n\n // Item GET enriches ownership: the creator is the owner.\n const getItem = await apiRequest(\n request,\n 'GET',\n `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,\n { token: adminToken },\n );\n expect(getItem.status()).toBe(200);\n const item = await readJsonSafe<{ conversation: SerializedConversation }>(getItem);\n expect(item?.conversation.conversationId).toBe(conversationId);\n expect(item?.conversation.isOwner).toBe(true);\n\n const listRes = await apiRequest(\n request,\n 'GET',\n `${CONVERSATIONS}?agent=${encodeURIComponent(agentId)}`,\n { token: adminToken },\n );\n expect(listRes.status()).toBe(200);\n const list = await readJsonSafe<{ items: SerializedConversation[]; nextCursor: string | null }>(listRes);\n expect(Array.isArray(list?.items)).toBe(true);\n expect(list?.items.some((c) => c.conversationId === conversationId)).toBe(true);\n\n const patchRes = await apiRequest(\n request,\n 'PATCH',\n `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,\n { token: adminToken, data: { title: 'Renamed title', status: 'closed' } },\n );\n expect(patchRes.status()).toBe(200);\n const patched = await readJsonSafe<SerializedConversation>(patchRes);\n expect(patched?.title).toBe('Renamed title');\n expect(patched?.status).toBe('closed');\n\n const delRes = await apiRequest(\n request,\n 'DELETE',\n `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,\n { token: adminToken },\n );\n expect(delRes.status(), 'delete returns 200 (soft delete)').toBe(200);\n expect((await readJsonSafe<{ ok: boolean }>(delRes))?.ok).toBe(true);\n\n const listAfter = await apiRequest(\n request,\n 'GET',\n `${CONVERSATIONS}?agent=${encodeURIComponent(agentId)}`,\n { token: adminToken },\n );\n expect(listAfter.status()).toBe(200);\n const listAfterBody = await readJsonSafe<{ items: SerializedConversation[] }>(listAfter);\n expect(listAfterBody?.items.some((c) => c.conversationId === conversationId)).toBe(false);\n } finally {\n await deleteConversationCascadeInDb({ tenantId, conversationId }).catch(() => undefined);\n }\n });\n\n test('validation + auth gates: missing agentId -> 400, unauthenticated -> 401', async ({ request, baseURL }) => {\n const adminToken = await getAuthToken(request, 'admin');\n\n const badCreate = await apiRequest(request, 'POST', CONVERSATIONS, {\n token: adminToken,\n data: { title: 'no agent id' },\n });\n expect(badCreate.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(badCreate))?.code).toBe('validation_error');\n\n const anon = await playwrightRequest.newContext({ baseURL });\n try {\n const res = await anon.fetch(CONVERSATIONS, { method: 'GET' });\n expect(res.status(), 'unauthenticated list is 401').toBe(401);\n } finally {\n await anon.dispose();\n }\n });\n\n test('cross-org caller cannot GET/PATCH/DELETE another org conversation (404)', async ({ request }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId } = getTokenScope(adminToken);\n const stamp = randomUUID().slice(0, 8);\n const password = 'Secret123!';\n const agentId = `it_conv.xorg_${stamp}`;\n const conversationId = `it-conv-xorg-${randomUUID()}`;\n\n let orgBId: string | null = null;\n let roleId: string | null = null;\n let userId: string | null = null;\n try {\n orgBId = await createOrganizationInDb({ name: `IT Conv OrgB ${stamp}`, tenantId });\n roleId = await createRoleFixture(request, adminToken, { name: `IT Conv Role ${stamp}` });\n userId = await createUserFixture(request, adminToken, {\n email: `it-conv-${stamp}@example.com`,\n password,\n organizationId: orgBId,\n roles: [roleId],\n });\n await setUserAclVisibility(request, adminToken, {\n userId,\n features: ['ai_assistant.view', 'ai_assistant.conversations.share'],\n organizations: [orgBId],\n });\n const otherToken = await getAuthToken(request, `it-conv-${stamp}@example.com`, password);\n expect(getTokenScope(otherToken).organizationId, 'fixture user is homed in org B').toBe(orgBId);\n\n const createRes = await apiRequest(request, 'POST', CONVERSATIONS, {\n token: adminToken,\n data: { agentId, conversationId, title: 'Org A private' },\n });\n expect(createRes.status()).toBe(201);\n\n const getRes = await apiRequest(\n request,\n 'GET',\n `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,\n { token: otherToken },\n );\n expect(getRes.status(), 'cross-org GET is 404 (not 403)').toBe(404);\n expect((await readJsonSafe<{ code?: string }>(getRes))?.code).toBe('conversation_not_found');\n\n const patchRes = await apiRequest(\n request,\n 'PATCH',\n `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,\n { token: otherToken, data: { title: 'hijack' } },\n );\n expect(patchRes.status(), 'cross-org PATCH is 404').toBe(404);\n\n const delRes = await apiRequest(\n request,\n 'DELETE',\n `${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,\n { token: otherToken },\n );\n expect(delRes.status(), 'cross-org DELETE is 404').toBe(404);\n } finally {\n await deleteConversationCascadeInDb({ tenantId, conversationId }).catch(() => undefined);\n await deleteUserAclInDb(userId ?? '').catch(() => undefined);\n await deleteUserIfExists(request, adminToken, userId);\n await deleteRoleIfExists(request, adminToken, roleId);\n await deleteOrganizationInDb(orgBId).catch(() => undefined);\n }\n });\n});\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }