@allbluecn/web-app 0.23.3 → 0.23.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/package.json +3 -3
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
  3. package/src/components/desktop/__tests__/cloud-login-rollback.test.tsx +161 -0
  4. package/src/components/desktop/cloud-login.tsx +29 -26
  5. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
  6. package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
  7. package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
  8. package/src/components/desktop-agent/agent-chat.tsx +221 -0
  9. package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
  10. package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
  11. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
  12. package/src/config/sidebar-routes.tsx +14 -1
  13. package/src/lib/changelog/sdk-versions.ts +1 -1
  14. package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
  15. package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
  16. package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
  17. package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
  18. package/src/lib/desktop-agent/availability.ts +39 -0
  19. package/src/lib/desktop-agent/connection.ts +72 -0
  20. package/src/lib/desktop-agent/errors.ts +21 -0
  21. package/src/lib/desktop-agent/tool-result.ts +170 -0
  22. package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
  23. package/src/lib/desktop-bridge-types.ts +22 -1
  24. package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
  25. package/src/lib/hotkeys/registry.ts +9 -1
  26. package/src/lib/queries/README.md +1 -0
  27. package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
  28. package/src/lib/queries/desktop-agent.ts +25 -0
  29. package/src/paraglide/messages/_index.d.ts +42 -0
  30. package/src/paraglide/messages/_index.js +42 -0
  31. package/src/paraglide/messages/agent_desktop_chatcwd1.d.ts +19 -0
  32. package/src/paraglide/messages/agent_desktop_chatcwd1.js +30 -0
  33. package/src/paraglide/messages/agent_desktop_chatemptyhint2.d.ts +17 -0
  34. package/src/paraglide/messages/agent_desktop_chatemptyhint2.js +30 -0
  35. package/src/paraglide/messages/agent_desktop_chatemptytitle2.d.ts +17 -0
  36. package/src/paraglide/messages/agent_desktop_chatemptytitle2.js +30 -0
  37. package/src/paraglide/messages/agent_desktop_chatindependenthint2.d.ts +17 -0
  38. package/src/paraglide/messages/agent_desktop_chatindependenthint2.js +30 -0
  39. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.d.ts +17 -0
  40. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.js +30 -0
  41. package/src/paraglide/messages/agent_desktop_chatplaceholder1.d.ts +17 -0
  42. package/src/paraglide/messages/agent_desktop_chatplaceholder1.js +30 -0
  43. package/src/paraglide/messages/agent_desktop_chatsend1.d.ts +17 -0
  44. package/src/paraglide/messages/agent_desktop_chatsend1.js +30 -0
  45. package/src/paraglide/messages/agent_desktop_chatstop1.d.ts +17 -0
  46. package/src/paraglide/messages/agent_desktop_chatstop1.js +30 -0
  47. package/src/paraglide/messages/agent_desktop_chatthinking1.d.ts +17 -0
  48. package/src/paraglide/messages/agent_desktop_chatthinking1.js +30 -0
  49. package/src/paraglide/messages/agent_desktop_errorconnection1.d.ts +17 -0
  50. package/src/paraglide/messages/agent_desktop_errorconnection1.js +30 -0
  51. package/src/paraglide/messages/agent_desktop_errorforbidden1.d.ts +17 -0
  52. package/src/paraglide/messages/agent_desktop_errorforbidden1.js +30 -0
  53. package/src/paraglide/messages/agent_desktop_errorgeneric1.d.ts +19 -0
  54. package/src/paraglide/messages/agent_desktop_errorgeneric1.js +30 -0
  55. package/src/paraglide/messages/agent_desktop_errorunauthorized1.d.ts +17 -0
  56. package/src/paraglide/messages/agent_desktop_errorunauthorized1.js +30 -0
  57. package/src/paraglide/messages/agent_desktop_errorunavailable1.d.ts +17 -0
  58. package/src/paraglide/messages/agent_desktop_errorunavailable1.js +30 -0
  59. package/src/paraglide/messages/agent_desktop_pagedescription1.d.ts +17 -0
  60. package/src/paraglide/messages/agent_desktop_pagedescription1.js +30 -0
  61. package/src/paraglide/messages/agent_desktop_title.d.ts +16 -0
  62. package/src/paraglide/messages/agent_desktop_title.js +29 -0
  63. package/src/paraglide/messages/agent_desktop_toolcountfiles2.d.ts +19 -0
  64. package/src/paraglide/messages/agent_desktop_toolcountfiles2.js +30 -0
  65. package/src/paraglide/messages/agent_desktop_toolcounthits2.d.ts +19 -0
  66. package/src/paraglide/messages/agent_desktop_toolcounthits2.js +30 -0
  67. package/src/paraglide/messages/agent_desktop_toolcountlines2.d.ts +19 -0
  68. package/src/paraglide/messages/agent_desktop_toolcountlines2.js +30 -0
  69. package/src/paraglide/messages/agent_desktop_toolemptydir2.d.ts +17 -0
  70. package/src/paraglide/messages/agent_desktop_toolemptydir2.js +30 -0
  71. package/src/paraglide/messages/agent_desktop_toolerror1.d.ts +17 -0
  72. package/src/paraglide/messages/agent_desktop_toolerror1.js +30 -0
  73. package/src/paraglide/messages/agent_desktop_toolfind1.d.ts +19 -0
  74. package/src/paraglide/messages/agent_desktop_toolfind1.js +30 -0
  75. package/src/paraglide/messages/agent_desktop_toolgrep1.d.ts +19 -0
  76. package/src/paraglide/messages/agent_desktop_toolgrep1.js +30 -0
  77. package/src/paraglide/messages/agent_desktop_toolls1.d.ts +19 -0
  78. package/src/paraglide/messages/agent_desktop_toolls1.js +30 -0
  79. package/src/paraglide/messages/agent_desktop_toolmore1.d.ts +19 -0
  80. package/src/paraglide/messages/agent_desktop_toolmore1.js +30 -0
  81. package/src/paraglide/messages/agent_desktop_toolnomatches2.d.ts +17 -0
  82. package/src/paraglide/messages/agent_desktop_toolnomatches2.js +30 -0
  83. package/src/paraglide/messages/agent_desktop_toolother1.d.ts +19 -0
  84. package/src/paraglide/messages/agent_desktop_toolother1.js +30 -0
  85. package/src/paraglide/messages/agent_desktop_toolread1.d.ts +19 -0
  86. package/src/paraglide/messages/agent_desktop_toolread1.js +30 -0
  87. package/src/paraglide/messages/agent_desktop_toolrunning1.d.ts +17 -0
  88. package/src/paraglide/messages/agent_desktop_toolrunning1.js +30 -0
  89. package/src/paraglide/messages/agent_desktop_tooltruncated1.d.ts +17 -0
  90. package/src/paraglide/messages/agent_desktop_tooltruncated1.js +30 -0
  91. package/src/paraglide/messages/agent_desktop_workspaceadd1.d.ts +17 -0
  92. package/src/paraglide/messages/agent_desktop_workspaceadd1.js +30 -0
  93. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.d.ts +17 -0
  94. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.js +30 -0
  95. package/src/paraglide/messages/agent_desktop_workspacedescription1.d.ts +17 -0
  96. package/src/paraglide/messages/agent_desktop_workspacedescription1.js +30 -0
  97. package/src/paraglide/messages/agent_desktop_workspaceempty1.d.ts +17 -0
  98. package/src/paraglide/messages/agent_desktop_workspaceempty1.js +30 -0
  99. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.d.ts +17 -0
  100. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.js +30 -0
  101. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.d.ts +17 -0
  102. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.js +30 -0
  103. package/src/paraglide/messages/agent_desktop_workspaceremove1.d.ts +17 -0
  104. package/src/paraglide/messages/agent_desktop_workspaceremove1.js +30 -0
  105. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.d.ts +17 -0
  106. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.js +30 -0
  107. package/src/paraglide/messages/agent_desktop_workspacetitle1.d.ts +17 -0
  108. package/src/paraglide/messages/agent_desktop_workspacetitle1.js +30 -0
  109. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.d.ts +17 -0
  110. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.js +30 -0
  111. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +17 -0
  112. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +30 -0
  113. package/src/paraglide/messages/sync_bind_rolled_back.d.ts +16 -0
  114. package/src/paraglide/messages/sync_bind_rolled_back.js +29 -0
  115. package/src/routeTree.gen.ts +23 -0
  116. package/src/routes/-tests/agent-gate.test.ts +45 -0
  117. package/src/routes/_nav/agent/route.lazy.tsx +63 -0
  118. package/src/routes/_nav/agent/route.tsx +21 -0
  119. package/src/routes/api/sync/account-status.ts +4 -2
  120. package/src/routes/api/sync/push.ts +11 -3
  121. package/src/server/__tests__/desktop-cloud-identity.test.ts +331 -0
  122. package/src/server/__tests__/desktop-session-merge.test.ts +42 -0
  123. package/src/server/__tests__/exchange-remote-token.test.ts +92 -10
  124. package/src/server/__tests__/remote-auth.test.ts +39 -0
  125. package/src/server/desktop-bootstrap.ts +16 -5
  126. package/src/server/desktop-cloud-identity.ts +247 -0
  127. package/src/server/remote-auth.ts +33 -1
  128. package/src/server/serverFns/auth/session.ts +27 -2
  129. package/src/server/serverFns/sync/transfer.ts +28 -27
  130. package/src/server/serverFns/workspace-kb.ts +10 -0
  131. package/src/server/sync/__tests__/ownership-transfer.test.ts +261 -48
  132. package/src/server/sync/__tests__/sync-first-push.e2e.test.ts +349 -0
  133. package/src/server/sync/account-status-cloud.ts +41 -0
  134. package/src/server/sync/ownership-transfer.ts +88 -49
