feat: add http smoke tests.

This commit is contained in:
killian-larcher
2026-06-14 17:58:17 +02:00
parent 25689b6e0d
commit f2b7e4bfff
17 changed files with 631 additions and 107 deletions
+50
View File
@@ -0,0 +1,50 @@
import {ApiHttpClient} from "./http-client";
import {env} from "@/env.mjs";
export async function signInDefaultUser(client: ApiHttpClient) {
const email = env.AUTH_DEFAULT_USER;
const password = env.AUTH_DEFAULT_PASSWORD;
if (!email || !password) throw new Error("AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD are required");
const result = await client.request("POST", "/api/auth/sign-in/email", {
email,
password,
callbackURL: "/dashboard",
});
if (!result.response.ok) {
throw new Error(`Sign-in failed: ${result.response.status} ${result.text}`);
}
const cookie = client.getCookieHeader();
if (!cookie) {
throw new Error("Sign-in succeeded but no auth cookie was captured");
}
return {
email,
cookie,
};
}
export async function createStandardApiKey(client: ApiHttpClient) {
const result = await client.request("POST", "/api/auth/api-key/create", {
name: "api-test-key",
configId: "standard",
});
if (!result.response.ok) {
throw new Error(
`API key creation failed: ${result.response.status} ${result.text}`,
);
}
const payload = result.json as { key?: string };
if (!payload?.key) {
throw new Error(`API key payload missing key: ${result.text}`);
}
return payload.key;
}
+29
View File
@@ -0,0 +1,29 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
export type ApiTestContext = {
baseUrl: string;
authCookie: string;
apiKey: string;
userEmail: string;
agentId: string | null;
databaseId: string | null;
backupId: string | null;
backupStorageId: string | null;
};
export const CONTEXT_PATH = fileURLToPath(
new URL("../.runtime-context.json", import.meta.url),
);
export function writeContext(context: ApiTestContext) {
writeFileSync(CONTEXT_PATH, JSON.stringify(context, null, 2), "utf8");
}
export function readContext(): ApiTestContext {
if (!existsSync(CONTEXT_PATH)) {
throw new Error(`Missing API test context at ${CONTEXT_PATH}`);
}
return JSON.parse(readFileSync(CONTEXT_PATH, "utf8")) as ApiTestContext;
}
+135
View File
@@ -0,0 +1,135 @@
function readIdArray(json: unknown, resourceName: string): Array<{ id: string }> {
if (!json || typeof json !== "object" || !("data" in json)) {
throw new Error(`Invalid ${resourceName} response: missing data array`);
}
const data = (json as { data?: unknown }).data;
if (!Array.isArray(data)) {
throw new Error(`Invalid ${resourceName} response: data is not an array`);
}
for (const item of data) {
if (!item || typeof item !== "object" || typeof (item as { id?: unknown }).id !== "string") {
throw new Error(
`Invalid ${resourceName} response: each item must include a string id`,
);
}
}
return data as Array<{ id: string }>;
}
function readBackupDetail(json: unknown) {
if (!json || typeof json !== "object" || !("data" in json)) {
throw new Error("Invalid backup detail response: missing data object");
}
const data = (json as { data?: unknown }).data;
if (!data || typeof data !== "object") {
throw new Error("Invalid backup detail response: data is not an object");
}
const id = (data as { id?: unknown }).id;
const storages = (data as { storages?: unknown }).storages;
if (typeof id !== "string") {
throw new Error("Invalid backup detail response: data.id must be a string");
}
if (!Array.isArray(storages)) {
throw new Error(
"Invalid backup detail response: data.storages is not an array",
);
}
for (const item of storages) {
if (
!item ||
typeof item !== "object" ||
typeof (item as { id?: unknown }).id !== "string" ||
typeof (item as { status?: unknown }).status !== "string"
) {
throw new Error(
"Invalid backup detail response: each storage must include string id and status",
);
}
}
return {
id,
storages: storages as Array<{ id: string; status: string }>,
};
}
export async function discoverAgentId(api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
}) {
const result = await api.get("/api/v1/agents");
if (!result.response.ok) {
throw new Error(`Failed to list agents: ${result.response.status} ${result.text}`);
}
const agents = readIdArray(result.json, "agents");
if (agents.length === 0) {
throw new Error("No agent could be discovered from /api/v1/agents.");
}
return agents[0].id;
}
export async function discoverDatabaseId(api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
}) {
const result = await api.get("/api/v1/databases");
if (!result.response.ok) {
throw new Error(
`Failed to list databases: ${result.response.status} ${result.text}`,
);
}
const databases = readIdArray(result.json, "databases");
if (databases.length === 0) {
throw new Error("No database could be discovered from /api/v1/databases.");
}
return databases[0].id;
}
export async function discoverBackupContext(
api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
},
databaseId: string | null,
) {
if (!databaseId) {
throw new Error("Backup discovery requires a database id.");
}
const backups = await api.get(`/api/v1/databases/${databaseId}/backup`);
if (!backups.response.ok) {
throw new Error(
`Failed to list backups: ${backups.response.status} ${backups.text}`,
);
}
const backupList = readIdArray(backups.json, "backups");
for (const backup of backupList) {
const detail = await api.get(`/api/v1/databases/${databaseId}/backup/${backup.id}`);
if (!detail.response.ok) {
continue;
}
const payload = readBackupDetail(detail.json);
const storage = payload.storages.find((item) => item.status === "success");
if (storage) {
return {
backupId: payload.id,
backupStorageId: storage.id,
};
}
}
throw new Error(
`No successful backup storage could be discovered for database ${databaseId}.`,
);
}
+98
View File
@@ -0,0 +1,98 @@
type JsonValue =
| Record<string, unknown>
| Array<unknown>
| string
| number
| boolean
| null;
function mergeCookieHeaders(
currentCookieHeader: string,
setCookies: string[],
): string {
const cookies = new Map<string, string>();
for (const value of currentCookieHeader.split("; ")) {
if (!value) {
continue;
}
const [name, ...rest] = value.split("=");
cookies.set(name, rest.join("="));
}
for (const value of setCookies) {
const [cookiePair] = value.split(";");
const [name, ...rest] = cookiePair.split("=");
cookies.set(name, rest.join("="));
}
return Array.from(cookies.entries())
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}
export class ApiHttpClient {
constructor(
private readonly baseUrl: string,
private cookieHeader = "",
) {}
getCookieHeader() {
return this.cookieHeader;
}
withApiKey(apiKey: string) {
return {
get: (path: string) =>
this.request("GET", path, undefined, { "x-api-key": apiKey }),
post: (path: string, body?: JsonValue) =>
this.request("POST", path, body, { "x-api-key": apiKey }),
patch: (path: string, body?: JsonValue) =>
this.request("PATCH", path, body, { "x-api-key": apiKey }),
delete: (path: string) =>
this.request("DELETE", path, undefined, { "x-api-key": apiKey }),
};
}
async request(
method: string,
path: string,
body?: JsonValue,
headers: HeadersInit = {},
) {
const requestHeaders = new Headers({
origin: new URL(this.baseUrl).origin,
...(body ? { "content-type": "application/json" } : {}),
...(this.cookieHeader ? { cookie: this.cookieHeader } : {}),
});
new Headers(headers).forEach((value, name) => {
requestHeaders.set(name, value);
});
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: requestHeaders,
...(body ? { body: JSON.stringify(body) } : {}),
});
const setCookies = response.headers.getSetCookie?.() ?? [];
if (setCookies.length > 0) {
this.cookieHeader = mergeCookieHeaders(this.cookieHeader, setCookies);
}
const text = await response.text();
let json: unknown = null;
if (text) {
try {
json = JSON.parse(text);
} catch {
json = text;
}
}
return { response, json, text };
}
}