174 lines
8.0 KiB
TypeScript
174 lines
8.0 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { PlatformApiClient } from "./client";
|
|
import type { GameClientBridgeManifestResponse, GameClientBridgeQueueRequest, GamePluginResponse, MarketplacePluginResponse } from "./types";
|
|
|
|
const now = "2026-07-20T08:00:00Z";
|
|
const later = "2026-07-20T09:00:00Z";
|
|
|
|
const status = {
|
|
serverInstanceId: "server-1",
|
|
pluginId: "game.scum",
|
|
available: false,
|
|
reason: "plugin-owned bridge runtime is unavailable",
|
|
profiles: [{
|
|
pluginId: "game.scum",
|
|
profileKey: "scum-client",
|
|
available: false,
|
|
reason: "declared runtime transport is unavailable",
|
|
commandTypes: ["scum.diagnostic.ping"],
|
|
snapshotTypes: ["scum.players"],
|
|
queryTemplateKeys: ["scum.player.search"]
|
|
}]
|
|
} as const;
|
|
|
|
const pendingCommand = {
|
|
id: "command-1",
|
|
serverInstanceId: "server-1",
|
|
pluginId: "game.scum",
|
|
profileKey: "scum-client",
|
|
commandType: "scum.diagnostic.ping",
|
|
priority: 20,
|
|
state: "pending",
|
|
requesterId: "user-1",
|
|
expiresAt: later,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
} as const;
|
|
|
|
const completedCommand = {
|
|
...pendingCommand,
|
|
state: "succeeded",
|
|
resultSummary: "diagnostic completed",
|
|
result: {
|
|
status: "succeeded",
|
|
summary: "diagnostic completed",
|
|
payload: { delivered: true, recipientCount: 12 },
|
|
completedAt: later
|
|
},
|
|
completedAt: later,
|
|
updatedAt: later
|
|
} as const;
|
|
|
|
const cancellation = {
|
|
commandId: pendingCommand.id,
|
|
state: "cancelled",
|
|
cancellation: { requestedBy: "user-1", reason: "maintenance window changed", cancelledAt: later },
|
|
updatedAt: later
|
|
} as const;
|
|
|
|
const snapshot = {
|
|
id: "snapshot-1",
|
|
serverInstanceId: "server-1",
|
|
pluginId: "game.scum",
|
|
profileKey: "scum-client",
|
|
type: "scum.players",
|
|
schemaVersion: "1",
|
|
streamKey: "current",
|
|
sequence: 7,
|
|
observedAt: now,
|
|
payload: { players: [{ playerId: "player-1", displayName: "Moonlight" }] },
|
|
retention: { keepForSeconds: 3600, maxRecords: 24 },
|
|
createdAt: now,
|
|
expiresAt: later
|
|
} as const;
|
|
|
|
const manifestDeclaration: GameClientBridgeManifestResponse = {
|
|
commands: [{
|
|
type: "scum.diagnostic.ping",
|
|
title: "Diagnostic ping",
|
|
permission: "server.game-client.command",
|
|
payloadSchemaRef: "schemas/bridge/commands/diagnostic-ping.request.json",
|
|
resultSchemaRef: "schemas/bridge/commands/diagnostic-ping.result.json",
|
|
timeoutSeconds: 30,
|
|
maxPayloadBytes: 4096
|
|
}],
|
|
snapshots: [{ type: "game.players", schemaVersion: "1", schemaRef: "schemas/bridge/snapshots/players.json", keepForSeconds: 3600, maxRecords: 24 }],
|
|
queryTemplates: [{
|
|
key: "game.player.search",
|
|
title: "Search players",
|
|
permission: "server.game-client.read",
|
|
engine: "sqlite",
|
|
transportKey: "sqlite-db",
|
|
targetKey: "game-db",
|
|
parameterSchemaRef: "schemas/bridge/queries/player-search.request.json",
|
|
resultSchemaRef: "schemas/bridge/queries/player-search.result.json",
|
|
maxRows: 50,
|
|
timeoutSeconds: 10
|
|
}],
|
|
commandRetentionSeconds: 86400,
|
|
maxCommands: 1000,
|
|
pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["game.players"], queryTemplateKeys: ["game.player.search"] }]
|
|
};
|
|
|
|
const pluginBridgeProjection: Pick<GamePluginResponse, "gameClientBridge"> & Pick<MarketplacePluginResponse, "gameClientBridge"> = {
|
|
gameClientBridge: manifestDeclaration
|
|
};
|
|
|
|
describe("PlatformApiClient Game Client Bridge operator API", () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("types plugin and marketplace manifest declarations", () => {
|
|
expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ type: "scum.diagnostic.ping" }], queryTemplates: [{ engine: "sqlite" }] });
|
|
expect(JSON.stringify(pluginBridgeProjection)).not.toMatch(/authKey|componentKey|sessionToken|credential|secretRef/i);
|
|
});
|
|
|
|
it("uses only server-scoped operator routes and preserves bounded filters and bodies", async () => {
|
|
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
const method = init?.method ?? "GET";
|
|
calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
|
if (url.endsWith("/game-client-bridge")) return jsonResponse(status);
|
|
if (url.includes("/game-client-bridge/commands?") && method === "GET") return jsonResponse({ items: [pendingCommand], count: 1 });
|
|
if (url.endsWith("/game-client-bridge/commands") && method === "POST") return jsonResponse(pendingCommand, 202);
|
|
if (url.endsWith(`/game-client-bridge/commands/${pendingCommand.id}`) && method === "GET") return jsonResponse(completedCommand);
|
|
if (url.endsWith(`/game-client-bridge/commands/${pendingCommand.id}/cancel`) && method === "POST") return jsonResponse(cancellation);
|
|
if (url.includes("/game-client-bridge/snapshots?")) return jsonResponse({ items: [snapshot], count: 1 });
|
|
throw new Error(`unexpected request: ${method} ${url}`);
|
|
}));
|
|
const client = new PlatformApiClient("/api/v1", () => "operator-session");
|
|
const queueRequest: GameClientBridgeQueueRequest = {
|
|
profileKey: "scum-client",
|
|
commandType: "scum.diagnostic.ping",
|
|
payload: { message: "Restart in ten minutes", channels: ["global"] },
|
|
idempotencyKey: "diagnostic-1",
|
|
priority: 20,
|
|
expiresAt: later
|
|
};
|
|
|
|
await expect(client.getGameClientBridgeStatus("server-1")).resolves.toMatchObject({ available: false, profiles: [{ profileKey: "scum-client" }] });
|
|
await expect(client.listGameClientBridgeCommands("server-1", { profileKey: "scum-client", state: "pending", commandType: "scum.diagnostic.ping" })).resolves.toMatchObject({ count: 1 });
|
|
await expect(client.queueGameClientBridgeCommand("server-1", queueRequest)).resolves.toMatchObject({ state: "pending" });
|
|
await expect(client.getGameClientBridgeCommand("server-1", pendingCommand.id)).resolves.toMatchObject({ result: { status: "succeeded", payload: { delivered: true } } });
|
|
await expect(client.cancelGameClientBridgeCommand("server-1", pendingCommand.id, { reason: "maintenance window changed" })).resolves.toMatchObject({ state: "cancelled" });
|
|
await expect(client.listGameClientBridgeSnapshots("server-1", { profileKey: "scum-client", type: "scum.players", streamKey: "current", observedAfter: now, limit: 20 })).resolves.toMatchObject({ count: 1, items: [{ sequence: 7 }] });
|
|
|
|
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
|
"GET /api/v1/server-instances/server-1/game-client-bridge",
|
|
"GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.diagnostic.ping",
|
|
"POST /api/v1/server-instances/server-1/game-client-bridge/commands",
|
|
"GET /api/v1/server-instances/server-1/game-client-bridge/commands/command-1",
|
|
"POST /api/v1/server-instances/server-1/game-client-bridge/commands/command-1/cancel",
|
|
"GET /api/v1/server-instances/server-1/game-client-bridge/snapshots?profileKey=scum-client&type=scum.players&streamKey=current&observedAfter=2026-07-20T08%3A00%3A00Z&limit=20"
|
|
]);
|
|
expect(calls[2]?.body).toEqual(queueRequest);
|
|
expect(calls[4]?.body).toEqual({ reason: "maintenance window changed" });
|
|
expect(JSON.stringify(calls)).not.toMatch(/sessionToken|componentKey|secretRef|hostPath|dsn|socket|credential|runEndpoint/i);
|
|
expect(calls.every((call) => !call.url.includes("/companion/"))).toBe(true);
|
|
});
|
|
|
|
it("URL-encodes server and command identifiers", async () => {
|
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(completedCommand));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new PlatformApiClient("/api/v1").getGameClientBridgeCommand("server/unsafe", "command/unsafe");
|
|
|
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server%2Funsafe/game-client-bridge/commands/command%2Funsafe");
|
|
});
|
|
});
|
|
|
|
function jsonResponse(value: unknown, statusCode = 200): Response {
|
|
return new Response(JSON.stringify(value), { status: statusCode, headers: { "Content-Type": "application/json" } });
|
|
}
|