feat: add direct PostgreSQL and ACK deployment support

This commit is contained in:
2026-08-12 19:12:00 +08:00
parent d0192081c7
commit c44274f098
55 changed files with 3317 additions and 1064 deletions

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { hashLocalPassword, queryDatabase, transactionQuery, verifyLocalPassword, withDatabaseTransaction, state } = vi.hoisted(() => {
const state = {
user: {} as Record<string, unknown>,
transactionTail: Promise.resolve() as Promise<unknown>
};
const transactionQuery = vi.fn(async (text: string, values: readonly unknown[] = []) => {
if (text.includes("SELECT * FROM platform_users")) return { rows: [{ ...state.user }], rowCount: 1 };
if (text.includes("SET failed_login_count=$2")) {
state.user.failed_login_count = values[1];
state.user.locked_until = values[2];
state.user.updated_at = values[3];
return { rows: [], rowCount: 1 };
}
if (text.includes("SET failed_login_count=0")) {
state.user.failed_login_count = 0;
state.user.locked_until = null;
state.user.last_login_at = values[1];
state.user.updated_at = values[1];
return { rows: [{ ...state.user }], rowCount: 1 };
}
if (text.includes("SET password_hash=$2")) {
state.user.password_hash = values[1];
state.user.password_salt = values[2];
state.user.session_version = Number(state.user.session_version) + 1;
state.user.updated_at = values[3];
return { rows: [{ ...state.user }], rowCount: 1 };
}
throw new Error(`Unexpected SQL: ${text}`);
});
const withDatabaseTransaction = vi.fn(<T>(callback: (client: { query: typeof transactionQuery }) => Promise<T>) => {
const run = state.transactionTail.then(() => callback({ query: transactionQuery }));
state.transactionTail = run.catch(() => undefined);
return run;
});
return {
hashLocalPassword: vi.fn(async (password: string) => ({ hash: `hash:${password}`, salt: `salt:${password}` })),
queryDatabase: vi.fn(),
transactionQuery,
verifyLocalPassword: vi.fn(async (password: string, hash: string) => hash === `hash:${password}`),
withDatabaseTransaction,
state
};
});
vi.mock("@/lib/server/database", () => ({
isPostgresBackend: () => true,
queryDatabase,
withDatabaseTransaction
}));
vi.mock("@/lib/server/data-store", () => ({
reassignOwnerData: vi.fn()
}));
vi.mock("@/lib/server/auth/password", () => ({
hashLocalPassword,
verifyLocalPassword
}));
import { authenticatePlatformUser, changeOwnPassword } from "@/lib/server/account-store";
describe("PostgreSQL account authentication", () => {
beforeEach(() => {
queryDatabase.mockReset();
transactionQuery.mockClear();
withDatabaseTransaction.mockClear();
hashLocalPassword.mockClear();
verifyLocalPassword.mockClear();
state.transactionTail = Promise.resolve();
state.user = {
id: "user-1",
phone: "13800138000",
display_name: "Concurrent user",
role: "super_admin",
organization_id: null,
status: "active",
password_hash: "hash",
password_salt: "salt",
failed_login_count: 0,
locked_until: null,
session_version: 1,
last_login_at: null,
legacy_subject: null,
created_at: new Date("2026-08-12T00:00:00.000Z"),
updated_at: new Date("2026-08-12T00:00:00.000Z")
};
});
it("allows only one concurrent password change using the same current password", async () => {
state.user.password_hash = "hash:current-password";
state.user.password_salt = "salt:current-password";
const attempts = await Promise.allSettled([
changeOwnPassword("user-1", "current-password", "next-password-one"),
changeOwnPassword("user-1", "current-password", "next-password-two")
]);
expect(attempts[0]).toMatchObject({ status: "fulfilled", value: { sessionVersion: 2 } });
expect(attempts[1]).toMatchObject({ status: "rejected", reason: { status: 400 } });
expect(state.user.password_hash).toBe("hash:next-password-one");
expect(state.user.session_version).toBe(2);
expect(withDatabaseTransaction).toHaveBeenCalledTimes(2);
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(2);
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("session_version=session_version+1"))).toHaveLength(1);
expect(hashLocalPassword).toHaveBeenCalledTimes(1);
expect(queryDatabase).not.toHaveBeenCalled();
});
it("serializes concurrent failures on the user row and locks on the fifth attempt", async () => {
const attempts = await Promise.allSettled(
Array.from({ length: 5 }, () => authenticatePlatformUser("138 0013 8000", "wrong-password"))
);
expect(attempts.map((attempt) => attempt.status === "rejected" && attempt.reason.status)).toEqual([401, 401, 401, 401, 423]);
expect(state.user.failed_login_count).toBe(0);
expect(new Date(String(state.user.locked_until)).getTime()).toBeGreaterThan(Date.now());
expect(withDatabaseTransaction).toHaveBeenCalledTimes(5);
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(5);
expect(queryDatabase).not.toHaveBeenCalled();
await expect(authenticatePlatformUser("13800138000", "wrong-password")).rejects.toMatchObject({ status: 423 });
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("SET failed_login_count=$2"))).toHaveLength(5);
});
});

