diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cc0254 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel an in-progress run when a newer commit is pushed to the same ref. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # pnpm version comes from the root package.json "packageManager" field (single source). + # Must run before setup-node so its `cache: pnpm` can find pnpm on PATH. + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm -r typecheck + + # The worker test suite needs no secrets: it sets its own dummy DISCORD_BOT_TOKEN, + # so it passes here without a .dev.vars (which is gitignored and never in CI). + - run: pnpm -r test + + - run: pnpm -r build diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index bb2f806..d8411ce 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -140,11 +140,19 @@ cd packages/worker wrangler secret put DISCORD_BOT_TOKEN # 貼上第 3 步的 token ``` -再建一個 `packages/worker/.dev.vars`(已被 gitignore)給「註冊 slash 指令」腳本與本地開發用: +再建一個 `packages/worker/.dev.vars`(已被 gitignore)給「註冊 slash 指令」腳本與本地開發用。直接從範本複製再填值: +```bash +cd packages/worker +cp .dev.vars.example .dev.vars # 然後填入真實值 +``` +內容(鍵見 `.dev.vars.example`): ``` +CLOUDFLARE_API_TOKEN=你的-cloudflare-api-token DISCORD_BOT_TOKEN=你的-bot-token DISCORD_APPLICATION_ID=你的-application-id +DISCORD_GUILD_ID=你的-guild-id ``` +> 註:跑測試(`pnpm test`)**不需要**這個檔——測試會自帶假 token,乾淨 clone/CI 沒有 `.dev.vars` 也能全綠。 --- diff --git a/package.json b/package.json index 9db01d5..9fb97c1 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,7 @@ "packageManager": "pnpm@10.33.0", "scripts": { "test": "pnpm -r test", - "typecheck": "pnpm -r typecheck" - }, - "pnpm": { - "onlyBuiltDependencies": ["esbuild", "workerd", "sharp"] + "typecheck": "pnpm -r typecheck", + "build": "pnpm -r build" } } diff --git a/packages/worker/.dev.vars.example b/packages/worker/.dev.vars.example new file mode 100644 index 0000000..9149eb6 --- /dev/null +++ b/packages/worker/.dev.vars.example @@ -0,0 +1,15 @@ +# Local dev secrets template. Copy to `.dev.vars` and fill in real values: +# cp .dev.vars.example .dev.vars +# +# `.dev.vars` is gitignored — never commit real secrets (this repo is public). +# NOTE: the test suite does NOT need this file — tests set their own dummy token, +# so `vitest run` passes on a clean checkout / in CI without any `.dev.vars`. +# +# These are only needed to actually run things locally: +# - `wrangler dev` -> CLOUDFLARE_API_TOKEN, DISCORD_BOT_TOKEN +# - `node scripts/register-commands.mjs` -> DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_GUILD_ID + +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token +DISCORD_BOT_TOKEN=your-discord-bot-token +DISCORD_APPLICATION_ID=your-discord-application-id +DISCORD_GUILD_ID=your-discord-guild-id diff --git a/packages/worker/test/adapters/discord-initiate.test.ts b/packages/worker/test/adapters/discord-initiate.test.ts index e3bef57..bbc651a 100644 --- a/packages/worker/test/adapters/discord-initiate.test.ts +++ b/packages/worker/test/adapters/discord-initiate.test.ts @@ -1,7 +1,7 @@ import { env } from "cloudflare:test"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { routeInteraction, type DiscordInteraction } from "../../src/adapters/discord/handler"; -import { taipeiPeriod } from "../../src/core/time"; +import { nextBillingPeriod } from "../../src/core/time"; const TS = "2026-05-01T00:00:00.000Z"; const WS = 9025; @@ -11,7 +11,11 @@ const NONADMIN = "rando-9025"; const PLAN = 9025; const SUB = 9025; const CHAN = "chan-9025"; -const PERIOD = taipeiPeriod(); +// The 發起繳費 modal opens the *next* billing period: the handler computes it with +// nextBillingPeriod(workspace.billing_day), which rolls to next month once today is past the +// billing day. Match that here (workspace billing_day = 5, seeded below) instead of assuming the +// current calendar month — that assumption only held on days 1–5 and broke after the 5th. +const PERIOD = nextBillingPeriod(5); const tasks: Promise[] = []; const CTX = { waitUntil: (p: Promise) => tasks.push(p) } as unknown as ExecutionContext; diff --git a/packages/worker/test/core/billing-initiate.test.ts b/packages/worker/test/core/billing-initiate.test.ts index bccbef6..bb48fcc 100644 --- a/packages/worker/test/core/billing-initiate.test.ts +++ b/packages/worker/test/core/billing-initiate.test.ts @@ -17,6 +17,9 @@ const notifier: Notifier = { }; beforeAll(async () => { + // Tests must not depend on .dev.vars (CI / a fresh clone has none). The notifier here is a + // fake, so this token only flips the env gate that lets initiateBillingOpened actually "send". + (env as any).DISCORD_BOT_TOKEN = "test-bot-token"; const settings = JSON.stringify({ discord_billing_channel_id: CHAN }); await env.DB.batch([ env.DB.prepare(`INSERT INTO workspaces (id,name,owner_id,channel_type,billing_day,settings,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`).bind(WS, "W", "o", "discord", 5, settings, TS, TS), diff --git a/packages/worker/test/core/scheduled.test.ts b/packages/worker/test/core/scheduled.test.ts index cb4643e..45b6aac 100644 --- a/packages/worker/test/core/scheduled.test.ts +++ b/packages/worker/test/core/scheduled.test.ts @@ -17,6 +17,9 @@ const notifier: Notifier = { }; beforeAll(async () => { + // Tests must not depend on .dev.vars (CI / a fresh clone has none). The notifier here is a + // fake, so this token only flips the env gate that lets runDailyTasks actually "send". + (env as any).DISCORD_BOT_TOKEN = "test-bot-token"; const settings = JSON.stringify({ discord_billing_channel_id: CHAN, overdue_days: 3, proof_retention_months: 24 }); await env.DB.batch([ env.DB.prepare(`INSERT INTO workspaces (id,name,owner_id,channel_type,billing_day,settings,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`).bind(WS, "W", "o", "discord", 5, settings, TS, TS), diff --git a/packages/worker/test/routes/admin.test.ts b/packages/worker/test/routes/admin.test.ts index d1873a2..b80e6b7 100644 --- a/packages/worker/test/routes/admin.test.ts +++ b/packages/worker/test/routes/admin.test.ts @@ -86,9 +86,14 @@ describe("admin API", () => { it("creates/rebuilds the persistent Discord payment message", async () => { await call("PATCH", "/admin/workspace", { settings: { discord_billing_channel_id: "chan-1" } }); + // Supply the bot token locally (CI has no .dev.vars), then restore it — the later + // billing/initiate test doesn't stub fetch, so it must keep its no-real-send behavior. + const prevToken = (env as any).DISCORD_BOT_TOKEN; + (env as any).DISCORD_BOT_TOKEN = "test-bot-token"; vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ id: "msg-123" }), { status: 200 }))); const res = await call("POST", "/admin/discord/payment-message"); vi.unstubAllGlobals(); + (env as any).DISCORD_BOT_TOKEN = prevToken; expect(res!.status).toBe(200); expect(((await res!.json()) as any).message_id).toBe("msg-123"); }); @@ -164,9 +169,12 @@ describe("admin notifications", () => { expect(st.billing_opened).toBeNull(); expect(st.overdue).toBeNull(); + const prevToken = (env as any).DISCORD_BOT_TOKEN; + (env as any).DISCORD_BOT_TOKEN = "test-bot-token"; vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 }))); const r = await call("POST", "/admin/notifications/resend", { type: "overdue", period: "2028-03" }); vi.unstubAllGlobals(); + (env as any).DISCORD_BOT_TOKEN = prevToken; expect(r!.status).toBe(200); expect(((await r!.json()) as any).count).toBeGreaterThanOrEqual(1); st = (await (await call("GET", "/admin/notifications?period=2028-03"))!.json()) as any; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..660fdbc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,11 @@ packages: - "packages/*" + +# pnpm 10 only runs build scripts for packages on this allowlist. Required so a fresh +# `pnpm install` (e.g. in CI) compiles these native deps — workerd powers the worker test +# pool, esbuild/sharp power the vite builds. (Moved here from package.json's "pnpm" field, +# which pnpm 10 no longer reads.) +onlyBuiltDependencies: + - esbuild + - workerd + - sharp