Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de633d4c9 | ||
|
|
b995662388 | ||
|
|
92434aec17 |
@@ -263,6 +263,7 @@ export interface CreateAppOptions {
|
||||
eventHub?: EventHub;
|
||||
generations?: GenerationSubmissionService;
|
||||
latestExports?: LatestExportService;
|
||||
localTestAuth?: boolean;
|
||||
models?: ModelConfigurationService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
@@ -2075,6 +2076,45 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
if (options.localTestAuth && options.registration) {
|
||||
app.get(
|
||||
"/api/v1/auth/local-test",
|
||||
{ schema: { hide: true } },
|
||||
async () => ({ available: true }),
|
||||
);
|
||||
app.post(
|
||||
"/api/v1/auth/local-test",
|
||||
{ schema: { hide: true } },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const result = options.registration!.createLocalTestSession();
|
||||
reply.header(
|
||||
"Set-Cookie",
|
||||
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
|
||||
);
|
||||
return {
|
||||
audience: result.audience,
|
||||
credits: {
|
||||
available_balance: result.credits.availableBalance,
|
||||
reserved_balance: result.credits.reservedBalance,
|
||||
},
|
||||
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
|
||||
status: result.status,
|
||||
user: {
|
||||
creator_name: result.user.creatorName,
|
||||
role: result.user.role,
|
||||
social_id: result.user.socialId,
|
||||
status: result.user.status,
|
||||
user_id: result.user.userId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return registrationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.post(
|
||||
"/api/v1/auth/login/complete",
|
||||
{
|
||||
|
||||
+19
-1
@@ -16,7 +16,12 @@ import { MockResendAdapter } from "./resend-adapter.js";
|
||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||
import { ModelConfigurationService, portableRuntimeModelCandidates } from "./model-configuration.js";
|
||||
import { GenerationSubmissionService } from "./generation-submission.js";
|
||||
import {
|
||||
GenerationModelConfigurationCatalog,
|
||||
ModelConfigurationService,
|
||||
portableRuntimeModelCandidates,
|
||||
} from "./model-configuration.js";
|
||||
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
||||
import { StickerReleaseService } from "./sticker-releases.js";
|
||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||
@@ -28,14 +33,17 @@ let credits: CreditService | undefined;
|
||||
let storage: ManagedStorage | undefined;
|
||||
let latestExports: LatestExportService | undefined;
|
||||
let models: ModelConfigurationService | undefined;
|
||||
let generations: GenerationSubmissionService | undefined;
|
||||
let recentAssets: RecentAssetService | undefined;
|
||||
let stickers: StickerReleaseService | undefined;
|
||||
let amap: AmapAdapter = new MockAmapAdapter();
|
||||
let localTestAuth = false;
|
||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||
if (credentialChannelEnabled) {
|
||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||
try {
|
||||
amap = clients.amap;
|
||||
localTestAuth = !clients.resendConfigured;
|
||||
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
||||
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||
.digest();
|
||||
@@ -57,6 +65,11 @@ if (credentialChannelEnabled) {
|
||||
stickers = new StickerReleaseService({ databasePath, storage });
|
||||
latestExports = new LatestExportService({ databasePath, storage });
|
||||
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
|
||||
generations = new GenerationSubmissionService({
|
||||
credits,
|
||||
models: new GenerationModelConfigurationCatalog(models),
|
||||
storage,
|
||||
});
|
||||
recentAssets = new RecentAssetService({ database: registration.database });
|
||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||
} catch (error) {
|
||||
@@ -66,6 +79,8 @@ if (credentialChannelEnabled) {
|
||||
stickers = undefined;
|
||||
latestExports?.close();
|
||||
latestExports = undefined;
|
||||
generations?.close();
|
||||
generations = undefined;
|
||||
storage?.close();
|
||||
storage = undefined;
|
||||
credits?.close();
|
||||
@@ -100,7 +115,9 @@ const app = await createApp({
|
||||
amap,
|
||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||
...(credits ? { credits } : {}),
|
||||
...(generations ? { generations } : {}),
|
||||
...(latestExports ? { latestExports } : {}),
|
||||
...(registration && localTestAuth ? { localTestAuth: true } : {}),
|
||||
...(models ? { models } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
...(registration ? { registration } : {}),
|
||||
@@ -122,6 +139,7 @@ if (controlPipeIndex >= 0) {
|
||||
await app.close();
|
||||
amap.dispose?.();
|
||||
latestExports?.close();
|
||||
generations?.close();
|
||||
credits?.close();
|
||||
projects?.close();
|
||||
registration?.close();
|
||||
|
||||
@@ -2,6 +2,8 @@ import { randomUUID, createHash } from "node:crypto";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
||||
import type { GenerationModelCatalog, GenerationModelSnapshot } from "./generation-submission.js";
|
||||
import { projectRatios } from "./projects.js";
|
||||
|
||||
export const modelIds = [
|
||||
"gemini-3.1-flash-image-preview",
|
||||
@@ -59,6 +61,45 @@ export interface ModelConfigurationView {
|
||||
models: ModelConfigView[];
|
||||
}
|
||||
|
||||
type ReadableModelConfiguration = Pick<ModelConfigurationService, "read">;
|
||||
|
||||
function generationRuntimeReason(reason: ModelRuntimeReason): GenerationModelSnapshot["runtimeAvailability"]["reason"] {
|
||||
if (reason === "gateway_balance_insufficient") return reason;
|
||||
if (reason === "contract_unverified" || reason === "contract_blocked") return "gateway_contract_invalid";
|
||||
if (reason === "available") return null;
|
||||
return "model_disabled";
|
||||
}
|
||||
|
||||
export class GenerationModelConfigurationCatalog implements GenerationModelCatalog {
|
||||
constructor(private readonly models: ReadableModelConfiguration) {}
|
||||
|
||||
readModel(modelId: string): GenerationModelSnapshot | undefined {
|
||||
const configuration = this.models.read();
|
||||
const model = configuration.models.find((entry) => entry.model_id === modelId);
|
||||
if (!model) return undefined;
|
||||
const supportedRatios = projectRatios.filter((ratio) => model.supported_ratios.includes(ratio));
|
||||
return {
|
||||
configSetVersion: configuration.config_set_version,
|
||||
configVersion: model.config_version,
|
||||
contractValidationStatus: model.contract_validation_status === "verified" ? "verified" : "unverified",
|
||||
creditCost: model.credit_cost,
|
||||
enabled: model.enabled,
|
||||
modelId: model.model_id,
|
||||
promptMaxLength: model.prompt_max_length,
|
||||
referenceLimits: {
|
||||
maxFileBytes: model.reference_limits.max_file_bytes,
|
||||
maxFiles: model.reference_limits.max_files,
|
||||
maxTotalBytes: model.reference_limits.max_total_bytes,
|
||||
},
|
||||
runtimeAvailability: {
|
||||
availableForNewJobs: model.runtime_availability.available_for_new_jobs,
|
||||
reason: generationRuntimeReason(model.runtime_availability.reason),
|
||||
},
|
||||
supportedRatios,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelConfigurationError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface RegistrationTransactionEvent {
|
||||
| "registration_send"
|
||||
| "registration_complete"
|
||||
| "registration_send_compensation"
|
||||
| "local_test_session"
|
||||
| "login_send"
|
||||
| "login_complete"
|
||||
| "admin_login_send"
|
||||
@@ -654,6 +655,60 @@ export class RegistrationService {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
createLocalTestSession(): LoginCompleteResult {
|
||||
const now = this.options.clock();
|
||||
return this.runImmediate("local_test_session", () => {
|
||||
const registrationId = "local-test-user-v1";
|
||||
const existing = this.database.prepare(`
|
||||
SELECT user_id, role, status FROM users WHERE registration_id = ?
|
||||
`).get(registrationId) as {
|
||||
role: "user" | "super_admin";
|
||||
status: "active" | "suspended" | "deleted";
|
||||
user_id: string;
|
||||
} | undefined;
|
||||
|
||||
if (existing) {
|
||||
if (existing.role !== "user" || existing.status !== "active") {
|
||||
throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended");
|
||||
}
|
||||
const session = this.insertSession(existing.user_id, "user", now);
|
||||
return {
|
||||
outcome: "committed",
|
||||
value: this.loginResult(this.readCompletedRegistration(existing.user_id, session.sessionId)),
|
||||
};
|
||||
}
|
||||
|
||||
const userId = randomUUID();
|
||||
this.database.prepare(`
|
||||
INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||
registration_id, created_at
|
||||
) VALUES (?, 'local-test-user@dada.invalid', 'user', 'active', 0, ?, ?)
|
||||
`).run(userId, registrationId, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO user_profiles (
|
||||
user_id, creator_name, social_id, private_content_notice_version,
|
||||
private_content_notice_acknowledged_at
|
||||
) VALUES (?, '本机测试用户', '@dada_local_test', NULL, NULL)
|
||||
`).run(userId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
|
||||
VALUES (?, 10, 0, ?)
|
||||
`).run(userId, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_ledger (
|
||||
ledger_id, user_id, operation_key, entry_type, amount,
|
||||
available_before, available_after, reserved_before, reserved_after, created_at
|
||||
) VALUES (?, ?, 'local-test-registration:v1', 'registration_grant', 10, 0, 10, 0, 0, ?)
|
||||
`).run(randomUUID(), userId, now);
|
||||
const session = this.insertSession(userId, "user", now);
|
||||
return {
|
||||
outcome: "committed",
|
||||
value: this.loginResult(this.readCompletedRegistration(userId, session.sessionId)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
applySecureConfig(candidate: SecureConfigCandidate) {
|
||||
const now = this.options.clock();
|
||||
const fail = (reason: string): never => {
|
||||
|
||||
@@ -33,6 +33,7 @@ export function initializeApiCredentialClients(credentials: Record<(typeof API_C
|
||||
return {
|
||||
adminAllowlistPepper: Buffer.from(adminPepper, "utf8"),
|
||||
amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(),
|
||||
resendConfigured: Boolean(credentials["Dada/P0A/api/resend"]),
|
||||
};
|
||||
} finally {
|
||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
||||
|
||||
@@ -124,6 +124,25 @@ button {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.auth-test-entry {
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid #b4b4af;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.auth-test-entry .auth-primary {
|
||||
margin-top: 0;
|
||||
border-color: #111111;
|
||||
background: #111111;
|
||||
color: #f2f500;
|
||||
}
|
||||
|
||||
.auth-test-entry .auth-primary:disabled {
|
||||
border-color: #777773;
|
||||
background: #deded9;
|
||||
color: #777773;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -77,6 +77,9 @@ export function UserAuthPage() {
|
||||
const [sendState, setSendState] = useState<SendState>("idle");
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [error, setError] = useState<string>();
|
||||
const [localTestAvailable, setLocalTestAvailable] = useState(false);
|
||||
const [localTestError, setLocalTestError] = useState<string>();
|
||||
const [localTestSubmitting, setLocalTestSubmitting] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||
const registrationReady = Boolean(
|
||||
@@ -93,6 +96,18 @@ export function UserAuthPage() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetch("/api/v1/auth/local-test", { credentials: "same-origin", signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return;
|
||||
const body = await response.json() as { available?: boolean };
|
||||
if (body.available === true) setLocalTestAvailable(true);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noticeOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
@@ -255,6 +270,27 @@ export function UserAuthPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function enterLocalTest() {
|
||||
if (localTestSubmitting) return;
|
||||
setLocalTestSubmitting(true);
|
||||
setLocalTestError(undefined);
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/local-test", {
|
||||
credentials: "same-origin",
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
setLocalTestError("本机测试会话未能建立,请重试。");
|
||||
return;
|
||||
}
|
||||
window.location.assign("/app");
|
||||
} catch {
|
||||
setLocalTestError("本机测试会话未能建立,请重试。");
|
||||
} finally {
|
||||
setLocalTestSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="auth-page">
|
||||
@@ -271,6 +307,14 @@ export function UserAuthPage() {
|
||||
<section className="auth-content">
|
||||
<a className="auth-admin-link" href="/admin/login">管理员登录</a>
|
||||
<div className="auth-panel">
|
||||
{localTestAvailable ? (
|
||||
<div className="auth-test-entry">
|
||||
<button className="auth-primary" disabled={localTestSubmitting} onClick={enterLocalTest} type="button">
|
||||
{localTestSubmitting ? "正在进入" : "直接进入本机测试"}
|
||||
</button>
|
||||
{localTestError ? <p className="auth-error" role="alert">{localTestError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
||||
<button
|
||||
aria-selected={mode === "login"}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface GenerationPollingProcessor {
|
||||
processNext(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class GenerationPollingLoop {
|
||||
private closed = false;
|
||||
private inFlight = false;
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private readonly processor: GenerationPollingProcessor,
|
||||
intervalMilliseconds = 250,
|
||||
) {
|
||||
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
|
||||
throw new Error("generation_polling_interval_invalid");
|
||||
}
|
||||
this.timer = setInterval(() => this.run(), intervalMilliseconds);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private run() {
|
||||
if (this.closed || this.inFlight) return;
|
||||
this.inFlight = true;
|
||||
void this.processor.processNext()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
this.inFlight = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
|
||||
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
||||
const maximumResponseBytes = 32 * 1024 * 1024;
|
||||
const requestTimeoutMilliseconds = 180_000;
|
||||
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
@@ -141,7 +142,10 @@ function buildRequest(request: GenerationAdapterRequest) {
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
||||
messages: [{ content, role: "user" }],
|
||||
messages: [
|
||||
{ content: geminiImageSystemInstruction, role: "system" },
|
||||
{ content, role: "user" },
|
||||
],
|
||||
model: geminiProviderModelId,
|
||||
stream: false,
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
|
||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
||||
import { GenerationPollingLoop } from "./generation-polling-loop.js";
|
||||
import { GenerationProcessor } from "./generation-processor.js";
|
||||
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||
@@ -52,13 +53,13 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let processor: GenerationProcessor | undefined;
|
||||
let generationTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let generationLoop: GenerationPollingLoop | undefined;
|
||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||
clearInterval(keepAlive);
|
||||
if (retentionTimer) clearInterval(retentionTimer);
|
||||
retention?.close();
|
||||
projectCleanup?.close();
|
||||
if (generationTimer) clearInterval(generationTimer);
|
||||
generationLoop?.close();
|
||||
processor?.close();
|
||||
storage?.close();
|
||||
});
|
||||
@@ -102,7 +103,7 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
||||
});
|
||||
logger.write({ error_category: "none", status_category: "ready" });
|
||||
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
||||
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
|
||||
generationLoop = new GenerationPollingLoop(processor);
|
||||
} catch {
|
||||
storageStatus = "unavailable";
|
||||
control.reportStatus("storage_unavailable");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GenerationModelConfigurationCatalog, type ModelConfigurationView } from "../../apps/api/src/model-configuration.js";
|
||||
|
||||
describe("POSTV1-04 generation runtime wiring", () => {
|
||||
it("constructs, injects and closes the production generation submission service", () => {
|
||||
const main = readFileSync("apps/api/src/main.ts", "utf8");
|
||||
|
||||
expect(main).toContain('import { GenerationSubmissionService } from "./generation-submission.js";');
|
||||
expect(main).toContain("let generations: GenerationSubmissionService | undefined;");
|
||||
expect(main).toContain("generations = new GenerationSubmissionService({");
|
||||
expect(main).toContain("models: new GenerationModelConfigurationCatalog(models),");
|
||||
expect(main).toContain("...(generations ? { generations } : {}),");
|
||||
expect(main).toContain("generations?.close();");
|
||||
});
|
||||
|
||||
it("maps the current model configuration into the generation submission contract", () => {
|
||||
const configuration: ModelConfigurationView = {
|
||||
config_set_version: 7,
|
||||
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||
recommended_model_id: "gemini-3.1-flash-image-preview",
|
||||
models: [{
|
||||
config_version: 3,
|
||||
contract_evidence_ref: "fixture-contract",
|
||||
contract_validation_status: "verified",
|
||||
credit_cost: 2,
|
||||
display_name: "Fixture model",
|
||||
enabled: true,
|
||||
error_mapping_profile: {},
|
||||
gateway_account_ref: "fixture-gateway",
|
||||
is_default: true,
|
||||
model_id: "gemini-3.1-flash-image-preview",
|
||||
prompt_max_length: 1_000,
|
||||
recommendation_priority: 1,
|
||||
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||
route_profile: {},
|
||||
runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-05T00:00:00.000Z", reason: "available" },
|
||||
safety_source: "provider",
|
||||
supported_ratios: ["3:4", "invalid"],
|
||||
}],
|
||||
};
|
||||
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||
|
||||
expect(catalog.readModel("gemini-3.1-flash-image-preview")).toEqual({
|
||||
configSetVersion: 7,
|
||||
configVersion: 3,
|
||||
contractValidationStatus: "verified",
|
||||
creditCost: 2,
|
||||
enabled: true,
|
||||
modelId: "gemini-3.1-flash-image-preview",
|
||||
promptMaxLength: 1_000,
|
||||
referenceLimits: { maxFileBytes: 10, maxFiles: 2, maxTotalBytes: 20 },
|
||||
runtimeAvailability: { availableForNewJobs: true, reason: null },
|
||||
supportedRatios: ["3:4"],
|
||||
});
|
||||
expect(catalog.readModel("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps model and contract blocks into generation error categories", () => {
|
||||
const configuration = {
|
||||
config_set_version: 1,
|
||||
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||
recommended_model_id: null,
|
||||
models: [],
|
||||
} satisfies ModelConfigurationView;
|
||||
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||
const base = {
|
||||
config_version: 1,
|
||||
contract_evidence_ref: null,
|
||||
contract_validation_status: "verified" as const,
|
||||
credit_cost: 1,
|
||||
display_name: "Fixture model",
|
||||
enabled: true,
|
||||
error_mapping_profile: {},
|
||||
gateway_account_ref: "fixture-gateway",
|
||||
is_default: true,
|
||||
model_id: "gemini-3.1-flash-image-preview" as const,
|
||||
prompt_max_length: 1_000,
|
||||
recommendation_priority: 1,
|
||||
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||
route_profile: {},
|
||||
safety_source: "provider",
|
||||
supported_ratios: ["3:4"],
|
||||
};
|
||||
|
||||
configuration.models = [{
|
||||
...base,
|
||||
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "contract_blocked" },
|
||||
}];
|
||||
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("gateway_contract_invalid");
|
||||
|
||||
configuration.models = [{
|
||||
...base,
|
||||
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "worker_degraded" },
|
||||
}];
|
||||
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("model_disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const fixedNow = Date.parse("2026-08-05T06:00:00.000Z");
|
||||
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
|
||||
function createRegistrationService() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-local-test-session-"));
|
||||
roots.push(root);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x51),
|
||||
clock: () => fixedNow,
|
||||
currentPrivacyNoticeVersion: "p0a-notice-v1",
|
||||
databasePath: join(root, "dada.sqlite3"),
|
||||
invitePepper: Buffer.alloc(32, 0x52),
|
||||
resend: new MockResendAdapter(),
|
||||
sessionPepper: Buffer.alloc(32, 0x53),
|
||||
});
|
||||
services.push(registration);
|
||||
return registration;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) {
|
||||
try { service.close(); } catch { /* already closed by the test */ }
|
||||
}
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("POSTV1-03 local test session", () => {
|
||||
it("does not expose the local test route unless explicitly enabled", async () => {
|
||||
const registration = createRegistrationService();
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||
|
||||
const status = await app.inject({ headers: writeHeaders, method: "GET", url: "/api/v1/auth/local-test" });
|
||||
const created = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
|
||||
|
||||
expect(status.statusCode).toBe(404);
|
||||
expect(created.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates one isolated fixture account and restores it without duplicate credits", async () => {
|
||||
const registration = createRegistrationService();
|
||||
const app = await createApp({
|
||||
browserGate: false,
|
||||
localTestAuth: true,
|
||||
networkBoundary: { allowTestPort: true },
|
||||
registration,
|
||||
});
|
||||
|
||||
const status = await app.inject({ headers: writeHeaders, method: "GET", url: "/api/v1/auth/local-test" });
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json()).toEqual({ available: true });
|
||||
|
||||
const first = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.json()).toMatchObject({
|
||||
audience: "user",
|
||||
credits: { available_balance: 10, reserved_balance: 0 },
|
||||
status: "authenticated",
|
||||
user: { creator_name: "本机测试用户", role: "user", social_id: "@dada_local_test", status: "active" },
|
||||
});
|
||||
expect(first.headers["set-cookie"]).toContain("dada_session=");
|
||||
|
||||
const session = await app.inject({
|
||||
headers: { cookie: first.headers["set-cookie"], host: "127.0.0.1:43121" },
|
||||
method: "GET",
|
||||
url: "/api/v1/auth/session",
|
||||
});
|
||||
expect(session.statusCode).toBe(200);
|
||||
expect(session.json()).toMatchObject({ authenticated: true, credits: { available_balance: 10 } });
|
||||
|
||||
const second = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.json().user.user_id).toBe(first.json().user.user_id);
|
||||
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM users").get()).toEqual({ count: 1 });
|
||||
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get()).toEqual({ count: 1 });
|
||||
expect(registration.database.prepare("SELECT counts_toward_stage_limit FROM users").get()).toEqual({ counts_toward_stage_limit: 0 });
|
||||
|
||||
const openapi = JSON.stringify(app.swagger());
|
||||
expect(openapi).not.toContain("/api/v1/auth/local-test");
|
||||
expect(openapi).not.toContain("local-test-user");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,23 @@ describe("TDD-WP7-EXT-003 production Amap adapter", () => {
|
||||
|
||||
const clients = initializeApiCredentialClients(credentials);
|
||||
expect(clients.amap).toBeInstanceOf(RealAmapAdapter);
|
||||
expect(clients.resendConfigured).toBe(true);
|
||||
expect(Object.values(credentials)).toEqual(["", "", ""]);
|
||||
clients.amap.dispose();
|
||||
clients.amap.dispose?.();
|
||||
clients.adminAllowlistPepper.fill(0);
|
||||
});
|
||||
|
||||
it("reports an empty Resend credential without retaining its value", () => {
|
||||
const credentials = {
|
||||
"Dada/P0A/admin/pepper": "fixture-admin-value",
|
||||
"Dada/P0A/api/amap": "",
|
||||
"Dada/P0A/api/resend": "",
|
||||
};
|
||||
|
||||
const clients = initializeApiCredentialClients(credentials);
|
||||
expect(clients.resendConfigured).toBe(false);
|
||||
expect(Object.values(credentials)).toEqual(["", "", ""]);
|
||||
clients.amap.dispose?.();
|
||||
clients.adminAllowlistPepper.fill(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,29 @@ test.beforeAll(async () => {
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
test("POSTV1-03 enters the workspace through the local test session", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/local-test", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ available: true }) });
|
||||
}
|
||||
expect(route.request().postData()).toBeNull();
|
||||
return route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ status: "authenticated" }) });
|
||||
});
|
||||
|
||||
await page.goto(webUrl);
|
||||
const button = page.getByRole("button", { name: "直接进入本机测试" });
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
|
||||
await expect(page).toHaveURL(`${webUrl}/app`);
|
||||
});
|
||||
|
||||
test("POSTV1-03 hides the local test entry when the API does not enable it", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/local-test", (route) => route.fulfill({ status: 404, body: "" }));
|
||||
await page.goto(webUrl);
|
||||
await expect(page.getByRole("button", { name: "直接进入本机测试" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
|
||||
contentType: "application/json",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GenerationPollingLoop } from "../../apps/worker/src/generation-polling-loop.js";
|
||||
|
||||
describe("POSTV1-05 generation polling loop", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not start another processor call while the current call is unresolved", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finishCurrentCall: (() => void) | undefined;
|
||||
const processNext = vi.fn(() => new Promise<void>((resolve) => {
|
||||
finishCurrentCall = resolve;
|
||||
}));
|
||||
const loop = new GenerationPollingLoop({ processNext }, 250);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(processNext).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishCurrentCall?.();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(processNext).toHaveBeenCalledTimes(2);
|
||||
|
||||
loop.close();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,14 @@ describe("POSTV1-02 OneAPI runtime adapter", () => {
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
||||
expect(init?.redirect).toBe("error");
|
||||
const payload = JSON.parse(String(init?.body)) as { messages: Array<{ content: unknown; role: string }> };
|
||||
expect(payload.messages).toEqual([
|
||||
{
|
||||
content: "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.",
|
||||
role: "system",
|
||||
},
|
||||
{ content: "一张用于本机验收的抽象色彩图", role: "user" },
|
||||
]);
|
||||
return new Response(JSON.stringify({
|
||||
choices: [{ message: { content: `})` } }],
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
|
||||
Reference in New Issue
Block a user