View File

@@ -30,8 +30,7 @@ describe("platform account ownership lifecycle", () => {
vi.stubEnv("ZHINIAN_DATA_DIR", dataDirectory);
vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1");
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", "test-platform-session-secret");
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "");
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "");
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
const organization = await createPlatformOrganization("归档组织");
archiveOwnerId = organization.archiveOwnerId;
user = await createPlatformUser({

View File

@@ -23,8 +23,7 @@ describe("platform password auth route", () => {
vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1");
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", SESSION_SECRET);
vi.stubEnv("ZHINIAN_AUTH_DISABLED", "");
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "");
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "");
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
resetLocalAuthRateLimitForTests();
const organization = await createPlatformOrganization("测试组织");

View File

@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { queryDatabase } = vi.hoisted(() => ({ queryDatabase: vi.fn() }));
vi.mock("@/lib/server/database", () => ({
getDataBackend: () => "postgres",
isPostgresBackend: () => true,
queryDatabase,
withDatabaseTransaction: vi.fn()
}));
import {
BillingStoreError,
createBillingPriceRule,
listBillingPriceRules,
postWalletEntry,
updateBillingPriceRule
} from "@/lib/server/billing-store";
describe("billing PostgreSQL mapping", () => {
beforeEach(() => queryDatabase.mockReset());
it("maps pg bigint, numeric, and timestamptz values safely", async () => {
queryDatabase.mockResolvedValueOnce({
rows: [{
id: "price-1",
provider: "bailian",
capability: "image.generate",
req_key: "image",
variant_key: null,
unit: "image",
standard_unit_price_fen: "9007199254740991",
markup_multiplier: "1.2500",
enabled: true,
conditions: {},
quantity_source: "image_count",
priority: 1,
note: null,
source: null,
parameter_dimensions: [],
created_at: new Date("2026-08-12T01:02:03.000Z"),
updated_at: new Date("2026-08-12T04:05:06.000Z")
}]
});
const [rule] = await listBillingPriceRules();
expect(rule).toMatchObject({
standardUnitPriceFen: Number.MAX_SAFE_INTEGER,
markupMultiplier: 1.25,
createdAt: "2026-08-12T01:02:03.000Z",
updatedAt: "2026-08-12T04:05:06.000Z"
});
expect(queryDatabase.mock.calls[0][0]).toContain("$1::boolean");
expect(queryDatabase.mock.calls[0][1]).toEqual([false]);
});
it("rejects bigint values that cannot be represented without precision loss", async () => {
queryDatabase.mockResolvedValueOnce({
rows: [{
id: "price-unsafe",
provider: "bailian",
capability: "image.generate",
unit: "image",
standard_unit_price_fen: "9007199254740992",
markup_multiplier: "1.1",
enabled: true,
conditions: {},
priority: 0,
parameter_dimensions: [],
created_at: new Date(),
updated_at: new Date()
}]
});
await expect(listBillingPriceRules()).rejects.toMatchObject({ status: 500 } satisfies Partial<BillingStoreError>);
});
it("canonicalizes nested condition objects and arrays before PostgreSQL inserts", async () => {
queryDatabase.mockResolvedValueOnce({
rows: [{
id: "price-canonical",
provider: "bailian",
capability: "image.generate",
req_key: "image",
variant_key: null,
unit: "image",
standard_unit_price_fen: "100",
markup_multiplier: "1.2",
enabled: true,
conditions: { quality: { values: ["high", "standard"] } },
quantity_source: "image_count",
priority: 0,
note: null,
source: null,
parameter_dimensions: [],
created_at: new Date("2026-08-12T01:00:00.000Z"),
updated_at: new Date("2026-08-12T01:00:00.000Z")
}]
});
await createBillingPriceRule({
id: "price-canonical",
provider: "bailian",
capability: "image.generate",
reqKey: "image",
unit: "image",
standardUnitPriceFen: 100,
markupMultiplier: 1.2,
enabled: true,
conditions: { quality: { values: ["standard", "high"] } },
quantitySource: "image_count"
});
expect(queryDatabase.mock.calls[0][1][9]).toBe(JSON.stringify({ quality: { values: ["high", "standard"] } }));
});
it("maps PostgreSQL unique violations during rule updates to conflict", async () => {
queryDatabase
.mockResolvedValueOnce({
rows: [{
id: "price-update",
provider: "bailian",
capability: "image.generate",
req_key: "image",
variant_key: null,
unit: "image",
standard_unit_price_fen: "100",
markup_multiplier: "1.2",
enabled: true,
conditions: {},
quantity_source: "image_count",
priority: 0,
note: null,
source: null,
parameter_dimensions: [],
created_at: new Date("2026-08-12T01:00:00.000Z"),
updated_at: new Date("2026-08-12T01:00:00.000Z")
}]
})
.mockRejectedValueOnce(Object.assign(new Error("duplicate key value violates unique constraint"), { code: "23505" }));
await expect(updateBillingPriceRule("price-update", {
conditions: { quality: { values: ["standard", "high"] } }
})).rejects.toMatchObject({ status: 409 } satisfies Partial<BillingStoreError>);
expect(queryDatabase.mock.calls[1][1][9]).toBe(JSON.stringify({ quality: { values: ["high", "standard"] } }));
});
it("calls the atomic wallet function with positional parameters and maps its row", async () => {
queryDatabase.mockResolvedValueOnce({
rows: [{
ledger_id: "ledger-1",
balance_after_fen: "1250",
balance_fen: "1250",
total_recharged_fen: "1500",
total_charged_fen: "250",
delta_fen: "-250",
created_at: new Date("2026-08-12T06:00:00.000Z"),
updated_at: new Date("2026-08-12T06:00:00.000Z")
}]
});
const result = await postWalletEntry({
organizationId: "org-1",
accountId: "account-1",
jobId: "job-1",
kind: "charge",
deltaFen: -250,
idempotencyKey: "charge:job-1",
description: "generation charge",
metadata: { provider: "bailian" }
});
expect(result.entry).toMatchObject({
id: "ledger-1",
deltaFen: -250,
balanceAfterFen: 1250,
createdAt: "2026-08-12T06:00:00.000Z"
});
expect(result.wallet).toMatchObject({ balanceFen: 1250, totalRechargedFen: 1500, totalChargedFen: 250 });
expect(queryDatabase.mock.calls[0][0]).toContain("billing_post_wallet_entry");
expect(queryDatabase.mock.calls[0][0]).toContain("$10::jsonb");
expect(queryDatabase.mock.calls[0][1].slice(1)).toEqual([
"org-1", "account-1", "job-1", "charge", -250, "CNY", "charge:job-1",
"generation charge", JSON.stringify({ provider: "bailian" })
]);
});
it("maps idempotency payload drift to a conflict", async () => {
queryDatabase.mockRejectedValueOnce(new Error("BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH"));
await expect(postWalletEntry({
organizationId: "org-1",
kind: "recharge",
deltaFen: 100,
idempotencyKey: "same-key",
description: "request"
})).rejects.toMatchObject({ status: 409 } satisfies Partial<BillingStoreError>);
});
});

View File

@@ -29,8 +29,7 @@ describe("organization billing", () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-billing-"));
vi.stubEnv("ZHINIAN_RUNTIME_DIR", runtimeDir);
vi.stubEnv("ZHINIAN_BILLING_REQUIRED", "1");
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "");
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "");
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
});
afterEach(async () => {
@@ -433,7 +432,8 @@ describe("organization billing", () => {
accountId: "user-1",
jobId: job.id,
idempotencyKey: `job-charge:${job.id}`,
description: "重复扣费"
description: "重试时更新的审计文案",
metadata: { retry: true }
})).entry.id).toBe(charged.billing?.ledgerEntryId);
const failed = await updateGenerationJob(charged.id, { status: "failed" });
@@ -445,6 +445,41 @@ describe("organization billing", () => {
expect(ledger[0].balanceAfterFen).toBe(1000);
});
it("scopes local wallet idempotency by organization and rejects payload drift", async () => {
const first = await postWalletEntry({
organizationId: "org-1",
kind: "recharge",
deltaFen: 100,
idempotencyKey: "shared-key",
description: "same request"
});
const otherOrganization = await postWalletEntry({
organizationId: "org-2",
kind: "recharge",
deltaFen: 100,
idempotencyKey: "shared-key",
description: "same request"
});
expect(otherOrganization.entry.id).not.toBe(first.entry.id);
const retried = await postWalletEntry({
organizationId: "org-1",
kind: "recharge",
deltaFen: 100,
idempotencyKey: "shared-key",
description: "updated audit label",
metadata: { retry: true }
});
expect(retried.entry.id).toBe(first.entry.id);
await expect(postWalletEntry({
organizationId: "org-1",
kind: "recharge",
deltaFen: 200,
idempotencyKey: "shared-key",
description: "same request"
})).rejects.toMatchObject({ status: 409 });
});
it("blocks an ordinary generation before provider dispatch when the organization balance is insufficient", async () => {
await createBillingPriceRule({
id: "rule-insufficient-image",

View File

@@ -11,24 +11,20 @@ import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
let runtimeDir = "";
let previousRuntimeDir: string | undefined;
let previousSupabaseUrl: string | undefined;
let previousSupabaseKey: string | undefined;
let previousDataBackend: string | undefined;
describe("local data store concurrency", () => {
beforeEach(async () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-store-"));
previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR;
previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
previousDataBackend = process.env.ZHINIAN_DATA_BACKEND;
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
process.env.ZHINIAN_DATA_BACKEND = "local";
});
afterEach(async () => {
restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir);
restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl);
restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey);
restoreEnv("ZHINIAN_DATA_BACKEND", previousDataBackend);
await rm(runtimeDir, { force: true, recursive: true });
});

View File

@@ -0,0 +1,25 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("PostgreSQL readiness contract", () => {
it("checks every runtime table and both atomic functions", async () => {
const source = await readFile(new URL("../lib/server/database.ts", import.meta.url), "utf8");
for (const table of [
"assets",
"generation_jobs",
"usage_events",
"projects",
"image_templates",
"platform_organizations",
"platform_users",
"platform_account_migrations",
"billing_price_rules",
"billing_wallets",
"billing_ledger"
]) {
expect(source).toContain(`('${table}',`);
}
expect(source).toContain("claim_generation_jobs(text,integer,integer)");
expect(source).toContain("billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)");
});
});

View File

@@ -13,24 +13,20 @@ import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"
let runtimeDir = "";
let previousRuntimeDir: string | undefined;
let previousSupabaseUrl: string | undefined;
let previousSupabaseKey: string | undefined;
let previousDataBackend: string | undefined;
describe("image templates", () => {
beforeEach(async () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-templates-"));
previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR;
previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
previousDataBackend = process.env.ZHINIAN_DATA_BACKEND;
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
process.env.ZHINIAN_DATA_BACKEND = "local";
});
afterEach(async () => {
restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir);
restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl);
restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey);
restoreEnv("ZHINIAN_DATA_BACKEND", previousDataBackend);
await rm(runtimeDir, { force: true, recursive: true });
});

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
createPostgresPool,
getScriptDataBackend,
quotePostgresIdentifier
} from "../scripts/postgres-client.mjs";
describe("PostgreSQL script configuration", () => {
it("requires an explicit data backend", () => {
expect(() => getScriptDataBackend({ NODE_ENV: "test" })).toThrow("ZHINIAN_DATA_BACKEND");
expect(getScriptDataBackend({ NODE_ENV: "test", ZHINIAN_DATA_BACKEND: "postgres" })).toBe("postgres");
});
it("rejects connection-string SSL options that could override the verified CA configuration", () => {
expect(() => createPostgresPool({
env: {
NODE_ENV: "test",
ZHINIAN_DATA_BACKEND: "postgres",
DATABASE_URL: "postgresql://app:secret@rds.example:5432/app?sslmode=no-verify"
}
})).toThrow("must not contain SSL query parameters");
});
it("quotes PostgreSQL role identifiers without allowing SQL syntax injection", () => {
expect(quotePostgresIdentifier("zhinian_app")).toBe('"zhinian_app"');
expect(() => quotePostgresIdentifier('app"role')).toThrow("identifier");
expect(() => quotePostgresIdentifier("bad\0role")).toThrow("identifier");
});
});