@@ -7,10 +7,11 @@
7
7
  // cookie 天然过鉴权(backend 进程无 cookie,不得由 bind-flow 直调——复审⑥ P1-8)。
8
8
  // 校验链:① readRemoteAuth().userId 必须等于 data.cloudUserId(转移目标必须等于
9
9
  // 已交换登录的云账号,防参数伪造;非 desktop / 无 remoteAuth 时 readRemoteAuth
10
- // 恒 null → 拒绝);② 复核目标为空账号(isAccountEmpty,与
11
- // /api/sync/account-status 端点同源);③ 调 transferOwnership 执行
12
- // GUEST_USER_ID → cloudUserId 的单事务转移。outbox 语义由触发器自然产出
13
- // (op='upsert' + base_version=0),本 serverFn 不触碰同步两表。
10
+ // 恒 null → 拒绝);② 复核目标为空账号(**云端权威**:SaaS /api/sync/account-status,
11
+ // I2 裁定——本地 PGlite 看不到云端数据,且转移后本地计数恒 >0 会破坏幂等重试);
12
+ // ③ 调 transferOwnership 执行 GUEST_USER_ID → cloudUserId 的单事务转移。
13
+ // outbox 语义由触发器自然产出(op='upsert' + base_version=0),本 serverFn
14
+ // 不触碰同步两表。
14
15
  // 身份字段(A8b 裁定):name/avatarConfig 等可省略——云昵称/头像仅 SaaS 权威
15
16
  // (remote-auth.json session 块不存 profile,renderer 拿不到),省略时 handler
16
17
  // 以 remoteAuth.sessionToken 自取 SaaS profile 补齐;字段全传则用传入值(向后
@@ -27,9 +28,9 @@ import { authContextMiddleware } from "@/server/middlewares/auth-context"
27
28
  import { checkAuthRateLimit } from "@/server/middlewares/rate-limit"
28
29
  import { readRemoteAuth, clearRemoteAuth } from "@/server/remote-auth"
29
30
  import { GUEST_USER_ID, isDesktopServer } from "@/server/desktop-guest"
30
- import { isAccountEmpty } from "@/server/sync/account-status"
31
+ import { probeCloudAccountStatus } from "@/server/sync/account-status-cloud"
31
32
  import { reverseTransferOwnership, transferOwnership } from "@/server/sync/ownership-transfer"
32
- import { fetchSaasProfile, DESKTOP_SAAS_URL } from "@/server/sync/saas-profile"
33
+ import { fetchSaasProfile } from "@/server/sync/saas-profile"
33
34
 
34
35
  const transferInputSchema = z.object({
35
36
  cloudUserId: z.string().min(1),
@@ -52,10 +53,17 @@ export const transferOwnershipFn = createServerFn({ method: "POST" })
52
53
  )
53
54
  }
54
55
 
