fix(organize,identity,sites,pi): W10-pre backend fixes from v0.2 visual QA

- slot: new manual_verify event (attention -> verifying, with reason); a
  successful organize of a download whose slot is in attention goes back
  through verifying and lands by verify_passed on the actually imported
  assets; the download's organize_blocked decision is resolved with
  resolution {key: manual_organize | organize_retry, organizationRecordId} (U1)
- acquisition: plan purpose is upgrade only when the slot already has a
  current asset; a movie slot gap is a fill (U7)
- identity: entities created from TMDB details get metadata fields projected
  in the same transaction; TMDB aliases deduped by normalized text, site-title
  aliases skipped when the text already exists (U9)
- pi: auth-mode test of a custom Base URL no longer marks the upstream ok, and
  says to use the minimal completion to probe the endpoint (U2)
- sites: PUT /sites/{id}/credentials replaces same-kind secrets in place and
  names new secrets with site id + random suffix (secret.name is unique; was a
  500 on update); M-Team user data uses config/definition apiBaseUrl instead
  of deriving api.<domain> from the site URL (Invalid URL on IP/local bases)
- copy: setup error says 初始化口令; backup job points at umt backup
- tests: schema expected tables add site_user_data; migration round-trip
  timeout 60s; fake secrets honor label as name; vertical/resolve/pi/slot/
  site-center tests for the above
- docs: 09-visual-qa W10-pre status table (U3 root cause: outbox events are
  never mirrored to SSE)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:29:29 +08:00
1 parent b7a7fd3958
commit 2b8fe667a5
20 files changed
+298 -42

No files matched your search

