mcpField
P
polar-testing

polar-testing

github.com/polarsource测试与质量5178 次安装2 周前更新
代码仓库

关于 polar-testing

面向开发者的 Polar 集成测试指南,介绍如何使用隔离的 sandbox 测试结账、Webhook 和许可证密钥。还涵盖本地事件转发、测试夹具、SDK 模拟及 CI 配置。

主要功能

  1. 01使用 sandbox 隔离测试数据
  2. 02通过 Polar CLI 转发及触发 Webhook
  3. 03生成 Webhook 测试夹具
  4. 04模拟 SDK 和 Webhook 负载
  5. 05配置集成测试与 CI

适用场景

  1. 01在不处理真实付款的情况下测试结账
  2. 02本地调试 Webhook 验证与处理
  3. 03为 Webhook 处理程序编写夹具测试
  4. 04在 CI 中运行 Polar 集成测试

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/polarsource/agent-toolkit --skill polar-testing

安装后在 Claude Code 中输入/polar-testing确认已加载。

详细介绍

Polar 测试指南

使用沙盒环境安全地测试 Polar 集成——这是一个完全隔离的服务,你可以在其中进行试验,而不会影响生产数据或处理真实付款。

沙盒环境

访问

沙盒与生产环境完全隔离。你需要分别准备:

  • 用户账户
  • 组织
  • 访问令牌
  • Webhook 端点
设置步骤
  1. 前往 https://sandbox.polar.sh/start
  2. 创建新账户(或在组织切换器中选择“Go to sandbox”)
  3. 创建测试组织
  4. 在 Settings → Developers 中生成访问令牌
SDK 配置

按照 polar-integration(“Setup”)中的说明配置客户端,并传入 environment: "sandbox":

import { createPolar } from "@polar-sh/sdk/2026-10";

const polar = createPolar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  environment: "sandbox",
});
import os

from polar.v2026_10 import Polar

polar = Polar(os.environ["POLAR_ACCESS_TOKEN"], environment="sandbox")
沙盒限制
  • 不会处理真实资金,数据也与生产环境隔离
  • 面向客户的邮件(收据、续订提醒)仅会发送给组织成员;可以使用 [email protected] 别名
  • API 每分钟允许 100 次请求,而生产环境为 500 次,这会影响大型集成测试套件

测试卡

Polar 使用 Stripe 处理付款,因此 Stripe 的测试卡均可使用。请使用未来的到期日期和任意 CVC:

卡号行为
4242 4242 4242 4242付款成功
4000 0000 0000 0002通用拒绝
4000 0000 0000 9995余额不足
4000 0027 6000 3184需要 3D Secure 身份验证

本地 Webhook 测试

使用 Polar CLI。它会将组织的 Webhook 事件直接转发到你的本地服务:无需隧道、公开 URL,也无需在控制面板中配置 Webhook 端点。

安装并登录
npm install -g @polar-sh/cli

polar auth login --sandbox

如需了解其他安装方式,请参阅 CLI 文档。

polar auth login 会打开浏览器,因此请让用户运行该命令。使用 polar auth whoami --json 检查结果:environments 列出已登录的环境,organization 是当前组织。

如果无法使用浏览器(CI、远程机器、云端 Agent),可以使用组织访问令牌代替 auth login:

export POLAR_ACCESS_TOKEN=polar_oat_...   # scopes: webhooks:read, webhooks:write, organizations:read
export POLAR_ENVIRONMENT=sandbox

如果 Shell 中已导出 POLAR_ACCESS_TOKEN(例如来自应用的 .env),CLI 会使用它,而不是登录凭据。

将事件转发到应用
polar webhook listen 3000/api/polar/webhook

listen 接受端口、port/path 或完整 URL。它会持续运行,直到停止,因此请在单独的终端或后台进程中启动,并在测试期间保持运行。它会转发组织的所有 Webhook 事件,包括真实沙盒操作产生的事件,例如完成测试结账。如果账户有多个组织,请使用 --org <id> 或 polar auth org 选择一个组织。

设置 Webhook 密钥

listen 使用本地密钥对转发的事件签名,而不是控制面板端点的密钥。请将密钥写入 .env,不要打印出来:

secret=$(polar webhook listen --print-secret) && echo "POLAR_WEBHOOK_SECRET=$secret" >> .env

如果 .env 中已有 POLAR_WEBHOOK_SECRET 行,请替换它,而不是追加,然后重启开发服务器。已部署的环境使用其控制面板 Webhook 端点的密钥。