55
- // ② 复核目标为空账号(仅空账号才允许归属转移,一期唯一路径)
56
- const prisma = await getPrisma()
57
- if (!(await isAccountEmpty(prisma, data.cloudUserId))) {
58
- throw new Error("[sync/transfer] 目标云账号非空账号,拒绝归属转移")
56
+ // ② 复核目标为空账号(仅空账号才允许归属转移,一期唯一路径)。
57
+ // I2 裁定:改用**云端权威**探测(SaaS /api/sync/account-status)——原先
58
+ // 用本地 PGlite 按 cloudUserId 计数,转移前恒 0(真)、转移后恒 >0(假),
59
+ // 幂等重试被自己的本地数据误判成「目标账号非空」;本地库也看不到云端数据。
60
+ // 不可达一律拒绝(宁可不转不可错转)。
61
+ const account = await probeCloudAccountStatus(remoteAuth.sessionToken)
62
+ if (account.unreachable) {
63
+ throw new Error("[sync/transfer] 无法确认目标账号状态(云端不可达),拒绝归属转移")
64
+ }
65
+ if (!account.empty) {
66
+ throw new Error("[sync/transfer] 目标账号非空,一期仅支持空账号转移")
59
67
  }
60
68
 
61
69
  // ②b 身份字段补齐(A8b 裁定):name 省略 = renderer 未传身份字段(一期主
@@ -76,6 +84,7 @@ export const transferOwnershipFn = createServerFn({ method: "POST" })
76
84
  }
77
85
 
78
86
  // ③ 单事务转移 GUEST → cloudUserId(失败整体回滚,调用方走 bind:fail 恢复)
87
+ const prisma = await getPrisma()
79
88
  return transferOwnership(prisma, GUEST_USER_ID, {
80
89
  id: data.cloudUserId,
81
90
  name,
@@ -92,7 +101,8 @@ export const transferOwnershipFn = createServerFn({ method: "POST" })
92
101
  // (登出者必须是当前云身份本人;无 remoteAuth = 已登出/非云态 → 拒绝);③ 会话
93
102
  // 身份 === data.cloudUserId(防「本地凭据会话与云身份并存」时误触发反向转移——
94
103
  // authjs 会话有效时 session.user.id 是本地用户而非云 id);④ 单事务
95
- // cloud → GUEST 反向转移(outbox/row_state 同事务清空防反向 op 上云)。
104
+ // cloud → GUEST 反向转移(outbox 同事务清空防反向 op 上云;row_state 保留以维持
105
+ // base_version 连续性——裁定 R2)。
96
106
  // 事务提交成功后立即清 remote-auth(幂等):反向转移一旦提交数据已 guest 属主,
97
107
  // 云身份残留会让 fallback 会话显示「云态零数据」假丢数据窗口(A8b 发现 2 的镜像
98
108
  // ——正向是转移成功 + 云身份未写,反向是转移成功 + 云身份未清)。renderer 侧的
@@ -132,9 +142,10 @@ export const reverseTransferOwnershipFn = createServerFn({ method: "POST" })
132
142
  })
133
143
 
134
144
  // claim 探测(A8b 编排时序第 3 步):exchange 激活后、bind:begin 前,renderer
135
- // 经本端点代持云会话 token 探测目标云账号是否空账号(isAccountEmpty 口径在 SaaS
136
- // 侧执行——账号状态只有 SaaS 权威,本地库看不到云端数据)。探测失败(SaaS 不可
137
- // 达/非 200/形状漂移)一律按非空处理(宁可不转不可错转),unreachable 标记供
145
+ // 经本端点代持云会话 token 探测目标云账号是否空账号(账号状态只有 SaaS 权威,
146
+ // 本地库看不到云端数据;探测逻辑与 transferOwnershipFn 的复核共用
147
+ // @/server/sync/account-status-cloud,I2 抽共享)。探测失败(SaaS 不可达/非
148
+ // 200/形状漂移)一律按非空处理(宁可不转不可错转),unreachable 标记供
138
149
  // renderer 区分提示;非空账号一期不支持转移。
139
150
  export const probeCloudAccountStatusFn = createServerFn({ method: "POST" })
140
151
  .middleware([authContextMiddleware])
@@ -152,17 +163,7 @@ export const probeCloudAccountStatusFn = createServerFn({ method: "POST" })
152
163
  throw new Error("[sync/transfer] 无云会话(未交换登录),无法探测账号状态")
153
164
  }
154
165
 
155
- try {
156
- const res = await fetch(`${DESKTOP_SAAS_URL}/api/sync/account-status`, {
157
- headers: { Cookie: `auth.session-token=${remoteAuth.sessionToken}` },
158
- credentials: "omit",
159
- signal: AbortSignal.timeout(10_000),
160
- })
161
- if (!res.ok) return { empty: false, unreachable: true }
162
- const json = (await res.json().catch(() => null)) as { empty?: unknown } | null
163
- if (typeof json?.empty !== "boolean") return { empty: false, unreachable: true }
164
- return json.empty ? { empty: true } : { empty: false }
165
- } catch {
166
- return { empty: false, unreachable: true }
167
- }
166
+ const status = await probeCloudAccountStatus(remoteAuth.sessionToken)
167
+ if (status.empty) return { empty: true }
168
+ return status.unreachable ? { empty: false, unreachable: true } : { empty: false }
168
169
  })
@@ -191,6 +191,16 @@ export const listWorkspaceAvailableKbsFn = createServerFn({ method: "GET" })
191
191
  return kbs.map((k) => ({ ...k, linked: linkedIds.has(k.id) }));
192
192
  });
193
193
 
194
+ // 工作空间 ↔ 知识库关联设置(R1 一期限制,明示避免误判为「同步丢了数据」):
195
+ // 本端点只写 `workspace_knowledge_bases` join 表,**不维护** KnowledgeBase.workspaceId
196
+ // 与 KnowledgeDocument.syncWorkspaceId ——而同步面的 scope 列正是后者(见
197
+ // sync-core 的 SYNC_TABLE_OVERRIDES:knowledge_bases.scope=workspaceId、
198
+ // knowledge_documents.scope=syncWorkspaceId)。因此:
199
+ // - 经本设置页关联的 KB **不进入同步面**(本机可见,不会上云、也不会被其他设备拉到);
200
+ // - 只有「创建工作空间时自动建立的 KB」走 workspaceId 绑定,才参与同步。
201
+ // 一期按设计限制收口(不在本端点做 scope 维护,避免隐式改归属语义);join 表
202
+ // 关联的 scope 维护留二期(届时需连带注册 workspace_knowledge_bases 或补冗余
203
+ // scope 列)。详见 sync-core/scripts/generate-registrations.mjs 的同一注记。
194
204
  export const setWorkspaceKbsFn = createServerFn({ method: "POST" })
195
205
  .middleware([authContextMiddleware])
196
206
  .validator(z.object({ workspaceId: z.string(), kbIds: z.array(z.string()) }))