@@ -459,4 +459,46 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临
const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`);
expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [] });
});
it("自动整理被拦(attention)后手动整理成功:Slot 经 verifying 落 satisfied,整理事项自动关闭(QA U1)", async () => {
await h.setSettings("organize", { defaultMode: "move" });
const e = await seedEntity(h.db, { kind: "movie", title: "被拦片", year: 2021 });
const slot = await seedSlot(h.db, { entityId: e });
const { dtId, planId } = await runToCompleted(e, "Blocked.Movie.2021.1080p.BluRay-HR", { hr: true });
// 新作品首次下载:计划用途是补缺,不是升级(QA U7)
expect(
(await h.db.selectFrom("acquisition_plan").select("purpose").where("id", "=", planId).executeTakeFirstOrThrow())
.purpose,
).toBe("fill");
const auto = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(auto.status).toBe("failed");
const slotRow = () =>
h.db.selectFrom("acquisition_slot").selectAll().where("id", "=", slot).executeTakeFirstOrThrow();
expect((await slotRow()).status).toBe("attention");
const dec = await h.db
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", `organize_blocked:${dtId}`)
.executeTakeFirstOrThrow();
expect(dec.status).toBe("open");
await h.setSettings("organize", { defaultMode: "copy" });
const manual = await planImport(h.d, { downloadTaskId: dtId, trigger: "manual", actorType: "user", actorId: null });
expect(manual.status).toBe("running");
const done = await executeRecord(h.d, manual.recordId!);
expect(done.status).toBe("succeeded");
const after = await slotRow();
expect(after.status).toBe("satisfied");
expect(after.current_asset_id).not.toBeNull();
const asset = await h.db
.selectFrom("media_asset")
.select("media_entity_id")
.where("id", "=", after.current_asset_id!)
.executeTakeFirstOrThrow();
expect(asset.media_entity_id).toBe(e);
const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow();
expect(closed.status).toBe("resolved");
expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: manual.recordId });
expect(closed.resolved_by).toBe("user");
}, 30_000);
});
@@ -967,7 +967,8 @@ async function reserve(
savePath: p.savePath,
...(p.evaluationExtra ?? {}),
});
const purpose = p.mode === "upgrade" ? ("upgrade" as const) : ("fill" as const);
// 升级 = 替换已有资产;电影 Slot 缺口(无资产)的 mode 虽记 upgrade,用途仍是补缺
const purpose = p.mode === "upgrade" && p.slot.current_asset_id ? ("upgrade" as const) : ("fill" as const);
if (c.draft) {
// 草案:同一 Slot+发布复用未提交的草案;Slot 状态与预算不动,expected_revision 记下当前 Slot 版本 +1
const key = `draft:${p.slot.id}:${p.rel.id}`;
@@ -168,7 +168,7 @@ export async function applySlotEvent(
? completion?.complete
? "verified"
: "verified_partial"
: (extra.reason ?? event.type),
: (extra.reason ?? (event.type === "manual_verify" ? event.reason : event.type)),
...(ended ? { next_evaluate_at: d.ports.now() } : {}),
})
.where("id", "=", slotId)
@@ -1509,6 +1509,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.executeTakeFirst();
if (slot && okAny) {
const old = slot.current_asset_id;
// 需处理的 Slot(如自动整理被拦)经人工整理入库:先回到核验,再由 verify_passed 按实际入库资产定终态
if (slot.status === "attention")
await applySlotEvent(d, trx, slot.id, {
type: "manual_verify",
reason: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
});
// 完成判定与订阅完成(含历史)统一由 applySlotEvent(verify_passed) 按总集数账本重算,这里不再本地判定
await applySlotEvent(
d,
@@ -1540,6 +1546,8 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
}
}
}
// 本下载任务先前被拦的整理事项:已有资产入库即视为处理完毕
if (okAny) await closeOrganizeDecisions(d, trx, dt.id, rec);
}
// 手动整理指定的 Slot(文件管理整理向导):核验通过后以新资产满足该 Slot(获取中的 Slot 不动,由其计划推进)
if (!dt && frozen.slotId && okAny) {
@@ -1590,6 +1598,36 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
}
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动"事项(resolution.key=manual_organize,记整理记录 id) */
async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
const rows = await trx
.updateTable("decision")
.set({
status: "resolved",
resolved_by: by,
resolved_at: d.ports.now(),
resolution: jsonb({
key: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
optionKey: null,
by,
note: "整理已成功入库",
organizationRecordId: rec.id,
}),
})
.where("dedupe_key", "=", `organize_blocked:${downloadTaskId}`)
.where("status", "in", ["open", "investigating", "awaiting_user"])
.returning("id")
.execute();
for (const r of rows)
await d.ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: r.id, status: "resolved" },
{ idempotencyKey: `decision.updated:${r.id}:resolved` },
);
}
/** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */
async function ensureEpisodeUnits(
exec: Exec,
@@ -190,7 +190,7 @@ export async function setupAdmin(
const admin = await loadAdmin(deps.db);
if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化");
await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" });
throw new ApiException("unauthorized", "setup token 无效。使用服务端日志里最新打印的那个");
throw new ApiException("unauthorized", "初始化口令无效。使用服务端日志里最新打印的那个");
}
const admin = await loadAdmin(deps.db);
if (!admin) throw new Error("admin_user 行丢失");
+8 -8
View File
@@ -104,14 +104,14 @@ export function registerPiRoutes(
if (!r.ok) throw new UpstreamError(r.kind as UpstreamError["kind"], r.message);
}
const t = await clientFor(ctx, row, deps.client).test({ mode, ...(req.model ? { model: req.model } : {}) });
out = {
ok: true,
source: t.source,
model: t.model,
errorKind: null,
message: t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
};
await setStatus(ctx, row.id, { status: "ok", verified: true });
// 自定义 Base URL 的凭证检查不连端点:不据此改写可用状态,提示用最小补全确认可达(QA U2)
const unprobed = mode === "auth" && !!row.base_url && row.auth_kind !== "oauth";
const notes = [
t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
unprobed ? "只核对了凭证与模型,没有连接 Base URL;用「最小补全」确认端点可达" : null,
].filter(Boolean);
out = { ok: true, source: t.source, model: t.model, errorKind: null, message: notes.join(";") || null };
if (!unprobed) await setStatus(ctx, row.id, { status: "ok", verified: true });
} catch (err) {
const e = err instanceof UpstreamError ? err : new UpstreamError("other", (err as Error).message);
out = { ok: false, source: null, model: req.model ?? row.default_model, errorKind: e.kind, message: e.message };
+1 -1
View File
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
key: "maintain.backup",
defaults: {
name: "备份",
description: "数据库与数据目录备份;目前由 scripts/backup.sh 在宿主机执行,未接入调度",
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
group: "maintain",
schedule: { type: "cron", cron: "30 3 * * *" },
enabled: false,
@@ -39,6 +39,7 @@ import {
deleteSiteAccount,
overallAccountStatus,
refreshSiteAccount,
secretName,
setSiteCredentials,
userDataHistory,
} from "../service/account.ts";
@@ -405,7 +406,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const ref = await rt().ctx.secrets.create({
purpose: SECRET_PURPOSE[body.authKind]!,
plaintext: body.credential,
label: `site_account:${body.label}`,
label: secretName({ site_id: body.siteId, label: body.label }),
});
try {
const row = await db()
@@ -442,7 +443,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
patch.secret_ref = await rt().ctx.secrets.create({
purpose: SECRET_PURPOSE[body.authKind ?? acc.auth_kind]!,
plaintext: body.credential,
label: `site_account:${acc.label}`,
label: secretName(acc),
});
}
// 换了凭证:状态回到未验证,清会话 Cookie,关闭"登录失效"待处理,站点健康复位
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
import {
getSiteDefinition,
loginNexusPhp,
type MteamDefinition,
mteamApiBase,
refreshSiteUserData,
SiteLoginError,
type SiteLoginFailure,
@@ -138,15 +140,30 @@ export async function setSiteCredentials(
const label = body.label;
const created: string[] = [];
const mk = async (purpose: SecretPurpose, plaintext: string, suffix = "") => {
const ref = await secrets.create({ purpose, plaintext, label: `site_account:${label}${suffix}` });
const ref = await secrets.create({ purpose, plaintext, label: secretName({ site_id: site.id, label }, suffix) });
created.push(ref);
return ref;
};
// 已有账号的同类凭证原地覆盖(replace),不新建 secret
const pre = await db
.selectFrom("site_account")
.select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref"])
.where("site_id", "=", site.id)
.where("label", "=", label)
.executeTakeFirst();
const reused = new Set<string>();
const put = async (existing: string | null | undefined, purpose: SecretPurpose, plaintext: string, suffix = "") => {
if (!existing) return mk(purpose, plaintext, suffix);
await secrets.replace(existing, plaintext);
reused.add(existing);
return existing;
};
const obsolete: string[] = [];
let result: SiteCredentialsResult;
try {
if (body.kind === "passkey") {
const ref = await mk("site_passkey", body.passkey, ":passkey");
const oldPasskey = pre?.auth_kind === "passkey" ? pre.secret_ref : pre?.passkey_secret_ref;
const ref = await put(oldPasskey, "site_passkey", body.passkey, ":passkey");
result = await db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
@@ -195,9 +212,11 @@ export async function setSiteCredentials(
});
} else {
const plaintext = body.kind === "password" ? body.password : body.kind === "cookie" ? body.cookie : body.apiKey;
const ref = await mk(PURPOSE[body.kind]!, plaintext);
const ref = await put(pre?.auth_kind === body.kind ? pre.secret_ref : null, PURPOSE[body.kind]!, plaintext);
const totpRef =
body.kind === "password" && body.totpSecret ? await mk("site_password", body.totpSecret, ":totp") : null;
body.kind === "password" && body.totpSecret
? await put(pre?.totp_secret_ref, "site_password", body.totpSecret, ":totp")
: null;
result = await db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
@@ -265,7 +284,7 @@ export async function setSiteCredentials(
for (const r of created) await secrets.remove(r).catch(() => {});
throw e;
}
for (const r of obsolete) await secrets.remove(r).catch(() => {});
for (const r of obsolete) if (!reused.has(r)) await secrets.remove(r).catch(() => {});
await audit(
db,
actor,
@@ -337,7 +356,11 @@ export async function refreshSiteAccount(
const umt = def.umt as { kind: string; login?: Parameters<typeof loginNexusPhp>[0]["login"]; loginSuccess?: string };
const profile = (def.umt as { profile?: Record<string, string> }).profile;
const vipLevels = (def.umt as { vipLevels?: string[] }).vipLevels;
const apiUrl = def.umt.kind === "mteam" ? ((site.config as { apiBaseUrl?: string })?.apiBaseUrl ?? null) : null;
// 馒头 API 基址与适配器一致:站点配置 > 定义里的 apiBaseUrl;不再由站点地址推 api.<根域>(IP / 本地地址会拼成非法 URL)
const apiUrl =
def.umt.kind === "mteam"
? (site.config as { apiBaseUrl?: string })?.apiBaseUrl || mteamApiBase(def as MteamDefinition)
: null;
const fetchUserData = async (cred: { cookie?: string | null; apikey?: string | null }): Promise<UserDataOutcome> => {
const info = await refreshSiteUserData({
@@ -433,12 +456,16 @@ export async function refreshSiteAccount(
}
}
/** secret.name 全局唯一:带站点 id 与随机后缀(各站点都有同名账号 default) */
export const secretName = (acc: { site_id: string; label: string }, suffix = "") =>
`site_account:${acc.site_id}:${acc.label}${suffix}:${randomUUID().slice(0, 8)}`;
/** 账密登录成功:会话 Cookie 存 secret;"只换 Cookie"时凭证类型落为 cookie,密码与 TOTP 丢弃 */
async function storeSession(rt: SourcesRuntime, acc: SiteAccountRow, cookie: string) {
const { db, secrets } = rt.ctx;
const cookieOnly = (acc.user_info as { saveCookieOnly?: boolean })?.saveCookieOnly === true;
if (cookieOnly) {
const ref = await secrets.create({ purpose: "site_cookie", plaintext: cookie, label: `site_account:${acc.label}` });
const ref = await secrets.create({ purpose: "site_cookie", plaintext: cookie, label: secretName(acc) });
const info = { ...(acc.user_info as Record<string, unknown>) };
delete info.saveCookieOnly;
await db
@@ -468,7 +495,7 @@ async function storeSession(rt: SourcesRuntime, acc: SiteAccountRow, cookie: str
const ref = await secrets.create({
purpose: "site_cookie",
plaintext: cookie,
label: `site_account:${acc.label}:session`,
label: secretName(acc, ":session"),
});
await db.updateTable("site_account").set({ session_secret_ref: ref }).where("id", "=", acc.id).execute();
acc.session_secret_ref = ref;
@@ -5,6 +5,7 @@ import { normalizeTitle, type ParsedRelease } from "@umt/domain";
import { toContractMediaKind } from "@umt/mapping";
import { jsonb } from "../rows.ts";
import type { Actor, Exec } from "./common.ts";
import { project, writeTmdbFields } from "./metadata.ts";
// 本地作品 / 内容单元 / 外部身份 / 别名的写入辅助。所有写入在调用方事务内完成。
@@ -57,6 +58,27 @@ export async function addAlias(
on conflict (media_entity_id, content_unit_id, normalized, kind) do nothing`.execute(exec);
}
/** 作品下已有同文本别名(任意类别)时不再登记,用于站点标题这类只为召回的补充别名 */
export async function addAliasIfAbsent(
exec: Exec,
entityId: string,
alias: string,
kind: AliasKind,
source: AliasSource,
language: string | null = null,
): Promise<void> {
const normalized = normalizeTitle(alias);
if (!normalized) return;
const hit = await exec
.selectFrom("title_alias")
.select("id")
.where("media_entity_id", "=", entityId)
.where("content_unit_id", "is", null)
.where("normalized", "=", normalized)
.executeTakeFirst();
if (!hit) await addAlias(exec, entityId, alias, kind, source, language);
}
/** 写一条外部身份(accepted)+ 修订 created;同一外部身份已被其它作品接受时返回 null(交给调用方处理冲突) */
export async function acceptIdentity(
exec: Exec,
@@ -141,16 +163,28 @@ export async function createEntityFromTmdb(
);
}
await seedAliases(exec, entity.id, m);
// 建档即带上 TMDB 详情里的字段(简介/首映/语言等);不记 last_metadata_refresh_at,季总集数仍由首次刷新补
const now = new Date();
await writeTmdbFields(exec, entity.id, m, `tmdb:${m.entityType}/${m.externalId}`, now);
await project(exec, entity.id, now);
return { entityId: entity.id, created: true };
}
export async function seedAliases(exec: Exec, entityId: string, m: MetadataCandidate): Promise<void> {
await addAlias(exec, entityId, m.title, "translated", "tmdb", null);
if (m.originalTitle) await addAlias(exec, entityId, m.originalTitle, "original", "tmdb", m.originalLanguage);
// 同一文本只登记一次(按首个出现的类别):TMDB 的译名/别名列表常重复主标题与原名
const seen = new Set<string>();
const add = async (text: string, kind: AliasKind, lang: string | null) => {
const n = normalizeTitle(text);
if (!n || seen.has(n)) return;
seen.add(n);
await addAlias(exec, entityId, text, kind, "tmdb", lang);
};
await add(m.title, "translated", null);
if (m.originalTitle) await add(m.originalTitle, "original", m.originalLanguage);
for (const t of m.titles) {
if (t.kind === "localized") await addAlias(exec, entityId, t.text, "translated", "tmdb", t.lang);
if (t.kind === "localized") await add(t.text, "translated", t.lang);
}
for (const a of m.aliases.slice(0, 50)) await addAlias(exec, entityId, a, "other", "tmdb", null);
for (const a of m.aliases.slice(0, 50)) await add(a, "other", null);
}
export class IdentityConflictError extends Error {
@@ -30,6 +30,24 @@ function tmdbFields(m: MetadataCandidate): Partial<Record<EntityMetadataField, u
};
}
/** TMDB 详情写入字段来源表(metadata_field);调用方随后 project() 投影最终值 */
export async function writeTmdbFields(
exec: Exec,
entityId: string,
detail: MetadataCandidate,
sourceRevision: string,
now: Date,
): Promise<void> {
for (const [field, value] of Object.entries(tmdbFields(detail))) {
await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at)
values (${entityId}, null, ${field}, ${jsonb(value)}::jsonb, ${null}, 'tmdb', ${sourceRevision}, ${now})
on conflict (media_entity_id, content_unit_id, field, source, language) do update set
value = excluded.value, source_revision = excluded.source_revision, fetched_at = excluded.fetched_at`.execute(
exec,
);
}
}
export interface RefreshResult {
status: "refreshed" | "skipped" | "failed";
reason?: string;
@@ -80,15 +98,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
}
const now = rt.now();
return ctx.db.transaction().execute(async (trx) => {
const fields = tmdbFields(detail);
for (const [field, value] of Object.entries(fields)) {
await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at)
values (${id}, null, ${field}, ${jsonb(value)}::jsonb, ${null}, 'tmdb', ${`tmdb:${ident.entity_type}/${ident.external_id}`}, ${now})
on conflict (media_entity_id, content_unit_id, field, source, language) do update set
value = excluded.value, source_revision = excluded.source_revision, fetched_at = excluded.fetched_at`.execute(
trx,
);
}
await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now);
await trx
.updateTable("external_identity")
.set({ verify_status: "ok", verified_at: now })
@@ -24,7 +24,7 @@ import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
import {
addAlias,
addAliasIfAbsent,
createEntityFromTmdb,
ensureUnits,
entityByExternal,
@@ -417,8 +417,8 @@ async function bindAuto(
.executeTakeFirstOrThrow();
const units = await ensureUnits(trx, entityId, entity.kind, parsed);
// 站点标题作为别名:下次同名资源可直接本地召回
if (parsed.titleCn) await addAlias(trx, entityId, parsed.titleCn, "site_title", "site", "zh");
if (parsed.titleEn) await addAlias(trx, entityId, parsed.titleEn, "site_title", "site", "en");
if (parsed.titleCn) await addAliasIfAbsent(trx, entityId, parsed.titleCn, "site_title", "site", "zh");
if (parsed.titleEn) await addAliasIfAbsent(trx, entityId, parsed.titleEn, "site_title", "site", "en");
const evidence = {
level,
+5
View File
@@ -158,6 +158,11 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
const t2 = (await req("POST", `/pi/upstreams/${a.id}/test`, {})).body.data;
expect(t2).toMatchObject({ ok: true, mode: "auth", source: "stored credential", model: "gpt-5" });
expect(t2.upstream.lastVerifiedAt).toBeTruthy();
// 自定义 Base URL:凭证检查不连端点,不改写可用状态,提示用最小补全(QA U2)
await h.db.updateTable("pi_upstream").set({ status: "error" }).where("id", "=", b.id).execute();
const tb = (await req("POST", `/pi/upstreams/${b.id}/test`, {})).body.data;
expect(tb).toMatchObject({ ok: true, mode: "auth", upstream: { status: "error", lastVerifiedAt: null } });
expect(tb.message).toMatch(/最小补全/);
const models = (await req("GET", `/pi/upstreams/${a.id}/models`)).body.data;
expect(models.items.length).toBeGreaterThan(10);
expect(models.refreshed).toBe(false);
+3 -3
View File
@@ -42,13 +42,13 @@ export function fakeSecrets(db: () => Kysely<DB>): SecretStore & { plain: Map<st
if (v === undefined) throw new Error(`unknown secret ${ref}`);
return v;
},
async create({ plaintext, purpose }) {
async create({ plaintext, purpose, label }) {
const id = randomUUID();
plain.set(id, plaintext);
// 业务表 secret_ref 有外键:落一行占位(密文不是真的)
// 业务表 secret_ref 有外键:落一行占位(密文不是真的);name 与真实实现一致取 label(secret.name 唯一)
await db()
.insertInto("secret")
.values({ id, name: `test-${id}`, purpose, ciphertext: Buffer.from("x"), nonce: Buffer.from("x") })
.values({ id, name: label ?? `test-${id}`, purpose, ciphertext: Buffer.from("x"), nonce: Buffer.from("x") })
.execute();
return id;
},
@@ -71,6 +71,10 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
.where("media_entity_id", "=", oppId)
.execute();
expect(aliases.map((a) => a.alias)).toEqual(expect.arrayContaining(["奥本海默", "Oppenheimer"]));
// 同一文本只登记一次;建档即带 TMDB 字段(QA U9)
expect(new Set(aliases.map((a) => a.alias)).size, JSON.stringify(aliases)).toBe(aliases.length);
const created = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow();
expect((created.metadata as Record<string, { source: string }>).overview?.source).toBe("tmdb");
const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow();
expect(rel).toMatchObject({ resolution_status: "resolved", media_entity_id: oppId, parser_version: "1" });
expect((rel.parsed as { year: number }).year).toBe(2023);
@@ -119,6 +119,10 @@ describe.skipIf(!up)("站点中心(mock NexusPHP)", () => {
expect(ds).toHaveLength(1);
expect((ds[0]!.options as { key: string }[]).map((o) => o.key)).toEqual(["use_cookie", "dismiss"]);
await json(await put({ kind: "password", label: "bad", username: "alice", password: "s3cret-pw" }));
// 同类凭证原地覆盖:secret 引用不变、明文已换(不新建 secret,不撞 secret.name 唯一)
const again = await account("bad");
expect(again.secret_ref).toBe(acc.secret_ref);
expect(h.secrets.plain.get(again.secret_ref!)).toBe("s3cret-pw");
expect((await account("bad")).status).toBe("unknown");
expect(await openDecisions(acc.id)).toHaveLength(0);
await json(await h.req(`/site-accounts/${acc.id}`, { method: "DELETE" }));
@@ -315,6 +319,21 @@ describe.skipIf(!up)("站点中心(mock M-Team API Key)", () => {
expect(pw).toMatchObject({ ok: false, reason: "unsupported", status: "unknown" });
});
it("换凭证类型再换回、同类覆盖:不撞 secret 名(联调:更新凭证 500)", async () => {
const put = (body: unknown) => h.req(`/sites/${siteId}/credentials`, { method: "PUT", body });
await json(await put({ kind: "cookie", cookie: "a=1" }));
await json(await put({ kind: "cookie", cookie: "a=2" }));
await json(await put({ kind: "api_key", apiKey: api.apiKey }));
const acc = await h.db
.selectFrom("site_account")
.selectAll()
.where("site_id", "=", siteId)
.where("label", "=", "default")
.executeTakeFirstOrThrow();
expect(acc.auth_kind).toBe("api_key");
expect(h.secrets.plain.get(acc.secret_ref!)).toBe(api.apiKey);
});
it("旧格式凭证 {authKind, credential} 仍可用", async () => {
const r = await json(
await h.req(`/sites/${siteId}/credentials`, {
@@ -326,3 +345,31 @@ describe.skipIf(!up)("站点中心(mock M-Team API Key)", () => {
expect(JSON.stringify(r)).not.toContain("pk-123");
});
});
describe.skipIf(!up)("站点中心(馒头 API 基址)", () => {
it("站点地址是 IP / 本地、未配 apiBaseUrl:用户数据走定义里的 API 基址,不拼出 api.<IP> 非法 URL(联调:Invalid URL)", async () => {
const seen: string[] = [];
const fetch = (async (input: RequestInfo | URL) => {
seen.push(input instanceof Request ? input.url : String(input));
return new Response(JSON.stringify({ code: "0", data: { id: 7, username: "mt", memberCount: {} } }), {
headers: { "content-type": "application/json" },
});
}) as typeof globalThis.fetch;
const h = await createHarness("sitecenter_mtbase", { fetch });
try {
const site = await json(
await h.req("/sites", {
body: { key: "mteam", name: "馒头", adapter: "mteam_api", baseUrl: "http://127.0.0.1:9/" },
}),
);
await json(
await h.req(`/sites/${site.id}/credentials`, { method: "PUT", body: { kind: "api_key", apiKey: "k-1" } }),
);
const r = (await json(await h.req(`/sites/${site.id}/refresh-login`, { body: { wait: true } }))).result;
expect(r, JSON.stringify(r)).toMatchObject({ ok: true, status: "active" });
expect(seen[0]).toBe("https://api.m-team.cc/api/member/profile");
} finally {
await h.close();
}
}, 60_000);
});