1248 lines
46 KiB
TypeScript
1248 lines
46 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { buildLocalServer } from '../src/local/index.js';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { injectWithAuth } from './test-utils.js';
|
|
|
|
describe('Workspace & Session API', () => {
|
|
let server: FastifyInstance;
|
|
let dataDir: string;
|
|
let workspaceId: string;
|
|
|
|
beforeAll(async () => {
|
|
// Create isolated temp dir for tests
|
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ws-api-'));
|
|
|
|
// Write a minimal config.json so WaggleConfig doesn't error
|
|
fs.writeFileSync(
|
|
path.join(dataDir, 'config.json'),
|
|
JSON.stringify({
|
|
defaultModel: 'test/model',
|
|
providers: {},
|
|
teamServer: { url: 'https://team.example.com' },
|
|
}),
|
|
'utf-8'
|
|
);
|
|
|
|
// Create personal.mind as empty file (MindDB will init schema)
|
|
// Don't create it — let MultiMind handle it
|
|
server = await buildLocalServer({ dataDir, port: 0 });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.close();
|
|
// Clean up temp dir
|
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// First, create a workspace to use for session tests
|
|
it('creates a workspace for session tests', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Session Test WS', group: 'Test' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.id).toBeTruthy();
|
|
workspaceId = body.id;
|
|
});
|
|
|
|
it('persists linked local storage and routes uploads to it without persisting secrets', async () => {
|
|
const linkedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-storage-'));
|
|
const content = 'linked workspace survives metadata reload';
|
|
const secretSentinel = 'must-never-reach-workspace-json';
|
|
const ownerSentinel = path.join(linkedRoot, 'owner-sentinel.txt');
|
|
fs.writeFileSync(ownerSentinel, 'user-owned data', 'utf-8');
|
|
let linkedWorkspaceId: string | undefined;
|
|
|
|
try {
|
|
const createRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: {
|
|
name: 'Linked Storage Persistence',
|
|
group: 'Test',
|
|
storageType: 'local',
|
|
storagePath: linkedRoot,
|
|
storageConfig: { secretKey: secretSentinel },
|
|
},
|
|
});
|
|
expect(createRes.statusCode).toBe(201);
|
|
const created = JSON.parse(createRes.body);
|
|
linkedWorkspaceId = created.id;
|
|
expect(created.storageType).toBe('local');
|
|
expect(created.storagePath).toBe(fs.realpathSync.native(linkedRoot));
|
|
expect(created.storageConfig).toBeUndefined();
|
|
|
|
const configPath = path.join(
|
|
dataDir,
|
|
'workspaces',
|
|
linkedWorkspaceId,
|
|
'workspace.json',
|
|
);
|
|
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
|
const onDisk = JSON.parse(rawConfig);
|
|
expect(onDisk.storageType).toBe('local');
|
|
expect(onDisk.storagePath).toBe(fs.realpathSync.native(linkedRoot));
|
|
expect(rawConfig).not.toContain(secretSentinel);
|
|
expect(onDisk.storageConfig).toBeUndefined();
|
|
|
|
const { WorkspaceManager } = await import('@waggle/core');
|
|
expect(new WorkspaceManager(dataDir).get(linkedWorkspaceId)).toMatchObject({
|
|
storageType: 'local',
|
|
storagePath: fs.realpathSync.native(linkedRoot),
|
|
});
|
|
|
|
const getRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${linkedWorkspaceId}`,
|
|
});
|
|
expect(getRes.statusCode).toBe(200);
|
|
const reloaded = JSON.parse(getRes.body);
|
|
expect(reloaded.storageType).toBe('local');
|
|
expect(reloaded.storagePath).toBe(fs.realpathSync.native(linkedRoot));
|
|
expect(reloaded.storageConfig).toBeUndefined();
|
|
|
|
for (const directory of ['attachments', 'exports', 'notes']) {
|
|
expect(fs.statSync(path.join(linkedRoot, directory)).isDirectory()).toBe(true);
|
|
}
|
|
|
|
const uploadRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${linkedWorkspaceId}/files/upload`,
|
|
payload: {
|
|
path: 'notes',
|
|
name: 'persistence-probe.txt',
|
|
data: Buffer.from(content, 'utf-8').toString('base64'),
|
|
},
|
|
});
|
|
expect(uploadRes.statusCode).toBe(201);
|
|
expect(
|
|
fs.readFileSync(path.join(linkedRoot, 'notes', 'persistence-probe.txt'), 'utf-8'),
|
|
).toBe(content);
|
|
expect(
|
|
fs.existsSync(
|
|
path.join(
|
|
dataDir,
|
|
'workspaces',
|
|
linkedWorkspaceId,
|
|
'files',
|
|
'notes',
|
|
'persistence-probe.txt',
|
|
),
|
|
),
|
|
).toBe(false);
|
|
} finally {
|
|
if (linkedWorkspaceId) {
|
|
const deleteRes = await injectWithAuth(server, {
|
|
method: 'DELETE',
|
|
url: `/api/workspaces/${linkedWorkspaceId}`,
|
|
});
|
|
expect(deleteRes.statusCode).toBe(204);
|
|
expect(fs.existsSync(linkedRoot)).toBe(true);
|
|
expect(fs.readFileSync(ownerSentinel, 'utf-8')).toBe('user-owned data');
|
|
}
|
|
fs.rmSync(linkedRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects invalid local storage bindings before writing metadata', async () => {
|
|
const regularFile = path.join(dataDir, 'not-a-storage-directory.txt');
|
|
fs.writeFileSync(regularFile, 'not a directory', 'utf-8');
|
|
const collisionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-collision-'));
|
|
fs.writeFileSync(path.join(collisionRoot, 'notes'), 'blocks the standard directory', 'utf-8');
|
|
const invalidPayloads = [
|
|
{
|
|
name: 'Local Storage Missing Path',
|
|
group: 'Test',
|
|
storageType: 'local',
|
|
},
|
|
{
|
|
name: 'Local Storage Regular File',
|
|
group: 'Test',
|
|
storageType: 'local',
|
|
storagePath: regularFile,
|
|
},
|
|
{
|
|
name: 'Local Storage Directory Collision',
|
|
group: 'Test',
|
|
storageType: 'local',
|
|
storagePath: collisionRoot,
|
|
},
|
|
];
|
|
const unexpectedlyCreated: string[] = [];
|
|
|
|
try {
|
|
for (const payload of invalidPayloads) {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload,
|
|
});
|
|
const body = JSON.parse(res.body);
|
|
if (res.statusCode === 201 && typeof body.id === 'string') {
|
|
unexpectedlyCreated.push(body.id);
|
|
}
|
|
expect(res.statusCode).toBe(400);
|
|
}
|
|
|
|
const listRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/workspaces',
|
|
});
|
|
const names = JSON.parse(listRes.body).map((workspace: { name: string }) => workspace.name);
|
|
for (const payload of invalidPayloads) {
|
|
expect(names).not.toContain(payload.name);
|
|
}
|
|
expect(fs.existsSync(path.join(collisionRoot, 'attachments'))).toBe(false);
|
|
expect(fs.existsSync(path.join(collisionRoot, 'exports'))).toBe(false);
|
|
expect(fs.statSync(path.join(collisionRoot, 'notes')).isFile()).toBe(true);
|
|
} finally {
|
|
for (const id of unexpectedlyCreated) {
|
|
await injectWithAuth(server, {
|
|
method: 'DELETE',
|
|
url: `/api/workspaces/${id}`,
|
|
});
|
|
}
|
|
fs.rmSync(collisionRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('accepts non-local storage paths without persisting them as host bindings', async () => {
|
|
const payloads = [
|
|
{
|
|
name: 'Storage Path Without Type',
|
|
group: 'Test',
|
|
storagePath: dataDir,
|
|
},
|
|
{
|
|
name: 'Virtual Storage With Retained Path',
|
|
group: 'Test',
|
|
storageType: 'virtual',
|
|
storagePath: dataDir,
|
|
},
|
|
{
|
|
name: 'Team Storage With Bucket Prefix',
|
|
group: 'Test',
|
|
storageType: 'team',
|
|
storagePath: 'team-prefix',
|
|
},
|
|
];
|
|
const createdIds: string[] = [];
|
|
|
|
try {
|
|
for (const payload of payloads) {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
|
|
const created = JSON.parse(res.body) as {
|
|
id: string;
|
|
storageType?: string;
|
|
storagePath?: string;
|
|
};
|
|
createdIds.push(created.id);
|
|
expect(created.storageType).toBeUndefined();
|
|
expect(created.storagePath).toBeUndefined();
|
|
|
|
const onDisk = JSON.parse(fs.readFileSync(
|
|
path.join(dataDir, 'workspaces', created.id, 'workspace.json'),
|
|
'utf-8',
|
|
)) as Record<string, unknown>;
|
|
expect(onDisk).not.toHaveProperty('storageType');
|
|
expect(onDisk).not.toHaveProperty('storagePath');
|
|
}
|
|
} finally {
|
|
for (const id of createdIds) {
|
|
await injectWithAuth(server, {
|
|
method: 'DELETE',
|
|
url: `/api/workspaces/${id}`,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it('rolls back prepared local directories when metadata creation fails', async () => {
|
|
const linkedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-rollback-'));
|
|
const ownerSentinel = path.join(linkedRoot, 'owner-sentinel.txt');
|
|
fs.writeFileSync(ownerSentinel, 'keep me', 'utf-8');
|
|
const updateSpy = vi.spyOn(server.workspaceManager, 'update')
|
|
.mockImplementationOnce(() => { throw new Error('simulated metadata failure'); });
|
|
|
|
try {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: {
|
|
name: 'Metadata Failure Rollback',
|
|
group: 'Test',
|
|
storageType: 'local',
|
|
storagePath: linkedRoot,
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(500);
|
|
for (const directory of ['attachments', 'exports', 'notes']) {
|
|
expect(fs.existsSync(path.join(linkedRoot, directory))).toBe(false);
|
|
}
|
|
expect(fs.readFileSync(ownerSentinel, 'utf-8')).toBe('keep me');
|
|
expect(server.workspaceManager.list().map(workspace => workspace.name))
|
|
.not.toContain('Metadata Failure Rollback');
|
|
} finally {
|
|
updateSpy.mockRestore();
|
|
fs.rmSync(linkedRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// --- I2: Team Workspace Creation ---
|
|
|
|
it('creates a team workspace with team fields', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: {
|
|
name: 'Team Project Alpha',
|
|
group: 'Team',
|
|
teamId: 'team-123',
|
|
teamServerUrl: 'https://team.example.com',
|
|
teamRole: 'member',
|
|
teamUserId: 'user-456',
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.name).toBe('Team Project Alpha');
|
|
expect(body.group).toBe('Team');
|
|
expect(body.teamId).toBe('team-123');
|
|
expect(body.teamServerUrl).toBe('https://team.example.com');
|
|
expect(body.teamRole).toBe('member');
|
|
expect(body.teamUserId).toBe('user-456');
|
|
|
|
// Verify it appears in workspace list
|
|
const listRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/workspaces',
|
|
});
|
|
const list = JSON.parse(listRes.body);
|
|
const teamWs = list.find((ws: { teamId?: string }) => ws.teamId === 'team-123');
|
|
expect(teamWs).toBeTruthy();
|
|
expect(teamWs.teamServerUrl).toBe('https://team.example.com');
|
|
|
|
// Clean up
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${body.id}` });
|
|
});
|
|
|
|
it('creates a regular workspace without team fields', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Regular WS', group: 'Personal' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.teamId).toBeUndefined();
|
|
expect(body.teamServerUrl).toBeUndefined();
|
|
|
|
// Clean up
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${body.id}` });
|
|
});
|
|
|
|
// --- Session Tests ---
|
|
|
|
it('lists sessions (empty initially)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(Array.isArray(body)).toBe(true);
|
|
expect(body.length).toBe(0);
|
|
});
|
|
|
|
let sessionId: string;
|
|
|
|
it('creates a session', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
payload: { title: 'My First Chat' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.id).toMatch(/^session-/);
|
|
expect(body.title).toBe('My First Chat');
|
|
expect(body.messageCount).toBe(0);
|
|
expect(body.lastActive).toBeTruthy();
|
|
expect(body.created).toBeTruthy();
|
|
sessionId = body.id;
|
|
});
|
|
|
|
it('creates a session without title (keeps it untitled)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.title).toBeNull();
|
|
});
|
|
|
|
it('lists sessions (shows created sessions)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.length).toBe(2);
|
|
// Should find our named session
|
|
const named = body.find((s: { title: string }) => s.title === 'My First Chat');
|
|
expect(named).toBeTruthy();
|
|
expect(named.messageCount).toBe(0);
|
|
});
|
|
|
|
it('reads session metadata including message count', async () => {
|
|
// Append messages to the session JSONL file (meta line already exists from creation)
|
|
const sessionPath = path.join(
|
|
dataDir, 'workspaces', workspaceId, 'sessions', `${sessionId}.jsonl`
|
|
);
|
|
// Read existing meta line
|
|
const existing = fs.readFileSync(sessionPath, 'utf-8');
|
|
const messages = [
|
|
JSON.stringify({ role: 'user', content: 'Hello there', timestamp: new Date().toISOString() }),
|
|
JSON.stringify({ role: 'assistant', content: 'Hi! How can I help?', timestamp: new Date().toISOString() }),
|
|
];
|
|
fs.writeFileSync(sessionPath, existing + messages.join('\n') + '\n', 'utf-8');
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
const session = body.find((s: { id: string }) => s.id === sessionId);
|
|
expect(session).toBeTruthy();
|
|
expect(session.messageCount).toBe(2);
|
|
// Title should come from meta line (set during creation)
|
|
expect(session.title).toBe('My First Chat');
|
|
});
|
|
|
|
it('auto-generates summary for session with 4+ messages', async () => {
|
|
// Create a new session with enough messages to trigger summary generation
|
|
const createRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
payload: { title: 'Summary Test Session' },
|
|
});
|
|
const { id: summarySessionId } = JSON.parse(createRes.body);
|
|
|
|
// Add 6 messages (3 exchanges) to the session file
|
|
const sessionPath = path.join(
|
|
dataDir, 'workspaces', workspaceId, 'sessions', `${summarySessionId}.jsonl`
|
|
);
|
|
const existing = fs.readFileSync(sessionPath, 'utf-8');
|
|
const messages = [
|
|
JSON.stringify({ role: 'user', content: 'Help me plan the Q2 marketing campaign', timestamp: '2026-03-09T10:00:00Z' }),
|
|
JSON.stringify({ role: 'assistant', content: 'I can help with that. Let me search for context about your marketing goals.', timestamp: '2026-03-09T10:01:00Z' }),
|
|
JSON.stringify({ role: 'user', content: 'We decided to focus on social media this quarter', timestamp: '2026-03-09T10:02:00Z' }),
|
|
JSON.stringify({ role: 'assistant', content: 'Good decision. Social media aligns well with your budget constraints.', timestamp: '2026-03-09T10:03:00Z' }),
|
|
JSON.stringify({ role: 'user', content: 'Can you draft an outline?', timestamp: '2026-03-09T10:04:00Z' }),
|
|
JSON.stringify({ role: 'assistant', content: 'Here is a draft marketing plan outline with 5 key initiatives.', timestamp: '2026-03-09T10:05:00Z' }),
|
|
];
|
|
fs.writeFileSync(sessionPath, existing + messages.join('\n') + '\n', 'utf-8');
|
|
|
|
// List sessions — this triggers lazy summary generation
|
|
const listRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
});
|
|
const sessions = JSON.parse(listRes.body);
|
|
const summarySession = sessions.find((s: { id: string }) => s.id === summarySessionId);
|
|
expect(summarySession).toBeTruthy();
|
|
expect(summarySession.summary).toBeTruthy();
|
|
expect(typeof summarySession.summary).toBe('string');
|
|
expect(summarySession.summary.length).toBeGreaterThan(5);
|
|
|
|
// Verify summary was persisted to the JSONL meta line
|
|
const updatedContent = fs.readFileSync(sessionPath, 'utf-8');
|
|
const firstLine = JSON.parse(updatedContent.split('\n')[0]);
|
|
expect(firstLine.summary).toBe(summarySession.summary);
|
|
|
|
// Clean up
|
|
await injectWithAuth(server, {
|
|
method: 'DELETE',
|
|
url: `/api/sessions/${summarySessionId}?workspace=${workspaceId}`,
|
|
});
|
|
});
|
|
|
|
it('does not generate summary for sessions with fewer than 4 messages', async () => {
|
|
// The session we created earlier had only 2 messages (added in a previous test)
|
|
// Create a session with just 2 messages
|
|
const createRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
payload: { title: 'Short Session' },
|
|
});
|
|
const { id: shortSessionId } = JSON.parse(createRes.body);
|
|
|
|
const sessionPath = path.join(
|
|
dataDir, 'workspaces', workspaceId, 'sessions', `${shortSessionId}.jsonl`
|
|
);
|
|
const existing = fs.readFileSync(sessionPath, 'utf-8');
|
|
const messages = [
|
|
JSON.stringify({ role: 'user', content: 'Quick question', timestamp: '2026-03-09T10:00:00Z' }),
|
|
JSON.stringify({ role: 'assistant', content: 'Sure, what is it?', timestamp: '2026-03-09T10:01:00Z' }),
|
|
];
|
|
fs.writeFileSync(sessionPath, existing + messages.join('\n') + '\n', 'utf-8');
|
|
|
|
const listRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
});
|
|
const sessions = JSON.parse(listRes.body);
|
|
const shortSession = sessions.find((s: { id: string }) => s.id === shortSessionId);
|
|
expect(shortSession).toBeTruthy();
|
|
expect(shortSession.summary).toBeNull();
|
|
|
|
// Clean up
|
|
await injectWithAuth(server, {
|
|
method: 'DELETE',
|
|
url: `/api/sessions/${shortSessionId}?workspace=${workspaceId}`,
|
|
});
|
|
});
|
|
|
|
it('deletes a session', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'DELETE',
|
|
url: `/api/sessions/${sessionId}?workspace=${workspaceId}`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.deleted).toBe(true);
|
|
|
|
// Verify it's gone
|
|
const listRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/sessions`,
|
|
});
|
|
const sessions = JSON.parse(listRes.body);
|
|
const deleted = sessions.find((s: { id: string }) => s.id === sessionId);
|
|
expect(deleted).toBeUndefined();
|
|
});
|
|
|
|
it('returns 404 when deleting non-existent session', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'DELETE',
|
|
url: `/api/sessions/nonexistent-session?workspace=${workspaceId}`,
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns empty array for sessions of non-existent workspace (graceful degradation)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/workspaces/nonexistent-ws/sessions',
|
|
});
|
|
// Returns 200 with empty array — intentional graceful degradation
|
|
// (default workspace might not be registered on first startup)
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.payload)).toEqual([]);
|
|
});
|
|
|
|
// --- Knowledge Graph Tests ---
|
|
|
|
it('returns knowledge graph (empty for fresh mind)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/graph',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toHaveProperty('nodes');
|
|
expect(body).toHaveProperty('edges');
|
|
expect(Array.isArray(body.nodes)).toBe(true);
|
|
expect(Array.isArray(body.edges)).toBe(true);
|
|
});
|
|
|
|
it('returns knowledge graph for workspace mind', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/memory/graph?workspace=${workspaceId}`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toHaveProperty('nodes');
|
|
expect(body).toHaveProperty('edges');
|
|
});
|
|
|
|
it('returns an empty graph for a non-existent workspace', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/graph?workspace=nonexistent',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({ nodes: [], edges: [] });
|
|
});
|
|
|
|
// --- API Key Test Endpoint ---
|
|
|
|
it('saves provider keys to the vault without persisting plaintext config secrets', async () => {
|
|
const key = 'sk-test-openai-key-1234567890';
|
|
const res = await injectWithAuth(server, {
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: { providers: { openai: { apiKey: key, models: ['gpt-4o'] } } },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).not.toContain(key);
|
|
|
|
const config = JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf-8')) as {
|
|
providers?: Record<string, { apiKey?: string; models?: string[] }>;
|
|
};
|
|
expect(config.providers?.openai).toMatchObject({ apiKey: '', models: ['gpt-4o'] });
|
|
expect(server.vault?.get('openai')?.value).toBe(key);
|
|
server.vault?.delete('openai');
|
|
});
|
|
|
|
it('validates OpenAI key format (valid)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/test-key',
|
|
payload: { provider: 'openai', apiKey: 'sk-1234567890abcdefghij' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.valid).toBe(true);
|
|
});
|
|
|
|
it('validates OpenAI key format (invalid prefix)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/test-key',
|
|
payload: { provider: 'openai', apiKey: 'bad-key-1234567890' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.valid).toBe(false);
|
|
expect(body.error).toMatch(/sk-/);
|
|
});
|
|
|
|
it('validates Anthropic key format (valid)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/test-key',
|
|
payload: { provider: 'anthropic', apiKey: 'sk-ant-1234567890abcdefg' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.valid).toBe(true);
|
|
});
|
|
|
|
it('validates Anthropic key format (invalid)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/test-key',
|
|
payload: { provider: 'anthropic', apiKey: 'sk-1234567890abcdefg' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.valid).toBe(false);
|
|
expect(body.error).toMatch(/sk-ant-/);
|
|
});
|
|
|
|
it('rejects test-key request without provider or apiKey', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/test-key',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('validates unknown provider key (just length check)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/test-key',
|
|
payload: { provider: 'some-provider', apiKey: 'abcdefghij' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.valid).toBe(true);
|
|
});
|
|
|
|
// --- F3: probe-provider (live-probe a STORED key) ---
|
|
|
|
it('probe-provider returns configured:false when no key is stored', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/probe-provider',
|
|
payload: { provider: 'anthropic' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toMatchObject({ configured: false, valid: false, verified: false });
|
|
});
|
|
|
|
it('probe-provider rejects a body missing provider (validateBody 400)', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/probe-provider',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('probe-provider reports valid:false when the provider rejects the stored key', async () => {
|
|
const { _clearKeyProbeCache } = await import('../src/local/llm-key-probe.js');
|
|
const configPath = path.join(dataDir, 'config.json');
|
|
const original = fs.readFileSync(configPath, 'utf-8');
|
|
// Stash a format-valid anthropic key in config (fallback path of the route).
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
defaultModel: 'test/model',
|
|
providers: { anthropic: { apiKey: 'sk-ant-1234567890abcdefg', models: [] } },
|
|
}),
|
|
'utf-8',
|
|
);
|
|
_clearKeyProbeCache(); // 60s TTL — avoid a stale verdict from another case
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 })));
|
|
try {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/probe-provider',
|
|
payload: { provider: 'anthropic' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.configured).toBe(true);
|
|
expect(body.valid).toBe(false);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
_clearKeyProbeCache();
|
|
fs.writeFileSync(configPath, original, 'utf-8');
|
|
}
|
|
});
|
|
|
|
// --- MODEL-GATE: probe-model (live-probe the resolved default model) ---
|
|
|
|
const exactProbeModel = 'openrouter/openai/test-model';
|
|
function configureExactProbeModel(): () => void {
|
|
const priorProvider = { ...server.agentState.llmProvider };
|
|
server.agentState.llmProvider = {
|
|
provider: 'anthropic-proxy',
|
|
health: 'degraded',
|
|
detail: 'Built-in provider proxy (verification pending)',
|
|
checkedAt: new Date().toISOString(),
|
|
};
|
|
server.vault.set('openrouter', 'openrouter-probe-model-test-key');
|
|
return () => {
|
|
server.agentState.llmProvider = priorProvider;
|
|
server.vault.delete('openrouter');
|
|
};
|
|
}
|
|
|
|
it('probe-model reports verified when the model endpoint answers 200', async () => {
|
|
const restoreModel = configureExactProbeModel();
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 })));
|
|
try {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/probe-model',
|
|
payload: { model: exactProbeModel },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toMatchObject({ model: exactProbeModel, configured: true, verified: true });
|
|
} finally {
|
|
restoreModel();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it('probe-model reports rejected on a 401 from the model endpoint', async () => {
|
|
const restoreModel = configureExactProbeModel();
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 })));
|
|
try {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/probe-model',
|
|
payload: { model: exactProbeModel },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toMatchObject({ configured: true, verified: false, rejected: true });
|
|
} finally {
|
|
restoreModel();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it('probe-model reports unverified (transient) when the model endpoint times out', async () => {
|
|
const restoreModel = configureExactProbeModel();
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('aborted')));
|
|
try {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/settings/probe-model',
|
|
payload: { model: exactProbeModel },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toMatchObject({ configured: true, verified: false });
|
|
expect(body.rejected).toBeUndefined();
|
|
} finally {
|
|
restoreModel();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
// --- E3: Progress extraction tests ---
|
|
|
|
it('extracts progress items from session content', async () => {
|
|
// Create a workspace and session with task/completion/blocker language
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Progress Test', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
const sessRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${ws.id}/sessions`,
|
|
payload: { title: 'Planning Session' },
|
|
});
|
|
const sess = JSON.parse(sessRes.body);
|
|
|
|
// Write messages with progress language
|
|
const sessionPath = path.join(
|
|
dataDir, 'workspaces', ws.id, 'sessions', `${sess.id}.jsonl`
|
|
);
|
|
const existing = fs.readFileSync(sessionPath, 'utf-8');
|
|
const messages = [
|
|
JSON.stringify({ role: 'user', content: 'We need to implement the search feature for the dashboard', timestamp: '2026-03-12T10:00:00Z' }),
|
|
JSON.stringify({ role: 'assistant', content: 'I can help with that. What kind of search?', timestamp: '2026-03-12T10:01:00Z' }),
|
|
JSON.stringify({ role: 'user', content: 'Full text search. Also, we completed the user authentication module last week.', timestamp: '2026-03-12T10:02:00Z' }),
|
|
JSON.stringify({ role: 'assistant', content: 'Great, the auth module is done. For search, we\'re blocked by the missing indexing service — it depends on the infrastructure team.', timestamp: '2026-03-12T10:03:00Z' }),
|
|
JSON.stringify({ role: 'user', content: 'Right, we should also plan the API documentation for the new endpoints', timestamp: '2026-03-12T10:04:00Z' }),
|
|
JSON.stringify({ role: 'assistant', content: 'Makes sense. Let me draft an outline for the API docs.', timestamp: '2026-03-12T10:05:00Z' }),
|
|
];
|
|
fs.writeFileSync(sessionPath, existing + messages.join('\n') + '\n', 'utf-8');
|
|
|
|
// Fetch workspace context — should include progressItems
|
|
const ctxRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/context`,
|
|
});
|
|
expect(ctxRes.statusCode).toBe(200);
|
|
const ctx = JSON.parse(ctxRes.body);
|
|
|
|
expect(ctx.progressItems).toBeDefined();
|
|
expect(Array.isArray(ctx.progressItems)).toBe(true);
|
|
|
|
// Should find at least a task (need to, should) and a blocker (blocked by)
|
|
const types = ctx.progressItems.map((p: { type: string }) => p.type);
|
|
expect(types).toContain('task');
|
|
|
|
// Clean up
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
// --- F1: Session search tests ---
|
|
|
|
it('searches sessions by content and returns matching snippets', async () => {
|
|
// Create a workspace with sessions containing searchable content
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Search Test WS', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
// Create two sessions — one with matching content, one without
|
|
const sess1Res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${ws.id}/sessions`,
|
|
payload: { title: 'Database Design' },
|
|
});
|
|
const sess1 = JSON.parse(sess1Res.body);
|
|
|
|
const sess2Res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${ws.id}/sessions`,
|
|
payload: { title: 'Marketing Plan' },
|
|
});
|
|
const sess2 = JSON.parse(sess2Res.body);
|
|
|
|
// Add content to session 1 (should match "database")
|
|
const path1 = path.join(dataDir, 'workspaces', ws.id, 'sessions', `${sess1.id}.jsonl`);
|
|
const existing1 = fs.readFileSync(path1, 'utf-8');
|
|
fs.writeFileSync(path1, existing1 +
|
|
JSON.stringify({ role: 'user', content: 'How should we design the database schema?' }) + '\n' +
|
|
JSON.stringify({ role: 'assistant', content: 'For your use case, a PostgreSQL database with normalized tables would work well.' }) + '\n',
|
|
'utf-8');
|
|
|
|
// Add content to session 2 (should NOT match "database")
|
|
const path2 = path.join(dataDir, 'workspaces', ws.id, 'sessions', `${sess2.id}.jsonl`);
|
|
const existing2 = fs.readFileSync(path2, 'utf-8');
|
|
fs.writeFileSync(path2, existing2 +
|
|
JSON.stringify({ role: 'user', content: 'What social media channels should we target?' }) + '\n' +
|
|
JSON.stringify({ role: 'assistant', content: 'Focus on LinkedIn and Twitter for B2B marketing.' }) + '\n',
|
|
'utf-8');
|
|
|
|
// Search for "database"
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/sessions/search?q=database`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const results = JSON.parse(res.body);
|
|
expect(Array.isArray(results)).toBe(true);
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].sessionId).toBe(sess1.id);
|
|
expect(results[0].title).toBe('Database Design');
|
|
expect(results[0].matchCount).toBeGreaterThan(0);
|
|
expect(results[0].snippets.length).toBeGreaterThan(0);
|
|
expect(results[0].snippets[0].text.toLowerCase()).toContain('database');
|
|
|
|
// Clean up
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('returns 400 for search query shorter than 2 characters', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Short Query WS', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/sessions/search?q=a`,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('returns empty results when no sessions match', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'No Match WS', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
// Create a session
|
|
const sessRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${ws.id}/sessions`,
|
|
payload: { title: 'Random Session' },
|
|
});
|
|
const sess = JSON.parse(sessRes.body);
|
|
|
|
const sessPath = path.join(dataDir, 'workspaces', ws.id, 'sessions', `${sess.id}.jsonl`);
|
|
const existing = fs.readFileSync(sessPath, 'utf-8');
|
|
fs.writeFileSync(sessPath, existing +
|
|
JSON.stringify({ role: 'user', content: 'Hello there' }) + '\n',
|
|
'utf-8');
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/sessions/search?q=quantum`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const results = JSON.parse(res.body);
|
|
expect(results).toHaveLength(0);
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('returns empty progressItems for workspace with no sessions', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Empty Progress', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
const ctxRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/context`,
|
|
});
|
|
const ctx = JSON.parse(ctxRes.body);
|
|
expect(ctx.progressItems).toBeDefined();
|
|
expect(ctx.progressItems).toHaveLength(0);
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
// ── F2: File registry tests ─────────────────────────────────────
|
|
|
|
it('returns empty file list for workspace with no ingested files', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Files Empty', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/files`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.files).toEqual([]);
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('records ingested files in registry and returns them via files endpoint', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Files Test', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
// Ingest a text file into the workspace
|
|
const textContent = Buffer.from('Hello world, this is a test file.').toString('base64');
|
|
const ingestRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/ingest',
|
|
payload: {
|
|
files: [{ name: 'readme.txt', content: textContent }],
|
|
workspaceId: ws.id,
|
|
},
|
|
});
|
|
expect(ingestRes.statusCode).toBe(200);
|
|
|
|
// Check files endpoint returns the ingested file
|
|
const filesRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/files`,
|
|
});
|
|
expect(filesRes.statusCode).toBe(200);
|
|
const filesBody = JSON.parse(filesRes.body);
|
|
expect(filesBody.files).toHaveLength(1);
|
|
expect(filesBody.files[0].name).toBe('readme.txt');
|
|
expect(filesBody.files[0].type).toBe('text');
|
|
expect(filesBody.files[0].sizeBytes).toBeGreaterThan(0);
|
|
expect(filesBody.files[0].ingestedAt).toBeDefined();
|
|
|
|
// Check file count appears in workspace context
|
|
const ctxRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/context`,
|
|
});
|
|
const ctx = JSON.parse(ctxRes.body);
|
|
expect(ctx.stats.fileCount).toBe(1);
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('surfaces a storage-provider file that is not in the ingest registry (union)', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Files Union Storage', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
// Write a file straight to the virtual storage root (bypasses the registry,
|
|
// simulating an upload that lands on the provider fs).
|
|
const filesRoot = path.join(dataDir, 'workspaces', ws.id, 'files');
|
|
fs.mkdirSync(filesRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(filesRoot, 'uploaded.txt'), 'uploaded content', 'utf-8');
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/files`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
const names = body.files.map((f: { name: string }) => f.name);
|
|
expect(names).toContain('uploaded.txt');
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('unions ingested (registry) and uploaded (storage) files', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Files Union Both', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
// Registry side: ingest a file.
|
|
await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/ingest',
|
|
payload: {
|
|
files: [{ name: 'ingested.txt', content: Buffer.from('ingested').toString('base64') }],
|
|
workspaceId: ws.id,
|
|
},
|
|
});
|
|
|
|
// Storage side: write a file to the provider fs directly.
|
|
const filesRoot = path.join(dataDir, 'workspaces', ws.id, 'files');
|
|
fs.mkdirSync(filesRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(filesRoot, 'stored.txt'), 'stored', 'utf-8');
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/files`,
|
|
});
|
|
const body = JSON.parse(res.body);
|
|
const names = body.files.map((f: { name: string }) => f.name);
|
|
expect(names).toContain('ingested.txt');
|
|
expect(names).toContain('stored.txt');
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('dedupes union by name — registry entry wins on collision', async () => {
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Files Union Dedup', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
// Same name on both sides.
|
|
await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/ingest',
|
|
payload: {
|
|
files: [{ name: 'dup.txt', content: Buffer.from('registry copy').toString('base64') }],
|
|
workspaceId: ws.id,
|
|
},
|
|
});
|
|
const filesRoot = path.join(dataDir, 'workspaces', ws.id, 'files');
|
|
fs.mkdirSync(filesRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(filesRoot, 'dup.txt'), 'storage copy', 'utf-8');
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/files`,
|
|
});
|
|
const body = JSON.parse(res.body);
|
|
const dupRows = body.files.filter((f: { name: string }) => f.name === 'dup.txt');
|
|
expect(dupRows).toHaveLength(1);
|
|
// Registry wins — its entry carries the ingest 'text' type + a summary.
|
|
expect(dupRows[0].type).toBe('text');
|
|
expect(dupRows[0].summary).not.toBe('');
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
// ── F3: Session export tests ─────────────────────────────────────
|
|
|
|
it('exports a session as markdown with title and messages', async () => {
|
|
// Create workspace + session
|
|
const wsRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Export Test', group: 'Test' },
|
|
});
|
|
const ws = JSON.parse(wsRes.body);
|
|
|
|
const sessRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/workspaces/${ws.id}/sessions`,
|
|
payload: { title: 'My Test Chat' },
|
|
});
|
|
const sess = JSON.parse(sessRes.body);
|
|
|
|
// Add messages to the session JSONL
|
|
const sessionsDir = path.join(dataDir, 'workspaces', ws.id, 'sessions');
|
|
const filePath = path.join(sessionsDir, `${sess.id}.jsonl`);
|
|
const existingContent = fs.readFileSync(filePath, 'utf-8');
|
|
fs.writeFileSync(filePath, existingContent
|
|
+ JSON.stringify({ role: 'user', content: 'Hello, what can you help me with?', timestamp: '2026-03-12T10:00:00Z' }) + '\n'
|
|
+ JSON.stringify({ role: 'assistant', content: 'I can help you with many things!', timestamp: '2026-03-12T10:00:05Z' }) + '\n'
|
|
);
|
|
|
|
// Export
|
|
const exportRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${ws.id}/sessions/${sess.id}/export`,
|
|
});
|
|
expect(exportRes.statusCode).toBe(200);
|
|
expect(exportRes.headers['content-type']).toContain('text/markdown');
|
|
|
|
const md = exportRes.body;
|
|
expect(md).toContain('# My Test Chat');
|
|
expect(md).toContain('**You**');
|
|
expect(md).toContain('Hello, what can you help me with?');
|
|
expect(md).toContain('**Assistant**');
|
|
expect(md).toContain('I can help you with many things!');
|
|
|
|
await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${ws.id}` });
|
|
});
|
|
|
|
it('returns 404 when exporting non-existent session', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/sessions/nonexistent-session/export`,
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns 404 for files of non-existent workspace', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/workspaces/nonexistent-ws-999/files',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
// J4: Team catch-up context
|
|
it('workspace context includes teamContext for team workspaces', async () => {
|
|
// Create a team workspace
|
|
const createRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: {
|
|
name: 'Team WS',
|
|
group: 'Team',
|
|
teamId: 'team-test-123',
|
|
teamServerUrl: 'https://team.example.com',
|
|
},
|
|
});
|
|
expect(createRes.statusCode).toBe(201);
|
|
const teamWsId = JSON.parse(createRes.body).id;
|
|
|
|
// Get context
|
|
const contextRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${teamWsId}/context`,
|
|
});
|
|
expect(contextRes.statusCode).toBe(200);
|
|
const context = JSON.parse(contextRes.body);
|
|
expect(context.teamContext).toBeDefined();
|
|
expect(context.teamContext.isTeam).toBe(true);
|
|
expect(context.teamContext.teamId).toBe('team-test-123');
|
|
});
|
|
|
|
it('workspace context omits teamContext for non-team workspaces', async () => {
|
|
const contextRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/workspaces/${workspaceId}/context`,
|
|
});
|
|
const context = JSON.parse(contextRes.body);
|
|
expect(context.teamContext).toBeUndefined();
|
|
});
|
|
});
|