# 集成测试 spy mock 必须覆盖所有 env-gated 短路分支

**Date**: 2026-05-19
**Module**: testing / NestJS DI 集成测试
**Related**: PR #434 / [2026-05-19-gitea-webhook-...md](2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md)

## 症状

`deploy_prepare happy-path upsert` 集成测试本地 15/15 全绿，CI 同测试同流程 2 fail。
打开 CI 诊断输出（专门加的 `console.error('[diag] ...', r.error)`）显示：

```
[diag] deploy_prepare 首次 failed: {
  "code": "gitea_token_missing",
  "message": "INTERNAL_APP_GITEA_API_TOKEN 未配置，无法颁发 push 凭据"
}
```

## 根因

测试 spy 覆盖了 `createRepo` / `getRepo` / `ensureWebhook` 三个 Gitea 方法，但**遗漏了
`issuePushCredential`**。该方法**第一步就检查 `this.apiToken`**：

```ts
issuePushCredential() {
  if (!this.apiToken) {
    return { ok: false, error: { code: 'gitea_token_missing', ... } };
  }
  ...
}
```

本地：`.env` 配了真 `INTERNAL_APP_GITEA_API_TOKEN`，short-circuit 不触发，函数正常返凭据 → 测试 happy-path 走通。
CI：env 没配（按测试规范不该依赖真 token），short-circuit 立即返失败 → 测试挂。

## 修复

在测试 beforeEach 里给 `issuePushCredential` 也加 spy：

```ts
issuePushCredentialSpy = jest
  .spyOn(giteaSvc, 'issuePushCredential')
  .mockReturnValue({
    ok: true,
    credential: {
      token: 'fake-push-token-for-test',
      expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      isEphemeral: false,
    },
  });
```

加完用 `env -u INTERNAL_APP_GITEA_API_TOKEN` 本地复现 CI 条件验证：

```bash
env -u INTERNAL_APP_GITEA_API_TOKEN bash scripts/run-backend-integration.sh <test-file>
```

15/15 pass。

## 通用原则：spy mock 要列出全部"对外部世界的接触面"，不只是当前测试关心的方法

任何 service 的方法**只要内部读 env / 调外部 API / 访问文件系统**，就是潜在 short-circuit
分支。Happy-path 测试 spy 必须覆盖 **被测代码路径上所有这类方法**，不只是"我这条 assertion 关心的"。

判别准则：**"如果 env 全空 + 网络全断 + 文件系统只读，被测代码会在哪一步 fail？"**——所有 fail
点都必须 spy 掉。

适用场景：

| Service 方法 | env 依赖 | 必须 mock 的场景 |
|---|---|---|
| `issuePushCredential` (本案) | `INTERNAL_APP_GITEA_API_TOKEN` | 任何 deploy_prepare 测试 |
| `createRepo` / `getRepo` / `ensureWebhook` (Gitea client) | 同上 + 网络 | 同上 |
| AI provider invoke (Qwen / OpenAI / Anthropic) | `*_API_KEY` | 任何走 LLM 的集成测试 |
| MinIO / S3 upload | `MINIO_ACCESS_KEY` 等 | 任何走对象存储的测试 |
| 邮件发送 (`EmailAdapter.send`) | SMTP env / `SES_*` | 任何走 notification 的测试 |

## 反例（已修正）

本案 PR #434 第一版 happy-path 测试只 spy 了 createRepo / getRepo / ensureWebhook，
看着齐全（"这三个都被 deploy_prepare 直接调"）。漏了 issuePushCredential 是因为
它的失败路径**离测试关心的 assertion 远**（在 createRepo 后、ensureWebhook 前），
本地有真 token 时这条路径完全走通从未触发，CI 一个 env 差异就暴露。

教训：spy 列表的判断不能基于"测试 assertion 关心什么"，要基于"代码路径会走过什么"。
画一遍代码路径 → 圈出所有外部调用 → 每个都 spy，不分主次。

## 元规则

跟同 PR 系列三个 learning 同源：

- [postDeployHint](2026-05-19-tool-return-value-beats-description-for-ai-followup-script.md)
- [logs build fallback](2026-05-19-logs-tool-needs-build-stage-fallback.md)
- [per-repo webhook 兜底](2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md)
- **本文**

都是"**显式枚举对外部世界的隐式假设**"系列。前三条是生产代码的，本条是测试代码的——
测试代码的"外部世界"包括 CI env，跟生产一样要枚举完，不能靠"本地凑巧能跑"。
