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

+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);
});