Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
551 lines
23 KiB
JavaScript
551 lines
23 KiB
JavaScript
// ADR-0068 WP-B BE — comment-thread + channel-readonly + send-as-space.
|
|
//
|
|
// Covers all four guards locked in the chat sign-off (2026-05-19, T-159008):
|
|
// 1. POST /conversations/:id/comment-thread idempotency (twice → same id, no 4xx).
|
|
// 2. is_readonly=true blocks non-owner sends (403 + comment_thread_child_id hint).
|
|
// 3. is_readonly=true + sender_kind='space' + chat owner → message goes through
|
|
// with sender_id preserved (audit) and sender_space_id stamped.
|
|
// 4. PATCH /messages/:id/content — only the real author (sender_id) may edit.
|
|
//
|
|
// Plus the cascade-archive contract verified at the DELETE handler boundary:
|
|
// 5. DELETE /conversations/:id first runs the UPDATE that flips comment-thread
|
|
// children to purpose='comments_archived' before any message DELETE.
|
|
//
|
|
// Strategy mirrors messageController-move-owner-gate.test.js: mock chatShared.js
|
|
// + the heavy agent helpers, build the routers in-memory, hit them via supertest.
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import { Router } from 'express';
|
|
|
|
const dbGetMock = vi.fn();
|
|
const dbRunMock = vi.fn();
|
|
const dbAllMock = vi.fn();
|
|
const canAdministerMock = vi.fn();
|
|
|
|
function fakeRequireAuth(req, _res, next) {
|
|
const id = Number(req.headers['x-test-user-id']);
|
|
req.user = { userId: id, id, role: req.headers['x-test-user-role'] || 'user' };
|
|
next();
|
|
}
|
|
|
|
const success = (res, data) => res.status(200).json({ success: true, data });
|
|
const created = (res, data) => res.status(201).json({ success: true, data });
|
|
const error = (res, code, msg, status = 500) =>
|
|
res.status(status).json({ success: false, error: code, message: msg });
|
|
const badRequest = (res, msg) => res.status(400).json({ success: false, error: msg });
|
|
const notFound = (res, msg) => res.status(404).json({ success: false, error: msg });
|
|
const forbidden = (res, msg) => res.status(403).json({ success: false, error: msg });
|
|
const unauthorized = (res, msg) => res.status(401).json({ success: false, error: msg });
|
|
|
|
vi.mock('../chatShared.js', () => ({
|
|
dbRun: (...args) => dbRunMock(...args),
|
|
dbGet: (...args) => dbGetMock(...args),
|
|
dbAll: (...args) => dbAllMock(...args),
|
|
isPostgres: () => true,
|
|
safeJsonParse: (v, fb) => { try { return v ? JSON.parse(v) : (fb ?? null); } catch { return fb ?? null; } },
|
|
apiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
success, created, error, badRequest, notFound, forbidden, unauthorized,
|
|
requireAuth: fakeRequireAuth,
|
|
getAttachmentBaseUrl: () => '',
|
|
conversationLock: { acquire: async (_id, fn) => fn() },
|
|
parseInvocationMentions: () => [],
|
|
parseInvocationCommands: () => [],
|
|
parseMentions: () => [],
|
|
parseDelegations: () => [],
|
|
parseReferenceMentions: () => [],
|
|
parseReferenceCommands: () => [],
|
|
BUBBLE_PAGE_SIZE: 50,
|
|
}));
|
|
|
|
vi.mock('../../../../services/messageMoveService.js', () => ({
|
|
moveMessages: vi.fn(),
|
|
MoveValidationError: class extends Error {},
|
|
MoveAuthError: class extends Error {},
|
|
}));
|
|
|
|
vi.mock('../../../../services/criterionTicketSpawnService.js', () => ({
|
|
spawnTicketFromCriterion: vi.fn(),
|
|
SpawnValidationError: class extends Error {},
|
|
}));
|
|
|
|
vi.mock('../../../../services/EffectiveRoleService.js', () => ({
|
|
canAdminister: (...args) => canAdministerMock(...args),
|
|
}));
|
|
|
|
vi.mock('../chatAgentHelpers.js', () => ({
|
|
resolveMentionedUser: vi.fn(),
|
|
resolveAgentUser: vi.fn(),
|
|
findAiAgentByCommand: vi.fn(),
|
|
autoJoinAgentToConversation: vi.fn(),
|
|
resolveAgentInfoForMessages: vi.fn(async (rows) => rows),
|
|
validateSubAgentRowIds: vi.fn(async () => []),
|
|
enrichSubAgents: vi.fn(async () => []),
|
|
}));
|
|
|
|
vi.mock('../chatAgentExecution.js', () => ({
|
|
executeAgentResponse: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../chatAgentAutoRespond.js', () => ({
|
|
getAutoRespondAgents: vi.fn(async () => []),
|
|
shouldAutoRespondWithAI: vi.fn(() => false),
|
|
getDefaultAgentForConversation: vi.fn(async () => null),
|
|
}));
|
|
|
|
vi.mock('../../../../services/inflight/queryActive.js', () => ({
|
|
queryActiveInflight: vi.fn(async () => []),
|
|
}));
|
|
|
|
const { default: registerMessageRoutes } = await import('../messageController.js');
|
|
const { default: registerConversationCrudRoutes } = await import('../conversationCrudController.js');
|
|
const { default: registerConversationExtrasRoutes } = await import('../conversationExtrasController.js');
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
const router = Router();
|
|
registerConversationCrudRoutes(router);
|
|
registerConversationExtrasRoutes(router);
|
|
registerMessageRoutes(router);
|
|
app.use('/api/v3/chat', router);
|
|
return app;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 1. POST /conversations/:id/comment-thread — idempotency
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('ADR-0068 WP-B1 — POST /conversations/:id/comment-thread', () => {
|
|
beforeEach(() => {
|
|
dbGetMock.mockReset();
|
|
dbRunMock.mockReset();
|
|
});
|
|
|
|
it('returns the existing child on the second call (no INSERT)', async () => {
|
|
// First call path is exercised by the integration suite; here we cover the
|
|
// pure idempotency branch — existing child already in the DB.
|
|
const existingChild = { id: 555, parent_conversation_id: 100, purpose: 'comments', title: 'Comments — #100' };
|
|
dbGetMock
|
|
.mockResolvedValueOnce({ id: 100, title: 'Parent', space_id: 11 }) // parent lookup
|
|
.mockResolvedValueOnce(existingChild); // existing-child lookup
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/100/comment-thread')
|
|
.set('x-test-user-id', '7')
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.data.found).toBe(true);
|
|
expect(res.body.data.child_id).toBe(555);
|
|
// No INSERT into conversations should have happened.
|
|
const insertConversationCalls = dbRunMock.mock.calls.filter(([sql]) =>
|
|
typeof sql === 'string' && /INSERT INTO conversations/i.test(sql)
|
|
);
|
|
expect(insertConversationCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects with 400 when conversation id is invalid', async () => {
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/notanumber/comment-thread')
|
|
.set('x-test-user-id', '7')
|
|
.send({});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 404 when parent does not exist', async () => {
|
|
dbGetMock.mockResolvedValueOnce(null); // parent lookup
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/9999/comment-thread')
|
|
.set('x-test-user-id', '7')
|
|
.send({});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 2-3. POST /conversations/:id/messages — readonly guard + send-as-space
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('ADR-0068 WP-B3 — readonly guard on POST /messages', () => {
|
|
beforeEach(() => {
|
|
dbGetMock.mockReset();
|
|
dbRunMock.mockReset();
|
|
canAdministerMock.mockReset();
|
|
});
|
|
|
|
it('non-owner is blocked with 403 and comment_thread_child_id hint', async () => {
|
|
// 1) conversation lookup (is_readonly = true, owner = 7)
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 10, type: 'chat', space_id: 11, created_by: 7,
|
|
is_readonly: true, parent_conversation_id: null, purpose: null,
|
|
});
|
|
// 2) comment-thread child lookup for the redirect hint
|
|
dbGetMock.mockResolvedValueOnce({ id: 555 });
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '8') // not the owner
|
|
.send({ content: 'forbidden words' });
|
|
|
|
expect(res.status).toBe(403);
|
|
const body = JSON.parse(res.body.error);
|
|
expect(body.code).toBe('READONLY_CONVERSATION');
|
|
expect(body.comment_thread_child_id).toBe(555);
|
|
});
|
|
|
|
it('owner posting as space bypasses the readonly lock', async () => {
|
|
// 1) conversation (readonly, owner=7)
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 10, type: 'chat', space_id: 11, created_by: 7,
|
|
is_readonly: true, parent_conversation_id: null, purpose: null,
|
|
});
|
|
// 2) participant lookup
|
|
dbGetMock.mockResolvedValueOnce({ conversation_id: 10, user_id: 7, role: 'admin' });
|
|
// 3) re-read of inserted message
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 901, conversation_id: 10, sender_id: 7, role: 'user', content: 'hello',
|
|
content_type: 'text', mentions: '[]', attachments: '[]',
|
|
sender_kind: 'space', sender_space_id: 11,
|
|
});
|
|
// 4) space_id lookup for the auto-respond path (line ~115)
|
|
dbGetMock.mockResolvedValueOnce({ space_id: 11 });
|
|
dbRunMock.mockResolvedValue({ lastInsertRowid: 901 });
|
|
canAdministerMock.mockResolvedValue(false); // owner gate passes via created_by
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '7')
|
|
.send({ content: 'hello', sender_kind: 'space', sender_space_id: 11 });
|
|
|
|
// POST /messages → 201 Created (canonical response helper).
|
|
expect(res.status).toBe(201);
|
|
// The INSERT carried sender_id=7 (real actor) AND the persona stamp.
|
|
const insertCall = dbRunMock.mock.calls.find(([sql]) =>
|
|
typeof sql === 'string' && /INSERT INTO messages/i.test(sql)
|
|
);
|
|
expect(insertCall).toBeDefined();
|
|
const params = insertCall[1];
|
|
// params order: [id, userId, role, content, content_type, mentionsJson,
|
|
// attachmentsJson, parent_id, metadataJson, sender_kind, sender_space_id]
|
|
expect(params[1]).toBe(7); // sender_id = real user (audit)
|
|
expect(params[9]).toBe('space'); // sender_kind
|
|
expect(params[10]).toBe(11); // sender_space_id
|
|
});
|
|
|
|
it('non-owner attempting send-as-space gets 403', async () => {
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 10, type: 'chat', space_id: 11, created_by: 7,
|
|
is_readonly: false, parent_conversation_id: null, purpose: null,
|
|
});
|
|
canAdministerMock.mockResolvedValue(false);
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '8')
|
|
.send({ content: 'impersonation attempt', sender_kind: 'space', sender_space_id: 11 });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/send-as-space/i);
|
|
});
|
|
|
|
it('send-as-space across different spaces is rejected with 400', async () => {
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 10, type: 'chat', space_id: 11, created_by: 7,
|
|
is_readonly: false, parent_conversation_id: null, purpose: null,
|
|
});
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '7')
|
|
.send({ content: 'wrong space', sender_kind: 'space', sender_space_id: 999 });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/must match the conversation's space/);
|
|
});
|
|
|
|
it('rejects unknown sender_kind values', async () => {
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 10, type: 'chat', space_id: 11, created_by: 7,
|
|
is_readonly: false, parent_conversation_id: null, purpose: null,
|
|
});
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '7')
|
|
.send({ content: 'agent injection', sender_kind: 'agent', sender_space_id: 11 });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid sender_kind/);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 4. PATCH /messages/:id/content — edit-only-real-actor
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('ADR-0068 WP-B4 — edit-only-real-actor on PATCH /messages/:id/content', () => {
|
|
beforeEach(() => {
|
|
dbGetMock.mockReset();
|
|
dbRunMock.mockReset();
|
|
});
|
|
|
|
it('blocks edits by a non-author with 403', async () => {
|
|
dbGetMock.mockResolvedValueOnce({ id: 901, conversation_id: 10, sender_id: 7, content: 'old', is_deleted: 0 });
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.patch('/api/v3/chat/messages/901/content')
|
|
.set('x-test-user-id', '8') // different user
|
|
.send({ content: 'tampered' });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(dbRunMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows the original author to edit', async () => {
|
|
dbGetMock.mockResolvedValueOnce({ id: 901, conversation_id: 10, sender_id: 7, content: 'old', is_deleted: 0 });
|
|
dbRunMock.mockResolvedValue({});
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.patch('/api/v3/chat/messages/901/content')
|
|
.set('x-test-user-id', '7')
|
|
.send({ content: 'fresh' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(dbRunMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 5. DELETE /conversations/:id — cascade-archive contract
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('ADR-0068 WP-B Cascade — DELETE /conversations/:id', () => {
|
|
beforeEach(() => {
|
|
dbGetMock.mockReset();
|
|
dbRunMock.mockReset();
|
|
});
|
|
|
|
it('archives comment-thread children BEFORE wiping the parent', async () => {
|
|
dbRunMock.mockResolvedValue({});
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.delete('/api/v3/chat/conversations/100')
|
|
.set('x-test-user-id', '7');
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
// Inspect the order of dbRun calls; archive must come first, then the
|
|
// hard-delete cascade on messages/participants/conversation.
|
|
const sqls = dbRunMock.mock.calls.map(([sql]) => sql);
|
|
const archiveIdx = sqls.findIndex(s => /UPDATE conversations[\s\S]*purpose\s*=\s*'comments_archived'/i.test(s));
|
|
const messagesDeleteIdx = sqls.findIndex(s => /DELETE FROM messages/i.test(s));
|
|
const conversationsDeleteIdx = sqls.findIndex(s => /DELETE FROM conversations/i.test(s));
|
|
|
|
expect(archiveIdx).toBeGreaterThanOrEqual(0);
|
|
expect(messagesDeleteIdx).toBeGreaterThan(archiveIdx);
|
|
expect(conversationsDeleteIdx).toBeGreaterThan(archiveIdx);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 6. GET /conversations/:id/messages — persona JOIN on `spaces`
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('ADR-0068 WP-B5 — persona enrichment on GET /messages', () => {
|
|
beforeEach(() => {
|
|
dbGetMock.mockReset();
|
|
dbRunMock.mockReset();
|
|
dbAllMock.mockReset();
|
|
});
|
|
|
|
it('JOINs spaces and surfaces nested sender_space {id,name,icon,color} for sender_kind=space rows', async () => {
|
|
// Row 1 — user post, no persona fields populated by the JOIN.
|
|
// Row 2 — space-persona post, JOIN columns populated.
|
|
// Row 3 — space-persona post BUT referenced space row deleted (FK
|
|
// preserved on the message, JOIN returns NULLs).
|
|
dbAllMock.mockResolvedValueOnce([
|
|
{
|
|
id: 901, conversation_id: 10, sender_id: 7, content: 'plain user',
|
|
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
|
|
sender_kind: 'user', sender_space_id: null,
|
|
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
|
|
_sender_space_name: null, _sender_space_icon: null, _sender_space_color: null,
|
|
created_at: '2026-05-19T10:00:00Z',
|
|
},
|
|
{
|
|
id: 902, conversation_id: 10, sender_id: 7, content: 'broadcast',
|
|
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
|
|
sender_kind: 'space', sender_space_id: 11,
|
|
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
|
|
_sender_space_name: 'Development', _sender_space_icon: '🛠', _sender_space_color: '#0ea5e9',
|
|
created_at: '2026-05-19T10:01:00Z',
|
|
},
|
|
{
|
|
id: 903, conversation_id: 10, sender_id: 7, content: 'orphaned persona',
|
|
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
|
|
sender_kind: 'space', sender_space_id: 999,
|
|
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
|
|
_sender_space_name: null, _sender_space_icon: null, _sender_space_color: null,
|
|
created_at: '2026-05-19T10:02:00Z',
|
|
},
|
|
]);
|
|
// conversation lookup (Promise.all sibling).
|
|
dbGetMock
|
|
.mockResolvedValueOnce({
|
|
settings: null, bound_table_id: null, bound_row_id: null,
|
|
is_processing: false, processing_started_at: null,
|
|
processing_agent_id: null, processing_agent_name: null,
|
|
parent_conversation_id: null, purpose: null, is_readonly: false,
|
|
})
|
|
// contextStats
|
|
.mockResolvedValueOnce({ total_tokens_in: 0, total_tokens_out: 0, total_messages: 3, text_messages: 3, tool_calls: 0, thinking_steps: 0 })
|
|
// lastAgentMsg
|
|
.mockResolvedValueOnce(null)
|
|
// active_agent_status row
|
|
.mockResolvedValueOnce(null)
|
|
// active_plan row
|
|
.mockResolvedValueOnce(null)
|
|
// getCommentThreadChildId → existing-child lookup (returns null = no child)
|
|
.mockResolvedValueOnce(null);
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '7');
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
// 1) The SQL fired by dbAll must include the persona JOIN + underscore aliases.
|
|
const messagesCall = dbAllMock.mock.calls.find(([sql]) =>
|
|
typeof sql === 'string' && /FROM messages m/i.test(sql)
|
|
);
|
|
expect(messagesCall).toBeDefined();
|
|
const sql = messagesCall[0];
|
|
expect(sql).toMatch(/LEFT JOIN spaces s ON m\.sender_space_id = s\.id/);
|
|
expect(sql).toMatch(/s\.name as _sender_space_name/);
|
|
expect(sql).toMatch(/s\.icon as _sender_space_icon/);
|
|
expect(sql).toMatch(/s\.theme_primary as _sender_space_color/);
|
|
|
|
// 2) Internal underscore aliases MUST NOT leak in the response.
|
|
const messages = res.body.data.messages;
|
|
expect(messages).toHaveLength(3);
|
|
for (const m of messages) {
|
|
expect(m).not.toHaveProperty('_sender_space_name');
|
|
expect(m).not.toHaveProperty('_sender_space_icon');
|
|
expect(m).not.toHaveProperty('_sender_space_color');
|
|
}
|
|
|
|
// 3) Plain user message — kind='user', sender_space=null.
|
|
const userMsg = messages.find(m => m.id === 901);
|
|
expect(userMsg.sender_kind).toBe('user');
|
|
expect(userMsg.sender_space_id).toBeNull();
|
|
expect(userMsg.sender_space).toBeNull();
|
|
|
|
// 4) Space-persona message — kind='space', nested payload populated.
|
|
const spaceMsg = messages.find(m => m.id === 902);
|
|
expect(spaceMsg.sender_kind).toBe('space');
|
|
expect(spaceMsg.sender_space_id).toBe(11);
|
|
expect(spaceMsg.sender_space).toEqual({
|
|
id: 11, name: 'Development', icon: '🛠', color: '#0ea5e9',
|
|
});
|
|
// Real-actor identity is preserved alongside persona stamp (audit invariant).
|
|
expect(spaceMsg.sender_id).toBe(7);
|
|
expect(spaceMsg.sender_name).toBe('Alice');
|
|
|
|
// 5) Orphaned persona (space row deleted, FK preserved on message) —
|
|
// raw sender_space_id still surfaced for FE fallback rendering, nested
|
|
// payload null so FE can render "(deleted space)" placeholder.
|
|
const orphanMsg = messages.find(m => m.id === 903);
|
|
expect(orphanMsg.sender_kind).toBe('space');
|
|
expect(orphanMsg.sender_space_id).toBe(999);
|
|
expect(orphanMsg.sender_space).toBeNull();
|
|
});
|
|
|
|
it('coerces NULL sender_kind to "user" on legacy rows', async () => {
|
|
dbAllMock.mockResolvedValueOnce([
|
|
{
|
|
id: 800, conversation_id: 10, sender_id: 7, content: 'legacy',
|
|
content_type: 'text', mentions: '[]', attachments: '[]', metadata: null,
|
|
sender_kind: null, sender_space_id: null,
|
|
sender_name: 'Alice', sender_avatar: null, sender_user_type: 'human',
|
|
_sender_space_name: null, _sender_space_icon: null, _sender_space_color: null,
|
|
created_at: '2026-05-19T09:00:00Z',
|
|
},
|
|
]);
|
|
dbGetMock
|
|
.mockResolvedValueOnce({
|
|
settings: null, bound_table_id: null, bound_row_id: null,
|
|
is_processing: false, processing_started_at: null,
|
|
processing_agent_id: null, processing_agent_name: null,
|
|
parent_conversation_id: null, purpose: null, is_readonly: false,
|
|
})
|
|
.mockResolvedValueOnce({ total_tokens_in: 0, total_tokens_out: 0, total_messages: 1, text_messages: 1, tool_calls: 0, thinking_steps: 0 })
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null);
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '7');
|
|
|
|
expect(res.status).toBe(200);
|
|
const [legacy] = res.body.data.messages;
|
|
expect(legacy.sender_kind).toBe('user');
|
|
expect(legacy.sender_space).toBeNull();
|
|
});
|
|
|
|
it('POST /messages response carries the nested sender_space for send-as-space', async () => {
|
|
// 1) conversation (not readonly, owner=7).
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 10, type: 'chat', space_id: 11, created_by: 7,
|
|
is_readonly: false, parent_conversation_id: null, purpose: null,
|
|
});
|
|
// 2) participant lookup.
|
|
dbGetMock.mockResolvedValueOnce({ conversation_id: 10, user_id: 7, role: 'admin' });
|
|
// 3) re-read of inserted message (SELECT * FROM messages WHERE id = $1).
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 950, conversation_id: 10, sender_id: 7, role: 'user', content: 'hello',
|
|
content_type: 'text', mentions: '[]', attachments: '[]', metadata: '{}',
|
|
sender_kind: 'space', sender_space_id: 11,
|
|
});
|
|
// 4) sender_space enrichment dbGet on spaces (POST-only, parity with GET).
|
|
dbGetMock.mockResolvedValueOnce({
|
|
id: 11, name: 'Development', icon: '🛠', theme_primary: '#0ea5e9',
|
|
});
|
|
// 5) space_id lookup for the auto-respond path.
|
|
dbGetMock.mockResolvedValueOnce({ space_id: 11 });
|
|
dbRunMock.mockResolvedValue({ lastInsertRowid: 950 });
|
|
canAdministerMock.mockResolvedValue(true);
|
|
|
|
const app = buildApp();
|
|
const res = await request(app)
|
|
.post('/api/v3/chat/conversations/10/messages')
|
|
.set('x-test-user-id', '7')
|
|
.send({ content: 'hello', sender_kind: 'space', sender_space_id: 11 });
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = res.body.data;
|
|
expect(body.sender_kind).toBe('space');
|
|
expect(body.sender_space_id).toBe(11);
|
|
expect(body.sender_space).toEqual({
|
|
id: 11, name: 'Development', icon: '🛠', color: '#0ea5e9',
|
|
});
|
|
// Real actor preserved on response (audit) even when posting as a space.
|
|
expect(body.sender_id).toBe(7);
|
|
});
|
|
});
|