Compare commits

...

10 commits

Author SHA1 Message Date
5a69d6ab75 style(visual-fidelity): missed Batch B row in admin/members
Some checks failed
Test / vitest (pull_request) Successful in 11m59s
Test / playwright (pull_request) Failing after 9m53s
Test / visual (pull_request) Failing after 9m20s
Test / Notify on failure (pull_request) Successful in 1s
.row-error background was the one rgba leftover from the
pages-admin slice — line had shifted from 1309 to 1307 after
earlier Batch B edits.
2026-04-30 11:47:44 +01:00
d6cdf45838 style(visual-fidelity): components — batches B,E,G,H
- B: token-equivalent rgba → color-mix in SignupFlowOverlay, OnboardingWidget
- E: drop text-white Tailwind utility from ImageUpload remove-button (now color: var(--parch-text) inline)
- G: typography off-scale snaps (9→10, 14→13, 15→16, 19→18 px)
- H: padding off-scale snaps in BoardPostCard/Form, CirclePicker, FilterBar, LoginModal
2026-04-30 00:13:13 +01:00
cb93f14160 style(visual-fidelity): pages-admin — batches B,C,F
- B: token-equivalent rgba → color-mix(srgb, var(--ember|green|candle) X%, transparent) so colors track dark mode
- C: drop stale var(--green, #...) fallbacks (canonical token now defined in main.css)
- F: inline circle badge → <CircleBadge/> in admin/index, members/[id], members/index
2026-04-30 00:13:09 +01:00
d93c16fbf7 style(visual-fidelity): pages-auth — batches D,G
font-weight 700 → 600 across auth pages; wiki-login hero 32→36
2026-04-30 00:13:05 +01:00
cad57b0083 style(visual-fidelity): pages-public — batches A,D,F,G,H
- about.vue: promote h3 → h2 on circle headings (h1→h2→h2→h2)
- coming-soon.vue: font-weight 700 → 600
- members/[id].vue: inline circle badge → <CircleBadge/>; hero size 42→36
- community-guidelines.vue: padding + font-size off-scale snaps
- board.vue: loading/empty padding 60→64
- series/index.vue, join.vue: padding off-scale snaps
2026-04-30 00:13:02 +01:00
1c2d1537a8 docs(backlog): log 2026-04-29 simplify-pass and deferred follow-ups 2026-04-29 21:50:43 +01:00
26791cc0e3 chore(simplify): trim narrating comments and dedup test body
Test file: drop step markers, regression explainers, and the lead
comment block that restated the contract; hoist the shared subscription
request body to a const; move Member mock defaults into the test that
uses them. Two it() cases unchanged.

Events page: drop WCAG comment that narrated what the
.past-toggle:focus-visible selector already says.
2026-04-29 21:50:00 +01:00
6527bbbe4e test(api): cover free-signup → subscription bridge-cookie hand-off
Two tests guarding the regression where /api/helcim/customer skipped
setPaymentBridgeCookie for $0 signups and left the user unable to
complete activation. Second test confirms the auth gate on
/api/helcim/subscription still rejects fresh unauthenticated calls.
2026-04-29 21:00:27 +01:00
90acc35792 fix(helcim): always issue payment-bridge cookie on signup
Free ($0) signups need the same short-lived bridge cookie as paid signups
so /api/helcim/subscription can identify the member during activation
without a verified auth session. Drops the contributionAmount > 0 guard
that broke free-tier activation in the same flow.
2026-04-29 21:00:22 +01:00
dbd46cc157 docs(backlog): strike EventSeriesBadge dead-code follow-up as shipped 2026-04-29 20:57:06 +01:00
31 changed files with 206 additions and 69 deletions

View file

@ -158,7 +158,7 @@ const slackLinks = computed(() => {
<style scoped>
.board-post {
border: 1px dashed var(--border);
padding: 18px 22px;
padding: 20px 24px;
background: var(--surface);
break-inside: avoid;
-webkit-column-break-inside: avoid;
@ -219,7 +219,7 @@ const slackLinks = computed(() => {
.post-title {
font-family: "Brygada 1918", serif;
font-size: 19px;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 12px;

View file

@ -138,7 +138,7 @@ function handleSubmit() {
<style scoped>
.post-form {
border: 1px dashed var(--border);
padding: 14px 16px;
padding: 16px 16px;
background: transparent;
}
@ -147,7 +147,7 @@ function handleSubmit() {
}
.form-title {
font-family: "Brygada 1918", serif;
font-size: 15px;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
}
@ -183,7 +183,7 @@ function handleSubmit() {
color: var(--text-faint);
text-transform: none;
letter-spacing: 0;
font-size: 9px;
font-size: 10px;
margin-left: 4px;
opacity: 0.7;
}

View file

@ -48,7 +48,7 @@ defineEmits(['update:modelValue'])
.circle-option {
border: 1px dashed var(--border);
padding: 14px 12px;
padding: 12px 12px;
background: var(--bg);
cursor: pointer;
transition: all 0.15s;
@ -83,7 +83,7 @@ defineEmits(['update:modelValue'])
}
.circle-tag {
font-size: 9px;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 6px;

View file

@ -104,7 +104,7 @@ const formatDate = (dateStr) => {
}
.em-circle {
font-size: 9px;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 2px;

View file

@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
<style scoped>
.filter-bar {
padding: 14px 32px;
padding: 16px 28px;
border-bottom: 1px dashed var(--border);
display: flex;
align-items: center;

View file

@ -12,8 +12,8 @@
>
<button
type="button"
class="absolute top-2 right-2 p-1 text-white rounded-full transition-colors"
style="background: var(--ember)"
class="absolute top-2 right-2 p-1 rounded-full transition-colors"
style="background: var(--ember); color: var(--parch-text)"
@click="removeImage"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />

View file

@ -40,7 +40,7 @@
type="email"
placeholder="your.email@example.com"
required
/>
>
</div>
<div class="info-box">
@ -182,7 +182,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.modal-overline {
font-family: 'Brygada 1918', serif;
font-size: 14px;
font-size: 13px;
font-weight: 600;
color: var(--candle);
margin-bottom: 12px;
@ -218,7 +218,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.info-box {
font-size: 11px;
color: var(--text-faint);
padding: 10px 14px;
padding: 12px 16px;
border: 1px dashed var(--border);
margin-bottom: 16px;
line-height: 1.6;

View file

@ -118,7 +118,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
display: inline-block;
margin-top: 8px;
padding: 4px 12px;
border: 1px dashed rgba(237, 228, 208, 0.25);
border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
color: var(--parch-accent);
font-size: 11px;
text-decoration: none;
@ -134,7 +134,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
.ow-progress {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed rgba(237, 228, 208, 0.12);
border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
font-size: 11px;
color: var(--parch-text-dim);
display: flex;
@ -153,7 +153,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
}
.ow-bar-empty {
color: rgba(237, 228, 208, 0.2);
color: color-mix(in srgb, var(--parch-text) 20%, transparent);
}
.ow-skip {

View file

@ -108,7 +108,7 @@ const stepLabel = computed(() => {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(42, 32, 21, 0.72);
background: color-mix(in srgb, var(--parch) 72%, transparent);
backdrop-filter: blur(4px);
display: flex;
align-items: center;

View file

@ -38,16 +38,16 @@
<div class="section-label">The Circles</div>
<div class="circles-grid">
<div id="community" class="circle-cell">
<h3 style="color: var(--c-community)">Community</h3>
<h2 style="color: var(--c-community)">Community</h2>
<p>For anyone exploring cooperative models.</p>
</div>
<div id="founder" class="circle-cell">
<h3 style="color: var(--c-founder)">Founder</h3>
<h2 style="color: var(--c-founder)">Founder</h2>
<p>For people actively building cooperatives.</p>
</div>
<div id="practitioner" class="circle-cell">
<h3 style="color: var(--c-practitioner)">Practitioner</h3>
<h2 style="color: var(--c-practitioner)">Practitioner</h2>
<p>For experienced practitioners sharing what they know.</p>
</div>
</div>

View file

@ -570,7 +570,7 @@ tbody td {
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c-founder);
border: 1px dashed rgba(138, 68, 32, 0.3);
border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
padding: 2px 8px;
}
@ -583,7 +583,7 @@ tbody td {
font-size: 10px;
font-weight: 600;
color: var(--c-founder);
border: 1px dashed rgba(138, 68, 32, 0.4);
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
border-radius: 50%;
}
@ -632,12 +632,12 @@ tbody td {
.status-upcoming {
color: var(--candle);
border-color: rgba(122, 90, 16, 0.3);
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
}
.status-ongoing {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
border-color: color-mix(in srgb, var(--green) 30%, transparent);
}
.status-past {
@ -647,7 +647,7 @@ tbody td {
.status-cancelled {
color: var(--ember);
border-color: rgba(138, 68, 32, 0.3);
border-color: color-mix(in srgb, var(--ember) 30%, transparent);
margin-top: 4px;
}

View file

@ -65,7 +65,7 @@
<span class="item-sub">{{ member.email }}</span>
</div>
<div class="item-meta">
<span class="badge" :class="member.circle">{{ member.circle }}</span>
<CircleBadge :circle="member.circle" />
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
</div>
</div>

View file

@ -16,7 +16,7 @@
<p v-if="member" class="member-email">{{ member.email }}</p>
</div>
<div v-if="member" class="header-badges">
<span class="badge" :class="member.circle">{{ member.circle }}</span>
<CircleBadge :circle="member.circle" />
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div>
</div>

View file

@ -108,9 +108,7 @@
</td>
<td class="col-email">{{ member.email }}</td>
<td>
<span class="badge" :class="member.circle">{{
member.circle
}}</span>
<CircleBadge :circle="member.circle" />
</td>
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
<td>
@ -1149,7 +1147,7 @@ th.sortable:hover {
text-transform: uppercase;
}
.badge.status-active {
color: var(--green, #3a6b3a);
color: var(--green);
border-color: rgba(58, 107, 58, 0.45);
}
.badge.status-pending_payment {
@ -1158,7 +1156,7 @@ th.sortable:hover {
}
.badge.status-suspended {
color: var(--ember);
border-color: rgba(138, 68, 32, 0.45);
border-color: color-mix(in srgb, var(--ember) 45%, transparent);
}
.badge.status-cancelled {
color: var(--text-faint);
@ -1306,7 +1304,7 @@ th.sortable:hover {
}
.row-error {
background: rgba(138, 68, 32, 0.04);
background: color-mix(in srgb, var(--ember) 4%, transparent);
}
/* ---- PREVIEW BOX ---- */

View file

@ -643,8 +643,8 @@ tbody td {
}
.status-accepted {
color: var(--green, #4a7);
border-color: var(--green, #4a7);
color: var(--green);
border-color: var(--green);
}
.status-expired {
@ -671,7 +671,7 @@ tbody td {
/* ---- STATUS INDICATORS ---- */
.status-ok {
color: var(--green, #4a7);
color: var(--green);
font-size: 11px;
}

View file

@ -850,7 +850,7 @@ const exportSeriesData = () => {
font-size: 11px;
font-weight: 600;
color: var(--c-founder);
border: 1px dashed rgba(138, 68, 32, 0.4);
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
border-radius: 50%;
flex-shrink: 0;
}
@ -931,12 +931,12 @@ const exportSeriesData = () => {
.status-active {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
border-color: color-mix(in srgb, var(--green) 30%, transparent);
}
.status-upcoming {
color: var(--candle);
border-color: rgba(122, 90, 16, 0.3);
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
}
.status-completed {
@ -946,7 +946,7 @@ const exportSeriesData = () => {
.status-ongoing {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
border-color: color-mix(in srgb, var(--green) 30%, transparent);
}
/* ---- LINK BUTTONS ---- */

View file

@ -954,8 +954,8 @@ const applyBatchVisibility = async (hidden) => {
}
.sync-created {
color: var(--green, #4a7);
border-color: var(--green, #4a7);
color: var(--green);
border-color: var(--green);
}
.sync-updated {

View file

@ -82,7 +82,7 @@ if (import.meta.server && !xsrf.value) {
.auth-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);

View file

@ -46,7 +46,7 @@ useHead({ title: "Signed Out — Ghost Guild" });
.auth-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);

View file

@ -70,7 +70,7 @@ const hasDetail = computed(
.auth-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);
@ -97,7 +97,7 @@ const hasDetail = computed(
.auth-detail-code {
color: var(--ember);
font-weight: 700;
font-weight: 600;
margin: 0 0 4px;
}

View file

@ -172,8 +172,8 @@ function resetForm() {
.wiki-login-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 700;
font-size: 36px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);
@ -240,7 +240,7 @@ function resetForm() {
.wiki-login-sent-heading {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
font-weight: 600;
color: var(--text-bright);
margin: 0;
}

View file

@ -357,13 +357,13 @@ onMounted(async () => {
/* ---- LOADING / EMPTY ---- */
.loading-state {
padding: 60px 24px;
padding: 64px 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
.empty-state {
padding: 60px 24px;
padding: 64px 24px;
text-align: center;
}
.empty-title {

View file

@ -124,7 +124,7 @@ const handleLogout = async () => {
.coming-soon-title {
font-family: var(--font-display);
font-size: 3rem;
font-weight: 700;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}

View file

@ -309,7 +309,7 @@ useHead({
}
.guidelines-section ul li {
position: relative;
padding: 2px 0 2px 18px;
padding: 2px 0 2px 16px;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
@ -365,7 +365,7 @@ useHead({
font-family: "Brygada 1918", serif;
font-style: italic;
color: var(--text-bright);
font-size: 15px;
font-size: 16px;
margin-top: 12px;
}

View file

@ -430,7 +430,6 @@ const isAlmostFull = (event) => {
border-color: var(--candle-faint);
color: var(--text-dim);
}
/* WCAG 2.4.7 — keyboard focus must be visibly indicated. */
.past-toggle:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;

View file

@ -747,7 +747,7 @@ onUnmounted(() => {
padding: 0;
}
.tier-list li {
padding: 5px 0;
padding: 4px 0;
font-size: 12px;
color: var(--text-dim);
border-bottom: 1px dashed var(--border);

View file

@ -37,9 +37,7 @@
<span class="profile-pronouns">{{ member.pronouns }}</span>
</div>
<div class="profile-meta">
<span v-if="member.circle" class="badge" :class="member.circle">
{{ circleLabels[member.circle] }}
</span>
<CircleBadge v-if="member.circle" :circle="member.circle" :label="circleLabels[member.circle]" />
<template v-if="member.studio">
<span class="meta-sep">&middot;</span>
<span class="profile-studio">{{ member.studio }}</span>
@ -372,7 +370,7 @@ useHead({
}
.profile-name {
font-family: "Brygada 1918", serif;
font-size: 42px;
font-size: 36px;
font-weight: 600;
color: var(--text-bright);
margin: 0;

View file

@ -185,7 +185,7 @@ const getEventStatus = (event) => {
display: flex;
align-items: baseline;
gap: 12px;
padding: 10px 28px;
padding: 12px 28px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}

View file

@ -140,7 +140,8 @@ See `docs/TODO.md` for:
- ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`.
- Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`.
- ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`.
- Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages in `app/` or `server/`. Candidate for deletion in a future cleanup pass.
- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed.
- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_.
### Known gotchas worth addressing post-launch

View file

@ -88,12 +88,11 @@ export default defineEventHandler(async (event) => {
member
})
// Paid-tier signups need to complete Helcim checkout in the same tab
// before the magic link can be clicked. Issue a short-lived, payment-only
// bridge cookie so /api/helcim/initialize-payment accepts the request.
if (body.contributionAmount > 0) {
// Signup completes (paid checkout or free activation) before the magic
// link is clicked, so issue a short-lived, payment-only bridge cookie
// that lets /api/helcim/initialize-payment and /api/helcim/subscription
// identify the member without a verified auth session.
setPaymentBridgeCookie(event, member)
}
return {
success: true,

View file

@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js'
import { createHelcimCustomer } from '../../../server/utils/helcim.js'
import customerHandler from '../../../server/api/helcim/customer.post.js'
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
import { sendWelcomeEmail } from '../../../server/utils/resend.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
// Deliberately does NOT mock server/utils/auth.js — the bridge-cookie
// hand-off between the two handlers is the contract under test.
vi.mock('../../../server/models/member.js', () => ({
default: {
findOne: vi.fn(),
create: vi.fn(),
findByIdAndUpdate: vi.fn(),
findById: vi.fn(),
findOneAndUpdate: vi.fn()
}
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({
createHelcimCustomer: vi.fn(),
createHelcimSubscription: vi.fn(),
generateIdempotencyKey: vi.fn().mockReturnValue('idem-1'),
listHelcimCustomerTransactions: vi.fn().mockResolvedValue([])
}))
vi.mock('../../../server/utils/magicLink.js', () => ({
sendMagicLink: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../../../server/utils/resend.js', () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true })
}))
vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null)
}))
vi.mock('../../../server/utils/payments.js', () => ({
upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true })
}))
vi.stubGlobal('helcimCustomerSchema', {})
vi.stubGlobal('helcimSubscriptionSchema', {})
const ALLOWED_ORIGIN = 'https://ghostguild.test'
const MEMBER_ID = '69f231152939bf109ac79d83'
const SUBSCRIPTION_BODY = {
customerId: 999,
customerCode: 'CST999',
contributionAmount: 0,
cadence: 'monthly',
cardToken: null
}
function extractBridgeCookie(event) {
const setCookie = event.node.res.getHeader('set-cookie')
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean)
const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge='))
if (!match) return null
return match.match(/payment-bridge=([^;]+)/)[1]
}
describe('signup → subscription bridge-cookie hand-off', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRateLimit()
process.env.BASE_URL = ALLOWED_ORIGIN
createHelcimCustomer.mockResolvedValue({ id: 999, customerCode: 'CST999' })
})
it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => {
Member.findOne.mockResolvedValue(null)
Member.create.mockResolvedValue({
_id: MEMBER_ID,
email: 'free@example.com',
name: 'Free User',
circle: 'community',
contributionAmount: 0,
status: 'pending_payment'
})
const customerEvent = createMockEvent({
method: 'POST',
path: '/api/helcim/customer',
headers: { origin: ALLOWED_ORIGIN },
body: {
name: 'Free User',
email: 'free@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true,
billingAddress: { country: 'CA' }
}
})
const result1 = await customerHandler(customerEvent)
expect(result1.success).toBe(true)
expect(result1.member.status).toBe('pending_payment')
const bridgeToken = extractBridgeCookie(customerEvent)
expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy()
Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' })
Member.findById.mockResolvedValue({
_id: MEMBER_ID,
email: 'free@example.com',
name: 'Free User',
circle: 'community',
contributionAmount: 0,
status: 'active'
})
const subscriptionEvent = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
headers: { origin: ALLOWED_ORIGIN },
cookies: { 'payment-bridge': bridgeToken },
body: SUBSCRIPTION_BODY
})
const result2 = await subscriptionHandler(subscriptionEvent)
expect(result2.success).toBe(true)
expect(result2.member.status).toBe('active')
expect(sendWelcomeEmail).toHaveBeenCalledTimes(1)
})
it('$0 signup with no bridge cookie carried forward → subscription returns 401', async () => {
const subscriptionEvent = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
headers: { origin: ALLOWED_ORIGIN },
body: SUBSCRIPTION_BODY
})
await expect(subscriptionHandler(subscriptionEvent)).rejects.toMatchObject({
statusCode: 401
})
})
})