mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
171 lines
6.3 KiB
TypeScript
171 lines
6.3 KiB
TypeScript
/**
|
|
* E2E: chat attachment upload + send back-fills the message link.
|
|
*
|
|
* Stays at the HTTP layer (auth → upload-file → send-chat-message → DB
|
|
* check) so the test doesn't depend on a real agent runtime being online.
|
|
* The UI wiring is covered by `chat-input.test.tsx` in @multica/views; this
|
|
* spec is the end-to-end contract proof: the backend really does persist
|
|
* chat_session_id at upload and back-fill chat_message_id at send.
|
|
*/
|
|
import "./env";
|
|
import { test, expect } from "@playwright/test";
|
|
import pg from "pg";
|
|
import { createTestApi } from "./helpers";
|
|
import type { TestApiClient } from "./fixtures";
|
|
|
|
const API_BASE =
|
|
process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
|
|
const DATABASE_URL =
|
|
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
|
|
|
interface UploadRow {
|
|
id: string;
|
|
url: string;
|
|
chat_session_id: string | null;
|
|
chat_message_id: string | null;
|
|
}
|
|
|
|
async function authedFetch(api: TestApiClient, path: string, init?: RequestInit) {
|
|
const token = api.getToken();
|
|
if (!token) throw new Error("test api client not logged in");
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${token}`,
|
|
...((init?.headers as Record<string, string>) ?? {}),
|
|
};
|
|
return fetch(`${API_BASE}${path}`, { ...init, headers });
|
|
}
|
|
|
|
test.describe("Chat attachments", () => {
|
|
let api: TestApiClient;
|
|
let pgClient: pg.Client | null = null;
|
|
let createdSessionId: string | null = null;
|
|
let createdAgentId: string | null = null;
|
|
let createdRuntimeId: string | null = null;
|
|
|
|
test.beforeEach(async () => {
|
|
api = await createTestApi();
|
|
pgClient = new pg.Client(DATABASE_URL);
|
|
await pgClient.connect();
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
try {
|
|
if (pgClient) {
|
|
if (createdSessionId) {
|
|
await pgClient.query(`DELETE FROM chat_session WHERE id = $1`, [createdSessionId]);
|
|
}
|
|
if (createdAgentId) {
|
|
await pgClient.query(`DELETE FROM agent WHERE id = $1`, [createdAgentId]);
|
|
}
|
|
if (createdRuntimeId) {
|
|
await pgClient.query(`DELETE FROM agent_runtime WHERE id = $1`, [createdRuntimeId]);
|
|
}
|
|
}
|
|
} finally {
|
|
if (pgClient) await pgClient.end();
|
|
pgClient = null;
|
|
createdSessionId = null;
|
|
createdAgentId = null;
|
|
createdRuntimeId = null;
|
|
await api.cleanup();
|
|
}
|
|
});
|
|
|
|
test("upload-file binds attachment to the chat_session; send back-fills chat_message_id", async () => {
|
|
expect(pgClient).not.toBeNull();
|
|
const pgc = pgClient!;
|
|
|
|
// Resolve the workspace + caller so we can seed an agent/runtime/session
|
|
// directly via SQL. Going through the HTTP API would require modelling
|
|
// local-daemon ownership which isn't needed for this contract test.
|
|
const workspaces = await api.getWorkspaces();
|
|
const ws = workspaces[0]!;
|
|
api.setWorkspaceSlug(ws.slug);
|
|
api.setWorkspaceId(ws.id);
|
|
|
|
const userRow = await pgc.query(
|
|
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
|
|
[api.getEmail()],
|
|
);
|
|
if (userRow.rows.length === 0) throw new Error("e2e user missing");
|
|
const userId = userRow.rows[0].id as string;
|
|
|
|
// Seed runtime + agent + chat_session.
|
|
const runtimeIns = await pgc.query(
|
|
`INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
|
device_info, metadata, last_seen_at
|
|
)
|
|
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
|
|
RETURNING id`,
|
|
[ws.id, `e2e chat runtime ${Date.now()}`, "e2e_chat_runtime", "E2E chat runtime"],
|
|
);
|
|
createdRuntimeId = runtimeIns.rows[0].id as string;
|
|
|
|
const agentIns = await pgc.query(
|
|
`INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks, owner_id
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
|
|
RETURNING id`,
|
|
[ws.id, `E2E Chat Agent ${Date.now()}`, createdRuntimeId, userId],
|
|
);
|
|
createdAgentId = agentIns.rows[0].id as string;
|
|
|
|
const sessionIns = await pgc.query(
|
|
`INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
|
|
VALUES ($1, $2, $3, 'E2E Chat Attachment Session', 'active')
|
|
RETURNING id`,
|
|
[ws.id, createdAgentId, userId],
|
|
);
|
|
createdSessionId = sessionIns.rows[0].id as string;
|
|
|
|
// 1. Upload a small PNG against the chat session.
|
|
const pngBytes = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
|
|
]);
|
|
const form = new FormData();
|
|
form.append("file", new Blob([new Uint8Array(pngBytes)], { type: "image/png" }), "e2e.png");
|
|
form.append("chat_session_id", createdSessionId);
|
|
const uploadRes = await authedFetch(api, "/api/upload-file", {
|
|
method: "POST",
|
|
body: form,
|
|
headers: { "X-Workspace-Slug": ws.slug },
|
|
});
|
|
expect(uploadRes.status).toBe(200);
|
|
const uploaded = (await uploadRes.json()) as UploadRow;
|
|
expect(uploaded.chat_session_id).toBe(createdSessionId);
|
|
expect(uploaded.chat_message_id).toBeNull();
|
|
expect(uploaded.url).toBeTruthy();
|
|
|
|
// 2. Send a chat message that references the attachment.
|
|
const sendRes = await authedFetch(api, `/api/chat/sessions/${createdSessionId}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Workspace-Slug": ws.slug,
|
|
},
|
|
body: JSON.stringify({
|
|
content: `look at this `,
|
|
attachment_ids: [uploaded.id],
|
|
}),
|
|
});
|
|
expect(sendRes.status).toBe(201);
|
|
const sendBody = (await sendRes.json()) as { message_id: string; task_id: string };
|
|
expect(sendBody.message_id).toBeTruthy();
|
|
|
|
// 3. DB check: the attachment row's chat_message_id matches the new message.
|
|
const after = await pgc.query<{ chat_message_id: string | null }>(
|
|
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
|
|
[uploaded.id],
|
|
);
|
|
expect(after.rows[0]?.chat_message_id).toBe(sendBody.message_id);
|
|
|
|
// 4. Clean up the attachment we created (chat_session cascade handles the
|
|
// rest in afterEach via chat_session row deletion).
|
|
await pgc.query(`DELETE FROM attachment WHERE id = $1`, [uploaded.id]);
|
|
});
|
|
});
|