import assert from "node:assert/strict"; import test from "node:test"; import { runAccountFieldRefresh, sanitizeRefreshFields, } from "../lib/account-field-refresh-service.ts"; function fakeDb(accounts) { const updates = []; return { db: { prepare: (sql) => { if (sql.startsWith("SELECT")) { return { all: async () => ({ results: accounts.map((account) => ({ id: account.id, platform: account.platform, platform_uid: account.platform_uid, profile_url: account.profile_url, })), success: true, meta: { changes: 0, last_row_id: 0 }, }), }; } assert.match(sql, /^UPDATE accounts SET [a-z_, ?= ]+ WHERE id = \?$/); return { bind: (...args) => ({ run: async () => { updates.push({ sql, args }); return { success: true, meta: { changes: 1, last_row_id: 0 } }; }, }), }; }, }, updates, }; } const profile = (overrides = {}) => ({ nickname: "昵称A", redId: "id-a", followers: 123, ipLocation: "上海", gender: "女", bio: "简介A", recentNoteTitles: [], providerTags: [], ...overrides, }); const noTag = async () => []; test("writes only the selected fields as dynamic columns", async () => { const accounts = [ { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, ]; const { db, updates } = fakeDb(accounts); const status = await runAccountFieldRefresh( db, async () => profile(), noTag, ["nickname", "ipLocation"], ); assert.equal(status.status, "done"); assert.equal(status.updated, 1); assert.equal(status.fields.join(","), "nickname,ipLocation"); assert.equal(updates.length, 1); assert.match(updates[0].sql, /SET nickname = \?, ip_location = \?/); assert.doesNotMatch(updates[0].sql, /public_account_id|followers|gender|bio|tags/); assert.deepEqual(updates[0].args, ["昵称A", "上海", "a1"]); }); test("tags field uses the tagger output and joins with commas", async () => { const accounts = [ { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, ]; const { db, updates } = fakeDb(accounts); const status = await runAccountFieldRefresh( db, async () => profile({ redId: "" }), async () => ["美妆", "学生党"], ["tags"], ); assert.equal(status.updated, 1); assert.match(updates[0].sql, /SET tags = \?/); assert.deepEqual(updates[0].args, ["美妆,学生党", "a1"]); }); test("empty scrape values and empty tagger result are skipped without writing", async () => { const accounts = [ { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, ]; const { db, updates } = fakeDb(accounts); const status = await runAccountFieldRefresh( db, async () => profile({ redId: " ", nickname: null, followers: null, ipLocation: "", gender: "", bio: "" }), noTag, ["publicAccountId", "nickname", "followers", "ipLocation", "gender", "bio", "tags"], ); assert.equal(status.updated, 0); assert.equal(status.skipped, 1); assert.equal(updates.length, 0); }); test("scrape failures count as failed and demo accounts are excluded", async () => { const accounts = [ { id: "boom", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/boom" }, { id: "demo", platform: "小红书", platform_uid: "xhs-8af3", profile_url: "https://www.xiaohongshu.com/user/profile/demo" }, { id: "ok", platform: "抖音", platform_uid: "uid-2", profile_url: "https://www.douyin.com/user/ok" }, ]; const { db, updates } = fakeDb(accounts); const status = await runAccountFieldRefresh( db, async (url) => { if (url.includes("boom")) throw new Error("mcp down"); return profile({ redId: "ok-2" }); }, noTag, ["publicAccountId"], ); assert.equal(status.total, 2); assert.equal(status.updated, 1); assert.equal(status.failed, 1); assert.deepEqual(updates[0].args.slice(-1), ["ok"]); }); test("sanitizeRefreshFields filters unknown values and falls back to account id", () => { assert.deepEqual(sanitizeRefreshFields(["nickname", "hacked", "tags"]), [ "nickname", "tags", ]); assert.deepEqual(sanitizeRefreshFields([]), ["publicAccountId"]); assert.deepEqual(sanitizeRefreshFields("bad"), ["publicAccountId"]); });