diff --git a/.github/workflows/shopify-live.yml b/.github/workflows/shopify-live.yml new file mode 100644 index 00000000..f946f487 --- /dev/null +++ b/.github/workflows/shopify-live.yml @@ -0,0 +1,70 @@ +name: Shopify live contract tests (dev store) + +# Runs the shopify-live suite (checkin-app/shopify-live/, see +# docs/designs/SHOPIFY_LIVE_TESTS.md) against the REAL dev store — catches +# Admin-API contract drift the mocked suites structurally cannot. Nightly + +# manual; NEVER part of PR CI or pre-commit. +# +# DEV STORE ONLY, by construction: +# - SHOPIFY_STORE_DOMAIN is pinned to the dev store below; +# - the suite's guard (shopify-live/guard.ts, unit-tested in normal CI) +# additionally requires the SHOPIFY_LIVE_ALLOWED_DOMAIN repo variable to +# match, and hard-refuses the production domain no matter what. +# +# Dormant until provisioned (all in repo Settings): +# variables: SHOPIFY_LIVE_ENABLED=true, +# SHOPIFY_LIVE_ALLOWED_DOMAIN=treehouse-dev-4folhtgx.myshopify.com +# secrets: SHOPIFY_LIVE_CLIENT_ID / SHOPIFY_LIVE_CLIENT_SECRET — the DEV +# store app's credentials (same app the ops-dev deploy uses). + +on: + schedule: + - cron: "17 8 * * *" # nightly, off-peak for the org + workflow_dispatch: + +concurrency: + group: shopify-live # serialize against the dev store's rate budget + cancel-in-progress: false + +jobs: + live: + if: vars.SHOPIFY_LIVE_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CHECKIN_ENV: dev # real-store code paths; never the local mock + SHOPIFY_STORE_DOMAIN: treehouse-dev-4folhtgx.myshopify.com + SHOPIFY_LIVE_ALLOWED_DOMAIN: ${{ vars.SHOPIFY_LIVE_ALLOWED_DOMAIN }} + SHOPIFY_CLIENT_ID: ${{ secrets.SHOPIFY_LIVE_CLIENT_ID }} + SHOPIFY_CLIENT_SECRET: ${{ secrets.SHOPIFY_LIVE_CLIENT_SECRET }} + defaults: + run: + working-directory: checkin-app + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + working-directory: . + run: npm ci + + # Sweep leftovers from any previously-crashed run BEFORE testing, so + # stale citest resources can't confuse this run's assertions. + - name: Janitor (pre) + run: npx tsx scripts/shopify-live-janitor.ts + + - name: Run live contract suite + run: npm run test:shopify-live + + # Sweep again even on failure — a red run must not leak catalog objects. + # 0-hour cutoff: everything citest-tagged goes, including this run's. + - name: Janitor (post) + if: always() + env: + SHOPIFY_LIVE_JANITOR_MAX_AGE_HOURS: "0" + run: npx tsx scripts/shopify-live-janitor.ts diff --git a/AGENTS.md b/AGENTS.md index c3120c0e..96c96889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,13 @@ routes, auth, and DB together. reseeds every run). Don't rely on re-running against the same DB locally. - `*.flow.test.ts` is excluded from the unit/integration runs (it needs a live server) — keep it that way; run it only via `npm run test:flow`. +- `*.shopify-live.ts` (`checkin-app/shopify-live/`) hits the REAL Shopify + dev store — excluded from every local/CI run; executed only by + `.github/workflows/shopify-live.yml` (`npm run test:shopify-live`). Dev-store-only + by a triple guard; see `checkin-app/docs/designs/SHOPIFY_LIVE_TESTS.md`. + Deliberately NOT named `*.test.ts`: that keeps them structurally invisible to + every other jest invocation, including scripts that override ignore patterns + on the CLI (the test:coverage class). - **No test tier exercises real Google OAuth.** Flow tests authenticate via persona-mint (a credentials sign-in), which — with JWT sessions — never touches the NextAuth **PrismaAdapter**; the adapter's user methods run only diff --git a/checkin-app/docs/designs/SHOPIFY_LIVE_TESTS.md b/checkin-app/docs/designs/SHOPIFY_LIVE_TESTS.md new file mode 100644 index 00000000..0f4e9d46 --- /dev/null +++ b/checkin-app/docs/designs/SHOPIFY_LIVE_TESTS.md @@ -0,0 +1,89 @@ +# Shopify LIVE contract tests (dev store) + +**Status:** Implemented (layer 1 of the Shopify test plan). Nightly workflow is +dormant until credentials are provisioned (§5). +**Related:** `docs/designs/SHOPIFY_DEV_STORE_WEBHOOK.md`, +`docs/PROGRAM_CAPACITY_AND_SCHOLARSHIPS.md`, `lib/shopify.ts`. + +## 1. Problem + +Every automated Shopify test today — unit (`lib/__tests__/shopify.test.ts`), +integration (`CHECKIN_ENV=local` mock), flow — runs against a mocked Shopify. +Mocks verify *our* request-building; they structurally cannot catch: + +- **Semantics drift** we depend on: `allocation_method: 'each'` valuing a + qty-N cart per-unit (the multi-child overcharge class, caught in review of + #930, not by tests), relative `inventory_levels/adjust` behavior, + `inventory_policy: deny` refusing oversell, product `status` transitions. +- **API-version sunsets**: `SHOPIFY_API_VERSION` is pinned; when Shopify + retires it, calls get silently served by a different version. +- **Contract shape**: fields and headers as Shopify actually stores/returns + them, not as our fixtures remember them. + +## 2. Shape + +`checkin-app/shopify-live/` — a separate jest project +(`jest.shopify-live.config.js`, `npm run test:shopify-live`) excluded from +unit/CI/pre-commit runs, mirroring the flow-tests convention. The suites drive +the app's REAL `lib/shopify.ts` functions (`createShopifySingleVariantProgram`, +`adjustProgramInventory`, `mintMemberDiscountCode`, `getAccessToken`) with no +fetch mocks; only `@/lib/prisma` and `@/lib/email` are mocked, so the lib's +failure-report path can't write to a DB or email the board from a test run. + +Suites (serialized, `maxWorkers: 1`, ~25 Admin calls per run against the +2 req/s REST budget): + +| Suite | Contract pinned | +|---|---| +| `product-inventory` | create → active product, managed `deny` variant at the right price; initial inventory = maxParticipants; relative ±1 adjusts (the #930 hold-ledger ops) | +| `discount` | minted price rule is `fixed_amount` + **`each`** + entitled-variant-scoped + single-use + ~48h window | +| `api-version-canary` | `shop.json` answers 200 **on the pinned version** (`x-shopify-api-version` echo) with no `x-shopify-api-deprecated-reason` | + +## 3. Dev-store-only, by construction + +`shopify-live/guard.ts` (pure; unit-tested in the NORMAL CI suite via +`src/lib/__tests__/shopifyLiveGuard.test.ts`, so the guard is verified even +though the live suite never runs in CI): + +1. **Denylist** — the production domain (public, from infra + `modules/checkin/overview.tf`) is refused unconditionally. +2. **Double key** — `SHOPIFY_STORE_DOMAIN` (pinned in the workflow) must equal + the `SHOPIFY_LIVE_ALLOWED_DOMAIN` repo variable (provisioned by a human): + retargeting requires two deliberate edits in two places. +3. **Shape** — `*.myshopify.com` only. + +## 4. Cleanup discipline + +- Every created resource is tagged: product titles carry `citest-`, + price rules use the reserved `PRG9999999xx-` programId range. +- Suites register ids the moment they exist (`trackProduct`/`trackPriceRule`) + and delete them in `afterAll`, so a failed assertion still cleans up. +- `scripts/shopify-live-janitor.ts` sweeps tagged resources: before each run + (24h cutoff — clears crashed-run leftovers) and after each run with + `always()` + 0h cutoff (a red run must not leak catalog objects). Safe to run + by hand. + +## 5. Provisioning (one-time, repo Settings) + +- Variables: `SHOPIFY_LIVE_ENABLED=true`, + `SHOPIFY_LIVE_ALLOWED_DOMAIN=treehouse-dev-4folhtgx.myshopify.com`. +- Secrets: `SHOPIFY_LIVE_CLIENT_ID` / `SHOPIFY_LIVE_CLIENT_SECRET` — the dev + store app's credentials (scopes: `write_products`, `write_inventory`, + `write_price_rules`, `read_locations`). +- Until then the workflow's `if:` gate keeps it dormant; the first + `workflow_dispatch` run is the acceptance test. + +Nightly red is **non-blocking** (notification-only) until the suite has a +week of green; then consider making it a required check on `promote-prod`. + +## 6. Deliberately deferred + +- **E2E webhook smoke** (Admin-API-created paid order → real `orders/paid` → + ops-dev activation) — layer 2 of the plan; needs the dev store's webhook + pointed at ops-dev (#740's registration script). +- **Config-drift reconciliation** (BoardSettings/program variant ids vs the + store) — layer 3. +- Product **archive-status** contract — belongs with #955 when it merges + (`setProgramListingArchived` doesn't exist on main yet). +- Hosted-checkout UI automation — Shopify's UI, brittle, low value; the + API-created-paid-order path covers everything downstream of payment. diff --git a/checkin-app/jest.config.js b/checkin-app/jest.config.js index 0b832554..2e720393 100644 --- a/checkin-app/jest.config.js +++ b/checkin-app/jest.config.js @@ -90,6 +90,9 @@ const customJestConfig = { // Flow tests drive a running dev server over HTTP — excluded from the // default/unit run; run with `npm run test:flow` (see AGENTS.md). '\\.flow\\.test\\.[jt]sx?$', + // (Shopify LIVE tests need no entry here: they are *.shopify-live.ts — + // no .test suffix — so no default testMatch or CLI-overridden ignore + // list can ever pick them up. See jest.shopify-live.config.js.) ], modulePathIgnorePatterns: [ '/../.claude/worktrees/', diff --git a/checkin-app/jest.shopify-live.config.js b/checkin-app/jest.shopify-live.config.js new file mode 100644 index 00000000..54d395f8 --- /dev/null +++ b/checkin-app/jest.shopify-live.config.js @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ dir: './' }); + +// LIVE Shopify contract suite (docs/designs/SHOPIFY_LIVE_TESTS.md): drives the +// real lib/shopify.ts functions against the REAL dev store. Excluded from the +// unit/CI/pre-commit runs (see testPathIgnorePatterns in jest.config.js); run +// via `npm run test:shopify-live` where dev-store credentials are provisioned +// (.github/workflows/shopify-live.yml). Serialized (maxWorkers 1) to respect +// the Admin API rate budget; generous timeout — each test is several real +// network round trips. +module.exports = createJestConfig({ + testEnvironment: 'node', + // Deliberately skips jest.setup.js: that file mocks prisma/next-auth and + // polyfills fetch for in-process unit tests — the live suite wants native + // fetch and mocks only what it declares (same convention as flow tests). + setupFilesAfterEnv: [], + // Deliberately NOT *.test.ts: the default jest testMatch (and any script + // that overrides testPathIgnorePatterns on the CLI, e.g. test:coverage) + // matches every *.test.ts under rootDir — the .test-free suffix makes the + // live suite structurally invisible to every other jest invocation. + testMatch: ['/shopify-live/**/*.shopify-live.[jt]s'], + moduleNameMapper: { '^@/(.*)$': '/src/$1' }, + modulePathIgnorePatterns: ['/.next/'], + maxWorkers: 1, + testTimeout: 90_000, +}); diff --git a/checkin-app/package.json b/checkin-app/package.json index f2557fd9..0b9cc030 100644 --- a/checkin-app/package.json +++ b/checkin-app/package.json @@ -10,6 +10,7 @@ "test": "jest --runInBand", "test:ci": "jest --ci --runInBand", "test:flow": "jest --config jest.flow.config.js --ci --runInBand --forceExit", + "test:shopify-live": "jest --config jest.shopify-live.config.js --ci --runInBand", "test:flow:standalone": "docker compose -f ../deploy/docker-compose.flow.yml up -d --wait --wait-timeout 600 && docker compose -f ../deploy/docker-compose.flow.yml exec -T app sh -lc \"cd checkin-app && npm run test:flow\"; ec=$?; docker compose -f ../deploy/docker-compose.flow.yml down -v; exit $ec", "test:integration": "INTEGRATION_DB=1 jest --ci --testPathIgnorePatterns=/node_modules/ --testRegex \"\\.integration\\.test\\.[jt]sx?$\"", "test:coverage": "INTEGRATION_DB=1 jest --ci --coverage --testPathIgnorePatterns \"/node_modules/|/\\.next/|/\\.claude/worktrees/|\\.flow\\.test\\.\"", diff --git a/checkin-app/scripts/shopify-live-janitor.ts b/checkin-app/scripts/shopify-live-janitor.ts new file mode 100644 index 00000000..80d35205 --- /dev/null +++ b/checkin-app/scripts/shopify-live-janitor.ts @@ -0,0 +1,71 @@ +/** + * Janitor for the Shopify LIVE test suite: deletes citest-tagged products and + * PRG9999999xx price rules older than MAX_AGE_HOURS from the DEV store, so a + * crashed run can never accumulate junk. Runs before and after the suite in + * .github/workflows/shopify-live.yml; safe to run by hand: + * + * npx tsx scripts/shopify-live-janitor.ts + * + * Refuses to run against anything but the allowed dev store (guard.ts). + */ +import { assertLiveTestStore, CITEST_PREFIX, CITEST_PROGRAM_ID_BASE } from "../shopify-live/guard"; + +const MAX_AGE_HOURS = Number(process.env.SHOPIFY_LIVE_JANITOR_MAX_AGE_HOURS ?? 24); +const API_VERSION = "2026-01"; // keep aligned with lib/shopify.ts SHOPIFY_API_VERSION + +const PRICE_RULE_RE = new RegExp(`^PRG${CITEST_PROGRAM_ID_BASE.toString().slice(0, -2)}\\d{2}-`); + +async function main(): Promise { + const domain = assertLiveTestStore(process.env); + const base = `https://${domain}/admin/api/${API_VERSION}`; + + const tokenRes = await fetch(`${base.replace(/\/admin\/api\/.*$/, "")}/admin/oauth/access_token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: process.env.SHOPIFY_CLIENT_ID!, + client_secret: process.env.SHOPIFY_CLIENT_SECRET!, + }).toString(), + signal: AbortSignal.timeout(20_000), + }); + if (!tokenRes.ok) throw new Error(`janitor: token exchange failed ${tokenRes.status}`); + const token = ((await tokenRes.json()) as { access_token: string }).access_token; + const headers = { "X-Shopify-Access-Token": token }; + + const cutoff = Date.now() - MAX_AGE_HOURS * 60 * 60 * 1000; + const isStale = (createdAt: string) => new Date(createdAt).getTime() < cutoff; + let deleted = 0; + + const productsRes = await fetch(`${base}/products.json?limit=250&fields=id,title,created_at`, { + headers, + signal: AbortSignal.timeout(20_000), + }); + if (!productsRes.ok) throw new Error(`janitor: products list failed ${productsRes.status}`); + const { products } = (await productsRes.json()) as { products: { id: number; title: string; created_at: string }[] }; + for (const p of products) { + if (p.title.includes(CITEST_PREFIX) && isStale(p.created_at)) { + const del = await fetch(`${base}/products/${p.id}.json`, { method: "DELETE", headers, signal: AbortSignal.timeout(20_000) }); + console.log(`janitor: product ${p.id} "${p.title}" -> ${del.status}`); + deleted++; + } + } + + const rulesRes = await fetch(`${base}/price_rules.json?limit=250`, { headers, signal: AbortSignal.timeout(20_000) }); + if (!rulesRes.ok) throw new Error(`janitor: price_rules list failed ${rulesRes.status}`); + const { price_rules } = (await rulesRes.json()) as { price_rules: { id: number; title: string; created_at: string }[] }; + for (const r of price_rules) { + if (PRICE_RULE_RE.test(r.title) && isStale(r.created_at)) { + const del = await fetch(`${base}/price_rules/${r.id}.json`, { method: "DELETE", headers, signal: AbortSignal.timeout(20_000) }); + console.log(`janitor: price_rule ${r.id} "${r.title}" -> ${del.status}`); + deleted++; + } + } + + console.log(`janitor: done — ${deleted} stale citest resource(s) deleted (cutoff ${MAX_AGE_HOURS}h).`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/checkin-app/shopify-live/api-version-canary.shopify-live.ts b/checkin-app/shopify-live/api-version-canary.shopify-live.ts new file mode 100644 index 00000000..039583e2 --- /dev/null +++ b/checkin-app/shopify-live/api-version-canary.shopify-live.ts @@ -0,0 +1,42 @@ +/** + * @jest-environment node + */ +/** + * LIVE canary: the pinned Admin API version (lib/shopify.ts SHOPIFY_API_VERSION) + * is still served as requested and not deprecation-flagged. Shopify sunsets + * versions quarterly; when the pin lapses, requests get silently served by the + * oldest supported version — this turns that into a red nightly instead of a + * subtle prod behavior change. + */ +jest.mock("@/lib/prisma", () => ({ __esModule: true, default: {} })); +jest.mock("@/lib/email", () => ({ sendEmail: jest.fn() })); + +import { getAccessToken, shopifyFetch, SHOPIFY_API_VERSION } from "@/lib/shopify"; +import { ensureLiveStore, adminUrl } from "./helpers"; + +describe("live: pinned API version canary", () => { + beforeAll(() => { + ensureLiveStore(); + }); + + it(`shop.json answers on ${SHOPIFY_API_VERSION} without deprecation or version fallback`, async () => { + const token = await getAccessToken(); + expect(token).not.toBeNull(); + + const res = await shopifyFetch( + adminUrl("shop.json"), + { headers: { "X-Shopify-Access-Token": token! } }, + "live shop.json canary", + ); + expect(res.status).toBe(200); + + // Served version must be the requested pin — a mismatch means Shopify + // no longer supports it and silently substituted another version. + expect(res.headers.get("x-shopify-api-version")).toBe(SHOPIFY_API_VERSION); + // Any value here means the call pattern is deprecated on this version. + expect(res.headers.get("x-shopify-api-deprecated-reason")).toBeNull(); + + const body = (await res.json()) as { shop: { myshopify_domain: string } }; + expect(body.shop.myshopify_domain).toBe(process.env.SHOPIFY_STORE_DOMAIN); + }); +}); diff --git a/checkin-app/shopify-live/discount.shopify-live.ts b/checkin-app/shopify-live/discount.shopify-live.ts new file mode 100644 index 00000000..b4ef4ba7 --- /dev/null +++ b/checkin-app/shopify-live/discount.shopify-live.ts @@ -0,0 +1,68 @@ +/** + * @jest-environment node + */ +/** + * LIVE contract: the member-discount price rule minted for single-pool member + * checkout. The per-unit pricing of multi-child household carts hangs entirely + * on allocation_method 'each' semantics (see the #930 review) — this pins the + * rule configuration Shopify actually stores, not what we think we sent. + */ +jest.mock("@/lib/prisma", () => ({ __esModule: true, default: {} })); +jest.mock("@/lib/email", () => ({ sendEmail: jest.fn() })); + +import { createShopifySingleVariantProgram, mintMemberDiscountCode } from "@/lib/shopify"; +import { CITEST_PROGRAM_ID_BASE } from "./guard"; +import { + ensureLiveStore, + testRunName, + trackProduct, + trackPriceRule, + cleanupTracked, + findPriceRuleByTitle, +} from "./helpers"; + +describe("live: member discount price-rule contract", () => { + let variantId: string; + + beforeAll(async () => { + ensureLiveStore(); + const created = await createShopifySingleVariantProgram(testRunName("discount"), 5000, 3); + expect(created).not.toBeNull(); + trackProduct(created!.shopifyProductId); + variantId = created!.shopifyVariantId; + }); + + afterAll(async () => { + await cleanupTracked(); + }); + + it("mints a code whose stored rule is fixed_amount, per-unit ('each'), scoped to the variant, single-use", async () => { + // programId from the janitor-recognizable reserved range (PRG9999999xx-…). + const code = await mintMemberDiscountCode(CITEST_PROGRAM_ID_BASE + 1, variantId, 1000); + expect(code).toMatch(new RegExp(`^PRG${CITEST_PROGRAM_ID_BASE + 1}-[0-9A-F]{8}$`)); + + const rule = await findPriceRuleByTitle(code!); + expect(rule).not.toBeNull(); + trackPriceRule(rule!.id); + + // The load-bearing semantics for multi-child member carts (variant:N): + // fixed_amount + 'each' takes the amount off EVERY unit; 'across' would + // subtract it once from the whole line (the overcharge bug class). + expect(rule!.value_type).toBe("fixed_amount"); + expect(rule!.value).toBe("-10.00"); + expect(rule!.allocation_method).toBe("each"); + expect(rule!.target_type).toBe("line_item"); + expect(rule!.target_selection).toBe("entitled"); + expect(rule!.entitled_variant_ids.map(String)).toEqual([variantId]); + expect(rule!.usage_limit).toBe(1); + expect(rule!.once_per_customer).toBe(true); + // ~48h validity window per design. + const windowMs = new Date(rule!.ends_at!).getTime() - new Date(rule!.starts_at).getTime(); + expect(windowMs).toBeGreaterThan(47 * 60 * 60 * 1000); + expect(windowMs).toBeLessThan(49 * 60 * 60 * 1000); + }); + + it("returns null (no rule) for a zero discount", async () => { + expect(await mintMemberDiscountCode(CITEST_PROGRAM_ID_BASE + 2, variantId, 0)).toBeNull(); + }); +}); diff --git a/checkin-app/shopify-live/guard.ts b/checkin-app/shopify-live/guard.ts new file mode 100644 index 00000000..77ad784c --- /dev/null +++ b/checkin-app/shopify-live/guard.ts @@ -0,0 +1,67 @@ +/** + * Hard dev-store-only guard for the Shopify LIVE test suite + janitor. + * + * These tests create and delete real products/price rules over the Admin API, + * so pointing them at the production store must be impossible by construction, + * not by convention. Three independent checks, all of which must pass: + * + * 1. DENYLIST — the production store domain (public, checked into + * infra modules/checkin/overview.tf) is refused outright, even if every + * other variable says to proceed. + * 2. DOUBLE KEY — SHOPIFY_STORE_DOMAIN must EQUAL + * SHOPIFY_LIVE_ALLOWED_DOMAIN. The workflow wires the store domain; a + * human provisions the allowed-domain repo variable. Retargeting the + * suite therefore requires two deliberate edits in two places. + * 3. SHAPE — the domain must be a *.myshopify.com storefront. + * + * Pure module (no jest, no app imports) so the normal unit suite can cover it + * — the guard itself is CI-tested even though the live suite never runs in CI. + */ + +/** The PRODUCTION store. Never a valid live-test target under any configuration. */ +export const PROD_STORE_DOMAIN = "9jhydb-ka.myshopify.com"; + +/** Title prefix for every resource the live suite creates; the janitor sweeps it. */ +export const CITEST_PREFIX = "citest-"; + +/** + * Reserved programId range for live-test discount codes: mintMemberDiscountCode + * titles rules `PRG-XXXX`, so this range makes them janitor-recognizable. + */ +export const CITEST_PROGRAM_ID_BASE = 999_999_900; + +export function assertLiveTestStore(env: NodeJS.ProcessEnv = process.env): string { + const domain = env.SHOPIFY_STORE_DOMAIN; + const allowed = env.SHOPIFY_LIVE_ALLOWED_DOMAIN; + + if (!domain || !env.SHOPIFY_CLIENT_ID || !env.SHOPIFY_CLIENT_SECRET) { + throw new Error( + "shopify-live: missing SHOPIFY_STORE_DOMAIN / SHOPIFY_CLIENT_ID / SHOPIFY_CLIENT_SECRET — " + + "these tests only run where dev-store credentials are provisioned (see .github/workflows/shopify-live.yml).", + ); + } + if (domain === PROD_STORE_DOMAIN) { + throw new Error( + `shopify-live: REFUSING to run against the production store (${PROD_STORE_DOMAIN}). ` + + "This suite creates and deletes real catalog objects.", + ); + } + if (!allowed) { + throw new Error( + "shopify-live: SHOPIFY_LIVE_ALLOWED_DOMAIN is not set. Set it to the dev store domain " + + "(second key of the two-person rule) — never to the production domain.", + ); + } + if (allowed === PROD_STORE_DOMAIN) { + throw new Error("shopify-live: SHOPIFY_LIVE_ALLOWED_DOMAIN must never be the production store."); + } + if (domain !== allowed) { + throw new Error( + `shopify-live: SHOPIFY_STORE_DOMAIN (${domain}) != SHOPIFY_LIVE_ALLOWED_DOMAIN (${allowed}) — refusing.`, + ); + } + if (!domain.endsWith(".myshopify.com")) { + throw new Error(`shopify-live: ${domain} is not a *.myshopify.com storefront — refusing.`); + } + return domain; +} diff --git a/checkin-app/shopify-live/helpers.ts b/checkin-app/shopify-live/helpers.ts new file mode 100644 index 00000000..70af4b05 --- /dev/null +++ b/checkin-app/shopify-live/helpers.ts @@ -0,0 +1,112 @@ +/** + * Shared plumbing for the Shopify LIVE contract suite (see + * docs/designs/SHOPIFY_LIVE_TESTS.md). These tests run the app's REAL + * lib/shopify.ts functions against the REAL dev store — no fetch mocks — to + * catch Admin-API contract drift that mocked tests structurally cannot. + * + * Every suite must call ensureLiveStore() in beforeAll (dev-store-only guard) + * and register created resources with track*() so afterAll + the janitor can + * clean up even after a mid-test crash. + */ +import { assertLiveTestStore, CITEST_PREFIX } from "./guard"; +import { getAccessToken, shopifyFetch, SHOPIFY_API_VERSION } from "@/lib/shopify"; + +export function ensureLiveStore(): string { + return assertLiveTestStore(process.env); +} + +/** Unique, janitor-recognizable name for this run's resources. */ +export function testRunName(suite: string): string { + return `${CITEST_PREFIX}${suite}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; +} + +async function authHeaders(): Promise> { + const token = await getAccessToken(); + if (!token) throw new Error("shopify-live: could not mint an Admin API token — check dev credentials"); + return { "Content-Type": "application/json", "X-Shopify-Access-Token": token }; +} + +export function adminUrl(path: string): string { + const domain = process.env.SHOPIFY_STORE_DOMAIN; + return `https://${domain}/admin/api/${SHOPIFY_API_VERSION}/${path}`; +} + +/** GET an Admin resource, throwing on non-2xx with the body in the message. */ +export async function adminGet(path: string): Promise { + const res = await shopifyFetch(adminUrl(path), { headers: await authHeaders() }, `live GET ${path}`); + if (!res.ok) throw new Error(`live GET ${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`); + return (await res.json()) as T; +} + +/** DELETE an Admin resource; 404 counts as already-gone (idempotent cleanup). */ +export async function adminDelete(path: string): Promise { + const res = await shopifyFetch(adminUrl(path), { method: "DELETE", headers: await authHeaders() }, `live DELETE ${path}`); + if (!res.ok && res.status !== 404) { + throw new Error(`live DELETE ${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`); + } +} + +// ── Cleanup registry: everything created gets registered the moment its id is +// known, so afterAll deletes it even when a later assertion throws. ────────── +const createdProductIds: string[] = []; +const createdPriceRuleIds: string[] = []; + +export function trackProduct(id: string | number | null | undefined): void { + if (id) createdProductIds.push(String(id)); +} +export function trackPriceRule(id: string | number | null | undefined): void { + if (id) createdPriceRuleIds.push(String(id)); +} + +export async function cleanupTracked(): Promise { + for (const id of createdPriceRuleIds.splice(0)) { + await adminDelete(`price_rules/${id}.json`).catch((e) => console.error("cleanup price_rule", id, e)); + } + for (const id of createdProductIds.splice(0)) { + await adminDelete(`products/${id}.json`).catch((e) => console.error("cleanup product", id, e)); + } +} + +// ── Typed slices of the Admin responses the assertions read. ──────────────── +export interface AdminVariant { + id: number; + product_id: number; + price: string; + inventory_item_id: number; + inventory_management: string | null; + inventory_policy: string; +} + +export async function getVariant(variantId: string): Promise { + const data = await adminGet<{ variant: AdminVariant }>(`variants/${variantId}.json`); + return data.variant; +} + +export async function getAvailable(inventoryItemId: number): Promise { + const data = await adminGet<{ inventory_levels: { available: number }[] }>( + `inventory_levels.json?inventory_item_ids=${inventoryItemId}`, + ); + if (data.inventory_levels.length === 0) throw new Error("no inventory level rows for item"); + return data.inventory_levels.reduce((sum, l) => sum + (l.available ?? 0), 0); +} + +export interface AdminPriceRule { + id: number; + title: string; + value_type: string; + value: string; + allocation_method: string; + target_type: string; + target_selection: string; + entitled_variant_ids: number[]; + usage_limit: number | null; + once_per_customer: boolean; + starts_at: string; + ends_at: string | null; +} + +/** Find the price rule mintMemberDiscountCode created — its title IS the code. */ +export async function findPriceRuleByTitle(title: string): Promise { + const data = await adminGet<{ price_rules: AdminPriceRule[] }>(`price_rules.json?limit=250`); + return data.price_rules.find((r) => r.title === title) ?? null; +} diff --git a/checkin-app/shopify-live/product-inventory.shopify-live.ts b/checkin-app/shopify-live/product-inventory.shopify-live.ts new file mode 100644 index 00000000..34b38f99 --- /dev/null +++ b/checkin-app/shopify-live/product-inventory.shopify-live.ts @@ -0,0 +1,75 @@ +/** + * @jest-environment node + */ +/** + * LIVE contract: single-pool product creation + relative inventory adjustment, + * exercised through the app's real lib functions against the real dev store. + * These are the exact call sequences the capacity model (#930, + * docs/PROGRAM_CAPACITY_AND_SCHOLARSHIPS.md) depends on. + */ +// The lib's failure path reports through prisma + email — keep the live suite +// DB- and mail-free (a live failure should fail the test, not email the board). +jest.mock("@/lib/prisma", () => ({ __esModule: true, default: {} })); +jest.mock("@/lib/email", () => ({ sendEmail: jest.fn() })); + +import { createShopifySingleVariantProgram, adjustProgramInventory } from "@/lib/shopify"; +import { + ensureLiveStore, + testRunName, + trackProduct, + cleanupTracked, + getVariant, + getAvailable, +} from "./helpers"; + +const MAX_PARTICIPANTS = 5; + +describe("live: single-pool product + inventory contract", () => { + let productId: string; + let variantId: string; + + beforeAll(() => { + ensureLiveStore(); + }); + + afterAll(async () => { + await cleanupTracked(); + }); + + it("createShopifySingleVariantProgram creates an active product with a managed, deny-policy variant", async () => { + const created = await createShopifySingleVariantProgram(testRunName("prodinv"), 2500, MAX_PARTICIPANTS); + expect(created).not.toBeNull(); + productId = created!.shopifyProductId; + variantId = created!.shopifyVariantId; + trackProduct(productId); + + const variant = await getVariant(variantId); + expect(String(variant.product_id)).toBe(productId); + expect(variant.price).toBe("25.00"); + // The capacity model's load-bearing settings: Shopify tracks the count + // and REFUSES oversell (policy deny) — see #930. + expect(variant.inventory_management).toBe("shopify"); + expect(variant.inventory_policy).toBe("deny"); + }); + + it("seeds initial inventory to maxParticipants", async () => { + const variant = await getVariant(variantId); + expect(await getAvailable(variant.inventory_item_id)).toBe(MAX_PARTICIPANTS); + }); + + it("adjustProgramInventory applies RELATIVE deltas (scholarship hold -1, release +1)", async () => { + const program = { shopifyVariantId: variantId, shopifyOrgMemberVariantId: null, shopifyNonOrgMemberVariantId: null }; + const variant = await getVariant(variantId); + + expect(await adjustProgramInventory(program, -1)).toBe(true); + expect(await getAvailable(variant.inventory_item_id)).toBe(MAX_PARTICIPANTS - 1); + + expect(await adjustProgramInventory(program, +1)).toBe(true); + expect(await getAvailable(variant.inventory_item_id)).toBe(MAX_PARTICIPANTS); + }); + + it("free programs (no price) create nothing", async () => { + expect(await createShopifySingleVariantProgram(testRunName("free"), null, 5)).toBeNull(); + expect(await createShopifySingleVariantProgram(testRunName("zero"), 0, 5)).toBeNull(); + }); +}); diff --git a/checkin-app/src/lib/__tests__/shopifyLiveGuard.test.ts b/checkin-app/src/lib/__tests__/shopifyLiveGuard.test.ts new file mode 100644 index 00000000..2bba5506 --- /dev/null +++ b/checkin-app/src/lib/__tests__/shopifyLiveGuard.test.ts @@ -0,0 +1,57 @@ +/** + * The dev-store-only guard for the Shopify LIVE suite. The live tests never run + * in CI, but the guard MUST — it's the thing standing between the suite and the + * production store, so it gets covered by the normal unit run. + */ +import { assertLiveTestStore, PROD_STORE_DOMAIN } from "../../../shopify-live/guard"; + +const DEV = "treehouse-dev-4folhtgx.myshopify.com"; + +const base = { + SHOPIFY_STORE_DOMAIN: DEV, + SHOPIFY_LIVE_ALLOWED_DOMAIN: DEV, + SHOPIFY_CLIENT_ID: "cid", + SHOPIFY_CLIENT_SECRET: "csec", +} as unknown as NodeJS.ProcessEnv; + +describe("assertLiveTestStore", () => { + it("passes for the dev store with the double key set", () => { + expect(assertLiveTestStore(base)).toBe(DEV); + }); + + it("REFUSES the production store even when every other variable allows it", () => { + expect(() => + assertLiveTestStore({ ...base, SHOPIFY_STORE_DOMAIN: PROD_STORE_DOMAIN, SHOPIFY_LIVE_ALLOWED_DOMAIN: PROD_STORE_DOMAIN }), + ).toThrow(/REFUSING.*production/); + }); + + it("refuses when the allowed-domain second key is the production store", () => { + expect(() => + assertLiveTestStore({ ...base, SHOPIFY_LIVE_ALLOWED_DOMAIN: PROD_STORE_DOMAIN }), + ).toThrow(/never be the production store/); + }); + + it("refuses on a domain/allowed-domain mismatch (single-edit retarget)", () => { + expect(() => + assertLiveTestStore({ ...base, SHOPIFY_STORE_DOMAIN: "other-store.myshopify.com" }), + ).toThrow(/!= SHOPIFY_LIVE_ALLOWED_DOMAIN/); + }); + + it("refuses when the allowed-domain key is missing entirely", () => { + const env = { ...base } as NodeJS.ProcessEnv; + delete env.SHOPIFY_LIVE_ALLOWED_DOMAIN; + expect(() => assertLiveTestStore(env)).toThrow(/SHOPIFY_LIVE_ALLOWED_DOMAIN is not set/); + }); + + it("refuses when credentials are missing", () => { + const env = { ...base } as NodeJS.ProcessEnv; + delete env.SHOPIFY_CLIENT_SECRET; + expect(() => assertLiveTestStore(env)).toThrow(/missing SHOPIFY_STORE_DOMAIN/); + }); + + it("refuses non-myshopify domains", () => { + expect(() => + assertLiveTestStore({ ...base, SHOPIFY_STORE_DOMAIN: "evil.example.com", SHOPIFY_LIVE_ALLOWED_DOMAIN: "evil.example.com" }), + ).toThrow(/not a \*\.myshopify\.com/); + }); +});