Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
270 lines
11 KiB
JavaScript
270 lines
11 KiB
JavaScript
// ADR-0031 P5 / ADR-133 WP-20 — moveMessages tests
|
|
//
|
|
// Covers:
|
|
// 1. Move 3 messages → source becomes 3 stubs, target gets 3 inserts with moved_from
|
|
// 2. Auth fail (non-participant) → throws MoveAuthError
|
|
// 3. Source/target same → throws MoveValidationError
|
|
// 4. Already-moved message → throws MoveValidationError
|
|
// 5. Agent context reader (formatMessageByLevel) replaces 'moved' content with breadcrumb
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
const dbGetMock = vi.fn();
|
|
const withTransactionAsyncMock = vi.fn();
|
|
|
|
vi.mock('../../database/connection.js', () => ({
|
|
dbGet: (...args) => dbGetMock(...args),
|
|
withTransactionAsync: (...args) => withTransactionAsyncMock(...args),
|
|
}));
|
|
|
|
vi.mock('../../utils/logger.js', () => ({
|
|
apiLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
}));
|
|
|
|
import { moveMessages, MoveValidationError, MoveAuthError } from '../messageMoveService.js';
|
|
import { formatMessageByLevel } from '../chat/agent-execution-shared/helpers.js';
|
|
|
|
describe('moveMessages — ADR-0031 P5 / ADR-133 WP-20', () => {
|
|
beforeEach(() => {
|
|
dbGetMock.mockReset();
|
|
withTransactionAsyncMock.mockReset();
|
|
});
|
|
|
|
function authBoth() {
|
|
dbGetMock.mockImplementation((sql, params) => {
|
|
if (/conversation_participants/.test(sql)) return Promise.resolve({ user_id: params[1] });
|
|
// ADR-0031 §Z / WP-24: actor snapshot for moved_by metadata
|
|
if (/FROM users WHERE id/i.test(sql)) {
|
|
return Promise.resolve({
|
|
id: params[0], name: 'Alice Mover', username: 'alice', avatar: '/uploads/alice.png',
|
|
});
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
}
|
|
|
|
it('moves 3 messages: stubs source, inserts target with moved_from', async () => {
|
|
authBoth();
|
|
const sourceMsgs = [
|
|
{ id: 101, conversation_id: 1, sender_id: 7, sender_type: 'human', role: 'user',
|
|
content: 'first', content_type: 'text', agent_id: null, model_used: null,
|
|
mentions: '[]', attachments: '[]', tool_results: null, metadata: {},
|
|
created_at: '2026-05-04T10:00:00Z' },
|
|
{ id: 102, conversation_id: 1, sender_id: 7, sender_type: 'human', role: 'user',
|
|
content: 'second', content_type: 'text', agent_id: null, model_used: null,
|
|
mentions: '[]', attachments: '[]', tool_results: null, metadata: {},
|
|
created_at: '2026-05-04T10:01:00Z' },
|
|
{ id: 103, conversation_id: 1, sender_id: 7, sender_type: 'human', role: 'user',
|
|
content: 'third', content_type: 'text', agent_id: null, model_used: null,
|
|
mentions: '[]', attachments: '[]', tool_results: null, metadata: {},
|
|
created_at: '2026-05-04T10:02:00Z' },
|
|
];
|
|
const insertCalls = [];
|
|
const updateCalls = [];
|
|
let insertCounter = 200;
|
|
|
|
withTransactionAsyncMock.mockImplementation(async (cb) => {
|
|
const trx = {
|
|
all: vi.fn(async (sql, params) => {
|
|
if (/FROM messages\b/i.test(sql) && /id = ANY/i.test(sql)) return sourceMsgs;
|
|
return [];
|
|
}),
|
|
run: vi.fn(async (sql, params) => {
|
|
if (/^\s*INSERT INTO messages/i.test(sql)) {
|
|
insertCalls.push({ sql, params });
|
|
return { lastInsertRowid: ++insertCounter, changes: 1 };
|
|
}
|
|
if (/^\s*UPDATE messages/i.test(sql)) {
|
|
updateCalls.push({ sql, params });
|
|
return { changes: 1 };
|
|
}
|
|
if (/^\s*UPDATE conversations/i.test(sql)) return { changes: 1 };
|
|
return { changes: 0 };
|
|
}),
|
|
};
|
|
return cb(trx);
|
|
});
|
|
|
|
const result = await moveMessages({
|
|
sourceConversationId: 1,
|
|
targetConversationId: 2,
|
|
messageIds: [101, 102, 103],
|
|
userId: 7,
|
|
});
|
|
|
|
expect(result.moved_count).toBe(3);
|
|
expect(result.source_message_ids).toEqual([101, 102, 103]);
|
|
expect(result.target_message_ids).toEqual([201, 202, 203]);
|
|
// ADR-0031 §Z / WP-24: batch_id is a v4 UUID, returned from the service
|
|
expect(result.batch_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
const expectedBatchId = result.batch_id;
|
|
|
|
expect(insertCalls.length).toBe(3);
|
|
// Each insert contains conversation_id=2 + metadata with moved_from (incl. batch_id + moved_by)
|
|
const expectedMovedBy = { user_id: 7, name: 'Alice Mover', avatar: '/uploads/alice.png' };
|
|
const firstInsertMeta = JSON.parse(insertCalls[0].params[11]);
|
|
expect(firstInsertMeta.moved_from).toEqual({
|
|
conversation_id: 1, message_id: 101, original_time: '2026-05-04T10:00:00Z',
|
|
batch_id: expectedBatchId, moved_by: expectedMovedBy,
|
|
});
|
|
expect(insertCalls[0].params[0]).toBe(2);
|
|
// All 3 target messages share the same batch_id and same moved_by snapshot
|
|
for (const call of insertCalls) {
|
|
const meta = JSON.parse(call.params[11]);
|
|
expect(meta.moved_from.batch_id).toBe(expectedBatchId);
|
|
expect(meta.moved_from.moved_by).toEqual(expectedMovedBy);
|
|
}
|
|
|
|
expect(updateCalls.length).toBe(3);
|
|
// First stub points to its new id 201, plus the full batch [201,202,203] so
|
|
// the frontend [Открыть →] button can scroll to the first moved message.
|
|
const firstStubMeta = JSON.parse(updateCalls[0].params[1]);
|
|
expect(firstStubMeta.moved_to).toEqual({
|
|
conversation_id: 2, message_id: 201, message_ids: [201, 202, 203],
|
|
batch_id: expectedBatchId, moved_by: expectedMovedBy,
|
|
});
|
|
expect(updateCalls[0].params[0]).toBe('Moved to chat #2');
|
|
expect(updateCalls[0].params[2]).toBe(101);
|
|
// Third stub: own message_id is 203, batch identical, moved_by identical
|
|
const thirdStubMeta = JSON.parse(updateCalls[2].params[1]);
|
|
expect(thirdStubMeta.moved_to.message_id).toBe(203);
|
|
expect(thirdStubMeta.moved_to.message_ids).toEqual([201, 202, 203]);
|
|
expect(thirdStubMeta.moved_to.batch_id).toBe(expectedBatchId);
|
|
expect(thirdStubMeta.moved_to.moved_by).toEqual(expectedMovedBy);
|
|
});
|
|
|
|
it('strips oversized avatar (>2KB) from moved_by snapshot', async () => {
|
|
const longAvatar = 'data:image/png;base64,' + 'A'.repeat(3000);
|
|
dbGetMock.mockImplementation((sql, params) => {
|
|
if (/conversation_participants/.test(sql)) return Promise.resolve({ user_id: params[1] });
|
|
if (/FROM users WHERE id/i.test(sql)) {
|
|
return Promise.resolve({ id: params[0], name: 'Bob', username: 'bob', avatar: longAvatar });
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
const insertCalls = [];
|
|
let counter = 200;
|
|
withTransactionAsyncMock.mockImplementation(async (cb) => cb({
|
|
all: async () => [{
|
|
id: 101, conversation_id: 1, sender_id: 7, sender_type: 'human', role: 'user',
|
|
content: 'x', content_type: 'text', agent_id: null, model_used: null,
|
|
mentions: '[]', attachments: '[]', tool_results: null, metadata: {},
|
|
created_at: '2026-05-04T10:00:00Z',
|
|
}],
|
|
run: async (sql, params) => {
|
|
if (/^\s*INSERT INTO messages/i.test(sql)) {
|
|
insertCalls.push({ sql, params });
|
|
return { lastInsertRowid: ++counter };
|
|
}
|
|
return { changes: 1 };
|
|
},
|
|
}));
|
|
|
|
await moveMessages({
|
|
sourceConversationId: 1, targetConversationId: 2, messageIds: [101], userId: 7,
|
|
});
|
|
const meta = JSON.parse(insertCalls[0].params[11]);
|
|
expect(meta.moved_from.moved_by).toEqual({ user_id: 7, name: 'Bob', avatar: null });
|
|
});
|
|
|
|
it('refuses move when caller is not a participant of target', async () => {
|
|
dbGetMock.mockImplementation((sql, params) => {
|
|
if (/conversation_participants/.test(sql)) {
|
|
// participant of source (#1) but not target (#2)
|
|
if (Number(params[0]) === 1) return Promise.resolve({ user_id: params[1] });
|
|
return Promise.resolve(null);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
await expect(moveMessages({
|
|
sourceConversationId: 1, targetConversationId: 2, messageIds: [101], userId: 7,
|
|
})).rejects.toThrow(MoveAuthError);
|
|
// transaction must not run
|
|
expect(withTransactionAsyncMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects identical source and target', async () => {
|
|
authBoth();
|
|
await expect(moveMessages({
|
|
sourceConversationId: 1, targetConversationId: 1, messageIds: [101], userId: 7,
|
|
})).rejects.toThrow(MoveValidationError);
|
|
});
|
|
|
|
it('rejects already-moved messages', async () => {
|
|
authBoth();
|
|
withTransactionAsyncMock.mockImplementation(async (cb) => {
|
|
const trx = {
|
|
all: vi.fn(async () => [{
|
|
id: 101, conversation_id: 1, sender_id: 7, sender_type: 'human', role: 'user',
|
|
content: 'old', content_type: 'moved', agent_id: null, model_used: null,
|
|
mentions: '[]', attachments: '[]', tool_results: null,
|
|
metadata: { moved_to: { conversation_id: 9, message_ids: [555] } },
|
|
created_at: '2026-05-04T10:00:00Z',
|
|
}]),
|
|
run: vi.fn(),
|
|
};
|
|
return cb(trx);
|
|
});
|
|
|
|
await expect(moveMessages({
|
|
sourceConversationId: 1, targetConversationId: 2, messageIds: [101], userId: 7,
|
|
})).rejects.toThrow(/already moved/);
|
|
});
|
|
|
|
it('rejects empty message_ids', async () => {
|
|
authBoth();
|
|
await expect(moveMessages({
|
|
sourceConversationId: 1, targetConversationId: 2, messageIds: [], userId: 7,
|
|
})).rejects.toThrow(MoveValidationError);
|
|
});
|
|
|
|
it('actorIsChatOwner=true skips participation lookup (chat-owner / admin override path)', async () => {
|
|
// Participant lookup must be bypassed; only the actor-snapshot dbGet runs.
|
|
dbGetMock.mockReset();
|
|
dbGetMock.mockImplementation((sql, params) => {
|
|
if (/FROM users WHERE id/i.test(sql)) {
|
|
return Promise.resolve({ id: params[0], name: 'Owner', username: 'owner', avatar: null });
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
withTransactionAsyncMock.mockImplementation(async (cb) => cb({
|
|
all: async () => [{
|
|
id: 101, conversation_id: 1, sender_id: 7, sender_type: 'human', role: 'user',
|
|
content: 'x', content_type: 'text', agent_id: null, model_used: null,
|
|
mentions: '[]', attachments: '[]', tool_results: null, metadata: {},
|
|
created_at: '2026-05-04T10:00:00Z',
|
|
}],
|
|
run: async (sql) => /^\s*INSERT/i.test(sql) ? { lastInsertRowid: 999 } : { changes: 1 },
|
|
}));
|
|
|
|
const result = await moveMessages({
|
|
sourceConversationId: 1, targetConversationId: 2, messageIds: [101], userId: 7,
|
|
actorIsChatOwner: true,
|
|
});
|
|
expect(result.moved_count).toBe(1);
|
|
// No participant lookup, but actor-snapshot dbGet still runs.
|
|
const sqls = dbGetMock.mock.calls.map(c => c[0]);
|
|
expect(sqls.some(sql => /conversation_participants/.test(sql))).toBe(false);
|
|
expect(sqls.some(sql => /FROM users WHERE id/i.test(sql))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('formatMessageByLevel — \'moved\' breadcrumb (ADR-0031 P5)', () => {
|
|
it('replaces moved-stub content with breadcrumb pointing to target chat', () => {
|
|
const stub = {
|
|
id: 999, content_type: 'moved',
|
|
content: 'Moved to chat #42',
|
|
metadata: { moved_to: { conversation_id: 42, message_ids: [201, 202] } },
|
|
};
|
|
const out = formatMessageByLevel(stub, {});
|
|
expect(out).toMatch(/\[Moved to chat #42/);
|
|
expect(out).toMatch(/metadata\.moved_to\.conversation_id/);
|
|
});
|
|
|
|
it('falls back gracefully if metadata.moved_to is missing', () => {
|
|
const stub = { id: 999, content_type: 'moved', content: 'Moved', metadata: {} };
|
|
const out = formatMessageByLevel(stub, {});
|
|
expect(out).toBe('[Moved to another chat]');
|
|
});
|
|
});
|