Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/shopify-live.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions checkin-app/docs/designs/SHOPIFY_LIVE_TESTS.md
Original file line number Diff line number Diff line change
@@ -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-<runId>`,
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.
3 changes: 3 additions & 0 deletions checkin-app/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
'<rootDir>/../.claude/worktrees/',
Expand Down
28 changes: 28 additions & 0 deletions checkin-app/jest.shopify-live.config.js
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/shopify-live/**/*.shopify-live.[jt]s'],
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
modulePathIgnorePatterns: ['<rootDir>/.next/'],
maxWorkers: 1,
testTimeout: 90_000,
});
1 change: 1 addition & 0 deletions checkin-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\\.\"",
Expand Down
71 changes: 71 additions & 0 deletions checkin-app/scripts/shopify-live-janitor.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
42 changes: 42 additions & 0 deletions checkin-app/shopify-live/api-version-canary.shopify-live.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading