Compare commits
12 Commits
968cc88c62
...
b1bb28f5aa
| Author | SHA1 | Date | |
|---|---|---|---|
| b1bb28f5aa | |||
| 766c6a624f | |||
| 3c672f8355 | |||
| b75755e6a6 | |||
| ff2755e218 | |||
| 58f5de03f2 | |||
| 12ff0b56fb | |||
| 2c7ea3cc45 | |||
| 668aec45c5 | |||
| cbcc06380d | |||
| e8f68e30e5 | |||
| 18a6a18426 |
59
README.md
59
README.md
@ -21,16 +21,65 @@ npm run build
|
|||||||
## Current Scope
|
## Current Scope
|
||||||
|
|
||||||
- Adds two after-search-rate columns to the Xingtu market list
|
- Adds two after-search-rate columns to the Xingtu market list
|
||||||
|
- Adds a popup-based Logto auth entry
|
||||||
- Hydrates the current page immediately
|
- Hydrates the current page immediately
|
||||||
- Provides plugin-owned filter, sort, and CSV export controls
|
- Provides plugin-owned filter, sort, and CSV export controls
|
||||||
|
- Gates the market tools until auth is available
|
||||||
- Triggers full-scan flow only when filter, sort, or export is used
|
- Triggers full-scan flow only when filter, sort, or export is used
|
||||||
|
|
||||||
|
## Auth Configuration
|
||||||
|
|
||||||
|
The Logto integration is wired with placeholder values in `src/shared/auth-config.ts`.
|
||||||
|
Replace these before real sign-in testing:
|
||||||
|
|
||||||
|
- `logtoEndpoint`
|
||||||
|
- `appId`
|
||||||
|
- `apiResource`
|
||||||
|
- Any extra scopes beyond `openid`, `profile`, and `offline_access`
|
||||||
|
|
||||||
|
The popup dev panel is controlled by `enableDevAuthPanel`.
|
||||||
|
|
||||||
|
## Popup Behavior
|
||||||
|
|
||||||
|
1. Load the unpacked extension from `dist/`
|
||||||
|
2. Click the extension icon
|
||||||
|
3. Confirm the popup shows `登录 Logto` when unauthenticated
|
||||||
|
4. After real Logto config is added, use the popup to sign in and sign out
|
||||||
|
|
||||||
|
## Protected API Mock Test
|
||||||
|
|
||||||
|
1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
|
||||||
|
2. Run `npm run mock:protected-api`
|
||||||
|
3. Run `npm run build`
|
||||||
|
4. Reload the unpacked extension from `dist/`
|
||||||
|
5. Open the popup and log in
|
||||||
|
6. Click `测试受保护接口`
|
||||||
|
7. Confirm the popup shows JSON containing `"source": "mock-protected-api"` and `"message": "authorized"`
|
||||||
|
|
||||||
|
## Batch Submit Mock Test
|
||||||
|
|
||||||
|
1. Run `npm run mock:protected-api`
|
||||||
|
2. Run `npm run build`
|
||||||
|
3. Reload the unpacked extension from `dist/`
|
||||||
|
4. Open `https://xingtu.cn/ad/creator/market`
|
||||||
|
5. Choose an export range in the plugin toolbar
|
||||||
|
6. Click `提交批次`
|
||||||
|
7. Enter a batch name in the browser prompt
|
||||||
|
8. Confirm the toolbar shows `批次提交成功`
|
||||||
|
9. Confirm the mock batch response accepts the payload and reports the submitted `batchId`
|
||||||
|
|
||||||
|
## Market Auth Gate
|
||||||
|
|
||||||
|
When the market page is opened without a valid auth state, the content script renders
|
||||||
|
`请先登录插件` and does not boot the filter, sort, or export toolbar.
|
||||||
|
|
||||||
## Manual Verification
|
## Manual Verification
|
||||||
|
|
||||||
1. Load the unpacked extension from `dist/`
|
1. Load the unpacked extension from `dist/`
|
||||||
2. Open `https://xingtu.cn/ad/creator/market`
|
2. Open `https://xingtu.cn/ad/creator/market`
|
||||||
3. Confirm the two new columns appear
|
3. Confirm the page shows the auth gate until login is available
|
||||||
4. Confirm current-page rows move through loading and then render values or failure states
|
4. After authentication is wired, confirm the two new columns appear
|
||||||
5. Apply a threshold filter and confirm the list hides unmatched rows
|
5. Confirm current-page rows move through loading and then render values or failure states
|
||||||
6. Apply a sort and confirm row order changes
|
6. Apply a threshold filter and confirm the list hides unmatched rows
|
||||||
7. Export CSV and confirm the file includes plugin status and after-search-rate fields
|
7. Apply a sort and confirm row order changes
|
||||||
|
8. Export CSV and confirm the file includes plugin status and after-search-rate fields
|
||||||
|
|||||||
@ -0,0 +1,216 @@
|
|||||||
|
# Logto 受保护 API Mock 联调设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前扩展已经具备基础的 Logto 登录能力:
|
||||||
|
|
||||||
|
- popup 可触发登录和登出
|
||||||
|
- background 可通过 `@logto/chrome-extension` 获取 access token
|
||||||
|
- 页面功能在未登录时会被 auth gate 拦住
|
||||||
|
|
||||||
|
但现阶段业务数据仍然来自页面站内接口,尚未真正验证“扩展拿到 Logto access token 后,请求受保护 API”这条链路。后续真实后端将由其他人提供,因此当前阶段的目标不是接入正式接口,而是先在本地完成一套可重复验证的模拟联调方案。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 新增一个专用于 Logto 受保护 API 的扩展客户端。
|
||||||
|
- 扩展请求受保护 API 前,必须先通过 background 获取 access token。
|
||||||
|
- 请求时自动附加 `Authorization: Bearer <token>` 请求头。
|
||||||
|
- 提供一个本地 mock 受保护 API,用于验证扩展到后端的完整调用链路。
|
||||||
|
- 提供自动化测试,分别覆盖:
|
||||||
|
- token 注入逻辑
|
||||||
|
- mock API 授权成功/失败行为
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不接入真实业务后端。
|
||||||
|
- 不做真实 JWT 签名校验、JWKS 拉取或资源权限判定。
|
||||||
|
- 不修改现有星图页面数据采集逻辑为正式后端模式。
|
||||||
|
- 不在本阶段设计复杂的 token 刷新监控或重试策略。
|
||||||
|
|
||||||
|
## 方案对比
|
||||||
|
|
||||||
|
### 方案 A:只做本地 mock 后端
|
||||||
|
|
||||||
|
- 直接搭一个假接口,扩展请求它并观察返回。
|
||||||
|
- 优点:最接近最终使用方式。
|
||||||
|
- 缺点:如果联调失败,不容易快速判断问题出在 token 注入还是 mock 服务本身。
|
||||||
|
|
||||||
|
### 方案 B:只做代码级测试
|
||||||
|
|
||||||
|
- 不起本地服务,只用测试替身验证请求头是否带 token。
|
||||||
|
- 优点:实现快,定位问题直接。
|
||||||
|
- 缺点:无法证明完整链路可运行。
|
||||||
|
|
||||||
|
### 方案 C:先代码级测试,再接本地 mock 后端
|
||||||
|
|
||||||
|
- 先用单元测试锁定 token 注入行为,再起 mock 服务完成真实联调。
|
||||||
|
- 优点:定位问题更快,同时保留完整链路验证。
|
||||||
|
- 缺点:改动稍多于单一方案。
|
||||||
|
|
||||||
|
推荐采用方案 C。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 1. 扩展受保护 API 客户端
|
||||||
|
|
||||||
|
新增一个独立客户端模块,职责如下:
|
||||||
|
|
||||||
|
- 向 background 发送 `auth:get-access-token` 消息
|
||||||
|
- 读取返回的 access token
|
||||||
|
- 使用该 token 请求指定后端地址
|
||||||
|
- 将接口成功、未授权、网络失败这三类结果转成明确错误
|
||||||
|
|
||||||
|
这个客户端不直接耦合星图 DOM,也不依赖 popup。它只负责“拿 token 并带 token 发请求”,这样后续替换正式后端时只需要调整接口地址和返回映射。
|
||||||
|
|
||||||
|
### 2. Background 认证桥接
|
||||||
|
|
||||||
|
现有 background 已支持 `auth:get-access-token`。本次不改变登录主流程,只把它当作唯一 token 来源:
|
||||||
|
|
||||||
|
- content script 不直接接触 Logto SDK
|
||||||
|
- 所有受保护 API 请求都通过 background 提供 token
|
||||||
|
|
||||||
|
这样可以保持认证逻辑集中,符合 MV3 扩展的边界约束。
|
||||||
|
|
||||||
|
### 3. 本地 mock 受保护 API
|
||||||
|
|
||||||
|
新增一个轻量本地服务作为测试后端,建议职责保持极小:
|
||||||
|
|
||||||
|
- 暴露固定测试 endpoint,例如 `/api/mock/protected`
|
||||||
|
- 检查请求头中是否存在 `Authorization`
|
||||||
|
- 如果请求头形如 `Bearer <非空字符串>`,返回固定假数据
|
||||||
|
- 如果没有该头,返回 `401`
|
||||||
|
|
||||||
|
本阶段不要求验证 token 是否来自真实 Logto,只验证“扩展是否按约定附带了 Bearer token”。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
完整调用链路如下:
|
||||||
|
|
||||||
|
1. 扩展中的业务入口调用受保护 API 客户端
|
||||||
|
2. 客户端向 background 发送 `auth:get-access-token`
|
||||||
|
3. background 返回当前 access token
|
||||||
|
4. 客户端带上 `Authorization: Bearer <token>` 请求本地 mock API
|
||||||
|
5. mock API 校验请求头并返回假数据
|
||||||
|
6. 客户端将结果回传给调用方
|
||||||
|
|
||||||
|
失败分支:
|
||||||
|
|
||||||
|
- 如果 background 没返回合法 token,客户端直接报错,不发请求
|
||||||
|
- 如果 mock API 返回 `401`,客户端将其识别为未授权错误
|
||||||
|
- 如果请求超时或网络失败,客户端抛出网络错误
|
||||||
|
|
||||||
|
## 接口设计
|
||||||
|
|
||||||
|
### 扩展内客户端接口
|
||||||
|
|
||||||
|
建议客户端提供单一入口,例如:
|
||||||
|
|
||||||
|
- `loadProtectedMockData()`
|
||||||
|
|
||||||
|
内部行为:
|
||||||
|
|
||||||
|
- 先取 token
|
||||||
|
- 再发 GET 请求
|
||||||
|
- 返回结构化响应对象或抛出明确错误
|
||||||
|
|
||||||
|
后续替换真实后端时,可以保留同样入口,内部再切换到真实 endpoint。
|
||||||
|
|
||||||
|
### Mock API 返回
|
||||||
|
|
||||||
|
成功时返回固定 JSON,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"source": "mock-protected-api",
|
||||||
|
"message": "authorized",
|
||||||
|
"receivedAuthHeader": "Bearer ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败时返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "unauthorized"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 无 token
|
||||||
|
|
||||||
|
- 判定条件:background 未返回 `auth:token`,或 token 为空字符串
|
||||||
|
- 处理方式:立即抛错,提示当前未登录或 token 不可用
|
||||||
|
|
||||||
|
### 未授权
|
||||||
|
|
||||||
|
- 判定条件:后端返回 `401` 或 `403`
|
||||||
|
- 处理方式:抛出授权失败错误,供上层展示“请重新登录”
|
||||||
|
|
||||||
|
### 网络失败
|
||||||
|
|
||||||
|
- 判定条件:fetch 抛异常、连接失败、超时
|
||||||
|
- 处理方式:抛出网络错误,不吞掉异常原因
|
||||||
|
|
||||||
|
## 实现边界
|
||||||
|
|
||||||
|
建议新增或调整如下模块:
|
||||||
|
|
||||||
|
### `src/content/market` 下新增受保护 API 客户端
|
||||||
|
|
||||||
|
- 不替换现有页面接口客户端
|
||||||
|
- 独立处理 mock 受保护 API 访问
|
||||||
|
- 便于后续把“页面抓取模式”和“后端接口模式”并行保留
|
||||||
|
|
||||||
|
### `src/shared/auth-messages.ts`
|
||||||
|
|
||||||
|
- 复用现有 `auth:get-access-token`
|
||||||
|
- 若现有消息结构足够,则不新增消息类型
|
||||||
|
|
||||||
|
### `scripts/` 或独立目录中的 mock 服务
|
||||||
|
|
||||||
|
- 提供本地测试服务启动脚本
|
||||||
|
- 默认监听本地固定端口
|
||||||
|
- 返回固定 JSON 结果
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
- 客户端请求前会先获取 access token
|
||||||
|
- 拿到 token 后,请求头中包含 `Authorization: Bearer <token>`
|
||||||
|
- token 缺失时不会发起 fetch
|
||||||
|
- 接口返回 `401` 时抛出未授权错误
|
||||||
|
- 接口返回成功时正确解析 JSON
|
||||||
|
|
||||||
|
### 联调测试
|
||||||
|
|
||||||
|
- 启动本地 mock 服务后,带 token 请求能成功
|
||||||
|
- 不带 token 请求返回 `401`
|
||||||
|
- 扩展客户端能读到 mock 返回的假数据
|
||||||
|
|
||||||
|
## 手动验证
|
||||||
|
|
||||||
|
1. 构建并加载扩展
|
||||||
|
2. 在 popup 中完成 Logto 登录
|
||||||
|
3. 启动本地 mock API
|
||||||
|
4. 触发扩展中的受保护接口请求
|
||||||
|
5. 确认 mock API 收到 `Authorization: Bearer <token>`
|
||||||
|
6. 确认扩展端收到成功响应
|
||||||
|
|
||||||
|
## 迁移到真实后端的路径
|
||||||
|
|
||||||
|
当真实后端可用时,仅需要替换以下内容:
|
||||||
|
|
||||||
|
- mock API 基地址
|
||||||
|
- 具体 endpoint 路径
|
||||||
|
- 返回数据结构映射
|
||||||
|
- 若真实后端要求额外 scope,则补充 `auth-config` 中的 scopes
|
||||||
|
|
||||||
|
核心认证链路保持不变:
|
||||||
|
|
||||||
|
- 仍由 background 提供 token
|
||||||
|
- 仍由独立客户端附带 `Bearer` 请求头
|
||||||
|
- 仍按未授权和网络错误分别处理
|
||||||
305
docs/superpowers/specs/2026-04-22-market-batch-submit-design.md
Normal file
305
docs/superpowers/specs/2026-04-22-market-batch-submit-design.md
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
# 星图达人批次提交设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前插件已经具备以下能力:
|
||||||
|
|
||||||
|
- 使用 Logto 登录并获取访问 `https://talent-search.intelligrow.cn` 的 access token
|
||||||
|
- 读取星图市场页中的达人列表数据
|
||||||
|
- 支持按当前页、前 5 页、前 10 页、全部、自定义页数进行多页采集
|
||||||
|
- 支持导出 CSV
|
||||||
|
|
||||||
|
下一阶段需要把“当前采集范围内的一批达人”提交给其他同学维护的后端接口,而不是逐条发送。用户期望在现有导出范围逻辑基础上,新增一个独立的“提交批次”动作,用来测试整批提交流程。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 在现有工具栏中新增一个独立的 `提交批次` 按钮
|
||||||
|
- 提交动作复用当前导出范围逻辑,支持多页达人数据采集
|
||||||
|
- 提交前通过浏览器 `prompt()` 让用户输入批次名称
|
||||||
|
- 自动生成 `batchId = 批次名称 + 时间戳`
|
||||||
|
- 将以下信息整合为单个批次 payload 发送给后端:
|
||||||
|
- Logto 用户 ID
|
||||||
|
- 星图达人 ID 列表
|
||||||
|
- 用户输入的批次名称
|
||||||
|
- 自动生成的批次 ID
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改造为逐条提交达人
|
||||||
|
- 不移除或替代现有 `导出 CSV`
|
||||||
|
- 不在首版实现复杂弹窗或表单 UI
|
||||||
|
- 不在首版实现批次列表管理、重试历史、草稿保存
|
||||||
|
|
||||||
|
## 方案对比
|
||||||
|
|
||||||
|
### 方案 A:只提交当前页
|
||||||
|
|
||||||
|
- 新按钮只处理当前页达人
|
||||||
|
- 优点:实现最简单
|
||||||
|
- 缺点:与现有多页导出/采集使用方式不一致,不符合真实业务需求
|
||||||
|
|
||||||
|
### 方案 B:提交当前导出范围内的一整批达人
|
||||||
|
|
||||||
|
- 复用现有导出范围控制器,提交当前选择范围内的所有达人
|
||||||
|
- 优点:与用户当前使用习惯一致,便于从测试过渡到真实业务
|
||||||
|
- 缺点:比只提交当前页多一层批次组装逻辑
|
||||||
|
|
||||||
|
### 方案 C:先导出 CSV,再从 CSV 结果提交
|
||||||
|
|
||||||
|
- 先走导出逻辑,再把导出结果转换为提交 payload
|
||||||
|
- 优点:数据来源统一
|
||||||
|
- 缺点:流程绕、耦合高,不适合作为长期模式
|
||||||
|
|
||||||
|
推荐采用方案 B。
|
||||||
|
|
||||||
|
## 交互设计
|
||||||
|
|
||||||
|
### 工具栏按钮
|
||||||
|
|
||||||
|
在现有工具栏中保留 `导出 CSV`,并新增:
|
||||||
|
|
||||||
|
- `提交批次`
|
||||||
|
|
||||||
|
两个按钮共享现有导出范围控件:
|
||||||
|
|
||||||
|
- 当前页
|
||||||
|
- 前 5 页
|
||||||
|
- 前 10 页
|
||||||
|
- 全部
|
||||||
|
- 自定义
|
||||||
|
|
||||||
|
### 批次名称输入
|
||||||
|
|
||||||
|
用户点击 `提交批次` 时:
|
||||||
|
|
||||||
|
1. 弹出浏览器 `prompt()`
|
||||||
|
2. 提示文案建议为:`请输入批次名称`
|
||||||
|
3. 用户输入如:`618 达人筛选第一批`
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
|
||||||
|
- 用户取消:直接终止,不报错
|
||||||
|
- 输入为空或仅空格:提示 `请输入批次名称`
|
||||||
|
- 输入有效:继续批次提交流程
|
||||||
|
|
||||||
|
### 提交状态
|
||||||
|
|
||||||
|
批次提交过程中:
|
||||||
|
|
||||||
|
- `提交批次` 按钮禁用
|
||||||
|
- `导出 CSV` 按钮与范围选择器一并禁用
|
||||||
|
- 状态文案可复用现有导出状态区域
|
||||||
|
|
||||||
|
建议状态文案:
|
||||||
|
|
||||||
|
- 当前页:`提交中...`
|
||||||
|
- 前 N 页:`提交中 3/5 页...`
|
||||||
|
- 全部:`提交中 第 3 页...`
|
||||||
|
|
||||||
|
### 成功与失败反馈
|
||||||
|
|
||||||
|
成功:
|
||||||
|
|
||||||
|
- 提示 `批次提交成功`
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
- 未登录:`请先登录插件`
|
||||||
|
- 批次名为空:`请输入批次名称`
|
||||||
|
- 接口失败:`批次提交失败,请稍后重试`
|
||||||
|
|
||||||
|
## 批次数据模型
|
||||||
|
|
||||||
|
首版建议 payload 结构如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logtoUserId": "p7pdhhtde8kj",
|
||||||
|
"creatorName": "王少卿",
|
||||||
|
"resource": "https://talent-search.intelligrow.cn",
|
||||||
|
"batchName": "618达人筛选第一批",
|
||||||
|
"batchId": "618达人筛选第一批-2026-04-22T12:30:00.000Z",
|
||||||
|
"createdAt": "2026-04-22T12:30:00.000Z",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"authorId": "111",
|
||||||
|
"authorName": "达人A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"authorId": "222",
|
||||||
|
"authorName": "达人B"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段来源
|
||||||
|
|
||||||
|
- `logtoUserId`
|
||||||
|
- 来源:当前登录用户的 Logto `sub`
|
||||||
|
- `creatorName`
|
||||||
|
- 来源:当前登录用户 `name` 或 `username`
|
||||||
|
- `resource`
|
||||||
|
- 来源:当前认证配置中的 API resource
|
||||||
|
- `batchName`
|
||||||
|
- 来源:用户在 `prompt()` 中输入
|
||||||
|
- `createdAt`
|
||||||
|
- 来源:当前时间的 ISO 字符串
|
||||||
|
- `batchId`
|
||||||
|
- 规则:`${batchName}-${createdAt}`
|
||||||
|
- `authors`
|
||||||
|
- 来源:当前导出范围内采集出的达人记录
|
||||||
|
|
||||||
|
### 达人字段
|
||||||
|
|
||||||
|
首版只要求最小字段:
|
||||||
|
|
||||||
|
- `authorId`
|
||||||
|
- `authorName`
|
||||||
|
|
||||||
|
后续如有需要,可增加:
|
||||||
|
|
||||||
|
- `price21To60s`
|
||||||
|
- 看后搜率字段
|
||||||
|
- 城市/地区等补充信息
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
完整流程如下:
|
||||||
|
|
||||||
|
1. 用户在工具栏选择导出范围
|
||||||
|
2. 用户点击 `提交批次`
|
||||||
|
3. 插件弹出 `prompt()` 请求输入批次名称
|
||||||
|
4. 插件检查登录状态与用户信息
|
||||||
|
5. 插件复用当前多页采集逻辑,获取当前范围内达人记录
|
||||||
|
6. 插件读取 Logto 用户 ID
|
||||||
|
7. 插件生成 `createdAt` 和 `batchId`
|
||||||
|
8. 插件组装整批 payload
|
||||||
|
9. 插件使用带 Bearer token 的客户端提交到后端
|
||||||
|
10. 插件显示成功或失败状态
|
||||||
|
|
||||||
|
## 模块边界
|
||||||
|
|
||||||
|
建议按以下边界实现:
|
||||||
|
|
||||||
|
### 工具栏层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 渲染 `提交批次` 按钮
|
||||||
|
- 管理按钮禁用态
|
||||||
|
- 回传点击事件
|
||||||
|
|
||||||
|
### 市场控制器层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 响应 `提交批次` 点击
|
||||||
|
- 调用 `prompt()` 获取批次名称
|
||||||
|
- 复用现有导出范围采集逻辑
|
||||||
|
- 调用批次 payload 组装器和提交客户端
|
||||||
|
|
||||||
|
### 批次 payload 组装层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 接收用户信息、批次名、时间戳、达人记录
|
||||||
|
- 生成标准批次对象
|
||||||
|
|
||||||
|
该层不负责网络请求,方便后续根据后端要求调整结构。
|
||||||
|
|
||||||
|
### 批次提交客户端
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 获取 access token
|
||||||
|
- 附带 `Authorization: Bearer <token>` 请求头
|
||||||
|
- 向后端提交整批 JSON
|
||||||
|
|
||||||
|
后续从 mock 接口切到真实接口时,应主要修改这一层。
|
||||||
|
|
||||||
|
## 认证与授权
|
||||||
|
|
||||||
|
当前系统已具备:
|
||||||
|
|
||||||
|
- Logto 登录能力
|
||||||
|
- 请求 `https://talent-search.intelligrow.cn` resource 的 access token
|
||||||
|
- `talent-search:read` scope
|
||||||
|
|
||||||
|
首版批次提交如果是写操作,后端后续可能需要新增写权限,例如:
|
||||||
|
|
||||||
|
- `talent-search:write`
|
||||||
|
- 或 `talent-search:batch:submit`
|
||||||
|
|
||||||
|
如果后端要求新的写权限,则前端需要同步更新 `auth-config.ts` 中的 `scopes`。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 用户取消输入
|
||||||
|
|
||||||
|
- 直接停止,不提示错误
|
||||||
|
|
||||||
|
### 批次名为空
|
||||||
|
|
||||||
|
- 不发请求
|
||||||
|
- 直接提示 `请输入批次名称`
|
||||||
|
|
||||||
|
### 未登录或取不到用户信息
|
||||||
|
|
||||||
|
- 不发请求
|
||||||
|
- 提示 `请先登录插件`
|
||||||
|
|
||||||
|
### 采集失败
|
||||||
|
|
||||||
|
- 复用当前导出多页采集失败逻辑
|
||||||
|
- 不生成不完整 payload
|
||||||
|
|
||||||
|
### 接口失败
|
||||||
|
|
||||||
|
- 提示 `批次提交失败,请稍后重试`
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
- 点击 `提交批次` 时会弹出 `prompt()`
|
||||||
|
- 用户取消输入时不会继续提交
|
||||||
|
- 空白批次名会报错
|
||||||
|
- `batchId` 按 `批次名 + ISO 时间戳` 生成
|
||||||
|
- payload 中包含:
|
||||||
|
- `logtoUserId`
|
||||||
|
- `batchName`
|
||||||
|
- `batchId`
|
||||||
|
- `authors[]`
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
|
||||||
|
- `提交批次` 复用当前导出范围采集逻辑
|
||||||
|
- 当前页模式只提交当前页达人
|
||||||
|
- 多页模式会合并多页达人
|
||||||
|
- 接口调用时带 Bearer token
|
||||||
|
- 成功时显示成功状态
|
||||||
|
- 失败时显示失败状态
|
||||||
|
|
||||||
|
## 手动验证
|
||||||
|
|
||||||
|
1. 登录插件
|
||||||
|
2. 选择导出范围
|
||||||
|
3. 点击 `提交批次`
|
||||||
|
4. 在 `prompt()` 中输入批次名
|
||||||
|
5. 确认请求 payload 中包含:
|
||||||
|
- Logto 用户 ID
|
||||||
|
- 多个达人 ID
|
||||||
|
- 批次名
|
||||||
|
- 批次 ID
|
||||||
|
6. 确认后端返回成功
|
||||||
|
|
||||||
|
## 后续扩展
|
||||||
|
|
||||||
|
当真实接口稳定后,可以继续扩展:
|
||||||
|
|
||||||
|
- 批次提交结果详情展示
|
||||||
|
- 最近提交批次列表
|
||||||
|
- 批次重试
|
||||||
|
- 批次备注、标签
|
||||||
|
- 写权限 scope 单独拆分
|
||||||
@ -5,9 +5,13 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs",
|
||||||
|
"mock:protected-api": "node scripts/mock-protected-api.mjs",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:watch": "vitest --passWithNoTests"
|
"test:watch": "vitest --passWithNoTests"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@logto/chrome-extension": "^0.1.27"
|
||||||
|
},
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
|
|||||||
117
scripts/mock-protected-api.mjs
Normal file
117
scripts/mock-protected-api.mjs
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
export function createMockProtectedApiServer({ port = 4319 } = {}) {
|
||||||
|
let server;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get baseUrl() {
|
||||||
|
const address = server?.address();
|
||||||
|
const resolvedPort =
|
||||||
|
typeof address === "object" && address ? address.port : port;
|
||||||
|
|
||||||
|
return `http://127.0.0.1:${resolvedPort}`;
|
||||||
|
},
|
||||||
|
async start() {
|
||||||
|
server = http.createServer(async (request, response) => {
|
||||||
|
if (request.url === "/api/mock/protected") {
|
||||||
|
const authHeader = readBearerToken(request, response);
|
||||||
|
if (!authHeader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
source: "mock-protected-api",
|
||||||
|
message: "authorized",
|
||||||
|
receivedAuthHeader: authHeader
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === "/api/mock/batches" && request.method === "POST") {
|
||||||
|
const authHeader = readBearerToken(request, response);
|
||||||
|
if (!authHeader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readJsonBody(request);
|
||||||
|
const authors = Array.isArray(payload?.authors) ? payload.authors : [];
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
source: "mock-batch-submit",
|
||||||
|
acceptedCount: authors.length,
|
||||||
|
batchId:
|
||||||
|
typeof payload?.batchId === "string" ? payload.batchId : null,
|
||||||
|
receivedAuthHeader: authHeader
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(404, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ ok: false, error: "not-found" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server.listen(port, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
if (!server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBearerToken(request, response) {
|
||||||
|
const authHeader = request.headers.authorization ?? "";
|
||||||
|
const isBearer =
|
||||||
|
typeof authHeader === "string" &&
|
||||||
|
authHeader.startsWith("Bearer ") &&
|
||||||
|
authHeader.length > "Bearer ".length;
|
||||||
|
|
||||||
|
if (!isBearer) {
|
||||||
|
response.writeHead(401, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request) {
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
for await (const chunk of request) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const server = createMockProtectedApiServer();
|
||||||
|
await server.start();
|
||||||
|
console.log(`mock protected api listening on ${server.baseUrl}`);
|
||||||
|
}
|
||||||
53
src/content/market/batch-payload.ts
Normal file
53
src/content/market/batch-payload.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||||
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
|
export interface BatchPayload {
|
||||||
|
authors: Array<{
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
}>;
|
||||||
|
batchId: string;
|
||||||
|
batchName: string;
|
||||||
|
createdAt: string;
|
||||||
|
creatorName: string;
|
||||||
|
logtoUserId: string;
|
||||||
|
resource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBatchPayload(options: {
|
||||||
|
authState: AuthStateValue;
|
||||||
|
batchName: string;
|
||||||
|
createdAt: string;
|
||||||
|
records: MarketRecord[];
|
||||||
|
}): BatchPayload {
|
||||||
|
const logtoUserId = options.authState.userInfo?.sub?.trim();
|
||||||
|
if (!logtoUserId) {
|
||||||
|
throw new Error("batch submit user id unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = options.authState.resource?.trim();
|
||||||
|
if (!resource) {
|
||||||
|
throw new Error("batch submit resource unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchName = options.batchName.trim();
|
||||||
|
if (!batchName) {
|
||||||
|
throw new Error("batch submit batch name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authors: options.records.map((record) => ({
|
||||||
|
authorId: record.authorId,
|
||||||
|
authorName: record.authorName
|
||||||
|
})),
|
||||||
|
batchId: `${batchName}-${options.createdAt}`,
|
||||||
|
batchName,
|
||||||
|
createdAt: options.createdAt,
|
||||||
|
creatorName:
|
||||||
|
options.authState.userInfo?.name ??
|
||||||
|
options.authState.userInfo?.username ??
|
||||||
|
logtoUserId,
|
||||||
|
logtoUserId,
|
||||||
|
resource
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { buildMarketCsv } from "./csv-exporter";
|
import { buildMarketCsv } from "./csv-exporter";
|
||||||
|
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||||
import {
|
import {
|
||||||
applyRowOrder,
|
applyRowOrder,
|
||||||
applyRowVisibility,
|
applyRowVisibility,
|
||||||
@ -16,6 +17,11 @@ import {
|
|||||||
setToolbarExportStatus
|
setToolbarExportStatus
|
||||||
} from "./plugin-toolbar";
|
} from "./plugin-toolbar";
|
||||||
import { createMarketResultStore } from "./result-store";
|
import { createMarketResultStore } from "./result-store";
|
||||||
|
import {
|
||||||
|
isAuthResponseMessage,
|
||||||
|
type AuthStateValue
|
||||||
|
} from "../../shared/auth-messages";
|
||||||
|
import { createBatchSubmitClient } from "../../shared/batch-submit-client";
|
||||||
import type {
|
import type {
|
||||||
MarketApiResult,
|
MarketApiResult,
|
||||||
MarketFilterState,
|
MarketFilterState,
|
||||||
@ -33,24 +39,38 @@ interface MutationObserverLike {
|
|||||||
export interface CreateMarketControllerOptions {
|
export interface CreateMarketControllerOptions {
|
||||||
buildCsv?: (records: MarketRecord[]) => string;
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
document: Document;
|
document: Document;
|
||||||
|
getAuthState?: () => Promise<AuthStateValue>;
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
mutationObserverFactory?: (
|
mutationObserverFactory?: (
|
||||||
callback: MutationCallback
|
callback: MutationCallback
|
||||||
) => MutationObserverLike;
|
) => MutationObserverLike;
|
||||||
onCsvReady?: (csv: string) => void;
|
onCsvReady?: (csv: string) => void;
|
||||||
|
promptBatchName?: () => string | null;
|
||||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||||
|
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||||
window: Window;
|
window: Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMarketController(options: CreateMarketControllerOptions) {
|
export function createMarketController(options: CreateMarketControllerOptions) {
|
||||||
const marketApiClient = createMarketApiClient();
|
const marketApiClient = createMarketApiClient();
|
||||||
|
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
const loadAuthorMetrics =
|
const loadAuthorMetrics =
|
||||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||||
|
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
||||||
const mutationObserverFactory =
|
const mutationObserverFactory =
|
||||||
options.mutationObserverFactory ??
|
options.mutationObserverFactory ??
|
||||||
((callback: MutationCallback) => new MutationObserver(callback));
|
((callback: MutationCallback) => new MutationObserver(callback));
|
||||||
|
const promptBatchName =
|
||||||
|
options.promptBatchName ??
|
||||||
|
(() => options.window.prompt("请输入批次名称"));
|
||||||
|
const submitBatch =
|
||||||
|
options.submitBatch ??
|
||||||
|
createBatchSubmitClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
sendMessage: sendRuntimeMessage
|
||||||
|
}).submitBatch;
|
||||||
const exportRangeController = createExportRangeController({
|
const exportRangeController = createExportRangeController({
|
||||||
document: options.document,
|
document: options.document,
|
||||||
onProgress: ({ currentPage, totalPages }) => {
|
onProgress: ({ currentPage, totalPages }) => {
|
||||||
@ -117,6 +137,48 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
} finally {
|
} finally {
|
||||||
setToolbarBusyState(toolbar, false);
|
setToolbarBusyState(toolbar, false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onSubmitBatch: async () => {
|
||||||
|
const exportTarget = readToolbarExportTarget(toolbar);
|
||||||
|
if (!exportTarget.target) {
|
||||||
|
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchName = promptBatchName();
|
||||||
|
if (batchName === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batchName.trim()) {
|
||||||
|
setToolbarExportStatus(toolbar, "请输入批次名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolbarBusyState(toolbar, true);
|
||||||
|
try {
|
||||||
|
const records = await exportRecords(exportTarget.target, "提交中");
|
||||||
|
const authState = await getAuthState();
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
throw new Error("请先登录插件");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = createBatchPayload({
|
||||||
|
authState,
|
||||||
|
batchName,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
records
|
||||||
|
});
|
||||||
|
await submitBatch(payload);
|
||||||
|
setToolbarExportStatus(toolbar, "批次提交成功");
|
||||||
|
} catch (error) {
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
error instanceof Error ? error.message : "批次提交失败,请稍后重试"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setToolbarBusyState(toolbar, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -225,9 +287,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
|
async function exportRecords(
|
||||||
|
target: MarketExportTarget,
|
||||||
|
inProgressLabel = "导出中"
|
||||||
|
): Promise<MarketRecord[]> {
|
||||||
if (target.mode === "count" && target.pageCount <= 1) {
|
if (target.mode === "count" && target.pageCount <= 1) {
|
||||||
setToolbarExportStatus(toolbar, "导出中...");
|
setToolbarExportStatus(toolbar, `${inProgressLabel}...`);
|
||||||
await prepareCurrentPageForExport();
|
await prepareCurrentPageForExport();
|
||||||
return getVisibleOrderedRecords();
|
return getVisibleOrderedRecords();
|
||||||
}
|
}
|
||||||
@ -366,56 +431,81 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
let previousFingerprint = "";
|
let previousFingerprint = "";
|
||||||
let stablePassCount = 0;
|
let stablePassCount = 0;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
for (let attempt = 0; attempt < 9; attempt += 1) {
|
||||||
await waitForDomSettled();
|
await waitForDomSettled();
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
options.window.setTimeout(resolve, 100);
|
options.window.setTimeout(
|
||||||
|
resolve,
|
||||||
|
previousFingerprint.includes("|missing:0") ? 25 : 50
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
collectCurrentPageSnapshots();
|
collectCurrentPageSnapshots();
|
||||||
const nextFingerprint = readVisibleRowHydrationFingerprint();
|
const hydrationSnapshot = readVisibleRowHydrationSnapshot();
|
||||||
if (!nextFingerprint) {
|
if (!hydrationSnapshot.fingerprint) {
|
||||||
stablePassCount = 0;
|
stablePassCount = 0;
|
||||||
previousFingerprint = "";
|
previousFingerprint = "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextFingerprint === previousFingerprint) {
|
if (hydrationSnapshot.fingerprint === previousFingerprint) {
|
||||||
stablePassCount += 1;
|
stablePassCount += 1;
|
||||||
} else {
|
} else {
|
||||||
previousFingerprint = nextFingerprint;
|
previousFingerprint = hydrationSnapshot.fingerprint;
|
||||||
stablePassCount = 1;
|
stablePassCount = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stablePassCount >= 3) {
|
if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readVisibleRowHydrationFingerprint(): string {
|
function readVisibleRowHydrationSnapshot(): {
|
||||||
|
fingerprint: string;
|
||||||
|
missingDefaultFieldCount: number;
|
||||||
|
} {
|
||||||
const table = syncMarketTable(options.document);
|
const table = syncMarketTable(options.document);
|
||||||
if (!table || table.rows.length === 0) {
|
if (!table || table.rows.length === 0) {
|
||||||
return "";
|
return {
|
||||||
|
fingerprint: "",
|
||||||
|
missingDefaultFieldCount: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return table.rows
|
const parts = table.rows.map((rowDom) => {
|
||||||
.map((rowDom) => {
|
|
||||||
const rowSnapshot = readRowSnapshot(rowDom);
|
const rowSnapshot = readRowSnapshot(rowDom);
|
||||||
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
||||||
(value) => typeof value === "string" && value.trim().length > 0
|
(value) => typeof value === "string" && value.trim().length > 0
|
||||||
).length;
|
).length;
|
||||||
|
const hasRepresentativeVideo = hasTextValue(
|
||||||
|
rowSnapshot.exportFields?.["代表视频"]
|
||||||
|
);
|
||||||
|
const hasPriceField =
|
||||||
|
hasTextValue(rowSnapshot.price21To60s) ||
|
||||||
|
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
|
||||||
|
const missingDefaultFieldCount =
|
||||||
|
Number(!hasRepresentativeVideo) + Number(!hasPriceField);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
rowSnapshot.authorId,
|
rowSnapshot.authorId,
|
||||||
populatedFieldCount,
|
populatedFieldCount,
|
||||||
rowSnapshot.price21To60s?.trim() ? "price" : "no-price"
|
hasRepresentativeVideo ? "video" : "no-video",
|
||||||
|
hasPriceField ? "price" : "no-price",
|
||||||
|
`missing:${missingDefaultFieldCount}`
|
||||||
].join(":");
|
].join(":");
|
||||||
})
|
});
|
||||||
.join("|");
|
|
||||||
|
return {
|
||||||
|
fingerprint: parts.join("|"),
|
||||||
|
missingDefaultFieldCount: parts.reduce((count, part) => {
|
||||||
|
const match = part.match(/missing:(\d+)$/);
|
||||||
|
return count + Number(match?.[1] ?? 0);
|
||||||
|
}, 0)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSync(): void {
|
function scheduleSync(): void {
|
||||||
@ -528,6 +618,32 @@ function mergeFieldMap<T extends Record<string, string | undefined>>(
|
|||||||
return merged as T;
|
return merged as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
|
||||||
|
return (message: unknown) =>
|
||||||
|
Promise.resolve(
|
||||||
|
(
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
chrome?: {
|
||||||
|
runtime?: {
|
||||||
|
sendMessage?: (payload: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).chrome?.runtime?.sendMessage?.(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAuthState(
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>
|
||||||
|
): Promise<AuthStateValue> {
|
||||||
|
const response = await sendMessage({ type: "auth:get-state" });
|
||||||
|
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||||
|
throw new Error("请先登录插件");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.value;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeStringValue(
|
function mergeStringValue(
|
||||||
current: string | undefined,
|
current: string | undefined,
|
||||||
incoming: string | undefined
|
incoming: string | undefined
|
||||||
|
|||||||
@ -4,9 +4,11 @@ export interface PluginToolbarHandlers {
|
|||||||
onApplyFilter(): Promise<void> | void;
|
onApplyFilter(): Promise<void> | void;
|
||||||
onApplySort(): Promise<void> | void;
|
onApplySort(): Promise<void> | void;
|
||||||
onExport(): Promise<void> | void;
|
onExport(): Promise<void> | void;
|
||||||
|
onSubmitBatch(): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginToolbarDom {
|
export interface PluginToolbarDom {
|
||||||
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
@ -70,6 +72,11 @@ export function ensurePluginToolbar(
|
|||||||
exportButton.dataset.pluginExport = "button";
|
exportButton.dataset.pluginExport = "button";
|
||||||
exportButton.textContent = "导出CSV";
|
exportButton.textContent = "导出CSV";
|
||||||
|
|
||||||
|
const batchSubmitButton = document.createElement("button");
|
||||||
|
batchSubmitButton.type = "button";
|
||||||
|
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||||
|
batchSubmitButton.textContent = "提交批次";
|
||||||
|
|
||||||
const exportRangeSelect = document.createElement("select");
|
const exportRangeSelect = document.createElement("select");
|
||||||
exportRangeSelect.dataset.pluginExportRange = "select";
|
exportRangeSelect.dataset.pluginExportRange = "select";
|
||||||
appendOption(exportRangeSelect, "current", "当前页");
|
appendOption(exportRangeSelect, "current", "当前页");
|
||||||
@ -98,7 +105,8 @@ export function ensurePluginToolbar(
|
|||||||
sortApplyButton,
|
sortApplyButton,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportButton
|
exportButton,
|
||||||
|
batchSubmitButton
|
||||||
);
|
);
|
||||||
root.append(exportStatusText);
|
root.append(exportStatusText);
|
||||||
document.body.prepend(root);
|
document.body.prepend(root);
|
||||||
@ -112,8 +120,12 @@ export function ensurePluginToolbar(
|
|||||||
exportButton.addEventListener("click", () => {
|
exportButton.addEventListener("click", () => {
|
||||||
void handlers.onExport();
|
void handlers.onExport();
|
||||||
});
|
});
|
||||||
|
batchSubmitButton.addEventListener("click", () => {
|
||||||
|
void handlers.onSubmitBatch();
|
||||||
|
});
|
||||||
exportRangeSelect.addEventListener("change", () => {
|
exportRangeSelect.addEventListener("change", () => {
|
||||||
syncCustomPagesInputVisibility({
|
syncCustomPagesInputVisibility({
|
||||||
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
@ -129,6 +141,7 @@ export function ensurePluginToolbar(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
@ -159,6 +172,9 @@ function appendOption(
|
|||||||
|
|
||||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
|
batchSubmitButton: root.querySelector(
|
||||||
|
'[data-plugin-batch-submit="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
exportButton: root.querySelector(
|
exportButton: root.querySelector(
|
||||||
'[data-plugin-export="button"]'
|
'[data-plugin-export="button"]'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
@ -255,6 +271,7 @@ export function setToolbarBusyState(
|
|||||||
isBusy: boolean
|
isBusy: boolean
|
||||||
): void {
|
): void {
|
||||||
[
|
[
|
||||||
|
toolbar.batchSubmitButton,
|
||||||
toolbar.exportButton,
|
toolbar.exportButton,
|
||||||
toolbar.filterApplyButton,
|
toolbar.filterApplyButton,
|
||||||
toolbar.sortApplyButton,
|
toolbar.sortApplyButton,
|
||||||
|
|||||||
155
src/popup/index.ts
Normal file
155
src/popup/index.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
renderDevPanel,
|
||||||
|
renderLoggedIn,
|
||||||
|
renderLoggedOut,
|
||||||
|
setProtectedApiResult
|
||||||
|
} from "./view";
|
||||||
|
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
||||||
|
import {
|
||||||
|
isAuthResponseMessage,
|
||||||
|
type AuthResponseMessage
|
||||||
|
} from "../shared/auth-messages";
|
||||||
|
import { createProtectedApiClient } from "../shared/protected-api-client";
|
||||||
|
|
||||||
|
interface BootPopupOptions {
|
||||||
|
config?: Partial<AuthConfig>;
|
||||||
|
document?: Document;
|
||||||
|
fetchProtectedApi?: () => Promise<unknown>;
|
||||||
|
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||||
|
const currentDocument = options.document ?? document;
|
||||||
|
const popupConfig = readAuthConfig(options.config);
|
||||||
|
const root = currentDocument.querySelector("#app");
|
||||||
|
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||||
|
|
||||||
|
if (!root || (HTMLElementCtor && !(root instanceof HTMLElementCtor))) {
|
||||||
|
throw new Error("popup root #app is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage =
|
||||||
|
options.sendMessage ??
|
||||||
|
((message: unknown) =>
|
||||||
|
Promise.resolve(
|
||||||
|
(
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
chrome?: {
|
||||||
|
runtime?: {
|
||||||
|
sendMessage?: (payload: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).chrome?.runtime?.sendMessage?.(message)
|
||||||
|
));
|
||||||
|
const fetchProtectedApi =
|
||||||
|
options.fetchProtectedApi ??
|
||||||
|
createProtectedApiClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
sendMessage
|
||||||
|
}).loadProtectedMockData;
|
||||||
|
|
||||||
|
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCurrentAuthState(
|
||||||
|
root: HTMLElement,
|
||||||
|
popupConfig: AuthConfig,
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
|
fetchProtectedApi: () => Promise<unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await sendMessage({ type: "auth:get-state" });
|
||||||
|
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||||
|
renderLoggedOut(root, "认证状态读取失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.value.isAuthenticated) {
|
||||||
|
renderLoggedOut(root, response.value.lastError);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-sign-in="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
|
actionMessage: { type: "auth:sign-in" },
|
||||||
|
fetchProtectedApi
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoggedIn(root, response.value);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-sign-out="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
|
actionMessage: { type: "auth:sign-out" },
|
||||||
|
fetchProtectedApi
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (popupConfig.enableDevAuthPanel) {
|
||||||
|
renderDevPanel(root, response.value);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-test-protected-api="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runProtectedApiProbe(root, fetchProtectedApi);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAuthAction(
|
||||||
|
root: HTMLElement,
|
||||||
|
popupConfig: AuthConfig,
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
|
options: {
|
||||||
|
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
||||||
|
fetchProtectedApi: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await sendMessage(options.actionMessage);
|
||||||
|
|
||||||
|
if (isActionError(response)) {
|
||||||
|
renderLoggedOut(root, response.error);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-sign-in="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runAuthAction(root, popupConfig, sendMessage, options);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderCurrentAuthState(
|
||||||
|
root,
|
||||||
|
popupConfig,
|
||||||
|
sendMessage,
|
||||||
|
options.fetchProtectedApi
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionError(response: unknown): response is Extract<AuthResponseMessage, { ok: false }> {
|
||||||
|
return (
|
||||||
|
isAuthResponseMessage(response) &&
|
||||||
|
!response.ok &&
|
||||||
|
response.type === "auth:error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProtectedApiProbe(
|
||||||
|
root: HTMLElement,
|
||||||
|
fetchProtectedApi: () => Promise<unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
setProtectedApiResult(root, "请求中...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchProtectedApi();
|
||||||
|
setProtectedApiResult(root, JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
setProtectedApiResult(
|
||||||
|
root,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
void bootPopup();
|
||||||
|
}
|
||||||
60
src/popup/view.ts
Normal file
60
src/popup/view.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { AuthStateValue } from "../shared/auth-messages";
|
||||||
|
|
||||||
|
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
||||||
|
root.innerHTML = `
|
||||||
|
<section data-popup-state="logged-out">
|
||||||
|
<h1>Star Chart Search Enhancer</h1>
|
||||||
|
<p>登录后才能使用星图增强功能</p>
|
||||||
|
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
||||||
|
<button type="button" data-popup-sign-in="button">登录 Logto</button>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLoggedIn(
|
||||||
|
root: HTMLElement,
|
||||||
|
authState: AuthStateValue
|
||||||
|
): void {
|
||||||
|
const userInfo = authState.userInfo;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<section data-popup-state="logged-in">
|
||||||
|
<h1>Star Chart Search Enhancer</h1>
|
||||||
|
<p>已登录</p>
|
||||||
|
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
||||||
|
<p>${userInfo?.email ?? ""}</p>
|
||||||
|
<button type="button" data-popup-sign-out="button">退出登录</button>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDevPanel(
|
||||||
|
root: HTMLElement,
|
||||||
|
authState: AuthStateValue
|
||||||
|
): void {
|
||||||
|
const panel = root.ownerDocument.createElement("section");
|
||||||
|
panel.dataset.popupDevPanel = "root";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<h2>dev auth panel</h2>
|
||||||
|
<p>resource: ${authState.resource ?? ""}</p>
|
||||||
|
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
|
||||||
|
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
||||||
|
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
|
||||||
|
<p>error: ${authState.lastError ?? ""}</p>
|
||||||
|
<button type="button" data-popup-test-protected-api="button">测试受保护接口</button>
|
||||||
|
<pre data-popup-protected-api-result="output"></pre>
|
||||||
|
`;
|
||||||
|
root.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setProtectedApiResult(root: HTMLElement, value: string): void {
|
||||||
|
const output = root.querySelector(
|
||||||
|
'[data-popup-protected-api-result="output"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.textContent = value;
|
||||||
|
}
|
||||||
65
src/shared/batch-submit-client.ts
Normal file
65
src/shared/batch-submit-client.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type { BatchPayload } from "../content/market/batch-payload";
|
||||||
|
import { isAuthResponseMessage } from "./auth-messages";
|
||||||
|
|
||||||
|
interface FetchResponseLike {
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<FetchResponseLike>;
|
||||||
|
|
||||||
|
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||||
|
|
||||||
|
export function createBatchSubmitClient(options: {
|
||||||
|
baseUrl: string;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
sendMessage: SendMessageLike;
|
||||||
|
}) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async submitBatch(payload: BatchPayload) {
|
||||||
|
const token = await readAccessToken(options.sendMessage);
|
||||||
|
const response = await fetchImpl(
|
||||||
|
new URL("/api/mock/batches", options.baseUrl).toString(),
|
||||||
|
{
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
method: "POST"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error("batch submit unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`batch submit failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||||
|
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isAuthResponseMessage(response) ||
|
||||||
|
!response.ok ||
|
||||||
|
response.type !== "auth:token" ||
|
||||||
|
!response.value.accessToken.trim()
|
||||||
|
) {
|
||||||
|
throw new Error("batch submit token unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.value.accessToken;
|
||||||
|
}
|
||||||
62
src/shared/protected-api-client.ts
Normal file
62
src/shared/protected-api-client.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { isAuthResponseMessage } from "./auth-messages";
|
||||||
|
|
||||||
|
interface FetchResponseLike {
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<FetchResponseLike>;
|
||||||
|
|
||||||
|
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||||
|
|
||||||
|
export function createProtectedApiClient(options: {
|
||||||
|
baseUrl: string;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
sendMessage: SendMessageLike;
|
||||||
|
}) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async loadProtectedMockData() {
|
||||||
|
const token = await readAccessToken(options.sendMessage);
|
||||||
|
const response = await fetchImpl(
|
||||||
|
new URL("/api/mock/protected", options.baseUrl).toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
method: "GET"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error("protected api unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`protected api request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||||
|
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isAuthResponseMessage(response) ||
|
||||||
|
!response.ok ||
|
||||||
|
response.type !== "auth:token" ||
|
||||||
|
!response.value.accessToken.trim()
|
||||||
|
) {
|
||||||
|
throw new Error("protected api token unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.value.accessToken;
|
||||||
|
}
|
||||||
@ -47,4 +47,84 @@ describe("background-index", () => {
|
|||||||
);
|
);
|
||||||
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
|
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("responds to auth:get-state with auth status", async () => {
|
||||||
|
const listeners: Array<
|
||||||
|
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||||
|
> = [];
|
||||||
|
const sendResponse = vi.fn();
|
||||||
|
|
||||||
|
registerBackgroundMessageHandler(
|
||||||
|
{
|
||||||
|
runtime: {
|
||||||
|
onMessage: {
|
||||||
|
addListener(listener) {
|
||||||
|
listeners.push(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authController: {
|
||||||
|
getAccessToken: vi.fn(),
|
||||||
|
getAuthState: vi.fn(async () => ({ isAuthenticated: false })),
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signOut: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(sendResponse).toHaveBeenCalledWith({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: false }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds to auth:get-access-token with the current token", async () => {
|
||||||
|
const listeners: Array<
|
||||||
|
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||||
|
> = [];
|
||||||
|
const sendResponse = vi.fn();
|
||||||
|
|
||||||
|
registerBackgroundMessageHandler(
|
||||||
|
{
|
||||||
|
runtime: {
|
||||||
|
onMessage: {
|
||||||
|
addListener(listener) {
|
||||||
|
listeners.push(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authController: {
|
||||||
|
getAccessToken: vi.fn(async () => "test-access-token"),
|
||||||
|
getAuthState: vi.fn(),
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signOut: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = listeners[0](
|
||||||
|
{ type: "auth:get-access-token" },
|
||||||
|
{},
|
||||||
|
sendResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(sendResponse).toHaveBeenCalledWith({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:token",
|
||||||
|
value: { accessToken: "test-access-token" }
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
54
tests/batch-payload.test.ts
Normal file
54
tests/batch-payload.test.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createBatchPayload } from "../src/content/market/batch-payload";
|
||||||
|
|
||||||
|
describe("batch-payload", () => {
|
||||||
|
test("builds a batch id from the batch name and timestamp", () => {
|
||||||
|
const payload = createBatchPayload({
|
||||||
|
authState: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://talent-search.intelligrow.cn",
|
||||||
|
userInfo: {
|
||||||
|
name: "王少卿",
|
||||||
|
sub: "p7pdhhtde8kj"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
batchName: "618达人筛选第一批",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
records: [
|
||||||
|
{ authorId: "111", authorName: "达人A", status: "success" },
|
||||||
|
{ authorId: "222", authorName: "达人B", status: "success" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
authors: [
|
||||||
|
{ authorId: "111", authorName: "达人A" },
|
||||||
|
{ authorId: "222", authorName: "达人B" }
|
||||||
|
],
|
||||||
|
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
|
||||||
|
batchName: "618达人筛选第一批",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
creatorName: "王少卿",
|
||||||
|
logtoUserId: "p7pdhhtde8kj",
|
||||||
|
resource: "https://talent-search.intelligrow.cn"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the user id is unavailable", () => {
|
||||||
|
expect(() =>
|
||||||
|
createBatchPayload({
|
||||||
|
authState: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://talent-search.intelligrow.cn",
|
||||||
|
userInfo: {
|
||||||
|
name: "王少卿"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
batchName: "批次A",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
records: [{ authorId: "111", authorName: "达人A", status: "success" }]
|
||||||
|
})
|
||||||
|
).toThrow(/user/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
tests/batch-submit-client.test.ts
Normal file
82
tests/batch-submit-client.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
||||||
|
|
||||||
|
describe("batch-submit-client", () => {
|
||||||
|
test("posts the batch payload with a Bearer token", async () => {
|
||||||
|
const sendMessage = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:token",
|
||||||
|
value: { accessToken: "abc123" }
|
||||||
|
}));
|
||||||
|
const fetchImpl = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ acceptedCount: 2, ok: true })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const client = createBatchSubmitClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
fetchImpl,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.submitBatch({
|
||||||
|
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||||
|
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||||
|
batchName: "批次A",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
creatorName: "王少卿",
|
||||||
|
logtoUserId: "p7pdhhtde8kj",
|
||||||
|
resource: "https://talent-search.intelligrow.cn"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
"http://127.0.0.1:4319/api/mock/batches",
|
||||||
|
expect.objectContaining({
|
||||||
|
body: JSON.stringify({
|
||||||
|
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||||
|
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||||
|
batchName: "批次A",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
creatorName: "王少卿",
|
||||||
|
logtoUserId: "p7pdhhtde8kj",
|
||||||
|
resource: "https://talent-search.intelligrow.cn"
|
||||||
|
}),
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: "Bearer abc123",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}),
|
||||||
|
method: "POST"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on unauthorized responses", async () => {
|
||||||
|
const client = createBatchSubmitClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
fetchImpl: vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ error: "unauthorized", ok: false })
|
||||||
|
})),
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:token",
|
||||||
|
value: { accessToken: "abc123" }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.submitBatch({
|
||||||
|
authors: [],
|
||||||
|
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||||
|
batchName: "批次A",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
creatorName: "王少卿",
|
||||||
|
logtoUserId: "p7pdhhtde8kj",
|
||||||
|
resource: "https://talent-search.intelligrow.cn"
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/unauthorized/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -44,14 +44,21 @@ describe("market-content-entry", () => {
|
|||||||
const createMarketController = vi.fn(() => ({
|
const createMarketController = vi.fn(() => ({
|
||||||
ready: Promise.resolve()
|
ready: Promise.resolve()
|
||||||
}));
|
}));
|
||||||
|
const sendMessage = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: true }
|
||||||
|
}));
|
||||||
|
|
||||||
window.history.replaceState({}, "", "/ad/creator/market");
|
window.history.replaceState({}, "", "/ad/creator/market");
|
||||||
(
|
(
|
||||||
globalThis as typeof globalThis & {
|
globalThis as typeof globalThis & {
|
||||||
chrome?: { runtime?: object };
|
chrome?: { runtime?: { sendMessage?: (message: unknown) => Promise<unknown> } };
|
||||||
}
|
}
|
||||||
).chrome = {
|
).chrome = {
|
||||||
runtime: {}
|
runtime: {
|
||||||
|
sendMessage
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.doMock("../src/content/market/index", () => ({
|
vi.doMock("../src/content/market/index", () => ({
|
||||||
@ -72,7 +79,12 @@ describe("market-content-entry", () => {
|
|||||||
|
|
||||||
const { bootContentScript } = await import("../src/content/index");
|
const { bootContentScript } = await import("../src/content/index");
|
||||||
await bootContentScript({
|
await bootContentScript({
|
||||||
createMarketController
|
createMarketController,
|
||||||
|
sendAuthMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: true }
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(createMarketController).toHaveBeenCalledTimes(1);
|
expect(createMarketController).toHaveBeenCalledTimes(1);
|
||||||
@ -90,6 +102,11 @@ describe("market-content-entry", () => {
|
|||||||
await bootContentScript({
|
await bootContentScript({
|
||||||
createMarketController,
|
createMarketController,
|
||||||
document,
|
document,
|
||||||
|
sendAuthMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: true }
|
||||||
|
})),
|
||||||
window: {
|
window: {
|
||||||
location: {
|
location: {
|
||||||
href: "https://www.xingtu.cn/ad/creator/market"
|
href: "https://www.xingtu.cn/ad/creator/market"
|
||||||
@ -128,7 +145,12 @@ describe("market-content-entry", () => {
|
|||||||
|
|
||||||
const { bootContentScript } = await import("../src/content/index");
|
const { bootContentScript } = await import("../src/content/index");
|
||||||
await bootContentScript({
|
await bootContentScript({
|
||||||
createMarketController
|
createMarketController,
|
||||||
|
sendAuthMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: true }
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
const controllerOptions = createMarketController.mock.calls[0]?.[0];
|
const controllerOptions = createMarketController.mock.calls[0]?.[0];
|
||||||
@ -163,8 +185,14 @@ describe("market-content-entry", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { bootContentScript } = await import("../src/content/index");
|
const { bootContentScript } = await import("../src/content/index");
|
||||||
|
sendMessage.mockClear();
|
||||||
await bootContentScript({
|
await bootContentScript({
|
||||||
createMarketController
|
createMarketController,
|
||||||
|
sendAuthMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: true }
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
const controllerOptions = createMarketController.mock.calls[0]?.[0];
|
const controllerOptions = createMarketController.mock.calls[0]?.[0];
|
||||||
@ -213,6 +241,28 @@ describe("market-content-entry", () => {
|
|||||||
).toBe("0.03% - 0.2%");
|
).toBe("0.03% - 0.2%");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("boots the controller only after auth succeeds", async () => {
|
||||||
|
const createMarketController = vi.fn(() => ({
|
||||||
|
ready: Promise.resolve()
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.history.replaceState({}, "", "/ad/creator/market");
|
||||||
|
|
||||||
|
const { bootContentScript } = await import("../src/content/index");
|
||||||
|
await bootContentScript({
|
||||||
|
createMarketController,
|
||||||
|
document,
|
||||||
|
sendAuthMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: true }
|
||||||
|
})),
|
||||||
|
window
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createMarketController).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("hydrates the real div-grid market rows on start", async () => {
|
test("hydrates the real div-grid market rows on start", async () => {
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
{
|
{
|
||||||
@ -481,6 +531,9 @@ describe("market-content-entry", () => {
|
|||||||
|
|
||||||
expect(exportRangeSelect?.value).toBe("first-5");
|
expect(exportRangeSelect?.value).toBe("first-5");
|
||||||
expect(customPagesInput?.hidden).toBe(true);
|
expect(customPagesInput?.hidden).toBe(true);
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-plugin-batch-submit="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
setSelectValue('[data-plugin-export-range="select"]', "custom");
|
setSelectValue('[data-plugin-export-range="select"]', "custom");
|
||||||
dispatchChange('[data-plugin-export-range="select"]');
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
@ -731,6 +784,7 @@ describe("market-content-entry", () => {
|
|||||||
|
|
||||||
click('[data-plugin-export="button"]');
|
click('[data-plugin-export="button"]');
|
||||||
|
|
||||||
|
expectButtonDisabled('[data-plugin-batch-submit="button"]', true);
|
||||||
expectButtonDisabled('[data-plugin-export="button"]', true);
|
expectButtonDisabled('[data-plugin-export="button"]', true);
|
||||||
expectButtonDisabled('[data-plugin-filter-apply="button"]', true);
|
expectButtonDisabled('[data-plugin-filter-apply="button"]', true);
|
||||||
expectButtonDisabled('[data-plugin-sort-apply="button"]', true);
|
expectButtonDisabled('[data-plugin-sort-apply="button"]', true);
|
||||||
@ -742,6 +796,7 @@ describe("market-content-entry", () => {
|
|||||||
await waitForMockCall(buildCsv, 80, 100);
|
await waitForMockCall(buildCsv, 80, 100);
|
||||||
|
|
||||||
expect(pagination.getClicks()).toBe(2);
|
expect(pagination.getClicks()).toBe(2);
|
||||||
|
expectButtonDisabled('[data-plugin-batch-submit="button"]', false);
|
||||||
expectButtonDisabled('[data-plugin-export="button"]', false);
|
expectButtonDisabled('[data-plugin-export="button"]', false);
|
||||||
expectSelectDisabled('[data-plugin-export-range="select"]', false);
|
expectSelectDisabled('[data-plugin-export-range="select"]', false);
|
||||||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||||||
@ -782,6 +837,107 @@ describe("market-content-entry", () => {
|
|||||||
).toContain("有效页数");
|
).toContain("有效页数");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("prompts for a batch name before submitting the current range", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
const promptBatchName = vi.fn(() => "618达人筛选第一批");
|
||||||
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
getAuthState: async () => ({
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://talent-search.intelligrow.cn",
|
||||||
|
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||||
|
}),
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
promptBatchName,
|
||||||
|
submitBatch,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
|
||||||
|
click('[data-plugin-batch-submit="button"]');
|
||||||
|
await waitForMockCall(submitBatch, 40, 50);
|
||||||
|
|
||||||
|
expect(promptBatchName).toHaveBeenCalledTimes(1);
|
||||||
|
expect(submitBatch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
batchId: expect.stringContaining("618达人筛选第一批-"),
|
||||||
|
batchName: "618达人筛选第一批",
|
||||||
|
logtoUserId: "p7pdhhtde8kj"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows an error when the batch name is blank", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
const promptBatchName = vi.fn(() => " ");
|
||||||
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
getAuthState: async () => ({
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://talent-search.intelligrow.cn",
|
||||||
|
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||||
|
}),
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
promptBatchName,
|
||||||
|
submitBatch,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-plugin-batch-submit="button"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(submitBatch).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||||
|
).toContain("请输入批次名称");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does nothing when the prompt is cancelled", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
const promptBatchName = vi.fn(() => null);
|
||||||
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
getAuthState: async () => ({
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://talent-search.intelligrow.cn",
|
||||||
|
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||||
|
}),
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
promptBatchName,
|
||||||
|
submitBatch,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-plugin-batch-submit="button"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(promptBatchName).toHaveBeenCalledTimes(1);
|
||||||
|
expect(submitBatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("export only includes records that are present on the current page", async () => {
|
test("export only includes records that are present on the current page", async () => {
|
||||||
document.body.innerHTML = buildMarketFixture();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
const resultStore = createMarketResultStore();
|
const resultStore = createMarketResultStore();
|
||||||
|
|||||||
80
tests/mock-protected-api.test.ts
Normal file
80
tests/mock-protected-api.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createMockProtectedApiServer } from "../scripts/mock-protected-api.mjs";
|
||||||
|
|
||||||
|
const servers: Array<{ close: () => Promise<void> }> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (servers.length > 0) {
|
||||||
|
await servers.pop()?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mock-protected-api", () => {
|
||||||
|
test("returns mock data when a Bearer token is present", async () => {
|
||||||
|
const server = createMockProtectedApiServer({ port: 0 });
|
||||||
|
await server.start();
|
||||||
|
servers.push(server);
|
||||||
|
|
||||||
|
const response = await fetch(`${server.baseUrl}/api/mock/protected`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer abc123"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.json()).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
ok: true,
|
||||||
|
source: "mock-protected-api"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 401 when the Authorization header is missing", async () => {
|
||||||
|
const server = createMockProtectedApiServer({ port: 0 });
|
||||||
|
await server.start();
|
||||||
|
servers.push(server);
|
||||||
|
|
||||||
|
const response = await fetch(`${server.baseUrl}/api/mock/protected`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
await expect(response.json()).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "unauthorized"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a batch payload when a Bearer token is present", async () => {
|
||||||
|
const server = createMockProtectedApiServer({ port: 0 });
|
||||||
|
await server.start();
|
||||||
|
servers.push(server);
|
||||||
|
|
||||||
|
const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||||
|
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||||
|
batchName: "批次A",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
creatorName: "王少卿",
|
||||||
|
logtoUserId: "p7pdhhtde8kj",
|
||||||
|
resource: "https://talent-search.intelligrow.cn"
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer abc123",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.json()).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
acceptedCount: 1,
|
||||||
|
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||||
|
ok: true,
|
||||||
|
source: "mock-batch-submit"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
188
tests/popup-entry.test.ts
Normal file
188
tests/popup-entry.test.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { bootPopup } from "../src/popup/index";
|
||||||
|
|
||||||
|
describe("popup-entry", () => {
|
||||||
|
let dom: JSDOM;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a sign-in button when unauthenticated", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: false }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dom.window.document.querySelector("button")?.textContent).toContain(
|
||||||
|
"登录"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the dev auth panel when enabled", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
config: { enableDevAuthPanel: true },
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
accessTokenExpiresAt: 1700000000000,
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://api.example.test",
|
||||||
|
scopes: ["openid", "profile"],
|
||||||
|
tokenAvailable: true,
|
||||||
|
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dom.window.document.body.textContent).toContain("resource");
|
||||||
|
expect(dom.window.document.body.textContent).toContain("token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a protected api test button in the dev panel", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
config: { enableDevAuthPanel: true },
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
tokenAvailable: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-test-protected-api="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking the dev button runs the protected api client and prints the result", async () => {
|
||||||
|
const fetchProtectedApi = vi.fn(async () => ({
|
||||||
|
message: "authorized",
|
||||||
|
ok: true,
|
||||||
|
source: "mock-protected-api"
|
||||||
|
}));
|
||||||
|
const sendMessage = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
tokenAvailable: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
config: { enableDevAuthPanel: true },
|
||||||
|
document: dom.window.document,
|
||||||
|
fetchProtectedApi,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector(
|
||||||
|
'[data-popup-test-protected-api="button"]'
|
||||||
|
) as HTMLButtonElement | null
|
||||||
|
)?.click();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchProtectedApi).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dom.window.document.body.textContent).toContain("authorized");
|
||||||
|
expect(dom.window.document.body.textContent).toContain("mock-protected-api");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking sign-out sends the auth:sign-out message", async () => {
|
||||||
|
const sendMessage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:ack"
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector('[data-popup-sign-out="button"]') as
|
||||||
|
| HTMLButtonElement
|
||||||
|
| null
|
||||||
|
)?.click();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the auth error when sign-in fails", async () => {
|
||||||
|
const sendMessage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
error: "redirect_uri_mismatch",
|
||||||
|
ok: false,
|
||||||
|
type: "auth:error"
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector('[data-popup-sign-in="button"]') as
|
||||||
|
| HTMLButtonElement
|
||||||
|
| null
|
||||||
|
)?.click();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(dom.window.document.body.textContent).toContain(
|
||||||
|
"redirect_uri_mismatch"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/protected-api-client.test.ts
Normal file
73
tests/protected-api-client.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createProtectedApiClient } from "../src/shared/protected-api-client";
|
||||||
|
|
||||||
|
describe("protected-api-client", () => {
|
||||||
|
test("requests a token before calling the protected endpoint", async () => {
|
||||||
|
const sendMessage = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:token",
|
||||||
|
value: { accessToken: "abc123" }
|
||||||
|
}));
|
||||||
|
const fetchImpl = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ ok: true })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const client = createProtectedApiClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
fetchImpl,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.loadProtectedMockData();
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:get-access-token" });
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
"http://127.0.0.1:4319/api/mock/protected",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: "Bearer abc123"
|
||||||
|
}),
|
||||||
|
method: "GET"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws before fetch when the token is unavailable", async () => {
|
||||||
|
const sendMessage = vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
type: "auth:error",
|
||||||
|
error: "token missing"
|
||||||
|
}));
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
|
||||||
|
const client = createProtectedApiClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
fetchImpl,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.loadProtectedMockData()).rejects.toThrow(/token/i);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws an authorization error on 401", async () => {
|
||||||
|
const client = createProtectedApiClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
fetchImpl: vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ ok: false, error: "unauthorized" })
|
||||||
|
})),
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:token",
|
||||||
|
value: { accessToken: "abc123" }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.loadProtectedMockData()).rejects.toThrow(/unauthorized/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user