发送测试事件
polar webhook trigger order.paid

trigger 会通过正在运行的 listen 发送示例事件。如果你的处理程序未接受该事件(响应非 2xx、连接错误或重定向),它会打印处理程序的响应正文,并以状态码 1 退出。查看输出、修复处理程序,然后重复运行同一命令,直到以状态码 0 退出。

重定向通常意味着身份验证中间件拦截了 Webhook 路由。请将该路由排除在中间件之外:Polar 投递 Webhook 时绝不会跟随重定向。

polar webhook trigger --list --json                     # every event you can send
polar webhook trigger order.paid --override [email protected]
polar webhook trigger order.paid --seed 7               # the same IDs on every run

--override 接受 path=value,并且可以重复使用。API 会拒绝在负载中不存在的路径。

集成测试

测试结账流程

使用具有 products:write 和 checkouts:write 作用域的组织访问令牌。组织令牌已隐含组织信息,因此无需传递 organization_id。

import { describe, it, expect, beforeAll } from "vitest";
import { createPolar } from "@polar-sh/sdk/2026-10";

const polar = createPolar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  environment: "sandbox",
});

describe("Polar Checkout", () => {
  let testProductId: string;

  beforeAll(async () => {
    const product = await polar.products.create({
      name: "Test Product",
      prices: [{ amount_type: "fixed", price_amount: 1000, price_currency: "usd" }],
    });
    testProductId = product.id;
  });

  it("creates a checkout session", async () => {
    const checkout = await polar.checkouts.create({
      products: [testProductId],
      success_url: "http://localhost:3000/success",
      customer_email: "[email protected]",
    });

    expect(checkout.status).toBe("open");
    expect(checkout.url).toContain("sandbox");
  });

  it("retrieves a checkout", async () => {
    const checkout = await polar.checkouts.create({
      products: [testProductId],
      success_url: "http://localhost:3000/success",
    });

    const retrieved = await polar.checkouts.get(checkout.id);
    expect(retrieved.id).toBe(checkout.id);
  });
});
测试 Webhook 处理程序

请根据真实负载生成测试数据,而不是手动编写。--dry-run 会打印负载而不发送,因此无需运行 listen:

mkdir -p test/fixtures
polar webhook trigger order.paid --dry-run --json --seed 1 > test/fixtures/order.paid.json
polar webhook trigger subscription.created --dry-run --json --seed 1 > test/fixtures/subscription.created.json

按照 polar webhook listen 的方式对其签名(Standard Webhooks):使用密钥字符串作为密钥,对 id.timestamp.body 计算 HMAC-SHA256,并进行 base64 编码。此测试会直接调用 polar-integration Recipe 3 中的 webhook 路由:

import { createHmac } from "node:crypto";
import { readFile } from "node:fs/promises";
import { beforeAll, describe, expect, it, vi } from "vitest";

const secret = "test_webhook_secret";
let POST: (request: Request) => Promise<Response>;

beforeAll(async () => {
  vi.stubEnv("POLAR_WEBHOOK_SECRET", secret);
  ({ POST } = await import("../app/api/polar/webhook/route"));
});

const sign = (id: string, timestamp: number, body: string) =>
  `v1,${createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64")}`;

const webhookRequest = (body: string, signature?: string) => {
  const id = "msg_test";
  const timestamp = Math.floor(Date.now() / 1000);
  return new Request("http://localhost/api/polar/webhook", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "webhook-id": id,
      "webhook-timestamp": String(timestamp),
      "webhook-signature": signature ?? sign(id, timestamp, body),
    },
    body,
  });
};

describe("Polar webhook handler", () => {
  it("accepts a signed order.paid event", async () => {
    const body = await readFile("test/fixtures/order.paid.json", "utf8");
    const response = await POST(webhookRequest(body));
    expect(response.status).toBe(202);
  });

  it("rejects an invalid signature", async () => {
    const body = await readFile("test/fixtures/order.paid.json", "utf8");
    const response = await POST(webhookRequest(body, "v1,invalid"));
    expect(response.status).toBe(403);
  });
});
测试许可证密钥验证

验证需要具有 license_keys:write 作用域的令牌。有效密钥会返回 status: "granted";未知密钥会抛出 ResourceNotFound:

import { describe, it, expect } from "vitest";
import { createPolar, errors } from "@polar-sh/sdk/2026-10";

const polar = createPolar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  environment: "sandbox",
});
const organization_id = process.env.POLAR_ORG_ID!;

