129 lines
8.1 KiB
TypeScript
129 lines
8.1 KiB
TypeScript
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 { CreditService } from "../../apps/api/src/credits.js";
|
|
import { GenerationSubmissionService, StaticGenerationModelCatalog } from "../../apps/api/src/generation-submission.js";
|
|
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
|
import { ProjectService } from "../../apps/api/src/projects.js";
|
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
|
import { GatewayBalanceRuntime } from "../../apps/worker/src/gateway-balance-runtime.js";
|
|
|
|
const roots: string[] = [];
|
|
const now = Date.parse("2026-08-02T14:00:00.000Z");
|
|
const models = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"];
|
|
const closeables: Array<{ close(): void }> = [];
|
|
const balanceEvidence: unknown[] = [];
|
|
|
|
function evidence(file: string, value: unknown) {
|
|
const root = process.env.DADA_EVIDENCE_DIR_GENERATION_RUNTIME;
|
|
if (!root) return;
|
|
const directory = resolve(root, "TDD-WP2-BAL-001-balance-impact");
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const value of closeables.splice(0).reverse()) value.close();
|
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
|
});
|
|
|
|
describe("TASK-WP2-06 gateway balance scope", () => {
|
|
it.each([
|
|
["model", [models[0]]],
|
|
["account", models],
|
|
["unknown", models],
|
|
] as const)("applies %s impact without rewriting model configuration", (impactScope, expected) => {
|
|
const root = mkdtempSync(join(tmpdir(), `dada-wp2-06-balance-${impactScope}-`));
|
|
roots.push(root);
|
|
const databasePath = join(root, "dada.sqlite3");
|
|
const registration = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x31),
|
|
clock: () => now,
|
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
|
databasePath,
|
|
invitePepper: Buffer.alloc(32, 0x32),
|
|
resend: new MockResendAdapter(),
|
|
sessionPepper: Buffer.alloc(32, 0x33),
|
|
});
|
|
const database = registration.database;
|
|
database.exec("CREATE TABLE model_config_integrity (model_id TEXT PRIMARY KEY, config_hash TEXT NOT NULL, enabled INTEGER NOT NULL, is_default INTEGER NOT NULL, recommendation_priority INTEGER NOT NULL)");
|
|
const insertConfig = database.prepare("INSERT INTO model_config_integrity VALUES (?, ?, 1, ?, ?)");
|
|
models.forEach((modelId, index) => insertConfig.run(modelId, `hash-${index + 1}`, index === 0 ? 1 : 0, index + 1));
|
|
const beforeConfig = database.prepare("SELECT * FROM model_config_integrity ORDER BY recommendation_priority").all();
|
|
registration.close();
|
|
|
|
const runtime = new GatewayBalanceRuntime({ clock: () => now, databasePath });
|
|
runtime.seedModels(models.map((modelId) => ({ gatewayAccountRef: "gateway-account-primary", modelId })));
|
|
const eventId = randomUUID();
|
|
const first = runtime.recordInsufficient({
|
|
eventId, gatewayAccountRef: "gateway-account-primary", impactScope, modelId: models[0], sourceCategory: "adapter_balance_signal",
|
|
});
|
|
const replay = runtime.recordInsufficient({
|
|
eventId, gatewayAccountRef: "gateway-account-primary", impactScope, modelId: models[0], sourceCategory: "adapter_balance_signal",
|
|
});
|
|
expect(replay).toEqual(first);
|
|
expect(first.runtimeUnavailableModelIds.toSorted()).toEqual([...expected].toSorted());
|
|
expect(runtime.listRuntime().filter((item) => !item.availableForNewJobs).map((item) => item.modelId).toSorted()).toEqual([...expected].toSorted());
|
|
expect(() => runtime.restoreWithoutConfirmedRecovery("gateway-account-primary", randomUUID())).toThrow("gateway_balance_recovery_unconfirmed");
|
|
const afterConfig = runtime.database.prepare("SELECT * FROM model_config_integrity ORDER BY recommendation_priority").all();
|
|
expect(afterConfig).toEqual(beforeConfig);
|
|
expect(runtime.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'gateway_balance_detected'").get()).toEqual({ count: 1 });
|
|
expect(() => runtime.database.prepare("UPDATE admin_operation_logs SET result = 'failed'").run()).toThrow("admin_operation_logs_immutable");
|
|
expect(() => runtime.database.prepare("DELETE FROM admin_operation_logs").run()).toThrow("admin_operation_logs_immutable");
|
|
balanceEvidence.push({ impact_scope: impactScope, response: first, runtime: runtime.listRuntime() });
|
|
evidence("response.json", { scopes: balanceEvidence });
|
|
evidence("db-diff.json", { config_unchanged: afterConfig, scopes: balanceEvidence });
|
|
evidence("worker-events.json", { replay_deduplicated: true, scopes: balanceEvidence });
|
|
evidence("external-calls.json", { automatic_recharge_calls: 0, recovery_calls: 0 });
|
|
runtime.close();
|
|
});
|
|
|
|
it("blocks a disabled model before job creation or credit reservation", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp2-06-model-disabled-"));
|
|
roots.push(root);
|
|
const databasePath = join(root, "dada.sqlite3");
|
|
const registration = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x41), clock: () => now,
|
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
|
invitePepper: Buffer.alloc(32, 0x42), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x43),
|
|
});
|
|
const projects = new ProjectService({ clock: () => now, databasePath });
|
|
const credits = new CreditService({ clock: () => now, databasePath });
|
|
const storage = new ManagedStorage({ dataRoot: root, databasePath });
|
|
const submissions = new GenerationSubmissionService({
|
|
clock: () => now, credits, storage,
|
|
models: new StaticGenerationModelCatalog([{
|
|
configSetVersion: 1, configVersion: 1, contractValidationStatus: "verified", creditCost: 1,
|
|
enabled: false, modelId: models[0], promptMaxLength: 1_000,
|
|
referenceLimits: { maxFileBytes: 1_024, maxFiles: 2, maxTotalBytes: 2_048 },
|
|
runtimeAvailability: { availableForNewJobs: false, reason: "model_disabled" }, supportedRatios: ["3:4"],
|
|
}]),
|
|
});
|
|
closeables.push(submissions, storage, credits, projects, registration);
|
|
const userId = randomUUID();
|
|
registration.database.prepare(`
|
|
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
|
VALUES (?, ?, 'user', 'active', 1, ?, ?)
|
|
`).run(userId, `${userId}@example.invalid`, randomUUID(), now);
|
|
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Disabled User', '@disabled')").run(userId);
|
|
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
|
await expect(submissions.submit({
|
|
clientSubmissionId: randomUUID(), confirmedCreditCost: 1, existingReferenceAssetIds: [],
|
|
idempotencyKey: `generation-${randomUUID()}-${randomUUID()}`, mode: "new_project", modelConfigVersion: 1,
|
|
modelId: models[0], newReferences: [], prompt: "禁用模型", ratio: "3:4", userId,
|
|
})).rejects.toMatchObject({ code: "generation_blocked", errorCategory: "model_disabled" });
|
|
const counts = Object.fromEntries(["projects", "generation_jobs", "credit_reservations", "outbox_events"].map((table) => [
|
|
table, (registration.database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count,
|
|
]));
|
|
expect(counts).toEqual({ credit_reservations: 0, generation_jobs: 0, outbox_events: 0, projects: 0 });
|
|
evidence("../TDD-WP2-ERR-001-model-disabled/response.json", { error_category: "model_disabled", status: "not_created", user_action: "choose_model_or_contact_admin" });
|
|
evidence("../TDD-WP2-ERR-001-model-disabled/db-diff.json", counts);
|
|
evidence("../TDD-WP2-ERR-001-model-disabled/external-calls.json", { adapter_calls: 0 });
|
|
});
|
|
});
|