Compare commits
14 Commits
b5529eb9d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd12838519 | ||
|
|
64e14ac30d | ||
|
|
2f2653c76f | ||
|
|
9e32a24f8c | ||
|
|
d9216d5712 | ||
|
|
f895eb9035 | ||
|
|
51472e9951 | ||
|
|
6bbd55111c | ||
|
|
b33fa10677 | ||
|
|
249ad5938f | ||
|
|
b371c698cc | ||
|
|
2a925e2954 | ||
|
|
800096542b | ||
|
|
cbfaf4a28b |
@@ -1886,3 +1886,506 @@
|
||||
- 这轮已经把测试能力从“人工执行命令”提升到“统一脚本 + 覆盖率配置 + 非回归报告”
|
||||
- 后续提测前的推荐入口应优先改为:
|
||||
- `bash ./scripts/test/verify_quality_gates.sh`
|
||||
|
||||
## 2026-05-30 已补齐 internal/app 无监听 handler 测试并抬过 70%
|
||||
|
||||
**目标**:优先补 `internal/app` 中不依赖本地监听端口的 handler 级测试,把 `watch` 包里最关键的 `internal/app` 稳定抬过 `70%`
|
||||
|
||||
**本次补测范围**:
|
||||
|
||||
- `internal/app/logical_groups_api_test.go`
|
||||
- 新增覆盖:
|
||||
- `GET /api/logical-groups`
|
||||
- `PUT /api/logical-groups/{groupID}`
|
||||
- `DELETE /api/logical-groups/{groupID}`
|
||||
- `POST /api/logical-groups/{groupID}/models`
|
||||
- `GET /api/logical-groups/{groupID}/models`
|
||||
- `DELETE /api/logical-groups/{groupID}/models/{model}`
|
||||
- `GET /api/logical-groups/{groupID}/routes`
|
||||
- `PUT /api/logical-groups/{groupID}/routes/{routeID}`
|
||||
- `DELETE /api/logical-groups/{groupID}/routes/{routeID}`
|
||||
- `GET /api/logical-groups/{groupID}/routes/{routeID}/models`
|
||||
- `internal/app/provider_accounts_api_test.go`
|
||||
- 新增覆盖:
|
||||
- `POST /api/provider-accounts/{accountID}/enable`
|
||||
- `POST /api/provider-accounts/{accountID}/retire`
|
||||
|
||||
**本地验证结果**:
|
||||
|
||||
- `go test ./internal/app -count=1` => `ok`
|
||||
- `go test -coverprofile=/tmp/internal-app.cover ./internal/app` => `coverage: 71.5% of statements`
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
|
||||
**覆盖率变化**:
|
||||
|
||||
- `internal/app`
|
||||
- 本轮前:`69.9%`
|
||||
- 本轮后:`71.5%`
|
||||
- 由此 `internal/app` 已越过 `70%`,可作为后续把 `watch` 包逐步升级为 `core` 的第一批候选
|
||||
|
||||
**结论**:
|
||||
|
||||
- 这轮没有继续加新测试脚本,而是直接补最短路径的 handler 级测试缺口
|
||||
- `internal/app` 已不再卡在 `70%` 以下
|
||||
- 下一步测试治理可以开始从“抬单包覆盖率”转向“按包把 `watch` 升级为 `core`”
|
||||
|
||||
## 2026-05-30 已将 internal/app 从 watch 提升为 core
|
||||
|
||||
**目标**:把刚刚抬过 `70%` 的 `internal/app` 从“显式观察”提升为“硬门槛”,避免后续 HTTP 控制面回归时只产生告警不阻断
|
||||
|
||||
**本次调整**:
|
||||
|
||||
- `tests/quality/coverage_thresholds.tsv`
|
||||
- `internal/app`
|
||||
- tier:`watch -> core`
|
||||
- threshold:`69.5 -> 70.0`
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
- 统一门禁回读:
|
||||
- `internal/app` = `71.5%`
|
||||
- 高于新的 `core` 门槛 `70.0%`
|
||||
- 同轮基础门禁保持通过:
|
||||
- `gofmt -l .`
|
||||
- `go vet ./...`
|
||||
- `go test -cover ./internal/...`
|
||||
- `go test ./tests/integration/... -count=1`
|
||||
|
||||
**结论**:
|
||||
|
||||
- `internal/app` 已完成第一批 `watch -> core` 升级
|
||||
- 当前 `core` 包集合变为:
|
||||
- `internal/access`
|
||||
- `internal/pack`
|
||||
- `internal/provision`
|
||||
- `internal/app`
|
||||
- 后续可继续按同样方法评估:
|
||||
- `internal/routing`
|
||||
- `internal/store/sqlite`
|
||||
- `internal/overlay`
|
||||
|
||||
## 2026-05-30 已将 internal/routing 从 watch 提升为 core
|
||||
|
||||
**目标**:把 `logical_group -> route -> sticky -> failover -> cooldown -> route log` 这条高杠杆运行时链路纳入硬门槛,避免只作为显式观察项放行回归
|
||||
|
||||
**评估结果**:
|
||||
|
||||
- `go test -coverprofile=/tmp/internal-routing.cover ./internal/routing` => `coverage: 73.1% of statements`
|
||||
- 现有测试覆盖面已包含:
|
||||
- `sticky key` 构造
|
||||
- `memory sticky store`
|
||||
- `redis sticky store` round-trip
|
||||
- `failure / cooldown` 状态
|
||||
- `async log writer` append / flush / close
|
||||
- 当前低覆盖点主要集中在:
|
||||
- `sticky_redis.go`
|
||||
- `ClearRouteFailure`
|
||||
- `ClearCooldown`
|
||||
- `logwriter.go`
|
||||
- `Flush`
|
||||
- `loop`
|
||||
- 这些缺口已记录,但不影响把该包提升为 `core`,因为包级覆盖率已稳定高于硬门槛,且核心行为已被真实测试覆盖
|
||||
|
||||
**本次调整**:
|
||||
|
||||
- `tests/quality/coverage_thresholds.tsv`
|
||||
- `internal/routing`
|
||||
- tier:`watch -> core`
|
||||
- threshold:`70.0` 保持不变
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
- 统一门禁回读:
|
||||
- `internal/routing = 73.1%`
|
||||
- 高于新的 `core` 门槛 `70.0%`
|
||||
|
||||
**结论**:
|
||||
|
||||
- `internal/routing` 已完成第二批 `watch -> core` 升级
|
||||
- 当前 `core` 包集合变为:
|
||||
- `internal/access`
|
||||
- `internal/pack`
|
||||
- `internal/provision`
|
||||
- `internal/app`
|
||||
- `internal/routing`
|
||||
- 下一批候选可继续看:
|
||||
- `internal/store/sqlite`
|
||||
- `internal/overlay`
|
||||
|
||||
## 2026-05-30 已将 internal/store/sqlite 从 watch 提升为 core
|
||||
|
||||
**目标**:把 SQLite repo 层从“高覆盖观察项”提升为“硬门槛”,因为这层已经承载:
|
||||
|
||||
- `logical_group / route`
|
||||
- `route logs / sticky audit / failover events`
|
||||
- `provider_accounts`
|
||||
- `provider_drafts / import_batches / reconcile_runs`
|
||||
|
||||
它已经是控制面和运行态的共同底座,回归代价过高,不适合继续只做告警
|
||||
|
||||
**评估结果**:
|
||||
|
||||
- `go test -coverprofile=/tmp/internal-store-sqlite.cover ./internal/store/sqlite` => `coverage: 76.1% of statements`
|
||||
- 现有测试面已覆盖:
|
||||
- `db` 打开、迁移、外键、ledger、legacy backfill
|
||||
- `logical_groups / routes / route_models`
|
||||
- `provider_accounts` 与 inventory sync
|
||||
- `route_decision_logs / route_failover_events / route_sticky_audit`
|
||||
- `providers / packs / hosts / import_batches / import_runs`
|
||||
- 当前低覆盖点主要集中在:
|
||||
- `provider_accounts_sync.go`
|
||||
- `SyncProviderAccountsFromLatestImportBatches`
|
||||
- 个别 `Upsert / Update / normalize` 分支
|
||||
- `hosts_repo.go` 中很窄的 error helper
|
||||
- 这些缺口已记录,但不阻碍把该包提升为 `core`,因为包级覆盖率显著高于阈值,且高杠杆 repo 主路径已有系统测试
|
||||
|
||||
**本次调整**:
|
||||
|
||||
- `tests/quality/coverage_thresholds.tsv`
|
||||
- `internal/store/sqlite`
|
||||
- tier:`watch -> core`
|
||||
- threshold:`75.0` 保持不变
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
- 统一门禁回读:
|
||||
- `internal/store/sqlite = 76.1%`
|
||||
- 高于新的 `core` 门槛 `75.0%`
|
||||
|
||||
**结论**:
|
||||
|
||||
- `internal/store/sqlite` 已完成第三批 `watch -> core` 升级
|
||||
- 当前 `core` 包集合变为:
|
||||
- `internal/access`
|
||||
- `internal/pack`
|
||||
- `internal/provision`
|
||||
- `internal/app`
|
||||
- `internal/routing`
|
||||
- `internal/store/sqlite`
|
||||
|
||||
## 2026-05-30 已将 internal/overlay 从 watch 提升为 core
|
||||
|
||||
**目标**:把 overlay patch 执行层纳入硬门槛。虽然它是工具包,但它直接决定:
|
||||
|
||||
- host overlay patch 是否真正打到输出目录
|
||||
- overlay metadata 是否正确落盘
|
||||
- patch 产物是否污染源目录或 `.git`
|
||||
|
||||
这层一旦回归,影响的是部署产物正确性,不适合继续只做观察告警
|
||||
|
||||
**评估结果**:
|
||||
|
||||
- `go test -coverprofile=/tmp/internal-overlay.cover ./internal/overlay` => `coverage: 71.6% of statements`
|
||||
- 现有测试覆盖面已包含:
|
||||
- `Apply` 的正常 patch 流
|
||||
- 相对 `pack dir` 支持
|
||||
- nested output dir 拒绝
|
||||
- missing patch 拒绝
|
||||
- metadata 写入
|
||||
- `.git` 过滤
|
||||
- symlink 保留
|
||||
- 当前低覆盖点主要集中在:
|
||||
- `applyPatchFile`
|
||||
- `Apply`
|
||||
- `copyFile`
|
||||
- 这些缺口主要是错误分支与 IO 边角,不影响把该包提升为 `core`,因为关键 overlay 主路径和最容易出事故的文件系统行为已经有测试覆盖
|
||||
|
||||
**本次调整**:
|
||||
|
||||
- `tests/quality/coverage_thresholds.tsv`
|
||||
- `internal/overlay`
|
||||
- tier:`watch -> core`
|
||||
- threshold:`70.0` 保持不变
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
- 统一门禁回读:
|
||||
- `internal/overlay = 71.6%`
|
||||
- 高于新的 `core` 门槛 `70.0%`
|
||||
|
||||
**结论**:
|
||||
|
||||
- `internal/overlay` 已完成第四批 `watch -> core` 升级
|
||||
- 当前覆盖率治理中的显式 `watch` 包已清零
|
||||
- 当前 `core` 包集合变为:
|
||||
- `internal/access`
|
||||
- `internal/pack`
|
||||
- `internal/provision`
|
||||
- `internal/app`
|
||||
- `internal/routing`
|
||||
- `internal/store/sqlite`
|
||||
- `internal/overlay`
|
||||
|
||||
## 2026-05-30 已补齐 routing / sqlite / overlay 的低覆盖热点分支
|
||||
|
||||
**目标**:在全部 `watch -> core` 升级完成后,不再继续调阈值,而是直接补已记录的低覆盖热点分支,降低“虽然过线但边角不稳”的风险
|
||||
|
||||
**本次补测点**:
|
||||
|
||||
- `internal/routing`
|
||||
- `RedisStickyStore.ClearRouteFailure`
|
||||
- `RedisStickyStore.ClearCooldown`
|
||||
- `AsyncLogWriter.Flush` 的 close 后路径
|
||||
- `internal/store/sqlite`
|
||||
- `SyncProviderAccountsFromLatestImportBatches`
|
||||
- 多 provider latest reconcilable batch 同步路径
|
||||
- `internal/overlay`
|
||||
- `applyPatchFile` invalid patch 错误路径
|
||||
- `Apply` existing output dir 错误路径
|
||||
- `copyFile` missing source 错误路径
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- 定向测试:
|
||||
- `go test ./internal/routing ./internal/store/sqlite ./internal/overlay -count=1` => `ok`
|
||||
- 定向覆盖率:
|
||||
- `go test -coverprofile=/tmp/routing-hotspots.cover ./internal/routing` => `75.5%`
|
||||
- `go test -coverprofile=/tmp/sqlite-hotspots.cover ./internal/store/sqlite` => `76.4%`
|
||||
- `go test -coverprofile=/tmp/overlay-hotspots.cover ./internal/overlay` => `75.4%`
|
||||
- 热点函数回读:
|
||||
- `internal/routing`
|
||||
- `Flush = 71.4%`
|
||||
- `ClearRouteFailure = 75.0%`
|
||||
- `ClearCooldown = 75.0%`
|
||||
- `internal/overlay`
|
||||
- `Apply = 70.6%`
|
||||
- `applyPatchFile = 90.0%`
|
||||
- `copyFile = 76.9%`
|
||||
- `internal/store/sqlite`
|
||||
- `SyncProviderAccountsFromLatestImportBatches = 66.7%`
|
||||
- 全量门禁:
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
|
||||
**覆盖率变化**:
|
||||
|
||||
- `internal/routing`
|
||||
- 本轮前:`73.1%`
|
||||
- 本轮后:`75.5%`
|
||||
- `internal/store/sqlite`
|
||||
- 本轮前:`76.1%`
|
||||
- 本轮后:`76.4%`
|
||||
- `internal/overlay`
|
||||
- 本轮前:`71.6%`
|
||||
- 本轮后:`75.4%`
|
||||
|
||||
**结论**:
|
||||
|
||||
- 这轮已经把此前执行板里明确记录的三组低覆盖热点分支补掉了一批
|
||||
- 当前质量治理不再只是“包级过线”,而是开始向关键边界分支收口
|
||||
|
||||
## 2026-05-30 已继续补齐 sqlite Upsert / redis RESP / overlay IO 的窄分支
|
||||
|
||||
**目标**:在上一轮热点补测之后,继续收口仍然低覆盖的窄分支,优先覆盖 `repo Upsert` 的默认值/trim 路径、`sticky_redis` 的异常 RESP 路径,以及 `overlay` 更细的文件系统异常
|
||||
|
||||
**本次新增测试点**:
|
||||
|
||||
- `internal/store/sqlite`
|
||||
- `ProvidersRepo.Upsert`
|
||||
- trim 输入
|
||||
- `ManifestJSON / DefaultModelsJSON / GroupTemplateJSON / ChannelTemplateJSON / PlanTemplateJSON / ImportOptionsJSON` 默认值
|
||||
- `PacksRepo.Upsert`
|
||||
- trim 输入
|
||||
- `ManifestJSON` 默认值
|
||||
- `ImportRunItemsRepo.Upsert`
|
||||
- `defaultJSON` 路径
|
||||
- nullable / trim 字段回读
|
||||
- `normalizeProviderAccountBindingState`
|
||||
- `assigned / unassigned / conflict / invalid` 归一化
|
||||
- `internal/routing`
|
||||
- `RedisStickyStore.open`
|
||||
- `AUTH` 错误返回
|
||||
- `SELECT` unexpected response
|
||||
- `RedisStickyStore.getJSON`
|
||||
- `GET` unexpected response
|
||||
- `RedisStickyStore.setJSON`
|
||||
- `SET` unexpected response
|
||||
- `internal/overlay`
|
||||
- `Apply`
|
||||
- `source dir` 实际是文件时拒绝
|
||||
- `copyFile`
|
||||
- target parent 路径被普通文件阻塞时失败
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- 定向测试:
|
||||
- `go test ./internal/routing ./internal/store/sqlite ./internal/overlay -count=1` => `ok`
|
||||
- 定向覆盖率:
|
||||
- `go test -coverprofile=/tmp/routing-redis-hotspots.cover ./internal/routing` => `77.1%`
|
||||
- `go test -coverprofile=/tmp/sqlite-upsert-hotspots.cover ./internal/store/sqlite` => `76.5%`
|
||||
- `go test -coverprofile=/tmp/overlay-io-hotspots.cover ./internal/overlay` => `76.9%`
|
||||
- 热点函数回读:
|
||||
- `internal/routing`
|
||||
- `open = 69.0%`
|
||||
- `getJSON = 76.9%`
|
||||
- `setJSON = 70.0%`
|
||||
- `internal/store/sqlite`
|
||||
- `normalizeProviderAccountBindingState = 100.0%`
|
||||
- `ProvidersRepo.Upsert = 52.0%`
|
||||
- `PacksRepo.Upsert = 54.5%`
|
||||
- `ImportRunItemsRepo.Upsert = 54.2%`
|
||||
- `internal/overlay`
|
||||
- `Apply = 72.5%`
|
||||
- `copyFile = 84.6%`
|
||||
- 全量门禁:
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
- 当前沙箱下 `httptest` 监听端口会被限制,统一门禁脚本已在 unrestricted 环境重跑确认真实结果
|
||||
|
||||
**覆盖率变化**:
|
||||
|
||||
- `internal/routing`
|
||||
- 本轮前:`75.5%`
|
||||
- 本轮后:`77.1%`
|
||||
- `internal/store/sqlite`
|
||||
- 本轮前:`76.4%`
|
||||
- 本轮后:`76.5%`
|
||||
- `internal/overlay`
|
||||
- 本轮前:`75.4%`
|
||||
- 本轮后:`76.9%`
|
||||
|
||||
**结论**:
|
||||
|
||||
- `routing` 的 Redis 异常响应路径已经明显收口,包级覆盖率继续抬升
|
||||
- `overlay` 的 IO 异常分支继续前进,`copyFile` 和 `Apply` 的风险点比上一轮更稳
|
||||
- `sqlite` 的包级提升有限,但 `Upsert` 默认值/trim 和 `binding_state` 规范化已被显式锁定
|
||||
- 剩余最值得继续补的点,已经缩成:
|
||||
- `ProvidersRepo.Upsert / PacksRepo.Upsert / ImportRunItemsRepo.Upsert` 更细的分支回读
|
||||
- `RedisStickyStore.open` 的剩余异常路径
|
||||
|
||||
## 2026-05-30 已继续收口 sqlite Upsert 校验分支与 Redis open 异常路径
|
||||
|
||||
**目标**:把上一轮仍然偏低的两块继续打透:
|
||||
|
||||
- `internal/store/sqlite`
|
||||
- `ProvidersRepo.Upsert / PacksRepo.Upsert / ImportRunItemsRepo.Upsert`
|
||||
- `internal/routing`
|
||||
- `RedisStickyStore.open`
|
||||
|
||||
**本次新增测试点**:
|
||||
|
||||
- `internal/store/sqlite`
|
||||
- `ProvidersRepo.Upsert`
|
||||
- `pack_id / provider_id / display_name / base_url / platform` 校验错误
|
||||
- `PacksRepo.Upsert`
|
||||
- `pack_id / version / checksum` 校验错误
|
||||
- `ImportRunItemsRepo.Upsert`
|
||||
- `item_id / run_id / base_url / provider_id / api_key_fingerprint / current_stage / confirmation_status / access_status / matched_account_state / account_resolution` 校验错误
|
||||
- `internal/routing`
|
||||
- `RedisStickyStore.open`
|
||||
- `AUTH unexpected response`
|
||||
- `AUTH read failure`
|
||||
- `SELECT read failure`
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- 定向测试:
|
||||
- `go test ./internal/store/sqlite ./internal/routing -count=1` => `ok`
|
||||
- 定向覆盖率:
|
||||
- `go test -coverprofile=/tmp/sqlite-upsert-followup.cover ./internal/store/sqlite` => `77.5%`
|
||||
- `go test -coverprofile=/tmp/routing-open-followup.cover ./internal/routing` => `78.3%`
|
||||
- 热点函数回读:
|
||||
- `internal/store/sqlite`
|
||||
- `ProvidersRepo.Upsert = 72.0%`
|
||||
- `PacksRepo.Upsert = 68.2%`
|
||||
- `ImportRunItemsRepo.Upsert = 95.8%`
|
||||
- `internal/routing`
|
||||
- `RedisStickyStore.open = 82.8%`
|
||||
- 全量门禁:
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
- 真实门禁在 unrestricted 环境重跑确认
|
||||
|
||||
**覆盖率变化**:
|
||||
|
||||
- `internal/store/sqlite`
|
||||
- 本轮前:`76.5%`
|
||||
- 本轮后:`77.5%`
|
||||
- `internal/routing`
|
||||
- 本轮前:`77.1%`
|
||||
- 本轮后:`78.3%`
|
||||
|
||||
**结论**:
|
||||
|
||||
- `ImportRunItemsRepo.Upsert` 已基本打透,`open` 也不再是 routing 的主要薄点
|
||||
- 现在真正剩下的窄点已经进一步收缩为:
|
||||
- `PacksRepo.Upsert` 的剩余更新分支
|
||||
- `ProvidersRepo.Upsert` 的剩余更新分支
|
||||
|
||||
## 2026-05-30 已完成 ProvidersRepo / PacksRepo Upsert 的最后一轮定点补测
|
||||
|
||||
**目标**:把上一轮剩下的最后两个窄点收口,直接命中 `ProvidersRepo.Upsert` 与 `PacksRepo.Upsert` 的回退/错误分支,而不是继续增加普通 happy path
|
||||
|
||||
**本次新增测试点**:
|
||||
|
||||
- `ProvidersRepo.Upsert`
|
||||
- `ExecContext` 返回错误
|
||||
- `LastInsertId` 不可用时回退到 `GetByPackIDAndProviderID`
|
||||
- 回退读取失败时返回 `sql.ErrNoRows`
|
||||
- `PacksRepo.Upsert`
|
||||
- `ExecContext` 返回错误
|
||||
- `LastInsertId` 不可用时回退到 `GetByPackID`
|
||||
- 回退读取失败时返回 `sql.ErrNoRows`
|
||||
|
||||
**实现方式**:
|
||||
|
||||
- 新增测试内 `execQuerierStub`
|
||||
- 新增测试内 `resultStub`
|
||||
- 通过包装真实 SQLite `QueryRowContext`,只替换 `ExecContext / LastInsertId` 行为
|
||||
- 这样能稳定命中 repo 自身的 fallback 分支,而不需要改生产代码
|
||||
|
||||
**验证结果**:
|
||||
|
||||
- 定向测试:
|
||||
- `go test ./internal/store/sqlite -count=1` => `ok`
|
||||
- 定向覆盖率:
|
||||
- `go test -coverprofile=/tmp/sqlite-final-upsert.cover ./internal/store/sqlite` => `78.1%`
|
||||
- 热点函数回读:
|
||||
- `ProvidersRepo.Upsert = 96.0%`
|
||||
- `PacksRepo.Upsert = 95.5%`
|
||||
- 全量门禁:
|
||||
- `gofmt -l .` => clean
|
||||
- `go vet ./...` => `ok`
|
||||
- `go test -cover ./internal/...` => `ok`
|
||||
- `go test ./tests/integration/... -count=1` => `ok`
|
||||
- `bash ./scripts/test/verify_quality_gates.sh` => `PASS`
|
||||
- 真实门禁已在 unrestricted 环境确认
|
||||
|
||||
**覆盖率变化**:
|
||||
|
||||
- `internal/store/sqlite`
|
||||
- 本轮前:`77.5%`
|
||||
- 本轮后:`78.1%`
|
||||
|
||||
**结论**:
|
||||
|
||||
- `ProvidersRepo.Upsert / PacksRepo.Upsert` 已不再是这轮质量治理的主要薄点
|
||||
- 这一波按执行板列出的热点定点补测,到这里已经基本收口
|
||||
|
||||
@@ -114,6 +114,153 @@ func TestAPIGetLogicalGroupReturnsAggregatedItem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIListLogicalGroupsReturnsRows(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListLogicalGroups: func(_ context.Context) ([]LogicalGroupInfo, error) {
|
||||
return []LogicalGroupInfo{{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
UsageScenario: "适合统一 GPT 产品入口",
|
||||
VisibilityScope: "login_required",
|
||||
PackageTier: "pro",
|
||||
PurchaseCTALabel: "升级到 Pro",
|
||||
PurchaseCTAURL: "https://sub.tksea.top/portal/upgrade/pro",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodGet, "/api/logical-groups", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
var payload struct {
|
||||
LogicalGroups []LogicalGroupInfo `json:"logical_groups"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(payload.LogicalGroups) != 1 || payload.LogicalGroups[0].LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("logical_groups = %+v, want one row gpt-shared", payload.LogicalGroups)
|
||||
}
|
||||
if payload.LogicalGroups[0].VisibilityScope != "login_required" {
|
||||
t.Fatalf("logical_groups[0].visibility_scope = %q, want login_required", payload.LogicalGroups[0].VisibilityScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUpdateLogicalGroupUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
UpdateLogicalGroup: func(_ context.Context, req UpdateLogicalGroupRequest) (LogicalGroupInfo, error) {
|
||||
if req.LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
|
||||
}
|
||||
if req.DisplayName != "GPT Shared Updated" || req.Status != "paused" {
|
||||
t.Fatalf("request = %+v, want updated display name and status", req)
|
||||
}
|
||||
return LogicalGroupInfo{
|
||||
LogicalGroupID: req.LogicalGroupID,
|
||||
DisplayName: req.DisplayName,
|
||||
Status: req.Status,
|
||||
Recommendation: req.Recommendation,
|
||||
VisibilityScope: req.VisibilityScope,
|
||||
PackageTier: req.PackageTier,
|
||||
PurchaseCTALabel: req.PurchaseCTALabel,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodPut, "/api/logical-groups/gpt-shared", map[string]any{
|
||||
"display_name": "GPT Shared Updated",
|
||||
"status": "paused",
|
||||
"recommendation": "先验证高质量推理链路",
|
||||
"visibility_scope": "entitled_only",
|
||||
"package_tier": "enterprise",
|
||||
"purchase_cta_label": "联系销售升级",
|
||||
}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
assertJSONContains(t, response.Body().Bytes(), "logical_group.logical_group_id", "gpt-shared")
|
||||
assertJSONContains(t, response.Body().Bytes(), "logical_group.package_tier", "enterprise")
|
||||
}
|
||||
|
||||
func TestAPIDeleteLogicalGroupUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
DeleteLogicalGroup: func(_ context.Context, groupID string) error {
|
||||
if groupID != "gpt-shared" {
|
||||
t.Fatalf("groupID = %q, want gpt-shared", groupID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodDelete, "/api/logical-groups/gpt-shared", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestAPICreateLogicalGroupModelUsesPathGroupID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
CreateLogicalGroupModel: func(_ context.Context, req CreateLogicalGroupModelRequest) (LogicalGroupModelInfo, error) {
|
||||
if req.LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
|
||||
}
|
||||
if req.PublicModel != "gpt-5.4" || req.Status != "active" {
|
||||
t.Fatalf("request = %+v, want public_model gpt-5.4 active", req)
|
||||
}
|
||||
return LogicalGroupModelInfo{
|
||||
PublicModel: req.PublicModel,
|
||||
Status: req.Status,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodPost, "/api/logical-groups/gpt-shared/models", map[string]any{
|
||||
"public_model": "gpt-5.4",
|
||||
"status": "active",
|
||||
}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusCreated)
|
||||
assertJSONContains(t, response.Body().Bytes(), "logical_group_model.public_model", "gpt-5.4")
|
||||
}
|
||||
|
||||
func TestAPIListLogicalGroupModelsUsesPathGroupID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListLogicalGroupModels: func(_ context.Context, groupID string) ([]LogicalGroupModelInfo, error) {
|
||||
if groupID != "gpt-shared" {
|
||||
t.Fatalf("groupID = %q, want gpt-shared", groupID)
|
||||
}
|
||||
return []LogicalGroupModelInfo{{PublicModel: "gpt-5.4", Status: "active"}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodGet, "/api/logical-groups/gpt-shared/models", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
var payload struct {
|
||||
Models []LogicalGroupModelInfo `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(payload.Models) != 1 || payload.Models[0].PublicModel != "gpt-5.4" {
|
||||
t.Fatalf("models = %+v, want one row gpt-5.4", payload.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDeleteLogicalGroupModelUsesPathValues(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
DeleteLogicalGroupModel: func(_ context.Context, req DeleteLogicalGroupModelRequest) error {
|
||||
if req.LogicalGroupID != "gpt-shared" || req.PublicModel != "gpt-5.4" {
|
||||
t.Fatalf("request = %+v, want gpt-shared/gpt-5.4", req)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodDelete, "/api/logical-groups/gpt-shared/models/gpt-5.4", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestAPICreateLogicalGroupRouteUsesPathGroupID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
CreateLogicalGroupRoute: func(_ context.Context, req CreateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error) {
|
||||
@@ -143,6 +290,89 @@ func TestAPICreateLogicalGroupRouteUsesPathGroupID(t *testing.T) {
|
||||
assertJSONContains(t, response.Body().Bytes(), "route.route_id", "asxs")
|
||||
}
|
||||
|
||||
func TestAPIListLogicalGroupRoutesUsesPathGroupID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListLogicalGroupRoutes: func(_ context.Context, groupID string) ([]LogicalGroupRouteInfo, error) {
|
||||
if groupID != "gpt-shared" {
|
||||
t.Fatalf("groupID = %q, want gpt-shared", groupID)
|
||||
}
|
||||
return []LogicalGroupRouteInfo{{
|
||||
RouteID: "asxs",
|
||||
LogicalGroupID: groupID,
|
||||
Name: "ASXS",
|
||||
Status: "active",
|
||||
ShadowGroupID: "gpt-shared__asxs",
|
||||
ShadowHostID: "remote43",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodGet, "/api/logical-groups/gpt-shared/routes", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
var payload struct {
|
||||
Routes []LogicalGroupRouteInfo `json:"routes"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(payload.Routes) != 1 || payload.Routes[0].RouteID != "asxs" {
|
||||
t.Fatalf("routes = %+v, want one row asxs", payload.Routes)
|
||||
}
|
||||
if payload.Routes[0].ShadowHostID != "remote43" {
|
||||
t.Fatalf("routes[0].shadow_host_id = %q, want remote43", payload.Routes[0].ShadowHostID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUpdateLogicalGroupRouteUsesPathValues(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
UpdateLogicalGroupRoute: func(_ context.Context, req UpdateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error) {
|
||||
if req.LogicalGroupID != "gpt-shared" || req.RouteID != "asxs" {
|
||||
t.Fatalf("request = %+v, want gpt-shared/asxs", req)
|
||||
}
|
||||
if req.Priority != 20 || req.Status != "degraded" {
|
||||
t.Fatalf("request = %+v, want priority 20 degraded", req)
|
||||
}
|
||||
return LogicalGroupRouteInfo{
|
||||
RouteID: req.RouteID,
|
||||
LogicalGroupID: req.LogicalGroupID,
|
||||
Name: req.Name,
|
||||
Status: req.Status,
|
||||
Priority: req.Priority,
|
||||
ShadowGroupID: req.ShadowGroupID,
|
||||
ShadowHostID: req.ShadowHostID,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodPut, "/api/logical-groups/gpt-shared/routes/asxs", map[string]any{
|
||||
"name": "ASXS Updated",
|
||||
"status": "degraded",
|
||||
"priority": 20,
|
||||
"shadow_group_id": "gpt-shared__asxs",
|
||||
"shadow_host_id": "remote43",
|
||||
}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
assertJSONContains(t, response.Body().Bytes(), "route.route_id", "asxs")
|
||||
assertJSONContains(t, response.Body().Bytes(), "route.status", "degraded")
|
||||
}
|
||||
|
||||
func TestAPIDeleteLogicalGroupRouteUsesPathValues(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
DeleteLogicalGroupRoute: func(_ context.Context, req DeleteLogicalGroupRouteRequest) error {
|
||||
if req.LogicalGroupID != "gpt-shared" || req.RouteID != "asxs" {
|
||||
t.Fatalf("request = %+v, want gpt-shared/asxs", req)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodDelete, "/api/logical-groups/gpt-shared/routes/asxs", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestAPICreateLogicalGroupRouteModelUsesPathValues(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
CreateLogicalGroupRouteModel: func(_ context.Context, req CreateLogicalGroupRouteModelRequest) (LogicalGroupRouteModelInfo, error) {
|
||||
@@ -170,6 +400,37 @@ func TestAPICreateLogicalGroupRouteModelUsesPathValues(t *testing.T) {
|
||||
assertJSONContains(t, response.Body().Bytes(), "route_model.public_model", "gpt-5.4")
|
||||
}
|
||||
|
||||
func TestAPIListLogicalGroupRouteModelsUsesPathValues(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListLogicalGroupRouteModels: func(_ context.Context, req ListLogicalGroupRouteModelsRequest) ([]LogicalGroupRouteModelInfo, error) {
|
||||
if req.LogicalGroupID != "gpt-shared" || req.RouteID != "asxs" {
|
||||
t.Fatalf("request = %+v, want gpt-shared/asxs", req)
|
||||
}
|
||||
return []LogicalGroupRouteModelInfo{{
|
||||
PublicModel: "gpt-5.4",
|
||||
ShadowModel: "gpt-5.4",
|
||||
Status: "active",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodGet, "/api/logical-groups/gpt-shared/routes/asxs/models", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
var payload struct {
|
||||
RouteModels []LogicalGroupRouteModelInfo `json:"route_models"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(payload.RouteModels) != 1 || payload.RouteModels[0].PublicModel != "gpt-5.4" {
|
||||
t.Fatalf("route_models = %+v, want one row gpt-5.4", payload.RouteModels)
|
||||
}
|
||||
if payload.RouteModels[0].ShadowModel != "gpt-5.4" {
|
||||
t.Fatalf("route_models[0].shadow_model = %q, want gpt-5.4", payload.RouteModels[0].ShadowModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewActionSetLogicalGroupCRUDFlow(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "logical-groups.db")
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000"
|
||||
|
||||
@@ -132,6 +132,46 @@ func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.account_status", "disabled")
|
||||
}
|
||||
|
||||
func TestAPIEnableProviderAccountUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
EnableProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
if req.AccountID != 42 {
|
||||
t.Fatalf("AccountID = %d, want 42", req.AccountID)
|
||||
}
|
||||
if req.AccountStatus != sqlite.ProviderAccountStatusActive {
|
||||
t.Fatalf("AccountStatus = %q, want active", req.AccountStatus)
|
||||
}
|
||||
return ProviderAccountInfo{ID: req.AccountID, AccountStatus: req.AccountStatus}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "POST", "/api/provider-accounts/42/enable", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.id", float64(42))
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.account_status", sqlite.ProviderAccountStatusActive)
|
||||
}
|
||||
|
||||
func TestAPIRetireProviderAccountUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
RetireProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
if req.AccountID != 42 {
|
||||
t.Fatalf("AccountID = %d, want 42", req.AccountID)
|
||||
}
|
||||
if req.AccountStatus != sqlite.ProviderAccountStatusDeprecated {
|
||||
t.Fatalf("AccountStatus = %q, want deprecated", req.AccountStatus)
|
||||
}
|
||||
return ProviderAccountInfo{ID: req.AccountID, AccountStatus: req.AccountStatus}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "POST", "/api/provider-accounts/42/retire", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.id", float64(42))
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.account_status", sqlite.ProviderAccountStatusDeprecated)
|
||||
}
|
||||
|
||||
func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "provider-accounts.db")
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000"
|
||||
|
||||
@@ -151,6 +151,19 @@ func TestApplyPatchFileRejectsMissingPatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchFileRejectsInvalidPatch(t *testing.T) {
|
||||
outputDir := t.TempDir()
|
||||
patchPath := filepath.Join(t.TempDir(), "invalid.patch")
|
||||
if err := os.WriteFile(patchPath, []byte("not a patch\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
err := applyPatchFile(context.Background(), outputDir, patchPath)
|
||||
if err == nil {
|
||||
t.Fatal("applyPatchFile() error = nil, want invalid patch failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMetadataIncludesSourceDirAndOverlays(t *testing.T) {
|
||||
metadataPath := filepath.Join(t.TempDir(), metadataFileName)
|
||||
overlays := []pack.HostOverlay{{OverlayID: "sample", PatchPath: "overlays/sample.patch"}}
|
||||
@@ -208,3 +221,63 @@ func TestCopyTreeSkipsGitAndPreservesSymlink(t *testing.T) {
|
||||
t.Fatalf("symlink target = %q, want backend/hello.txt", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRejectsExistingOutputDir(t *testing.T) {
|
||||
sourceDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "backend"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
outputDir := filepath.Join(t.TempDir(), "existing-output")
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(outputDir) error = %v", err)
|
||||
}
|
||||
|
||||
_, err := Apply(context.Background(), ApplyRequest{
|
||||
PackDir: t.TempDir(),
|
||||
SourceDir: sourceDir,
|
||||
OutputDir: outputDir,
|
||||
Overlays: []pack.HostOverlay{{OverlayID: "sample", PatchPath: "overlays/sample.patch"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("Apply() error = %v, want existing output rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRejectsSourceFile(t *testing.T) {
|
||||
sourceDir := filepath.Join(t.TempDir(), "source.txt")
|
||||
if err := os.WriteFile(sourceDir, []byte("not a dir"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := Apply(context.Background(), ApplyRequest{
|
||||
PackDir: t.TempDir(),
|
||||
SourceDir: sourceDir,
|
||||
Overlays: []pack.HostOverlay{{OverlayID: "sample", PatchPath: "overlays/sample.patch"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "must be a directory") {
|
||||
t.Fatalf("Apply() error = %v, want source dir rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileRejectsMissingSource(t *testing.T) {
|
||||
err := copyFile(filepath.Join(t.TempDir(), "missing.txt"), filepath.Join(t.TempDir(), "target.txt"), 0o644)
|
||||
if err == nil {
|
||||
t.Fatal("copyFile() error = nil, want missing source failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileRejectsTargetParentThatIsAFile(t *testing.T) {
|
||||
parentFile := filepath.Join(t.TempDir(), "parent-file")
|
||||
if err := os.WriteFile(parentFile, []byte("block mkdir"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(parentFile) error = %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "source.txt")
|
||||
if err := os.WriteFile(sourcePath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(sourcePath) error = %v", err)
|
||||
}
|
||||
|
||||
err := copyFile(sourcePath, filepath.Join(parentFile, "nested", "target.txt"), 0o644)
|
||||
if err == nil {
|
||||
t.Fatal("copyFile() error = nil, want parent path mkdir failure")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,22 @@ func TestAsyncLogWriterFlushesQueuedEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncLogWriterFlushAfterCloseReturnsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := NewAsyncLogWriter(&recordingRouteLogSink{}, AsyncLogWriterOptions{
|
||||
QueueSize: 1,
|
||||
FlushInterval: time.Hour,
|
||||
MaxBatchSize: 1,
|
||||
})
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if err := writer.Flush(context.Background()); err != nil {
|
||||
t.Fatalf("Flush() after close error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingRouteLogSink struct {
|
||||
appendCalls int
|
||||
}
|
||||
|
||||
@@ -181,6 +181,18 @@ func TestRedisStickyStoreRoundTripWithFakeServer(t *testing.T) {
|
||||
if state, ok, err := store.GetCooldown(ctx, "asxs"); err != nil || !ok || state.Reason != "degraded" {
|
||||
t.Fatalf("GetCooldown() = (%+v, %v, %v), want reason degraded", state, ok, err)
|
||||
}
|
||||
if err := store.ClearRouteFailure(ctx, "asxs"); err != nil {
|
||||
t.Fatalf("ClearRouteFailure() error = %v", err)
|
||||
}
|
||||
if _, ok, err := store.GetRouteFailure(ctx, "asxs"); err != nil || ok {
|
||||
t.Fatalf("GetRouteFailure() after clear = (ok=%v, err=%v), want false nil", ok, err)
|
||||
}
|
||||
if err := store.ClearCooldown(ctx, "asxs"); err != nil {
|
||||
t.Fatalf("ClearCooldown() error = %v", err)
|
||||
}
|
||||
if _, ok, err := store.GetCooldown(ctx, "asxs"); err != nil || ok {
|
||||
t.Fatalf("GetCooldown() after clear = (ok=%v, err=%v), want false nil", ok, err)
|
||||
}
|
||||
if err := store.Delete(ctx, key); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
@@ -424,6 +436,153 @@ func TestRedisStickyStoreRequiresAddr(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStickyStoreOpenRejectsAuthError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newScriptedRedisServer(t, func(conn net.Conn, reader *bufio.Reader) {
|
||||
command, err := readRESPArray(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(command) > 0 && strings.ToUpper(command[0]) == "AUTH" {
|
||||
_, _ = io.WriteString(conn, "-ERR invalid password\r\n")
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
store := &RedisStickyStore{cfg: RedisConfig{Addr: server.Addr(), Password: "secret"}}
|
||||
_, _, err := store.open(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "redis AUTH read") {
|
||||
t.Fatalf("open() error = %v, want auth read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStickyStoreOpenRejectsAuthUnexpectedResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newScriptedRedisServer(t, func(conn net.Conn, reader *bufio.Reader) {
|
||||
command, err := readRESPArray(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(command) > 0 && strings.ToUpper(command[0]) == "AUTH" {
|
||||
_, _ = io.WriteString(conn, ":1\r\n")
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
store := &RedisStickyStore{cfg: RedisConfig{Addr: server.Addr(), Password: "secret"}}
|
||||
_, _, err := store.open(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "redis AUTH unexpected response") {
|
||||
t.Fatalf("open() error = %v, want auth unexpected response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStickyStoreOpenRejectsAuthReadFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newScriptedRedisServer(t, func(conn net.Conn, reader *bufio.Reader) {
|
||||
if _, err := readRESPArray(reader); err != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
store := &RedisStickyStore{cfg: RedisConfig{Addr: server.Addr(), Password: "secret"}}
|
||||
_, _, err := store.open(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "redis AUTH read") {
|
||||
t.Fatalf("open() error = %v, want auth read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStickyStoreOpenRejectsSelectUnexpectedResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newScriptedRedisServer(t, func(conn net.Conn, reader *bufio.Reader) {
|
||||
command, err := readRESPArray(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(command) > 0 && strings.ToUpper(command[0]) == "AUTH" {
|
||||
_, _ = io.WriteString(conn, "+OK\r\n")
|
||||
}
|
||||
command, err = readRESPArray(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(command) > 0 && strings.ToUpper(command[0]) == "SELECT" {
|
||||
_, _ = io.WriteString(conn, ":1\r\n")
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
store := &RedisStickyStore{cfg: RedisConfig{Addr: server.Addr(), Password: "secret", DB: 2}}
|
||||
_, _, err := store.open(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "redis SELECT unexpected response") {
|
||||
t.Fatalf("open() error = %v, want select unexpected response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStickyStoreOpenRejectsSelectReadFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newScriptedRedisServer(t, func(conn net.Conn, reader *bufio.Reader) {
|
||||
command, err := readRESPArray(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(command) > 0 && strings.ToUpper(command[0]) == "AUTH" {
|
||||
_, _ = io.WriteString(conn, "+OK\r\n")
|
||||
}
|
||||
if _, err := readRESPArray(reader); err != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
store := &RedisStickyStore{cfg: RedisConfig{Addr: server.Addr(), Password: "secret", DB: 2}}
|
||||
_, _, err := store.open(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "redis SELECT read") {
|
||||
t.Fatalf("open() error = %v, want select read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStickyStoreGetJSONRejectsUnexpectedResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newScriptedRedisServer(t, func(conn net.Conn, reader *bufio.Reader) {
|
||||
if _, err := readRESPArray(reader); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(conn, "+OK\r\n")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
store := &RedisStickyStore{cfg: RedisConfig{Addr: server.Addr()}}
|
||||
_, _, err := store.getJSON(context.Background(), "sticky-key")
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected response") {
|
||||
t.Fatalf("getJSON() error = %v, want unexpected response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStickyStoreSetJSONRejectsUnexpectedResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newScriptedRedisServer(t, func(conn net.Conn, reader *bufio.Reader) {
|
||||
if _, err := readRESPArray(reader); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(conn, ":1\r\n")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
store := &RedisStickyStore{cfg: RedisConfig{Addr: server.Addr()}}
|
||||
err := store.setJSON(context.Background(), "sticky-key", map[string]string{"route_id": "asxs"}, time.Second)
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected response") {
|
||||
t.Fatalf("setJSON() error = %v, want unexpected response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRuntimeBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -458,3 +617,35 @@ func TestRedisStickyStoreFixturePathExists(t *testing.T) {
|
||||
t.Fatal("temp dir base should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
type scriptedRedisServer struct {
|
||||
listener net.Listener
|
||||
handler func(net.Conn, *bufio.Reader)
|
||||
}
|
||||
|
||||
func newScriptedRedisServer(t *testing.T, handler func(net.Conn, *bufio.Reader)) *scriptedRedisServer {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
server := &scriptedRedisServer{listener: ln, handler: handler}
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
handler(conn, bufio.NewReader(conn))
|
||||
}()
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *scriptedRedisServer) Addr() string {
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
|
||||
func (s *scriptedRedisServer) Close() {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
|
||||
@@ -281,6 +281,123 @@ func TestImportRunItemsRepoCreateUpdateAndLease(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRunItemsRepoUpsertDefaultsOptionalJSONAndNullableFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
store := openTestDB(t)
|
||||
|
||||
run := ImportRun{RunID: "run-upsert-defaults", HostID: "host-upsert", Mode: "strict", AccessMode: "subscription", State: "running"}
|
||||
if err := store.ImportRuns().Create(ctx, run); err != nil {
|
||||
t.Fatalf("ImportRuns().Create() error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.ImportRunItems().Upsert(ctx, ImportRunItem{
|
||||
ItemID: "item-upsert-defaults",
|
||||
RunID: "run-upsert-defaults",
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
ProviderID: "provider-upsert",
|
||||
APIKeyFingerprint: "fp-upsert",
|
||||
CurrentStage: "confirm",
|
||||
ConfirmationStatus: "pending",
|
||||
AccessStatus: "unknown",
|
||||
MatchedAccountState: "active",
|
||||
AccountResolution: "created",
|
||||
ResolvedSmokeModel: " gpt-5.4 ",
|
||||
LastRetryAt: " ",
|
||||
NextRetryAt: " ",
|
||||
LeaseOwner: " worker-upsert ",
|
||||
LeaseUntil: " 2026-05-23T12:00:00Z ",
|
||||
LastErrorStage: " confirm ",
|
||||
LastError: " timeout ",
|
||||
LegacyProviderID: " legacy-provider ",
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportRunItems().Upsert() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.ImportRunItems().GetByItemID(ctx, "item-upsert-defaults")
|
||||
if err != nil {
|
||||
t.Fatalf("ImportRunItems().GetByItemID() error = %v", err)
|
||||
}
|
||||
if got.RequestedModelsJSON != "[]" {
|
||||
t.Fatalf("RequestedModelsJSON = %q, want []", got.RequestedModelsJSON)
|
||||
}
|
||||
if got.RawModelsJSON != "[]" {
|
||||
t.Fatalf("RawModelsJSON = %q, want []", got.RawModelsJSON)
|
||||
}
|
||||
if got.NormalizedModelsJSON != "[]" {
|
||||
t.Fatalf("NormalizedModelsJSON = %q, want []", got.NormalizedModelsJSON)
|
||||
}
|
||||
if got.CanonicalFamiliesJSON != "[]" {
|
||||
t.Fatalf("CanonicalFamiliesJSON = %q, want []", got.CanonicalFamiliesJSON)
|
||||
}
|
||||
if got.RecommendedModelsJSON != "[]" {
|
||||
t.Fatalf("RecommendedModelsJSON = %q, want []", got.RecommendedModelsJSON)
|
||||
}
|
||||
if got.CapabilityProfileJSON != "{}" {
|
||||
t.Fatalf("CapabilityProfileJSON = %q, want {}", got.CapabilityProfileJSON)
|
||||
}
|
||||
if got.AdvisoryMessagesJSON != "[]" {
|
||||
t.Fatalf("AdvisoryMessagesJSON = %q, want []", got.AdvisoryMessagesJSON)
|
||||
}
|
||||
if got.ResolvedSmokeModel != "gpt-5.4" {
|
||||
t.Fatalf("ResolvedSmokeModel = %q, want gpt-5.4", got.ResolvedSmokeModel)
|
||||
}
|
||||
if got.LeaseOwner != "worker-upsert" {
|
||||
t.Fatalf("LeaseOwner = %q, want worker-upsert", got.LeaseOwner)
|
||||
}
|
||||
if got.LeaseUntil != "2026-05-23T12:00:00Z" {
|
||||
t.Fatalf("LeaseUntil = %q, want trimmed lease_until", got.LeaseUntil)
|
||||
}
|
||||
if got.LastErrorStage != "confirm" {
|
||||
t.Fatalf("LastErrorStage = %q, want confirm", got.LastErrorStage)
|
||||
}
|
||||
if got.LastError != "timeout" {
|
||||
t.Fatalf("LastError = %q, want timeout", got.LastError)
|
||||
}
|
||||
if got.LegacyProviderID != "legacy-provider" {
|
||||
t.Fatalf("LegacyProviderID = %q, want legacy-provider", got.LegacyProviderID)
|
||||
}
|
||||
if got.LastRetryAt != "" || got.NextRetryAt != "" {
|
||||
t.Fatalf("retry timestamps = (%q, %q), want empty strings", got.LastRetryAt, got.NextRetryAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRunItemsRepoUpsertValidationErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
store := openTestDB(t)
|
||||
run := ImportRun{RunID: "run-upsert-validation", HostID: "host-upsert-validation", Mode: "strict", AccessMode: "subscription", State: "running"}
|
||||
if err := store.ImportRuns().Create(ctx, run); err != nil {
|
||||
t.Fatalf("ImportRuns().Create() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
item ImportRunItem
|
||||
}{
|
||||
{name: "missing item_id", item: ImportRunItem{RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", ProviderID: "provider", APIKeyFingerprint: "fp", CurrentStage: "confirm", ConfirmationStatus: "pending", AccessStatus: "unknown", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing run_id", item: ImportRunItem{ItemID: "item", BaseURL: "https://api.example.com/v1", ProviderID: "provider", APIKeyFingerprint: "fp", CurrentStage: "confirm", ConfirmationStatus: "pending", AccessStatus: "unknown", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing base_url", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", ProviderID: "provider", APIKeyFingerprint: "fp", CurrentStage: "confirm", ConfirmationStatus: "pending", AccessStatus: "unknown", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing provider_id", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", APIKeyFingerprint: "fp", CurrentStage: "confirm", ConfirmationStatus: "pending", AccessStatus: "unknown", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing fingerprint", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", ProviderID: "provider", CurrentStage: "confirm", ConfirmationStatus: "pending", AccessStatus: "unknown", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing current_stage", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", ProviderID: "provider", APIKeyFingerprint: "fp", ConfirmationStatus: "pending", AccessStatus: "unknown", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing confirmation_status", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", ProviderID: "provider", APIKeyFingerprint: "fp", CurrentStage: "confirm", AccessStatus: "unknown", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing access_status", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", ProviderID: "provider", APIKeyFingerprint: "fp", CurrentStage: "confirm", ConfirmationStatus: "pending", MatchedAccountState: "active", AccountResolution: "created"}},
|
||||
{name: "missing matched_account_state", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", ProviderID: "provider", APIKeyFingerprint: "fp", CurrentStage: "confirm", ConfirmationStatus: "pending", AccessStatus: "unknown", AccountResolution: "created"}},
|
||||
{name: "missing account_resolution", item: ImportRunItem{ItemID: "item", RunID: "run-upsert-validation", BaseURL: "https://api.example.com/v1", ProviderID: "provider", APIKeyFingerprint: "fp", CurrentStage: "confirm", ConfirmationStatus: "pending", AccessStatus: "unknown", MatchedAccountState: "active"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := store.ImportRunItems().Upsert(ctx, tt.item); err == nil {
|
||||
t.Fatal("Upsert() error = nil, want validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRunEventsRepoCreateAndHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -113,6 +114,133 @@ func TestPacksRepoUpsertUpdatesExisting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacksRepoUpsertTrimsAndDefaultsManifest(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
id, err := store.Packs().Upsert(context.Background(), Pack{
|
||||
PackID: " upsert-pack-json ",
|
||||
Version: " 1.2.3 ",
|
||||
Checksum: " chk-json ",
|
||||
Vendor: " vendor-a ",
|
||||
TargetHost: " sub2api ",
|
||||
MinHostVersion: " 0.1.0 ",
|
||||
MaxHostVersion: " 0.2.x ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Packs().GetByID(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID() error = %v", err)
|
||||
}
|
||||
if got.PackID != "upsert-pack-json" {
|
||||
t.Fatalf("PackID = %q, want upsert-pack-json", got.PackID)
|
||||
}
|
||||
if got.Version != "1.2.3" {
|
||||
t.Fatalf("Version = %q, want 1.2.3", got.Version)
|
||||
}
|
||||
if got.Checksum != "chk-json" {
|
||||
t.Fatalf("Checksum = %q, want chk-json", got.Checksum)
|
||||
}
|
||||
if got.Vendor != "vendor-a" {
|
||||
t.Fatalf("Vendor = %q, want vendor-a", got.Vendor)
|
||||
}
|
||||
if got.TargetHost != "sub2api" {
|
||||
t.Fatalf("TargetHost = %q, want sub2api", got.TargetHost)
|
||||
}
|
||||
if got.MinHostVersion != "0.1.0" {
|
||||
t.Fatalf("MinHostVersion = %q, want 0.1.0", got.MinHostVersion)
|
||||
}
|
||||
if got.MaxHostVersion != "0.2.x" {
|
||||
t.Fatalf("MaxHostVersion = %q, want 0.2.x", got.MaxHostVersion)
|
||||
}
|
||||
if got.ManifestJSON != "{}" {
|
||||
t.Fatalf("ManifestJSON = %q, want {}", got.ManifestJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacksRepoUpsertFallsBackWhenLastInsertIDUnavailable(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
db := store.SQLDB()
|
||||
repo := newPacksRepo(execQuerierStub{
|
||||
execFn: func(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
if _, err := db.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resultStub{lastInsertErr: errors.New("last insert unavailable")}, nil
|
||||
},
|
||||
queryFn: db.QueryContext,
|
||||
queryRowFn: db.QueryRowContext,
|
||||
})
|
||||
|
||||
id, err := repo.Upsert(ctx, Pack{
|
||||
PackID: "fallback-pack",
|
||||
Version: "1.0.0",
|
||||
Checksum: "fallback-checksum",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert() error = %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("Upsert() id = %d, want positive", id)
|
||||
}
|
||||
got, err := store.Packs().GetByPackID(ctx, "fallback-pack")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByPackID() error = %v", err)
|
||||
}
|
||||
if got.ID != id {
|
||||
t.Fatalf("fallback id = %d, want persisted id %d", id, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacksRepoUpsertReturnsExecError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newPacksRepo(execQuerierStub{
|
||||
execFn: func(context.Context, string, ...any) (sql.Result, error) {
|
||||
return nil, errors.New("exec boom")
|
||||
},
|
||||
queryFn: func(context.Context, string, ...any) (*sql.Rows, error) {
|
||||
return nil, errors.New("unexpected query")
|
||||
},
|
||||
queryRowFn: func(context.Context, string, ...any) *sql.Row {
|
||||
panic("unexpected QueryRowContext")
|
||||
},
|
||||
})
|
||||
|
||||
_, err := repo.Upsert(ctx, Pack{
|
||||
PackID: "pack-exec-error",
|
||||
Version: "1.0.0",
|
||||
Checksum: "checksum",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "exec boom") {
|
||||
t.Fatalf("Upsert() error = %v, want exec boom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacksRepoUpsertReturnsFallbackReadError(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
db := store.SQLDB()
|
||||
repo := newPacksRepo(execQuerierStub{
|
||||
execFn: func(context.Context, string, ...any) (sql.Result, error) {
|
||||
return resultStub{lastInsertErr: errors.New("last insert unavailable")}, nil
|
||||
},
|
||||
queryFn: db.QueryContext,
|
||||
queryRowFn: db.QueryRowContext,
|
||||
})
|
||||
|
||||
_, err := repo.Upsert(ctx, Pack{
|
||||
PackID: "pack-missing-after-upsert",
|
||||
Version: "1.0.0",
|
||||
Checksum: "checksum",
|
||||
})
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("Upsert() error = %v, want sql.ErrNoRows fallback read error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacksRepoValidationErrors(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
@@ -134,6 +262,27 @@ func TestPacksRepoValidationErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacksRepoUpsertValidationErrors(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pack Pack
|
||||
}{
|
||||
{"empty pack_id", Pack{Version: "v", Checksum: "c"}},
|
||||
{"empty version", Pack{PackID: "p", Checksum: "c"}},
|
||||
{"empty checksum", Pack{PackID: "p", Version: "v"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := store.Packs().Upsert(context.Background(), tt.pack)
|
||||
if err == nil {
|
||||
t.Fatal("Upsert() error = nil, want validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacksRepoGetByIDNotFound(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
_, err := store.Packs().GetByID(context.Background(), 999)
|
||||
|
||||
@@ -325,6 +325,27 @@ func TestSyncProviderAccountsFromImportBatchPreservesManualDisabledStatus(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProviderAccountBindingState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: ProviderAccountBindingStateAssigned, want: ProviderAccountBindingStateAssigned},
|
||||
{input: " " + ProviderAccountBindingStateUnassigned + " ", want: ProviderAccountBindingStateUnassigned},
|
||||
{input: ProviderAccountBindingStateConflict, want: ProviderAccountBindingStateConflict},
|
||||
{input: "invalid", want: ""},
|
||||
{input: " ", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := normalizeProviderAccountBindingState(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeProviderAccountBindingState(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncProviderAccountsFromImportBatchInfersRouteFromShadowBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -561,3 +582,86 @@ func TestSyncProviderAccountsFromImportBatchPromotesSingleReadyGatewayAccount(t
|
||||
t.Fatalf("LastProbeStatus = %q, want gateway_ready", account.LastProbeStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncProviderAccountsFromLatestImportBatchesSyncsEachLatestReconcilableBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := openTestDBWithFK(t)
|
||||
ctx := context.Background()
|
||||
hostID := createTestHost(t, store)
|
||||
packID := createTestPack(t, store)
|
||||
providerAID, err := store.Providers().Create(ctx, Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "provider-a",
|
||||
DisplayName: "Provider A",
|
||||
BaseURL: "https://api.provider-a.example/v1",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create(provider-a) error = %v", err)
|
||||
}
|
||||
providerBID, err := store.Providers().Create(ctx, Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "provider-b",
|
||||
DisplayName: "Provider B",
|
||||
BaseURL: "https://api.provider-b.example/v1",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create(provider-b) error = %v", err)
|
||||
}
|
||||
|
||||
createBatchWithAccount := func(providerID int64, accountID, keyFingerprint string) {
|
||||
t.Helper()
|
||||
batchID, err := store.ImportBatches().Create(ctx, ImportBatch{
|
||||
HostID: hostID,
|
||||
PackID: packID,
|
||||
ProviderID: providerID,
|
||||
Mode: "strict",
|
||||
BatchStatus: "succeeded",
|
||||
AccessStatus: "subscription_ready",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportBatches().Create(%s) error = %v", accountID, err)
|
||||
}
|
||||
if _, err := store.ImportBatchItems().Create(ctx, ImportBatchItem{
|
||||
BatchID: batchID,
|
||||
KeyFingerprint: keyFingerprint,
|
||||
AccountStatus: "passed",
|
||||
ProbeSummaryJSON: `{"account_id":"` + accountID + `","probe_status":"passed"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportBatchItems().Create(%s) error = %v", accountID, err)
|
||||
}
|
||||
for _, resource := range []ManagedResource{
|
||||
{BatchID: batchID, HostID: hostID, ResourceType: "group", HostResourceID: "group-" + accountID, ResourceName: "Group " + accountID},
|
||||
{BatchID: batchID, HostID: hostID, ResourceType: "account", HostResourceID: accountID, ResourceName: "Account " + accountID},
|
||||
} {
|
||||
if _, err := store.ManagedResources().Create(ctx, resource); err != nil {
|
||||
t.Fatalf("ManagedResources().Create(%s/%s) error = %v", accountID, resource.ResourceType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createBatchWithAccount(providerAID, "account-a1", "sha256:a1")
|
||||
createBatchWithAccount(providerBID, "account-b1", "sha256:b1")
|
||||
|
||||
if err := SyncProviderAccountsFromLatestImportBatches(ctx, store); err != nil {
|
||||
t.Fatalf("SyncProviderAccountsFromLatestImportBatches() error = %v", err)
|
||||
}
|
||||
|
||||
accountA, err := store.ProviderAccounts().GetByHostIDAndAccountID(ctx, hostID, "account-a1")
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetByHostIDAndAccountID(account-a1) error = %v", err)
|
||||
}
|
||||
if accountA.AccountStatus != ProviderAccountStatusActive || accountA.KeyFingerprint != "sha256:a1" {
|
||||
t.Fatalf("account-a1 = %+v, want active sha256:a1", accountA)
|
||||
}
|
||||
|
||||
accountB, err := store.ProviderAccounts().GetByHostIDAndAccountID(ctx, hostID, "account-b1")
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetByHostIDAndAccountID(account-b1) error = %v", err)
|
||||
}
|
||||
if accountB.AccountStatus != ProviderAccountStatusActive || accountB.KeyFingerprint != "sha256:b1" {
|
||||
t.Fatalf("account-b1 = %+v, want active sha256:b1", accountB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,44 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type resultStub struct {
|
||||
lastInsertID int64
|
||||
lastInsertErr error
|
||||
}
|
||||
|
||||
func (r resultStub) LastInsertId() (int64, error) {
|
||||
if r.lastInsertErr != nil {
|
||||
return 0, r.lastInsertErr
|
||||
}
|
||||
return r.lastInsertID, nil
|
||||
}
|
||||
|
||||
func (r resultStub) RowsAffected() (int64, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
type execQuerierStub struct {
|
||||
execFn func(context.Context, string, ...any) (sql.Result, error)
|
||||
queryFn func(context.Context, string, ...any) (*sql.Rows, error)
|
||||
queryRowFn func(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func (s execQuerierStub) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
return s.execFn(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (s execQuerierStub) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
return s.queryFn(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (s execQuerierStub) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
|
||||
return s.queryRowFn(ctx, query, args...)
|
||||
}
|
||||
|
||||
func TestProvidersRepoCreateAndGet(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
@@ -170,6 +205,154 @@ func TestProvidersRepoUpsertUpdatesExisting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersRepoUpsertTrimsAndDefaultsOptionalJSON(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
packID := createTestPack(t, store)
|
||||
|
||||
id, err := store.Providers().Upsert(context.Background(), Provider{
|
||||
PackID: packID,
|
||||
ProviderID: " upsert-json ",
|
||||
DisplayName: " Upsert JSON ",
|
||||
BaseURL: " https://json.example.com/v1 ",
|
||||
Platform: " openai ",
|
||||
AccountType: " apikey ",
|
||||
SmokeTestModel: " gpt-5.4 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Providers().GetByID(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID() error = %v", err)
|
||||
}
|
||||
if got.ProviderID != "upsert-json" {
|
||||
t.Fatalf("ProviderID = %q, want upsert-json", got.ProviderID)
|
||||
}
|
||||
if got.DisplayName != "Upsert JSON" {
|
||||
t.Fatalf("DisplayName = %q, want Upsert JSON", got.DisplayName)
|
||||
}
|
||||
if got.BaseURL != "https://json.example.com/v1" {
|
||||
t.Fatalf("BaseURL = %q, want trimmed base url", got.BaseURL)
|
||||
}
|
||||
if got.Platform != "openai" {
|
||||
t.Fatalf("Platform = %q, want openai", got.Platform)
|
||||
}
|
||||
if got.AccountType != "apikey" {
|
||||
t.Fatalf("AccountType = %q, want apikey", got.AccountType)
|
||||
}
|
||||
if got.SmokeTestModel != "gpt-5.4" {
|
||||
t.Fatalf("SmokeTestModel = %q, want gpt-5.4", got.SmokeTestModel)
|
||||
}
|
||||
if got.DefaultModelsJSON != "[]" {
|
||||
t.Fatalf("DefaultModelsJSON = %q, want []", got.DefaultModelsJSON)
|
||||
}
|
||||
if got.GroupTemplateJSON != "{}" {
|
||||
t.Fatalf("GroupTemplateJSON = %q, want {}", got.GroupTemplateJSON)
|
||||
}
|
||||
if got.ChannelTemplateJSON != "{}" {
|
||||
t.Fatalf("ChannelTemplateJSON = %q, want {}", got.ChannelTemplateJSON)
|
||||
}
|
||||
if got.PlanTemplateJSON != "{}" {
|
||||
t.Fatalf("PlanTemplateJSON = %q, want {}", got.PlanTemplateJSON)
|
||||
}
|
||||
if got.ImportOptionsJSON != "{}" {
|
||||
t.Fatalf("ImportOptionsJSON = %q, want {}", got.ImportOptionsJSON)
|
||||
}
|
||||
if got.ManifestJSON != "{}" {
|
||||
t.Fatalf("ManifestJSON = %q, want {}", got.ManifestJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersRepoUpsertFallsBackWhenLastInsertIDUnavailable(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
packID := createTestPack(t, store)
|
||||
ctx := context.Background()
|
||||
|
||||
db := store.SQLDB()
|
||||
repo := newProvidersRepo(execQuerierStub{
|
||||
execFn: func(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
if _, err := db.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resultStub{lastInsertErr: errors.New("last insert unavailable")}, nil
|
||||
},
|
||||
queryFn: db.QueryContext,
|
||||
queryRowFn: db.QueryRowContext,
|
||||
})
|
||||
|
||||
id, err := repo.Upsert(ctx, Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "fallback-provider",
|
||||
DisplayName: "Fallback Provider",
|
||||
BaseURL: "https://fallback.example.com/v1",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert() error = %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("Upsert() id = %d, want positive", id)
|
||||
}
|
||||
got, err := store.Providers().GetByPackIDAndProviderID(ctx, packID, "fallback-provider")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByPackIDAndProviderID() error = %v", err)
|
||||
}
|
||||
if got.ID != id {
|
||||
t.Fatalf("fallback id = %d, want persisted id %d", id, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersRepoUpsertReturnsExecError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newProvidersRepo(execQuerierStub{
|
||||
execFn: func(context.Context, string, ...any) (sql.Result, error) {
|
||||
return nil, errors.New("exec boom")
|
||||
},
|
||||
queryFn: func(context.Context, string, ...any) (*sql.Rows, error) {
|
||||
return nil, errors.New("unexpected query")
|
||||
},
|
||||
queryRowFn: func(context.Context, string, ...any) *sql.Row {
|
||||
panic("unexpected QueryRowContext")
|
||||
},
|
||||
})
|
||||
|
||||
_, err := repo.Upsert(ctx, Provider{
|
||||
PackID: 1,
|
||||
ProviderID: "provider-exec-error",
|
||||
DisplayName: "Provider Exec Error",
|
||||
BaseURL: "https://exec-error.example.com/v1",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "exec boom") {
|
||||
t.Fatalf("Upsert() error = %v, want exec boom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersRepoUpsertReturnsFallbackReadError(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
db := store.SQLDB()
|
||||
repo := newProvidersRepo(execQuerierStub{
|
||||
execFn: func(context.Context, string, ...any) (sql.Result, error) {
|
||||
return resultStub{lastInsertErr: errors.New("last insert unavailable")}, nil
|
||||
},
|
||||
queryFn: db.QueryContext,
|
||||
queryRowFn: db.QueryRowContext,
|
||||
})
|
||||
|
||||
_, err := repo.Upsert(ctx, Provider{
|
||||
PackID: createTestPack(t, store),
|
||||
ProviderID: "provider-missing-after-upsert",
|
||||
DisplayName: "Provider Missing After Upsert",
|
||||
BaseURL: "https://missing.example.com/v1",
|
||||
Platform: "openai",
|
||||
})
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("Upsert() error = %v, want sql.ErrNoRows fallback read error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersRepoValidationErrors(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
packID := createTestPack(t, store)
|
||||
@@ -194,6 +377,30 @@ func TestProvidersRepoValidationErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersRepoUpsertValidationErrors(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
packID := createTestPack(t, store)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider Provider
|
||||
}{
|
||||
{"pack_id zero", Provider{ProviderID: "p", DisplayName: "d", BaseURL: "b", Platform: "openai"}},
|
||||
{"empty provider_id", Provider{PackID: packID, DisplayName: "d", BaseURL: "b", Platform: "openai"}},
|
||||
{"empty display_name", Provider{PackID: packID, ProviderID: "p", BaseURL: "b", Platform: "openai"}},
|
||||
{"empty base_url", Provider{PackID: packID, ProviderID: "p", DisplayName: "d", Platform: "openai"}},
|
||||
{"empty platform", Provider{PackID: packID, ProviderID: "p", DisplayName: "d", BaseURL: "b"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := store.Providers().Upsert(context.Background(), tt.provider)
|
||||
if err == nil {
|
||||
t.Fatal("Upsert() error = nil, want validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersRepoGetByPackIDAndProviderIDNotFound(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
_, err := store.Providers().GetByPackIDAndProviderID(context.Background(), 999, "p")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
internal/access core 70.0 core access closure logic
|
||||
internal/pack core 70.0 pack loading and validation
|
||||
internal/provision core 70.0 provider import orchestration
|
||||
internal/app watch 69.5 large HTTP surface; keep explicit non-regression until more handler tests land
|
||||
internal/overlay watch 70.0 utility package should stay above a healthy baseline
|
||||
internal/routing watch 70.0 route resolve and sticky runtime should not regress
|
||||
internal/store/sqlite watch 75.0 sqlite repo layer is high leverage and should stay well-covered
|
||||
internal/app core 70.0 HTTP control plane is now above 70 and must stay as a hard gate
|
||||
internal/overlay core 70.0 overlay patch execution affects real deployment artifacts and must stay as a hard gate
|
||||
internal/routing core 70.0 route resolve and sticky runtime are product-critical and must stay above the hard gate
|
||||
internal/store/sqlite core 75.0 sqlite repo layer is high leverage and must stay as a hard gate
|
||||
|
||||
|
Reference in New Issue
Block a user