Files
browser/platform_web/api/client.test.ts
T
2026-07-11 14:56:10 +08:00

507 lines
23 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient, setPlatformApiSessionToken } from "./client";
import type { AiProviderResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1"],
defaultModel: "gpt-4.1",
relayMode: "direct",
timeoutMs: 30000,
status: "active",
redactionPolicy: "default"
};
const plugin: GamePluginResponse = {
id: "game.example",
name: "Example Server",
version: "0.1.0",
serverType: "example",
serverDisplayName: "Example Server",
manifestRef: "artifact://manifests/game.example/0.1.0",
createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: ["process.start", "logs.read"],
declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"],
permissions: { ai: true, logs: true, files: false, jobs: false, artifacts: false },
lifecycleActions: { start: "actions/start.json" },
bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"],
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
status: "installed"
};
const marketplacePlugin: MarketplacePluginResponse = {
id: "game.example",
name: "Example Server",
description: "Development plugin",
version: "0.1.0",
serverType: "example",
serverDisplayName: "Example Server",
supportedOs: ["linux", "darwin"],
manifestRef: "artifact://manifests/game.example/0.1.0",
createFormSchemaRef: "schemas/create-form.schema.json",
capabilities: ["process.install", "process.start", "logs.read"],
declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"],
permissions: { ai: true, logs: true, files: false, jobs: false, artifacts: false },
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"],
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
status: "installed",
source: "platform-registry"
};
const server: ServerInstanceResponse = {
id: "server-1",
pluginId: "game.example",
pluginVersion: "0.1.0",
runEndpointId: "run-local",
name: "Example Survival #1",
ownerUserId: "user-owner",
adminUserIds: ["user-admin-1"],
state: "running",
configVersion: 1,
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
const endpoint: RunEndpointResponse = {
id: "run-local",
displayName: "Local Run",
version: "0.1.0",
status: "online",
capabilities: ["process.install", "process.start", "process.stop"],
capacity: { maxJobs: 4, runningJobs: 0, queuedJobs: 1 },
lastHeartbeatAt: "2026-07-03T00:00:00Z"
};
const job: JobResponse = {
id: "job-1",
serverInstanceId: server.id,
runEndpointId: endpoint.id,
capability: "process.start",
idempotencyKey: "idem-start",
state: "queued",
progress: { percent: 0, message: "queued" },
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
const artifact = {
id: "artifact-1",
ownerKind: "job",
ownerId: job.id,
sizeBytes: 18,
checksum: "sha256:artifactchecksum",
state: "available",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
describe("PlatformApiClient AI providers", () => {
afterEach(() => {
setPlatformApiSessionToken(null);
vi.restoreAllMocks();
});
it("calls AI provider management endpoints with named contracts", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/ai-providers") && (!init?.method || init.method === "GET")) {
return jsonResponse({ items: [provider], count: 1 });
}
if (url.endsWith("/api/v1/ai-providers") && init?.method === "POST") {
return jsonResponse(provider);
}
if (url.endsWith("/api/v1/ai-providers/ai.openai") && init?.method === "PUT") {
return jsonResponse({ ...provider, name: "OpenAI Relay" });
}
if (url.endsWith("/api/v1/ai-providers/ai.openai/status") && init?.method === "POST") {
return jsonResponse({ ...provider, status: "disabled" });
}
if (url.endsWith("/api/v1/ai-providers/ai.openai/test") && init?.method === "POST") {
return jsonResponse({ providerId: provider.id, mode: "metadata", success: true, message: "metadata validation passed" });
}
if (url.endsWith("/api/v1/ai-providers/ai.openai/models")) {
return jsonResponse({ providerId: provider.id, defaultModel: provider.defaultModel, models: provider.models });
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.listAiProviders()).resolves.toMatchObject({ count: 1 });
await expect(client.createAiProvider(provider)).resolves.toMatchObject({ id: provider.id });
await expect(client.updateAiProvider(provider.id, { ...provider, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" });
await expect(client.setAiProviderStatus(provider.id, { status: "disabled" })).resolves.toMatchObject({ status: "disabled" });
await expect(client.testAiProvider(provider.id)).resolves.toMatchObject({ success: true, mode: "metadata" });
await expect(client.listAiProviderModels(provider.id)).resolves.toMatchObject({ models: ["gpt-4.1"] });
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it("calls console shell resource endpoints with named contracts", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/healthz")) {
return jsonResponse({ service: "platform", status: "ok", version: "0.1.0", time: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/game-plugins")) {
return jsonResponse({ items: [plugin], count: 1 });
}
if (url.endsWith("/api/v1/server-instances") && (!init?.method || init.method === "GET")) {
return jsonResponse({ items: [server], count: 1 });
}
if (url.endsWith("/api/v1/metrics/platform")) {
return jsonResponse({ cpuPercent: 28, memoryPercent: 42, diskPercent: 19, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/metrics/server-instances")) {
return jsonResponse({
items: [
{
serverInstanceId: server.id,
online: true,
playerCount: 5,
maxPlayers: 20,
tps: 19.8,
latencyMs: 42,
cpuPercent: 31,
memoryPercent: 44,
diskPercent: 22,
source: "platform-derived",
collectedAt: "2026-07-03T00:00:00Z"
}
],
count: 1
});
}
if (url.endsWith("/api/v1/server-instances/server-1/config")) {
return jsonResponse({
serverInstanceId: server.id,
configVersion: 1,
format: "properties",
key: "server.properties",
content: "server.name=Example Survival #1\n",
source: "platform-derived",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
expectedConfigVersion: 1,
key: "server.properties",
proposedContent: "server.name=Example Survival #2\n"
});
return jsonResponse({
serverInstanceId: server.id,
configVersion: 1,
key: "server.properties",
currentContent: "server.name=Example Survival #1\n",
proposedContent: "server.name=Example Survival #2\n",
diff: [
{ kind: "removed", oldNumber: 1, content: "server.name=Example Survival #1" },
{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }
],
hasChanges: true,
source: "platform-review",
reviewedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/config/approve") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
expectedConfigVersion: 1,
key: "server.properties",
proposedContent: "server.name=Example Survival #2\n",
idempotencyKey: "idem-config"
});
return jsonResponse({
status: "queued",
preview: {
serverInstanceId: server.id,
configVersion: 1,
key: "server.properties",
currentContent: "server.name=Example Survival #1\n",
proposedContent: "server.name=Example Survival #2\n",
diff: [{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }],
hasChanges: true,
source: "platform-review",
reviewedAt: "2026-07-03T00:00:00Z"
},
job: { ...job, id: "job-config-write", capability: "config.write", targetKey: "server.properties", inputRef: "input://server-config/server-1/server.properties/v1" }
});
}
if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
serverInstanceId: server.id,
operation: "read",
key: "logs/latest.log",
idempotencyKey: "idem-file"
});
return jsonResponse({
status: "queued",
serverInstanceId: server.id,
operation: "read",
key: "logs/latest.log",
job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" }
});
}
if (url.endsWith("/api/v1/run/endpoints")) {
return jsonResponse({ items: [endpoint], count: 1 });
}
if (url.endsWith("/api/v1/jobs")) {
return jsonResponse({ items: [job], count: 1 });
}
if (url.endsWith("/api/v1/jobs?serverInstanceId=server-1")) {
return jsonResponse({ items: [job], count: 1 });
}
if (url.endsWith("/api/v1/artifacts?ownerKind=job&ownerId=job-1&state=available")) {
return jsonResponse({ items: [artifact], count: 1 });
}
if (url.endsWith("/api/v1/artifacts/artifact-1/download") && init?.method === "POST") {
return jsonResponse({
artifactId: artifact.id,
ownerKind: artifact.ownerKind,
ownerId: artifact.ownerId,
filename: "artifact-1.bin",
contentType: "application/octet-stream",
sizeBytes: artifact.sizeBytes,
checksum: artifact.checksum,
state: artifact.state,
downloadUrl: "/api/v1/artifacts/artifact-1/content",
expiresAt: "2026-07-03T00:15:00Z",
rangeSupported: true,
chunkSizeBytes: 1048576,
storageBehavior: "platform-memory-transfer-session"
});
}
if (url.endsWith("/api/v1/artifacts/artifact-1/content?offset=0&limit=8")) {
return new Response(new TextEncoder().encode("artifact").buffer, {
status: 206,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": "8",
"Content-Range": "bytes 0-7/18",
"X-Artifact-Id": artifact.id,
"X-Artifact-Checksum": artifact.checksum,
"X-Artifact-Content-Checksum": "sha256:chunkchecksum",
"X-Artifact-Storage": "platform-memory-transfer-session"
}
});
}
if (url.endsWith("/api/v1/server-instances/workflows/create") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "create", instance: { ...server, state: "installing" }, job: { ...job, capability: "process.install" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/start") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "start", instance: server, job });
}
if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) {
return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 });
}
if (url.endsWith("/api/v1/server-instances/server-1/administrators") && init?.method === "POST") {
return jsonResponse({ ...server, adminUserIds: [...server.adminUserIds, "user-2"] });
}
if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") {
return jsonResponse({ ...server, adminUserIds: [] });
}
if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") {
return jsonResponse({
pluginId: plugin.id,
routeKey: "logs",
action: "logs.query",
allowed: true,
requiredPermissions: ["server.logs.read"],
effectivePermissions: ["server.logs.read"]
});
}
if (url.endsWith("/api/v1/plugin-bridge/execute") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
requestId: "req-bridge",
pluginId: plugin.id,
routeKey: "logs",
serverInstanceId: server.id,
action: "logs.query",
payload: { logStreamId: "log-1" }
});
return jsonResponse({
requestId: "req-bridge",
pluginId: plugin.id,
routeKey: "logs",
serverInstanceId: server.id,
action: "logs.query",
status: "ok",
result: { entryCount: "0" }
});
}
if (url.endsWith("/api/v1/ai/invocations") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
requestId: "ai-1",
serverInstanceId: server.id,
purpose: "config.suggest",
prompt: "Tune PVP safely",
currentConfig: "server.name=Example Survival #1\n"
});
return jsonResponse({
requestId: "ai-1",
purpose: "config.suggest",
providerId: "ai.openai",
model: "gpt-4.1",
status: "ok",
recommendation: "Review before applying.",
configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "review required" },
usage: { providerId: "ai.openai", model: "gpt-4.1", inputTokens: 20, outputTokens: 12, mocked: true }
});
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.health()).resolves.toMatchObject({ status: "ok" });
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
await expect(
client.previewServerConfigDiff(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n" })
).resolves.toMatchObject({ hasChanges: true, source: "platform-review" });
await expect(
client.approveServerConfigWrite(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n", idempotencyKey: "idem-config" })
).resolves.toMatchObject({ status: "queued", job: { capability: "config.write", targetKey: "server.properties" } });
await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" }
});
await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] });
await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum });
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({
action: "create"
});
await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" });
await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" });
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] });
await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({
allowed: true
});
await expect(
client.executePluginBridge({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", payload: { logStreamId: "log-1" } })
).resolves.toMatchObject({ status: "ok", result: { entryCount: "0" } });
await expect(
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(24);
});
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/plugin-marketplace/plugins?status=installed&serverType=example&capability=logs.read&keyword=example")) {
return jsonResponse({ items: [marketplacePlugin], count: 1 });
}
if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example") && (!init?.method || init.method === "GET")) {
return jsonResponse(marketplacePlugin);
}
if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example/state") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ action: "disable" });
return jsonResponse({ ...marketplacePlugin, status: "disabled" });
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.listMarketplacePlugins({ status: "installed", serverType: "example", capability: "logs.read", keyword: "example" })).resolves.toMatchObject({ count: 1 });
await expect(client.getMarketplacePlugin(marketplacePlugin.id)).resolves.toMatchObject({ source: "platform-registry" });
await expect(client.setMarketplacePluginState(marketplacePlugin.id, { action: "disable" })).resolves.toMatchObject({ status: "disabled" });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("surfaces config diff preview failures from the platform", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") {
return new Response(JSON.stringify({ code: "validation", message: "expectedConfigVersion must match server instance" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.previewServerConfigDiff(server.id, { expectedConfigVersion: 0, key: "server.properties", proposedContent: "changed=true\n" })).rejects.toThrow(
"expectedConfigVersion must match server instance"
);
});
it("keeps raw key fields out of provider responses", () => {
expect("apiKey" in provider).toBe(false);
expect("rawApiKey" in provider).toBe(false);
expect(provider.apiKeyRef).toBe("secret://providers/openai");
});
it("calls auth endpoints and attaches bearer sessions", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/auth/login") && init?.method === "POST") {
return jsonResponse({
user: { id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] },
sessionId: "session-token",
status: "authenticated",
message: "登录成功"
});
}
if (url.endsWith("/api/v1/users/current")) {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token");
return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] });
}
if (url.endsWith("/api/v1/auth/logout") && init?.method === "POST") {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token");
return new Response(null, { status: 204 });
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
const login = await client.login({ account: "operator.local@example.test", password: "operator-local" });
expect(login.sessionId).toBe("session-token");
setPlatformApiSessionToken(login.sessionId ?? null);
await expect(client.getCurrentUser()).resolves.toMatchObject({ id: "user-admin" });
await expect(client.logout()).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}