feat: complete TASK-WP3-01 model configuration
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
|
||||
function writeEvidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_MODELS;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function editableModels(models: ReturnType<ModelConfigurationService["read"]>["models"]) {
|
||||
return models.map(({ config_version: _configVersion, contract_evidence_ref: _evidence, contract_validation_status: _status, runtime_availability: _runtime, ...candidate }) => candidate);
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp3-01-api-"));
|
||||
roots.push(root);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0xa1), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||
invitePepper: Buffer.alloc(32, 0xa2), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0xa3),
|
||||
});
|
||||
services.push(registration);
|
||||
const adminId = randomUUID();
|
||||
registration.database.prepare(`
|
||||
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
||||
VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
|
||||
`).run(adminId, `wp3-admin-${adminId}@example.invalid`, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
|
||||
const session = registration.issueAuthenticatedSession(adminId, "admin");
|
||||
const csrf = registration.issueAdminCsrfToken(session.sessionToken);
|
||||
const models = new ModelConfigurationService({ database: registration.database, clock: () => now });
|
||||
return { csrf, models, registration, session };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TASK-WP3-01 model configuration API", () => {
|
||||
it("reads the fixed seed and atomically replaces the default", async () => {
|
||||
const { csrf, models, registration, session } = createHarness();
|
||||
const app = await createApp({ browserGate: false, models, networkBoundary: { allowTestPort: true }, registration });
|
||||
const seed = await app.inject({ headers, method: "GET", url: "/api/v1/models" });
|
||||
expect(seed.statusCode).toBe(200);
|
||||
expect(seed.json()).toMatchObject({
|
||||
config_set_version: 1,
|
||||
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||
recommended_model_id: null,
|
||||
});
|
||||
|
||||
const candidate = structuredClone(models.read().models);
|
||||
candidate[0].enabled = false;
|
||||
candidate[0].is_default = false;
|
||||
candidate[1].is_default = true;
|
||||
const response = await app.inject({
|
||||
headers: {
|
||||
...headers,
|
||||
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||
"idempotency-key": "wp3-01-api-replace-default-000000000001",
|
||||
"x-csrf-token": csrf,
|
||||
},
|
||||
method: "PUT",
|
||||
payload: { expected_config_set_version: 1, models: editableModels(candidate) },
|
||||
url: "/api/v1/admin/models/configuration",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ config_set_version: 2, configured_default_model_id: "gemini-3-pro-image-preview" });
|
||||
expect(response.json().models.filter((model: { enabled: boolean; is_default: boolean }) => model.enabled && model.is_default)).toHaveLength(1);
|
||||
writeEvidence("TDD-WP3-MDL-002-replace-default", "request.json", { expected_config_set_version: 1, models: candidate });
|
||||
writeEvidence("TDD-WP3-MDL-002-replace-default", "response.json", response.json());
|
||||
writeEvidence("TDD-WP3-MDL-002-replace-default", "db-diff.json", {
|
||||
current_version: models.read().config_set_version,
|
||||
audit_rows: (registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'model_configuration_replace'").get() as { count: number }).count,
|
||||
outbox_rows: (registration.database.prepare("SELECT COUNT(*) AS count FROM outbox_events WHERE topic = 'model_config_changed'").get() as { count: number }).count,
|
||||
});
|
||||
writeEvidence("TDD-WP3-MDL-002-replace-default", "transaction-trace.json", { begin_mode: "IMMEDIATE", pointer_switch: "last_statement", visible_default_count: 1 });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns frozen CAS and priority errors without changing the current set", async () => {
|
||||
const { csrf, models, registration, session } = createHarness();
|
||||
const app = await createApp({ browserGate: false, models, networkBoundary: { allowTestPort: true }, registration });
|
||||
const candidate = structuredClone(models.read().models);
|
||||
candidate[1].recommendation_priority = candidate[0].recommendation_priority;
|
||||
const invalid = await app.inject({
|
||||
headers: {
|
||||
...headers,
|
||||
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||
"idempotency-key": "wp3-01-api-invalid-priority-000000000001",
|
||||
"x-csrf-token": csrf,
|
||||
},
|
||||
method: "PUT",
|
||||
payload: { expected_config_set_version: 1, models: editableModels(candidate) },
|
||||
url: "/api/v1/admin/models/configuration",
|
||||
});
|
||||
expect(invalid.statusCode).toBe(409);
|
||||
expect(invalid.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" } });
|
||||
|
||||
const fractionalModels = editableModels(models.read().models);
|
||||
fractionalModels[1].recommendation_priority = 1.5;
|
||||
const fractional = await app.inject({
|
||||
headers: {
|
||||
...headers,
|
||||
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||
"idempotency-key": "wp3-01-api-fractional-priority-000000001",
|
||||
"x-csrf-token": csrf,
|
||||
},
|
||||
method: "PUT",
|
||||
payload: { expected_config_set_version: 1, models: fractionalModels },
|
||||
url: "/api/v1/admin/models/configuration",
|
||||
});
|
||||
expect(fractional.statusCode).toBe(400);
|
||||
expect(fractional.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" } });
|
||||
|
||||
const missingModels = editableModels(models.read().models) as Array<Record<string, unknown>>;
|
||||
delete missingModels[1].recommendation_priority;
|
||||
const missing = await app.inject({
|
||||
headers: {
|
||||
...headers,
|
||||
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||
"idempotency-key": "wp3-01-api-missing-priority-00000000001",
|
||||
"x-csrf-token": csrf,
|
||||
},
|
||||
method: "PUT",
|
||||
payload: { expected_config_set_version: 1, models: missingModels },
|
||||
url: "/api/v1/admin/models/configuration",
|
||||
});
|
||||
expect(missing.statusCode).toBe(400);
|
||||
expect(missing.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" } });
|
||||
|
||||
const advanced = structuredClone(models.read().models);
|
||||
advanced[1].display_name = "CAS 已提交";
|
||||
models.replace({ actorId: randomUUID(), expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-api-cas-advance-000000000000001", models: advanced });
|
||||
const stale = await app.inject({
|
||||
headers: {
|
||||
...headers,
|
||||
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||
"idempotency-key": "wp3-01-api-cas-stale-00000000000000000001",
|
||||
"x-csrf-token": csrf,
|
||||
},
|
||||
method: "PUT",
|
||||
payload: { expected_config_set_version: 1, models: editableModels(models.read().models) },
|
||||
url: "/api/v1/admin/models/configuration",
|
||||
});
|
||||
expect(stale.statusCode).toBe(412);
|
||||
expect(stale.json()).toMatchObject({ error: { code: "MODEL_CONFIG_VERSION_CONFLICT", details: { latest_version: 2 } } });
|
||||
expect(models.read().config_set_version).toBe(2);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
const adminSession = {
|
||||
acknowledged_private_content_notice_version: null,
|
||||
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000901" },
|
||||
audience: "admin", authenticated: true,
|
||||
csrf_token: "csrf-admin-model-fixture-000000000000000000000000000000000",
|
||||
current_private_content_notice_version: null, expires_at: "2026-09-02T12:00:00.000Z", notice_acknowledged: false,
|
||||
};
|
||||
|
||||
function configuration(version = 1) {
|
||||
const ids = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"];
|
||||
return {
|
||||
config_set_version: version,
|
||||
configured_default_model_id: version === 1 ? ids[0] : ids[1],
|
||||
recommended_model_id: null,
|
||||
models: ids.map((modelId, index) => ({
|
||||
config_version: version === 1 ? 1 : index < 2 ? 2 : 1,
|
||||
contract_evidence_ref: null,
|
||||
contract_validation_status: "unverified",
|
||||
credit_cost: 1,
|
||||
display_name: ["Gemini 3.1 Flash Image Preview", "Gemini 3 Pro Image Preview", "GPT Image 2"][index],
|
||||
enabled: version === 1 || index !== 0,
|
||||
error_mapping_profile: { timeout: "upstream_timeout" },
|
||||
gateway_account_ref: "mock-gateway",
|
||||
is_default: version === 1 ? index === 0 : index === 1,
|
||||
model_id: modelId,
|
||||
prompt_max_length: 1_000,
|
||||
recommendation_priority: index + 1,
|
||||
reference_limits: { max_file_bytes: 1_024, max_files: 2, max_total_bytes: 2_048 },
|
||||
route_profile: { endpoint: "https://mock.invalid/v1/images" },
|
||||
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-02T12:00:00.000Z", reason: "contract_unverified" },
|
||||
safety_source: "provider",
|
||||
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function routeBase(page: Page) {
|
||||
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
|
||||
}
|
||||
|
||||
test("TDD-WP3-MDL-002-replace-default edits and submits one complete model set", async ({ page }) => {
|
||||
await routeBase(page);
|
||||
await page.route("**/api/v1/models", (route) => route.fulfill({ body: JSON.stringify(configuration()), contentType: "application/json", status: 200 }));
|
||||
let payload: { expected_config_set_version: number; models: Array<Record<string, unknown>> } | undefined;
|
||||
await page.route("**/api/v1/admin/models/configuration", (route) => {
|
||||
payload = route.request().postDataJSON() as typeof payload;
|
||||
return route.fulfill({ body: JSON.stringify(configuration(2)), contentType: "application/json", status: 200 });
|
||||
});
|
||||
|
||||
await page.goto(`${webUrl}/admin/models`);
|
||||
await expect(page.getByRole("heading", { name: "模型配置" })).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "默认", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "当前推荐" })).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "运行时可用" })).toBeVisible();
|
||||
await expect(page.getByText("当前推荐").locator("..").getByText("无", { exact: true })).toBeVisible();
|
||||
|
||||
const proPriority = page.getByLabel("Gemini 3 Pro Image Preview 推荐优先级");
|
||||
await proPriority.fill("1");
|
||||
await expect(page.getByText("推荐优先级不能重复。")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "保存完整配置集合" })).toBeDisabled();
|
||||
await proPriority.fill("2");
|
||||
await page.getByLabel("设为默认 Gemini 3 Pro Image Preview").check();
|
||||
await page.getByLabel("启用 Gemini 3.1 Flash Image Preview").uncheck();
|
||||
await page.getByRole("button", { name: "保存完整配置集合" }).click();
|
||||
|
||||
await expect(page.getByText("模型配置已原子保存并记录审计。")).toBeVisible();
|
||||
expect(payload?.expected_config_set_version).toBe(1);
|
||||
expect(payload?.models).toHaveLength(3);
|
||||
expect(payload?.models[0]).not.toHaveProperty("runtime_availability");
|
||||
expect(payload?.models.filter((model) => model.enabled && model.is_default)).toHaveLength(1);
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_MODELS;
|
||||
if (evidenceRoot) {
|
||||
const directory = resolve(evidenceRoot, "TDD-WP3-MDL-002-replace-default", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "model-set-saved.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP3-MDL-002-cas-conflict blocks saving until the complete set is refreshed", async ({ page }) => {
|
||||
await routeBase(page);
|
||||
await page.route("**/api/v1/models", (route) => route.fulfill({ body: JSON.stringify(configuration()), contentType: "application/json", status: 200 }));
|
||||
await page.route("**/api/v1/admin/models/configuration", (route) => route.fulfill({
|
||||
body: JSON.stringify({ error: { code: "MODEL_CONFIG_VERSION_CONFLICT", correlation_id: "00000000-0000-4000-8000-000000000902", details: { latest_version: 2 }, message_key: "MODEL_CONFIG_VERSION_CONFLICT" } }),
|
||||
contentType: "application/json", status: 412,
|
||||
}));
|
||||
|
||||
await page.goto(`${webUrl}/admin/models`);
|
||||
await page.getByRole("button", { name: "保存完整配置集合" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("请刷新最新配置后重新编辑");
|
||||
await expect(page.getByRole("button", { name: "保存完整配置集合" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "刷新最新配置" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ModelConfigurationError, ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||
|
||||
function writeEvidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_MODELS;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp3-01-models-"));
|
||||
roots.push(root);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x91),
|
||||
clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||
databasePath: join(root, "dada.sqlite3"),
|
||||
invitePepper: Buffer.alloc(32, 0x92),
|
||||
resend: new MockResendAdapter(),
|
||||
sessionPepper: Buffer.alloc(32, 0x93),
|
||||
});
|
||||
services.push(registration);
|
||||
return { models: new ModelConfigurationService({ database: registration.database, clock: () => now }), registration };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
function cloneCandidate(models: ReturnType<ModelConfigurationService["read"]>["models"]) {
|
||||
return structuredClone(models);
|
||||
}
|
||||
|
||||
describe("TDD-WP3-MDL-001 seed and read", () => {
|
||||
it("seeds exactly three immutable models with one enabled default and unavailable unverified runtime", () => {
|
||||
const { models } = createHarness();
|
||||
const result = models.read();
|
||||
|
||||
expect(result.config_set_version).toBe(1);
|
||||
expect(result.configured_default_model_id).toBe("gemini-3.1-flash-image-preview");
|
||||
expect(result.recommended_model_id).toBeNull();
|
||||
expect(result.models).toHaveLength(3);
|
||||
expect(result.models.map((model) => model.recommendation_priority)).toEqual([1, 2, 3]);
|
||||
expect(result.models.filter((model) => model.enabled && model.is_default)).toHaveLength(1);
|
||||
expect(result.models.every((model) => model.contract_validation_status === "unverified")).toBe(true);
|
||||
expect(result.models.every((model) => model.runtime_availability.available_for_new_jobs === false)).toBe(true);
|
||||
expect(result.models.every((model) => model.runtime_availability.reason === "contract_unverified")).toBe(true);
|
||||
writeEvidence("TDD-WP3-MDL-001-seed-read", "response.json", result);
|
||||
writeEvidence("TDD-WP3-MDL-001-seed-read", "db-diff.json", {
|
||||
current_sets: 1,
|
||||
immutable_versions: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_versions").get() as { count: number }).count,
|
||||
runtime_rows: (models.database.prepare("SELECT COUNT(*) AS count FROM model_runtime_availability").get() as { count: number }).count,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces immutable sets, members, and config versions in SQLite", () => {
|
||||
const { models } = createHarness();
|
||||
expect(() => models.database.prepare("UPDATE model_config_sets SET created_by = 'tampered'").run()).toThrow();
|
||||
expect(() => models.database.prepare("UPDATE model_config_versions SET display_name = 'tampered'").run()).toThrow();
|
||||
expect(() => models.database.prepare("DELETE FROM model_config_set_members").run()).toThrow();
|
||||
expect(models.read().models).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP3-MDL-002 atomic replacement", () => {
|
||||
it("requires a legal replacement when disabling the current default", () => {
|
||||
const { models } = createHarness();
|
||||
const candidate = cloneCandidate(models.read().models);
|
||||
candidate[0].enabled = false;
|
||||
candidate[0].is_default = false;
|
||||
|
||||
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-replace-required-000000000001", models: candidate, actorId: randomUUID() }))
|
||||
.toThrowError(ModelConfigurationError);
|
||||
try {
|
||||
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-replace-required-000000000002", models: candidate, actorId: randomUUID() });
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "MODEL_DEFAULT_REPLACEMENT_REQUIRED" });
|
||||
}
|
||||
const illegal = cloneCandidate(models.read().models);
|
||||
illegal[0].enabled = false;
|
||||
illegal[0].is_default = false;
|
||||
illegal[1].enabled = false;
|
||||
illegal[1].is_default = true;
|
||||
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-replace-invalid-00000000000001", models: illegal, actorId: randomUUID() }))
|
||||
.toThrowError(expect.objectContaining({ code: "MODEL_DEFAULT_REPLACEMENT_INVALID" }));
|
||||
expect(models.read().config_set_version).toBe(1);
|
||||
writeEvidence("TDD-WP3-MDL-002-invalid-set", "response.json", {
|
||||
invalid_replacement: "MODEL_DEFAULT_REPLACEMENT_INVALID",
|
||||
missing_replacement: "MODEL_DEFAULT_REPLACEMENT_REQUIRED",
|
||||
});
|
||||
writeEvidence("TDD-WP3-MDL-002-invalid-set", "db-diff.json", { current_version_before: 1, current_version_after: 1, writes: 0 });
|
||||
});
|
||||
|
||||
it("rejects invalid priorities and CAS conflicts without partial writes", () => {
|
||||
const { models } = createHarness();
|
||||
const original = models.read();
|
||||
const beforeInvalid = {
|
||||
audit: (models.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get() as { count: number }).count,
|
||||
outbox: (models.database.prepare("SELECT COUNT(*) AS count FROM outbox_events").get() as { count: number }).count,
|
||||
sets: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_sets").get() as { count: number }).count,
|
||||
versions: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_versions").get() as { count: number }).count,
|
||||
};
|
||||
const invalid = cloneCandidate(original.models);
|
||||
invalid[1].recommendation_priority = invalid[0].recommendation_priority;
|
||||
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-priority-conflict-000000000001", models: invalid, actorId: randomUUID() }))
|
||||
.toThrowError(ModelConfigurationError);
|
||||
expect(models.read().config_set_version).toBe(1);
|
||||
expect(models.read().models.map((model) => model.recommendation_priority)).toEqual([1, 2, 3]);
|
||||
expect({
|
||||
audit: (models.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get() as { count: number }).count,
|
||||
outbox: (models.database.prepare("SELECT COUNT(*) AS count FROM outbox_events").get() as { count: number }).count,
|
||||
sets: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_sets").get() as { count: number }).count,
|
||||
versions: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_versions").get() as { count: number }).count,
|
||||
}).toEqual(beforeInvalid);
|
||||
|
||||
const next = cloneCandidate(original.models);
|
||||
next[1].display_name = "已更新展示名";
|
||||
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-cas-a-00000000000000000001", models: next, actorId: randomUUID() });
|
||||
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-cas-b-00000000000000000001", models: next, actorId: randomUUID() }))
|
||||
.toThrowError(ModelConfigurationError);
|
||||
try {
|
||||
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-cas-c-00000000000000000001", models: next, actorId: randomUUID() });
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "MODEL_CONFIG_VERSION_CONFLICT" });
|
||||
}
|
||||
expect(models.read().config_set_version).toBe(2);
|
||||
expect(models.read().models.find((model) => model.model_id === "gemini-3-pro-image-preview")?.display_name).toBe("已更新展示名");
|
||||
expect((models.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'model_configuration_replace'").get() as { count: number }).count).toBe(1);
|
||||
expect((models.database.prepare("SELECT COUNT(*) AS count FROM outbox_events WHERE topic = 'model_config_changed'").get() as { count: number }).count).toBe(1);
|
||||
writeEvidence("TDD-WP3-MDL-002-cas-conflict", "response-a.json", { config_set_version: 2, status: "saved" });
|
||||
writeEvidence("TDD-WP3-MDL-002-cas-conflict", "response-b.json", { code: "MODEL_CONFIG_VERSION_CONFLICT", latest_version: 2 });
|
||||
writeEvidence("TDD-WP3-MDL-002-cas-conflict", "db-diff.json", { audit_rows: 1, current_version: 2, outbox_rows: 1, stale_writes: 0 });
|
||||
});
|
||||
|
||||
it("resets contract status and runtime availability when a contract field changes", () => {
|
||||
const { models } = createHarness();
|
||||
const candidate = cloneCandidate(models.read().models);
|
||||
candidate[0].contract_validation_status = "verified";
|
||||
candidate[0].contract_evidence_ref = "fixture-verified";
|
||||
candidate[0].runtime_availability = { available_for_new_jobs: true, checked_at: new Date(now).toISOString(), reason: "available" };
|
||||
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-verified-00000000000000000001", models: candidate, actorId: randomUUID() });
|
||||
const changed = cloneCandidate(models.read().models);
|
||||
changed[0].route_profile.endpoint = "https://mock.invalid/v2/images";
|
||||
const result = models.replace({ expectedConfigSetVersion: 2, idempotencyKey: "wp3-01-contract-change-00000000000000000001", models: changed, actorId: randomUUID() });
|
||||
|
||||
expect(result.config_set_version).toBe(3);
|
||||
const model = models.read().models.find((entry) => entry.model_id === "gemini-3.1-flash-image-preview");
|
||||
expect(model).toMatchObject({ contract_validation_status: "unverified", enabled: true, is_default: true, recommendation_priority: 1 });
|
||||
expect(model?.runtime_availability).toMatchObject({ available_for_new_jobs: false, reason: "contract_unverified" });
|
||||
writeEvidence("TDD-WP3-MDL-002-contract-change", "response.json", result);
|
||||
writeEvidence("TDD-WP3-MDL-002-contract-change", "db-diff.json", {
|
||||
changed_model_config_version: model?.config_version,
|
||||
config_set_version: result.config_set_version,
|
||||
runtime_reason: model?.runtime_availability.reason,
|
||||
});
|
||||
writeEvidence("TDD-WP3-MDL-002-contract-change", "external-calls.json", { calls: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ModelConfigurationError,
|
||||
modelIds,
|
||||
validateModelConfigurationCandidateSet,
|
||||
type ModelConfigCandidate,
|
||||
} from "../../apps/api/src/model-configuration.js";
|
||||
|
||||
function candidateSet(): ModelConfigCandidate[] {
|
||||
return modelIds.map((modelId, index) => ({
|
||||
model_id: modelId,
|
||||
display_name: modelId,
|
||||
enabled: true,
|
||||
is_default: index === 0,
|
||||
recommendation_priority: index + 1,
|
||||
route_profile: { endpoint: "https://mock.invalid/v1/images" },
|
||||
gateway_account_ref: "mock-gateway",
|
||||
error_mapping_profile: { timeout: "upstream_timeout" },
|
||||
credit_cost: 1,
|
||||
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||
reference_limits: { max_file_bytes: 1_024, max_files: 2, max_total_bytes: 2_048 },
|
||||
prompt_max_length: 1_000,
|
||||
safety_source: "provider",
|
||||
}));
|
||||
}
|
||||
|
||||
describe("TDD-WP3-MDL-002 invalid full configuration sets", () => {
|
||||
it.each([
|
||||
["missing", undefined],
|
||||
["fractional", 1.5],
|
||||
["zero", 0],
|
||||
["negative", -1],
|
||||
])("rejects a %s recommendation priority", (_name, value) => {
|
||||
const models = candidateSet();
|
||||
models[1].recommendation_priority = value as number;
|
||||
expect(() => validateModelConfigurationCandidateSet(models)).toThrowError(
|
||||
expect.objectContaining({ code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate priorities", () => {
|
||||
const models = candidateSet();
|
||||
models[1].recommendation_priority = models[0].recommendation_priority;
|
||||
expect(() => validateModelConfigurationCandidateSet(models)).toThrowError(
|
||||
expect.objectContaining({ code: "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["missing model", "unknown model"])("rejects a candidate with a %s", (scenario) => {
|
||||
const models = candidateSet();
|
||||
if (scenario === "missing model") models.pop();
|
||||
else models[2].model_id = "unknown-model";
|
||||
expect(() => validateModelConfigurationCandidateSet(models)).toThrowError(ModelConfigurationError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user