View File

@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("PostgreSQL application-role privilege contract", () => {
it("uses explicit current-object grants and no blanket future-object grants", async () => {
const source = await readFile(new URL("../scripts/migrate-postgres.mjs", import.meta.url), "utf8");
expect(source).toContain('["billing_wallets", "SELECT, INSERT, UPDATE"]');
expect(source).toContain('["billing_ledger", "SELECT, INSERT"]');
expect(source).toContain("applicationRoleTablePrivileges().map");
expect(source).toContain("REVOKE ALL ON TABLE");
expect(source).not.toContain("ALTER DEFAULT PRIVILEGES");
expect(source).not.toContain("ALL FUNCTIONS IN SCHEMA");
});
});

View File

@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("PostgreSQL account script concurrency contract", () => {
it("serializes bootstrap and legacy imports that target the same logical account", async () => {
const [bootstrap, legacyImport] = await Promise.all([
readFile(new URL("../scripts/bootstrap-admin.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/import-legacy-accounts.mjs", import.meta.url), "utf8")
]);
expect(bootstrap).toContain("pg_advisory_xact_lock");
expect(legacyImport).toContain("pg_advisory_xact_lock(hashtextextended($1, 0))");
expect(legacyImport).toContain("session_version=platform_users.session_version+1");
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -20,8 +20,7 @@ let runtimeDir = "";
const previousEnv = new Map<string, string | undefined>();
const envNames = [
"ZHINIAN_RUNTIME_DIR",
"NEXT_PUBLIC_SUPABASE_URL",
"SUPABASE_SERVICE_ROLE_KEY",
"ZHINIAN_DATA_BACKEND",
"ZHINIAN_API_KEYS",
"JIMENG_VISUAL_MOCK",
"IMAGE_GENERATE_ENGINE",
@@ -42,13 +41,12 @@ describe("task management and public API helpers", () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-tasks-"));
for (const name of envNames) previousEnv.set(name, process.env[name]);
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
process.env.ZHINIAN_DATA_BACKEND = "local";
process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b";
process.env.JIMENG_VISUAL_MOCK = "true";
delete process.env.IMAGE_GENERATE_ENGINE;
delete process.env.EVOLINK_MOCK;
delete process.env.EVOLINK_API_KEY;
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
delete process.env.VOLCENGINE_ACCESS_KEY_ID;
delete process.env.VOLCENGINE_SECRET_ACCESS_KEY;
delete process.env.ALI_OSS_ENDPOINT;

View File

@@ -15,15 +15,14 @@ import type { GenerationJob, UsageContext } from "@/lib/types";
let runtimeDir = "";
const previousEnv = new Map<string, string | undefined>();
const envNames = ["ZHINIAN_RUNTIME_DIR", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY"];
const envNames = ["ZHINIAN_RUNTIME_DIR", "ZHINIAN_DATA_BACKEND"];
describe("usage metering and reports", () => {
beforeEach(async () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-usage-"));
for (const name of envNames) previousEnv.set(name, process.env[name]);
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
process.env.ZHINIAN_DATA_BACKEND = "local";
});
afterEach(async () => {

View File

@@ -0,0 +1,58 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const schemaPaths = [
"../database/migrations/0001_initial_schema.sql",
"../supabase/schema.sql"
];
function readSchema(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8").replace(/\r\n/g, "\n");
}
describe.each(schemaPaths)("wallet SQL contract: %s", (schemaPath) => {
const sql = readSchema(schemaPath);
it("scopes wallet idempotency to an organization", () => {
expect(sql).not.toMatch(/idempotency_key\s+text\s+not\s+null\s+unique/i);
expect(sql).toMatch(/create unique index if not exists billing_ledger_organization_idempotency_idx\s+on billing_ledger\s*\(organization_id, idempotency_key\)/i);
expect(sql).toMatch(/drop constraint %I/);
expect(sql).toMatch(/drop index %I\.%I/);
expect(sql).toMatch(/hashtextextended\(jsonb_build_array\(p_organization_id, p_idempotency_key\)::text, 0\)/i);
expect(sql).toMatch(/from billing_ledger\s+where organization_id = p_organization_id\s+and idempotency_key = p_idempotency_key/i);
});
it("rejects reuse of an idempotency key with a different payload", () => {
for (const comparison of [
"v_existing.account_id is distinct from v_account_id",
"v_existing.job_id is distinct from p_job_id",
"v_existing.kind is distinct from p_kind",
"v_existing.delta_fen is distinct from p_delta_fen",
"v_existing.currency is distinct from p_currency"
]) {
expect(sql.toLowerCase()).toContain(comparison);
}
expect(sql.toLowerCase()).not.toContain("v_existing.description is distinct from p_description");
expect(sql.toLowerCase()).not.toContain("v_existing.metadata is distinct from coalesce(p_metadata");
expect(sql).toMatch(/errcode = 'P0001'[\s\S]*message = 'BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH'/);
});
it("fails safely instead of deleting duplicate usage events", () => {
expect(sql).not.toMatch(/delete\s+from\s+usage_events/i);
expect(sql).toMatch(/group by job_id\s+having count\(\*\) > 1/i);
expect(sql).toMatch(/errcode = '23505'[\s\S]*USAGE_EVENTS_DUPLICATE_JOB_ID/);
expect(sql.indexOf("USAGE_EVENTS_DUPLICATE_JOB_ID")).toBeLessThan(sql.indexOf("create unique index if not exists usage_events_job_id_idx"));
});
});
it("keeps the migration and Supabase compatibility snapshot synchronized", () => {
const migration = readSchema(schemaPaths[0]);
const snapshot = readSchema(schemaPaths[1]).replace(
/^-- Compatibility snapshot for existing Supabase deployments\.\r?\n-- New PostgreSQL\/RDS deployments must use `npm run db:migrate`; do not use this\r?\n-- file as an unversioned migration source\.\r?\n\r?\n/,
""
);
expect(snapshot).toBe(migration);
});

View File

@@ -21,4 +21,11 @@ describe("worker script configuration", () => {
stderr: expect.stringContaining("ZHINIAN_INTERNAL_WORKER_TOKEN is required")
});
});
it("bounds each internal tick request with AbortSignal.timeout", async () => {
const source = await import("node:fs/promises").then(({ readFile }) =>
readFile(new URL("../scripts/worker.mjs", import.meta.url), "utf8")
);
expect(source).toContain("AbortSignal.timeout(requestTimeoutMs)");
});
});