describe("License keys", () => {
  it("validates a granted key", async () => {
    const result = await polar.licenseKeys.validate({ key: process.env.TEST_LICENSE_KEY!, organization_id });
    expect(result.status).toBe("granted");
  });

  it("rejects an unknown key", async () => {
    await expect(
      polar.licenseKeys.validate({ key: "INVALID-KEY", organization_id }),
    ).rejects.toBeInstanceOf(errors.ResourceNotFound);
  });
});

在单元测试中模拟 Polar

模拟 SDK

模拟 createPolar,并保留真实的 webhooks 和 errors 导出。响应使用与 API 相同的 snake_case 字段:

import { vi } from "vitest";

vi.mock("@polar-sh/sdk/2026-10", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@polar-sh/sdk/2026-10")>()),
  createPolar: () => ({
    checkouts: {
      create: vi.fn().mockResolvedValue({
        id: "checkout_mock",
        url: "https://sandbox.polar.sh/checkout/mock",
        status: "open",
      }),
      get: vi.fn().mockResolvedValue({ id: "checkout_mock", status: "succeeded" }),
    },
    customers: {
      getStateExternal: vi.fn().mockResolvedValue({
        active_subscriptions: [{ id: "sub_mock", status: "active", product_id: "prod_mock" }],
        granted_benefits: [],
        active_meters: [],
      }),
    },
    subscriptions: {
      revoke: vi.fn(),
      update: vi.fn(),
    },
  }),
}));
模拟 Webhook 负载

加载通过 polar webhook trigger <event> --dry-run --json 生成的测试数据(参见“测试 Webhook 处理程序”)。手写负载会与真实格式产生偏差,而 validateEvent 只会检查签名和事件类型,因此这种偏差不会被发现。

import orderPaid from "./fixtures/order.paid.json";
import subscriptionCreated from "./fixtures/subscription.created.json";

CI/CD 集成

GitHub Actions
name: Test Polar Integration

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    env:
      POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_SANDBOX_TOKEN }}
      POLAR_ORG_ID: ${{ secrets.POLAR_SANDBOX_ORG_ID }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - run: npm ci

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration

如果 CI 也运行 Polar CLI,请添加 POLAR_ENVIRONMENT: sandbox。

环境设置

专门为 CI 创建沙盒凭据:

  1. 为 CI 创建专用的沙盒组织
  2. 为其创建组织访问令牌,且只授予测试所需的作用域(polar auth token --scope products:write --scope checkouts:write 会打开已预选这些作用域的令牌页面)
  3. 提交 webhook 测试数据;处理程序测试会使用测试密钥对其签名,无需 webhook 端点
  4. 将令牌和组织 ID 存储在 GitHub Secrets 中

调试提示

检查 Webhook 投递情况

在本地,polar webhook listen 会记录每个转发事件及处理程序的状态;如果处理程序失败,polar webhook trigger 会打印其响应正文。

对于已部署的端点:

  1. 前往 sandbox.polar.sh → Settings → Webhooks
  2. 点击你的端点
  3. 查看投递历史记录和负载
  4. 检查响应代码和错误
常见问题

Webhook 签名不匹配

  • 确保使用的是沙盒 webhook 密钥
  • 在本地,使用 polar webhook listen --print-secret 输出的密钥;更改 .env 后重启开发服务器
  • 检查传入的是原始正文(而非解析后的 JSON)
  • 确认时间戳在容差范围内(5 分钟)

结账未完成

  • 使用测试卡,而非真实银行卡
  • 检查浏览器控制台是否有错误
  • 确认 success_url 正确

API 返回 401

  • 确认使用的是沙盒令牌,并调用沙盒 API
  • 检查令牌是否已过期或被撤销

API 返回 403

  • 令牌缺少端点所需的作用域
检查 API 错误

SDK 会抛出带有 HTTP 状态和响应正文的类型化错误:

import { PolarClientError } from "@polar-sh/sdk";

try {
  await polar.checkouts.create({ products: [productId], success_url: "http://localhost:3000/success" });
} catch (error) {
  if (error instanceof PolarClientError) console.error(error.statusCode, error.error);
  throw error;
}

测试清单

上线前:

  • 结账流程成功完成
  • 收到并处理 Webhook
  • 订阅生命周期正常(创建、取消、撤销)
  • 权益已正确授予和撤销
  • 许可证密钥验证正常
  • 已处理付款被拒的情况
  • 可访问客户门户
  • 已测试退款流程

评论

支持 Markdown