@@ -5,8 +5,10 @@
5
5
  // 归属转移核心(正向,桌面本地优先 P2-A8a)回归哨兵。mock prisma 下钉死六条
6
6
  // 安全不变式(真库断言归 A13 集成实测):
7
7
  // ① 属主 UPDATE 覆盖固定清单的每一张表且 where 恒为 fromUserId;
8
- // ② AiProviderConfig.userId(配置类)不在任何 UPDATE 中(TRANSFER_EXCLUDED);
9
- // ③ 云身份行 upsert(载荷含传入昵称/头像)+ guest 行 deleteMany 被调;
8
+ // ② AiProviderConfig.userId 随身份翻转(C3 裁定:移出排除清单——不翻转的话
9
+ // 末尾 user.deleteMany 会经 ON DELETE CASCADE 把 AI 配置一起删掉),且该表
10
+ // 不在同步面(SYNC_TABLES 无 ai_provider_configs → 翻转不产出 outbox);
11
+ // ③ 云身份行建行(载荷含传入昵称/头像 + 用户级配置复制)+ guest 行 deleteMany 被调;
10
12
  // ④ syncOutbox/syncRowState 零触达(转移开着触发器自然产出 op='upsert' +
11
13
  // base_version=0,推送拓扑 A7 已修为 upsert 正向——实现若手碰同步两表即红);
12
14
  // ⑤ 幂等:第二次调用 UPDATE 计数 0、guest 行 deleteMany 计 0(delete 会抛
@@ -17,18 +19,22 @@
17
19
  // ⑧ probeCloudAccountStatusFn 三态——空账号 { empty: true } / 非空 { empty: false }
18
20
  // / 探测失败(非 200、网络异常、形状漂移)一律 { empty: false, unreachable: true }
19
21
  // (宁可不转不可错转)。
22
+ // C3 追加:⑨ AI 配置行翻转先于 guest 行删除(CASCADE 无从命中);反向转移把
23
+ // 云行的用户级配置列带回重建的 guest 行。
20
24
  // 另有清单对齐断言:实现导出的属主清单与测试内固定清单逐项相等——schema 增删
21
25
  // User-relation FK 时此测试红(防实现清单与 schema 漂移的哨兵)。
22
26
  // @vitest-environment node
23
27
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
24
28
  import type { ZodType } from 'zod'
29
+ import { SYNC_TABLE_MAP } from '@allbluecn/sync-core/registrations'
25
30
 
26
- // ── 固定哨兵清单(schema.prisma @ a3adcc0a3 全量 User-relation FK 标量列 79 处,
27
- // 减 TRANSFER_EXCLUDED 的 AiProviderConfig.userId = 78;扫描口径见
28
- // ownership-transfer.ts 头注释)──────────────────────────────────────────
31
+ // ── 固定哨兵清单(schema.prisma @ a3adcc0a3 全量 User-relation FK 标量列 79 处;
32
+ // 扫描口径见 ownership-transfer.ts 头注释。C3 起 AiProviderConfig.userId 在内,
33
+ // TRANSFER_EXCLUDED 清零——79 处 = 翻转清单全长)────────────────────────────
29
34
  const TEST_OWNERSHIP_COLUMNS: readonly string[] = [
30
35
  'Account.userId',
31
36
  'AgentConversation.userId',
37
+ 'AiProviderConfig.userId',
32
38
  'AiTask.createdById',
33
39
  'AiUsageLog.userId',
34
40
  'AnyHubApp.userId',
@@ -132,11 +138,34 @@ const touchable = () => ({
132
138
 
133
139
  type TouchableMock = ReturnType<typeof touchable>
134
140
  type DelegateMap = Record<string, { updateMany: UpdateManyFn }>
141
+ type UserRow = Record<string, unknown> & { id: string }
142
+
143
+ /** 用户级配置列样本(C3):aiSourceMode 故意为 null(不复制分支的哨兵) */
144
+ const GUEST_PREFERENCES = {
145
+ id: GUEST,
146
+ aiApiKey: 'sk-guest',
147
+ aiProvider: 'openrouter',
148
+ aiModel: 'openai/gpt-4o-mini',
149
+ aiSourceMode: null,
150
+ locale: 'zh',
151
+ timezone: 'Asia/Shanghai',
152
+ } as const
153
+ const CLOUD_PREFERENCES = {
154
+ id: CLOUD,
155
+ aiApiKey: 'sk-cloud',
156
+ aiProvider: 'openai',
157
+ locale: 'en',
158
+ } as const
159
+
135
160
  type PrismaMock = DelegateMap & {
136
161
  aiProviderConfig: { updateMany: UpdateManyFn }
137
162
  syncOutbox: TouchableMock
138
163
  syncRowState: TouchableMock
139
- user: { upsert: UpdateManyFn; deleteMany: UpdateManyFn }
164
+ user: {
165
+ findUnique: UpdateManyFn
166
+ upsert: UpdateManyFn
167
+ deleteMany: UpdateManyFn
168
+ }
140
169
  $transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>
141
170
  /** A9:$executeRaw 只存在于事务闭包的 tx 上;外层 prisma 恒 undefined
142
171
  * (实现若绕过事务直连外层 raw 清表,此处 undefined 直接 TypeError 红) */
@@ -155,13 +184,24 @@ function makePrismaMock(): PrismaMock {
155
184
  // 表写在事务闭包外(污染窗口)即 TypeError 红(同闭包哨兵,A9)
156
185
  const tx = {
157
186
  ...delegates,
158
- // 排除类与同步两表:实现绝不以 delegate 触达,桩在此「能接住误触」
159
- aiProviderConfig: { updateMany: vi.fn() },
160
187
  syncOutbox: touchable(),
161
188
  syncRowState: touchable(),
162
189
  // deleteMany 首跑模拟真删(count=1);delete(单行)对已删行抛 P2025,
163
190
  // 实现必须走 deleteMany——此处无 delete 桩,误用即 TypeError 红
164
- user: { upsert: vi.fn(async () => ({})), deleteMany: vi.fn(async () => ({ count: 1 })) },
191
+ // findUnique:C2 起云身份行统一走 ensureCloudIdentity(id 存在性 + email/
192
+ // username 冲突探测),默认 null = 空库首建;guest 行的用户级配置列样本
193
+ // 经 readUserPreferences 读出(C3)。云身份行默认「不存在」——反向转移用例
194
+ // 需要读到云行配置时用 mockCloudPreferences 显式注入。
195
+ user: {
196
+ findUnique: vi.fn(async (args?: { where?: Record<string, string> }) => {
197
+ const [field, value] = Object.entries(args?.where ?? {})[0] ?? []
198
+ if (field !== 'id') return null // email / username 冲突探测:恒空闲
199
+ if (value === GUEST) return GUEST_PREFERENCES
200
+ return null
201
+ }),
202
+ upsert: vi.fn(async () => ({})),
203
+ deleteMany: vi.fn(async () => ({ count: 1 })),
204
+ },
165
205
  $executeRaw: vi.fn(async () => 1),
166
206
  }
167
207
  const prisma = { ...tx } as Record<string, unknown>
@@ -182,6 +222,18 @@ const setUpdateCounts = (prisma: PrismaMock, count: number) => {
182
222
  }
183
223
  }
184
224
 
225
+ /** 反向转移用例:让 readUserPreferences(CLOUD) 读到云身份行的用户级配置样本
226
+ * (正向路径的 ensureCloudIdentity 存在性探测仍返回 null = 空库首建) */
227
+ const mockCloudPreferences = (prisma: PrismaMock) => {
228
+ prisma.user.findUnique.mockImplementation(async (args?: { where?: Record<string, string> }) => {
229
+ const [field, value] = Object.entries(args?.where ?? {})[0] ?? []
230
+ if (field !== 'id') return null
231
+ if (value === GUEST) return GUEST_PREFERENCES
232
+ if (value === CLOUD) return CLOUD_PREFERENCES
233
+ return null
234
+ })
235
+ }
236
+
185
237
  import {
186
238
  OWNERSHIP_COLUMNS,
187
239
  TRANSFER_EXCLUDED,
@@ -238,18 +290,37 @@ describe('transferOwnership(核心)', () => {
238
290
  }
239
291
  })
240
292
 
241
- it('②AiProviderConfig.userId 不在任何 UPDATE 中(配置类排除)', async () => {
293
+ it('②AiProviderConfig 随身份翻转(不再排除)+ 不在同步面(翻转不上云)', async () => {
242
294
  await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
243
295
 
244
- expect(prisma.aiProviderConfig.updateMany).not.toHaveBeenCalled()
245
- // 排除常量钉死 + 实现清单零残留(双保险:改常量名/漏排除均在此报)
246
- expect(TRANSFER_EXCLUDED).toContain('AiProviderConfig.userId')
247
- expect(
248
- OWNERSHIP_COLUMNS.some((c) => c.table === 'AiProviderConfig'),
249
- ).toBe(false)
296
+ // C3:AI 配置行必须翻转(不翻转 → 末尾删 guest 行经 CASCADE 静默删掉配置)
297
+ expect(prisma.aiProviderConfig.updateMany).toHaveBeenCalledTimes(1)
298
+ expect(prisma.aiProviderConfig.updateMany).toHaveBeenCalledWith({
299
+ where: { userId: GUEST },
300
+ data: { userId: CLOUD },
301
+ })
302
+ // 排除清单已清零(口径位保留);实现清单里在内
303
+ expect(TRANSFER_EXCLUDED).toEqual([])
304
+ expect(OWNERSHIP_COLUMNS.some((c) => c.table === 'AiProviderConfig')).toBe(true)
305
+ // 复核同步面:ai_provider_configs 未注册 → 翻转不产出 change log / outbox
306
+ expect(SYNC_TABLE_MAP.has('ai_provider_configs')).toBe(false)
307
+ for (const regTable of SYNC_TABLE_MAP.keys()) {
308
+ expect(regTable).not.toBe('ai_provider_configs')
309
+ }
310
+ // 同步两表零触达(与既有 ④ 同口径,此处对 AI 配置翻转再钉一次)
311
+ expect(prisma.syncOutbox.updateMany).not.toHaveBeenCalled()
312
+ expect(prisma.syncRowState.updateMany).not.toHaveBeenCalled()
313
+ })
314
+
315
+ it('②b AI 配置翻转子序先于 guest 行删除(CASCADE 无从命中)', async () => {
316
+ await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
317
+
318
+ const flipOrder = prisma.aiProviderConfig.updateMany.mock.invocationCallOrder[0]
319
+ const deleteOrder = (prisma.user.deleteMany as UpdateManyFn).mock.invocationCallOrder[0]
320
+ expect(flipOrder).toBeLessThan(deleteOrder)
250
321
  })
251
322
 
252
- it('③云身份行 upsert(载荷含传入昵称/头像)+ guest 行 delete 被调', async () => {
323
+ it('③云身份行建行(载荷含传入昵称/头像 + 用户级配置复制)+ guest 行 delete 被调', async () => {
253
324
  await transferOwnership(prisma as never, GUEST, {
254
325
  id: CLOUD,
255
326
  name: CLOUD_NAME,
@@ -271,6 +342,15 @@ describe('transferOwnership(核心)', () => {
271
342
  avatarConfig: CLOUD_AVATAR,
272
343
  status: 'ACTIVE',
273
344
  })
345
+ // C3:用户级配置列随身份复制(AI key 不丢;aiSourceMode=null 不复制)
346
+ expect(arg.create).toMatchObject({
347
+ aiApiKey: 'sk-guest',
348
+ aiProvider: 'openrouter',
349
+ aiModel: 'openai/gpt-4o-mini',
350
+ locale: 'zh',
351
+ timezone: 'Asia/Shanghai',
352
+ })
353
+ expect('aiSourceMode' in arg.create).toBe(false)
274
354
  // password 随机占位(同 desktop-bootstrap 惯例:凭据登录必失败)
275
355
  expect(typeof arg.create.password).toBe('string')
276
356
  expect((arg.create.password as string).length).toBeGreaterThan(0)
@@ -295,6 +375,44 @@ describe('transferOwnership(核心)', () => {
295
375
  ).toEqual([])
296
376
  })
297
377
 
378
+ it('④b R2 重置 row_state 版本基线:base_version=0 + dirty=true,同事务、翻转之后、删 guest 行之前', async () => {
379
+ await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
380
+
381
+ // raw 只经事务闭包的 tx(外层 prisma 无 $executeRaw——绕过事务即 TypeError 红)
382
+ const raw = prisma.__tx!.$executeRaw
383
+ expect(raw).toHaveBeenCalledTimes(1)
384
+ const sql = String((raw.mock.calls[0] as [TemplateStringsArray])[0])
385
+ expect(sql).toContain('UPDATE sync_row_state')
386
+ expect(sql).toContain('base_version = 0')
387
+ expect(sql).toContain('dirty = true')
388
+ // 不删行:删掉后 pull 把「有行但无 row_state」视为非 dirty → 直接覆盖本地行
389
+ expect(sql).not.toContain('DELETE')
390
+ expect(prisma.$executeRaw).toBeUndefined()
391
+
392
+ // 执行序:全部属主翻转 → 基线重置 → 删 guest 行
393
+ const lastFlipOrder = Math.max(
394
+ ...TEST_MODELS.flatMap((m) =>
395
+ ((prisma[delegateOf(m)] as { updateMany: UpdateManyFn }).updateMany.mock
396
+ .invocationCallOrder as number[]),
397
+ ),
398
+ )
399
+ const resetOrder = raw.mock.invocationCallOrder[0]
400
+ const deleteOrder = (prisma.user.deleteMany as UpdateManyFn).mock.invocationCallOrder[0]
401
+ expect(resetOrder).toBeGreaterThan(lastFlipOrder)
402
+ expect(resetOrder).toBeLessThan(deleteOrder)
403
+ })
404
+
405
+ it('④c R2 幂等二跑同样执行重置(UPDATE 全表天然幂等,不抛错)', async () => {
406
+ await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
407
+ setUpdateCounts(prisma, 0)
408
+ prisma.user.deleteMany.mockResolvedValue({ count: 0 })
409
+
410
+ await expect(
411
+ transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME }),
412
+ ).resolves.toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: 0 })
413
+ expect(prisma.__tx!.$executeRaw).toHaveBeenCalledTimes(2)
414
+ })
415
+
298
416
  it('⑤幂等:重复调用 UPDATE 计数 0,返回 { tables, rows: 0 } 不抛错', async () => {
299
417
  await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
300
418
 
@@ -394,24 +512,26 @@ describe('reverseTransferOwnership(A9 反向核心)', () => {
394
512
  expect(upsertOrder).toBeLessThan(firstUpdateOrder)
395
513
  })
396
514
 
397
- it('③outbox/row_state 同事务清空:$executeRaw 含两条 DELETE,且在闭包内于翻转后执行', async () => {
515
+ it('③outbox 清空 / row_state 不动(裁定 R2):单条 DELETE,且在闭包内于翻转后执行', async () => {
398
516
  await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
399
517
 
400
518
  // raw 清表只经事务闭包的 tx(外层 prisma 无 $executeRaw——绕过事务即 TypeError 红)
401
519
  const raw = prisma.__tx!.$executeRaw
402
- expect(raw).toHaveBeenCalledTimes(2)
520
+ expect(raw).toHaveBeenCalledTimes(1)
403
521
  const sqls = (raw.mock.calls as Array<[TemplateStringsArray]>).map((c) =>
404
522
  String(c[0]),
405
523
  )
406
524
  expect(sqls.some((s) => s.includes('DELETE FROM sync_outbox'))).toBe(true)
407
- expect(sqls.some((s) => s.includes('DELETE FROM sync_row_state'))).toBe(true)
525
+ // 裁定 R2:row_state 保留(清掉会让重登后首推 base_version 全 0 → 全量
526
+ // versionDiverged;outbox 是「待推 op」的唯一载体,清它就够)
527
+ expect(sqls.some((s) => s.includes('DELETE FROM sync_row_state'))).toBe(false)
408
528
  expect(prisma.$executeRaw).toBeUndefined()
409
529
 
410
530
  // 同一事务:$transaction 恰一次(无第二事务 / 无事务外窗口)
411
531
  expect(prisma.$transaction).toHaveBeenCalledTimes(1)
412
532
 
413
- // 闭包内执行序:guest 行重建 → 属主翻转 → 云行删除 → raw 清表(最后清同步两表,
414
- // 清空后引擎无从产出反向 op——审查重点「防反向 op 推上云污染」)
533
+ // 闭包内执行序:guest 行重建 → 属主翻转 → 云行删除 → 清 outbox(清空后引擎
534
+ // 无从产出反向 op——审查重点「防反向 op 推上云污染」)
415
535
  const rawOrder = raw.mock.invocationCallOrder
416
536
  const lastUpdateOrder = Math.max(
417
537
  ...(prisma.story.updateMany as UpdateManyFn).mock.invocationCallOrder,
@@ -421,22 +541,42 @@ describe('reverseTransferOwnership(A9 反向核心)', () => {
421
541
  expect(Math.min(...rawOrder)).toBeGreaterThan(deleteOrder)
422
542
  })
423
543
 
424
- it('④AiProviderConfig.userId 不翻转(配置类排除同正向)', async () => {
544
+ it('④AiProviderConfig 反向翻转 + 云行用户级配置复制到重建的 guest 行(C3)', async () => {
545
+ mockCloudPreferences(prisma)
425
546
  await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
426
- expect(prisma.aiProviderConfig.updateMany).not.toHaveBeenCalled()
547
+
548
+ // AI 配置行随身份回 guest(同正向;不翻转会被云行删除级联带走)
549
+ expect(prisma.aiProviderConfig.updateMany).toHaveBeenCalledWith({
550
+ where: { userId: CLOUD },
551
+ data: { userId: GUEST },
552
+ })
553
+
554
+ // guest 行重建时带走云态填的配置(AI key / 语言等;只在 create 分支生效)
555
+ const arg = (prisma.user.upsert as UpdateManyFn).mock.calls[0][0] as {
556
+ create: Record<string, unknown>
557
+ update: Record<string, unknown>
558
+ }
559
+ expect(arg.create).toMatchObject({
560
+ id: GUEST,
561
+ aiApiKey: 'sk-cloud',
562
+ aiProvider: 'openai',
563
+ locale: 'en',
564
+ })
565
+ // 幂等语义不变:update 仍留空(已存在的影子行保留本机值)
566
+ expect(arg.update).toEqual({})
427
567
  })
428
568
 
429
- it('⑤幂等二跑:UPDATE 计 0、deleteMany 计 0、raw 清表照常,返回 rows=0 不抛错', async () => {
569
+ it('⑤幂等二跑:UPDATE 计 0、deleteMany 计 0、raw 清 outbox 照常,返回 rows=0 不抛错', async () => {
430
570
  await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
431
571
 
432
572
  // 二跑:属主列 where cloudUserId 匹配 0 行(已回 guest);云身份行已删(deleteMany
433
- // 计 0 no-op);guest 行 upsert 幂等(update 留空);raw 清表照常执行
573
+ // 计 0 no-op);guest 行 upsert 幂等(update 留空);raw 清 outbox 照常执行
434
574
  setUpdateCounts(prisma, 0)
435
575
  prisma.user.deleteMany.mockResolvedValue({ count: 0 })
436
576
  await expect(
437
577
  reverseTransferOwnership(prisma as never, CLOUD, GUEST),
438
578
  ).resolves.toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: 0 })
439
- expect(prisma.__tx!.$executeRaw).toHaveBeenCalledTimes(4) // 2(首跑)+ 2(二跑)
579
+ expect(prisma.__tx!.$executeRaw).toHaveBeenCalledTimes(2) // 1(首跑)+ 1(二跑)
440
580
  })
441
581
 
442
582
  it('⑥审查重点(mock 翻译):反向转移后 guest 对核心表读写可用——核心五表清单对齐 + 幂等二跑', async () => {
@@ -495,7 +635,6 @@ vi.mock('@allbluecn/database', () => ({
495
635
  const sfMocks = vi.hoisted(() => ({
496
636
  session: null as null | { user: { id: string } },
497
637
  remoteAuth: null as null | { userId: string; sessionToken?: string },
498
- accountEmpty: true,
499
638
  prisma: null as unknown,
500
639
  checkAuthRateLimit: vi.fn(),
501
640
  clearRemoteAuth: vi.fn(),
@@ -578,9 +717,8 @@ vi.mock('@/server/remote-auth', () => ({
578
717
  readRemoteAuth: async () => sfMocks.remoteAuth,
579
718
  clearRemoteAuth: sfMocks.clearRemoteAuth,
580
719
  }))
581
- vi.mock('@/server/sync/account-status', () => ({
582
- isAccountEmpty: async () => sfMocks.accountEmpty,
583
- }))
720
+ // I2:空账号复核改云端权威(SaaS /api/sync/account-status),本地 isAccountEmpty
721
+ // 不再参与转移判定——下面 transferFn 用例全部经 fetchMock 注入云端三态。
584
722
  vi.mock('@/server/middlewares/rate-limit', () => ({
585
723
  checkAuthRateLimit: sfMocks.checkAuthRateLimit,
586
724
  }))
@@ -602,25 +740,60 @@ const probeFn = probeCloudAccountStatusFn as unknown as () => Promise<{
602
740
  unreachable?: boolean
603
741
  }>
604
742
 
605
- // SaaS fetch stub(saas-profile.ts / probeCloudAccountStatusFn 消费;vi.stubGlobal
743
+ // SaaS fetch stub(saas-profile.ts / account-status-cloud.ts 消费;vi.stubGlobal
606
744
  // 拦截,不真出网——惯例照 exchange-remote-token.test.ts)
607
745
  const fetchMock = vi.hoisted(() => vi.fn())
608
746
 
747
+ interface FetchRoute {
748
+ ok?: boolean
749
+ status?: number
750
+ body?: unknown
751
+ /** true = 模拟网络异常(fetch reject) */
752
+ reject?: boolean
753
+ }
754
+
755
+ /**
756
+ * 按 URL 分流 stub:account-status(云端空账号探测,I2)与 desktop-callback
757
+ * (profile 自取)两个端点。缺省不传即「该端点不应被调」(调用即抛错)。
758
+ */
759
+ function stubSaasFetch(routes: { accountStatus?: FetchRoute; profile?: FetchRoute }) {
760
+ fetchMock.mockImplementation(async (url: unknown) => {
761
+ const target = String(url)
762
+ const route = target.includes('/api/sync/account-status')
763
+ ? routes.accountStatus
764
+ : target.includes('/api/auth/desktop-callback')
765
+ ? routes.profile
766
+ : undefined
767
+ if (!route) throw new Error(`unexpected fetch: ${target}`)
768
+ if (route.reject) throw new Error('ECONNREFUSED')
769
+ return {
770
+ ok: route.ok,
771
+ status: route.status ?? (route.ok ? 200 : 503),
772
+ json: async () => route.body,
773
+ }
774
+ })
775
+ }
776
+
609
777
  describe('transferOwnershipFn(serverFn 校验)', () => {
610
778
  beforeEach(() => {
611
779
  sfMocks.session = { user: { id: GUEST } }
612
780
  sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
613
- sfMocks.accountEmpty = true
614
781
  sfMocks.prisma = makePrismaMock()
615
782
  setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
616
783
  fetchMock.mockReset()
784
+ // 默认:云端权威探测 = 空账号(转移放行)
785
+ stubSaasFetch({
786
+ accountStatus: { ok: true, body: { empty: true } },
787
+ profile: { ok: true, body: SAAS_PROFILE },
788
+ })
789
+ vi.stubGlobal('fetch', fetchMock)
617
790
  })
618
791
 
619
792
  afterEach(() => {
620
793
  vi.unstubAllGlobals()
621
794
  })
622
795
 
623
- it('⑥remoteAuth.userId ≠ data.cloudUserId → 抛错,零触库', async () => {
796
+ it('⑥remoteAuth.userId ≠ data.cloudUserId → 抛错,零触库零出网', async () => {
624
797
  sfMocks.remoteAuth = { userId: 'attacker-other' }
625
798
 
626
799
  await expect(
@@ -630,6 +803,7 @@ describe('transferOwnershipFn(serverFn 校验)', () => {
630
803
  const p = sfMocks.prisma as PrismaMock
631
804
  expect(p.story.updateMany).not.toHaveBeenCalled()
632
805
  expect(p.user.upsert).not.toHaveBeenCalled()
806
+ expect(fetchMock).not.toHaveBeenCalled()
633
807
  })
634
808
 
635
809
  it('无 remoteAuth(非 desktop / 未交换登录)→ 抛错', async () => {
@@ -641,13 +815,44 @@ describe('transferOwnershipFn(serverFn 校验)', () => {
641
815
  expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
642
816
  })
643
817
 
644
- it('账号非空(account-status.empty=false)→ 抛错拒绝转移', async () => {
645
- sfMocks.accountEmpty = false
818
+ it('I2 云端权威:账号非空(account-status.empty=false)→ 抛错拒绝转移', async () => {
819
+ stubSaasFetch({ accountStatus: { ok: true, body: { empty: false } } })
646
820
 
647
821
  await expect(
648
822
  transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
649
823
  ).rejects.toThrow(/空账号|非空/)
650
824
  expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
825
+ // 判定来自云端端点(带云会话 token cookie),不是本地库计数
826
+ const [url, init] = fetchMock.mock.calls[0] as [string, { headers: { Cookie: string } }]
827
+ expect(url).toContain('/api/sync/account-status')
828
+ expect(init.headers.Cookie).toBe('auth.session-token=tok-123')
829
+ })
830
+
831
+ it('I2 云端权威:探测不可达(网络异常 / 非 200 / 形状漂移)→ 拒绝转移', async () => {
832
+ stubSaasFetch({ accountStatus: { reject: true } })
833
+ await expect(
834
+ transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
835
+ ).rejects.toThrow(/不可达|状态/)
836
+ expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
837
+
838
+ stubSaasFetch({ accountStatus: { ok: false, status: 503 } })
839
+ await expect(
840
+ transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
841
+ ).rejects.toThrow(/不可达|状态/)
842
+
843
+ stubSaasFetch({ accountStatus: { ok: true, body: { empty: 'yes' } } })
844
+ await expect(
845
+ transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
846
+ ).rejects.toThrow(/不可达|状态/)
847
+ })
848
+
849
+ it('I2 幂等语义:转移后再次进入(本地已有数据)仍按云端判定放行', async () => {
850
+ // 本地 mock 的 updateMany 一律返回 count=1(等价「本地已有转移后数据」),
851
+ // 云端仍报告空账号 → 允许重复执行(转移本身幂等,UPDATE 匹配 0 行)
852
+ const result = (await transferFn({
853
+ data: { cloudUserId: CLOUD, name: CLOUD_NAME },
854
+ })) as { tables: number }
855
+ expect(result.tables).toBe(TEST_OWNERSHIP_COLUMNS.length)
651
856
  })
652
857
 
653
858
  it('正向:guest 会话 + remoteAuth 匹配 + 空账号 → 以 GUEST→cloudUserId 转移并回传结果', async () => {
@@ -682,7 +887,6 @@ describe('reverseTransferOwnershipFn(A9 serverFn 校验)', () => {
682
887
  beforeEach(() => {
683
888
  sfMocks.session = { user: { id: CLOUD } }
684
889
  sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
685
- sfMocks.accountEmpty = true
686
890
  sfMocks.prisma = makePrismaMock()
687
891
  setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
688
892
  sfMocks.clearRemoteAuth.mockClear()
@@ -770,10 +974,13 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
770
974
  beforeEach(() => {
771
975
  sfMocks.session = { user: { id: GUEST } }
772
976
  sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
773
- sfMocks.accountEmpty = true
774
977
  sfMocks.prisma = makePrismaMock()
775
978
  setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
776
979
  fetchMock.mockReset()
980
+ stubSaasFetch({
981
+ accountStatus: { ok: true, body: { empty: true } },
982
+ profile: { ok: true, body: SAAS_PROFILE },
983
+ })
777
984
  vi.stubGlobal('fetch', fetchMock)
778
985
  })
779
986
 
@@ -782,16 +989,17 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
782
989
  })
783
990
 
784
991
  it('name 省略 → 以 remoteAuth.sessionToken 自取 profile,upsert 载荷携带自取资料', async () => {
785
- fetchMock.mockResolvedValue({ ok: true, json: async () => SAAS_PROFILE })
786
-
787
992
  const result = (await transferFn({ data: { cloudUserId: CLOUD } })) as { rows: number }
788
993
  expect(result.rows).toBe(TEST_OWNERSHIP_COLUMNS.length)
789
994
 
790
995
  // 自取请求:SaaS exchange 端点 + 云会话 token cookie(照 exchange 先例)
791
- expect(fetchMock).toHaveBeenCalledTimes(1)
792
- const [url, init] = fetchMock.mock.calls[0] as [string, { headers: { Cookie: string } }]
793
- expect(url).toContain('/api/auth/desktop-callback?exchange=true')
794
- expect(init.headers.Cookie).toBe('auth.session-token=tok-123')
996
+ // I2 起首次请求是 account-status 探测(云端权威),profile 为第二次
997
+ expect(fetchMock).toHaveBeenCalledTimes(2)
998
+ const profileCall = (fetchMock.mock.calls as Array<[string, { headers: { Cookie: string } }]>).find(
999
+ ([url]) => url.includes('/api/auth/desktop-callback?exchange=true'),
1000
+ )
1001
+ expect(profileCall).toBeDefined()
1002
+ expect(profileCall![1].headers.Cookie).toBe('auth.session-token=tok-123')
795
1003
 
796
1004
  // 云身份行 upsert:昵称/邮箱/头像来自 SaaS(缺 name 兜底 = cloudUserId)
797
1005
  const arg = (sfMocks.prisma as PrismaMock).user.upsert.mock.calls[0][0] as {
@@ -807,11 +1015,13 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
807
1015
  })
808
1016
  })
809
1017
 
810
- it('字段全传 → 用传入值,零 SaaS 出网(A8a 调用形态向后兼容)', async () => {
1018
+ it('字段全传 → 用传入值,不请求 profile(A8a 调用形态向后兼容)', async () => {
811
1019
  await transferFn({
812
1020
  data: { cloudUserId: CLOUD, name: CLOUD_NAME, email: CLOUD_EMAIL, avatarConfig: CLOUD_AVATAR },
813
1021
  })
814
- expect(fetchMock).not.toHaveBeenCalled()
1022
+ // I2 起必有 account-status 探测;但身份字段全传时不得再打 profile 端点
1023
+ const urls = (fetchMock.mock.calls as Array<[string]>).map(([url]) => url)
1024
+ expect(urls.some((u) => u.includes('/api/auth/desktop-callback'))).toBe(false)
815
1025
  const arg = (sfMocks.prisma as PrismaMock).user.upsert.mock.calls[0][0] as {
816
1026
  create: Record<string, unknown>
817
1027
  }
@@ -823,7 +1033,10 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
823
1033
  })
824
1034
 
825
1035
  it('name 省略且 SaaS 资料获取失败 → 抛错走 bind:fail,零触库', async () => {
826
- fetchMock.mockResolvedValue({ ok: false, status: 503 })
1036
+ stubSaasFetch({
1037
+ accountStatus: { ok: true, body: { empty: true } },
1038
+ profile: { ok: false, status: 503 },
1039
+ })
827
1040
 
828
1041
  await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/SaaS/)
829
1042
  const p = sfMocks.prisma as PrismaMock
@@ -833,7 +1046,7 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
833
1046
 
834
1047
  it('name 省略且无 sessionToken → 抛错,零出网零触库', async () => {
835
1048
  sfMocks.remoteAuth = { userId: CLOUD }
836
- await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/token/)
1049
+ await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/token|状态|不可达/)
837
1050
  expect(fetchMock).not.toHaveBeenCalled()
838
1051
  expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
839
1052
  })