Files
multica/e2e/chat-attachments.spec.ts
DimaS 2f24057bc2 feat(issues): add date filter (#4129)
Co-authored-by: “646826” <“646826@gmail.com”>
2026-06-16 08:38:53 +08:00

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 ![](${uploaded.url})`,
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]);
});
});