Compare commits
17 commits
596754acce
...
4d44e7045c
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d44e7045c | |||
| c1367ebd29 | |||
| ac5e979c78 | |||
| 0a41b30db7 | |||
| 5f93d4c2e3 | |||
| bd4561fea7 | |||
| 2611a2a973 | |||
| 5432dfe8f2 | |||
| 0eeb3c351f | |||
| bafe24b778 | |||
| 00073ec52c | |||
| edef1b86be | |||
| 0d83003f87 | |||
| 521efb0890 | |||
| bb0dbfe53e | |||
| 3f42307c64 | |||
| 0c489cf2c3 |
|
|
@ -273,6 +273,14 @@ p a, blockquote a {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Nuxt UI placeholder contrast ----
|
||||
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
|
||||
AA on cream and white backgrounds (≈2.4:1). Override globally to --text-dim
|
||||
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
|
||||
[data-slot="placeholder"] {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---- SHARED USelectMenu STYLES ----
|
||||
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
|
||||
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
|
||||
|
|
|
|||
|
|
@ -25,17 +25,45 @@ export const useMemberPayment = () => {
|
|||
paymentSuccess.value = false
|
||||
|
||||
try {
|
||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||
// to re-save it, breaking retries after a partial-failed signup.
|
||||
const [, existing] = await Promise.all([
|
||||
getOrCreateCustomer(),
|
||||
$fetch('/api/helcim/existing-card').catch((err) => {
|
||||
// Fast-path: when both Helcim ids are already cached on the member doc
|
||||
// AND a card's on file, we can skip the paid getOrCreateCustomer round
|
||||
// trip entirely and go straight to subscription creation.
|
||||
const hasCachedHelcimIds = Boolean(
|
||||
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
|
||||
)
|
||||
|
||||
let existing = null
|
||||
let probedExistingCard = false
|
||||
let cardToken = null
|
||||
|
||||
if (hasCachedHelcimIds) {
|
||||
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
||||
return null
|
||||
}),
|
||||
])
|
||||
})
|
||||
probedExistingCard = true
|
||||
if (existing?.cardToken) {
|
||||
customerId.value = memberData.value.helcimCustomerId
|
||||
customerCode.value = memberData.value.helcimCustomerCode
|
||||
cardToken = existing.cardToken
|
||||
}
|
||||
}
|
||||
|
||||
let cardToken = existing?.cardToken || null
|
||||
if (!cardToken) {
|
||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||
// to re-save it, breaking retries after a partial-failed signup.
|
||||
const [, existingFromFull] = await Promise.all([
|
||||
getOrCreateCustomer(),
|
||||
probedExistingCard
|
||||
? Promise.resolve(existing)
|
||||
: $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
||||
return null
|
||||
}),
|
||||
])
|
||||
|
||||
cardToken = existingFromFull?.cardToken || null
|
||||
}
|
||||
|
||||
if (!cardToken) {
|
||||
await initializeHelcimPay(
|
||||
|
|
|
|||
|
|
@ -133,9 +133,8 @@ const filterOptions = [
|
|||
const { data: eventsData } = await useFetch("/api/events");
|
||||
const { data: seriesData } = await useFetch("/api/series");
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
const now = new Date();
|
||||
if (!eventsData.value) return [];
|
||||
return eventsData.value.filter((event) => {
|
||||
if (!includePastEvents.value && new Date(event.startDate) < now)
|
||||
|
|
|
|||
|
|
@ -131,12 +131,10 @@ const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
|
|||
|
||||
const { data: wikiFeature } = await useFetch(
|
||||
"/api/site-content/homepage.wiki_feature",
|
||||
{ default: () => ({ title: "", body: "" }) }
|
||||
{ default: () => ({ title: "", body: "" }) },
|
||||
);
|
||||
|
||||
const hasCustomWikiFeature = computed(
|
||||
() => !!wikiFeature.value?.body?.trim()
|
||||
);
|
||||
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
|
||||
|
||||
const customWikiParagraphs = computed(() => {
|
||||
const body = wikiFeature.value?.body?.trim() || "";
|
||||
|
|
@ -166,7 +164,7 @@ const circleData = [
|
|||
label: "Practitioner",
|
||||
metaphor: "The alcove",
|
||||
blurb:
|
||||
"Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.",
|
||||
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -64,26 +64,37 @@
|
|||
<!-- Left: Monthly Contribution -->
|
||||
<div class="join-col">
|
||||
<div class="section-label" style="margin-bottom: 12px">
|
||||
{{ cadence === 'annual' ? 'Annual Contribution' : 'Monthly Contribution' }}
|
||||
{{
|
||||
cadence === "annual"
|
||||
? "Annual Contribution"
|
||||
: "Monthly Contribution"
|
||||
}}
|
||||
</div>
|
||||
<h2>Pay what you can</h2>
|
||||
<ul class="tier-list">
|
||||
<li><span class="tier-amt">$0</span> I need support right now</li>
|
||||
<li><span class="tier-amt">{{ formatContributionAmount(5) }}</span> I can contribute</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I can sustain the community
|
||||
(suggested)
|
||||
<span class="tier-amt">{{ formatContributionAmount(5) }}</span> I
|
||||
can contribute
|
||||
</li>
|
||||
<li><span class="tier-amt">{{ formatContributionAmount(30) }}</span> I can support others too</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I want to sponsor multiple
|
||||
members
|
||||
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I
|
||||
can sustain the community (suggested)
|
||||
</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(30) }}</span> I
|
||||
can support others too
|
||||
</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I
|
||||
want to sponsor multiple members
|
||||
</li>
|
||||
</ul>
|
||||
<p class="charity-note">
|
||||
Baby Ghosts Studio Development Fund is a registered Canadian charity.
|
||||
Members who file Canadian taxes can claim their contributions.
|
||||
We'll help you set up tax receipts once you've joined.
|
||||
Baby Ghosts Studio Development Fund is a registered Canadian
|
||||
charity. Members who file Canadian taxes can claim their
|
||||
contributions. We'll help you set up tax receipts once you've
|
||||
joined.
|
||||
</p>
|
||||
<p class="solidarity-note">
|
||||
Pay what you can. If you can pay more, you're making room for
|
||||
|
|
@ -118,7 +129,7 @@
|
|||
type="text"
|
||||
placeholder="Your name"
|
||||
required
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="join-email">Email Address</label>
|
||||
|
|
@ -129,7 +140,7 @@
|
|||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Circle</label>
|
||||
|
|
@ -141,7 +152,7 @@
|
|||
type="radio"
|
||||
name="circle"
|
||||
value="community"
|
||||
>
|
||||
/>
|
||||
<label for="circle-community">
|
||||
<span
|
||||
class="circle-label-name"
|
||||
|
|
@ -158,7 +169,7 @@
|
|||
type="radio"
|
||||
name="circle"
|
||||
value="founder"
|
||||
>
|
||||
/>
|
||||
<label for="circle-founder">
|
||||
<span
|
||||
class="circle-label-name"
|
||||
|
|
@ -175,7 +186,7 @@
|
|||
type="radio"
|
||||
name="circle"
|
||||
value="practitioner"
|
||||
>
|
||||
/>
|
||||
<label for="circle-practitioner">
|
||||
<span
|
||||
class="circle-label-name"
|
||||
|
|
@ -197,7 +208,7 @@
|
|||
type="radio"
|
||||
name="cadence"
|
||||
value="monthly"
|
||||
>
|
||||
/>
|
||||
<label for="cadence-monthly">
|
||||
<span class="circle-label-name">Per Month</span>
|
||||
</label>
|
||||
|
|
@ -209,7 +220,7 @@
|
|||
type="radio"
|
||||
name="cadence"
|
||||
value="annual"
|
||||
>
|
||||
/>
|
||||
<label for="cadence-annual">
|
||||
<span class="circle-label-name">Per Year</span>
|
||||
</label>
|
||||
|
|
@ -230,9 +241,13 @@
|
|||
step="1"
|
||||
inputmode="numeric"
|
||||
class="contribution-input"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
|
||||
<div
|
||||
class="contribution-presets"
|
||||
role="group"
|
||||
aria-label="Suggested amounts"
|
||||
>
|
||||
<button
|
||||
v-for="preset in CONTRIBUTION_PRESETS"
|
||||
:key="preset.amount"
|
||||
|
|
@ -243,24 +258,30 @@
|
|||
${{ preset.amount }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
||||
<p v-if="guidanceLabel" class="contribution-guidance">
|
||||
{{ guidanceLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="form.contributionAmount > 0" class="form-group">
|
||||
<div class="billing-summary">
|
||||
<p class="billing-summary-line">
|
||||
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month × 12)</span>.
|
||||
You'll be charged <strong>${{ firstCharge }} today</strong
|
||||
><span v-if="cadence === 'annual'">
|
||||
(${{ form.contributionAmount }}/month × 12)</span
|
||||
>.
|
||||
</p>
|
||||
<p class="billing-summary-line">
|
||||
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
|
||||
Then
|
||||
<strong
|
||||
>${{ firstCharge }} every
|
||||
{{ cadence === "annual" ? "year" : "month" }}</strong
|
||||
>, until you cancel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
v-model="form.agreedToGuidelines"
|
||||
type="checkbox"
|
||||
>
|
||||
<input v-model="form.agreedToGuidelines" type="checkbox" />
|
||||
<span>
|
||||
I agree to the Ghost Guild
|
||||
<NuxtLink to="/community-guidelines" target="_blank"
|
||||
|
|
@ -338,12 +359,11 @@
|
|||
<h2>Practicing</h2>
|
||||
<p>
|
||||
For those already running cooperative studios or with deep
|
||||
experience in cooperative practice. You are here to teach, advise,
|
||||
mentor, and help shape the program itself. Alumni.
|
||||
experience in cooperative practice. You're here to support newcomers
|
||||
and help shape the Cooperative Foundations program.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Flow overlay: covers the page from form submit through redirect.
|
||||
|
|
@ -434,7 +454,8 @@ const isFormValid = computed(() => {
|
|||
form.name &&
|
||||
form.email &&
|
||||
form.circle &&
|
||||
Number.isInteger(form.contributionAmount) && form.contributionAmount >= 0 &&
|
||||
Number.isInteger(form.contributionAmount) &&
|
||||
form.contributionAmount >= 0 &&
|
||||
form.agreedToGuidelines
|
||||
);
|
||||
});
|
||||
|
|
@ -830,7 +851,7 @@ onUnmounted(() => {
|
|||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--parch);
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.contribution-input:focus {
|
||||
|
|
@ -847,7 +868,7 @@ onUnmounted(() => {
|
|||
padding: 0.25rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--parch);
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -1017,6 +1038,7 @@ onUnmounted(() => {
|
|||
.checkbox-label a,
|
||||
.checkbox-label :deep(a) {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- ERROR & SUCCESS BOXES ---- */
|
||||
|
|
@ -1126,5 +1148,4 @@ onUnmounted(() => {
|
|||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -85,21 +85,46 @@ const initialize = async () => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||
// to re-save it, breaking retries after a partial-failed signup.
|
||||
const [customer, existing] = await Promise.all([
|
||||
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }),
|
||||
$fetch('/api/helcim/existing-card').catch((err) => {
|
||||
// Fast-path: when both Helcim ids are already cached on the member doc
|
||||
// AND a card's on file, skip the paid get-or-create-customer round trip.
|
||||
const hasCachedHelcimIds = Boolean(
|
||||
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
|
||||
);
|
||||
|
||||
let existing = null;
|
||||
let probedExistingCard = false;
|
||||
if (hasCachedHelcimIds) {
|
||||
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
customerId.value = customer.customerId;
|
||||
customerCode.value = customer.customerCode;
|
||||
hasExistingCard.value = Boolean(existing?.cardToken);
|
||||
});
|
||||
probedExistingCard = true;
|
||||
if (existing?.cardToken) {
|
||||
customerId.value = memberData.value.helcimCustomerId;
|
||||
customerCode.value = memberData.value.helcimCustomerCode;
|
||||
hasExistingCard.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExistingCard.value) {
|
||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||
// to re-save it, breaking retries after a partial-failed signup.
|
||||
const [customer, existingFromFull] = await Promise.all([
|
||||
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }),
|
||||
probedExistingCard
|
||||
? Promise.resolve(existing)
|
||||
: $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
customerId.value = customer.customerId;
|
||||
customerCode.value = customer.customerCode;
|
||||
hasExistingCard.value = Boolean(existingFromFull?.cardToken);
|
||||
|
||||
if (!hasExistingCard.value) {
|
||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||
}
|
||||
}
|
||||
step.value = 'ready';
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -712,10 +712,6 @@ useHead({
|
|||
|
||||
.posts-empty-link {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.posts-empty-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 154 KiB |
|
|
@ -11,6 +11,7 @@ test.describe('Admin board channels page', () => {
|
|||
|
||||
test('create, edit, and delete a channel', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/board-channels')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -18,14 +19,14 @@ test.describe('Admin board channels page', () => {
|
|||
const suffix = Date.now().toString().slice(-6)
|
||||
const channelName = `e2e-channel-${suffix}`
|
||||
const editedName = `e2e-channel-${suffix}-edited`
|
||||
const slackId = `C${suffix}XYZ`
|
||||
|
||||
// --- Create ---
|
||||
// Create flow only takes a name; the Slack channel ID is auto-assigned on
|
||||
// creation and only becomes editable in the Edit modal.
|
||||
await adminPage.getByRole('button', { name: '+ New Channel' }).click()
|
||||
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
|
||||
|
||||
await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName)
|
||||
await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId)
|
||||
await adminPage.locator('input[placeholder="e.g., coop-formation"]').fill(channelName)
|
||||
|
||||
// Select the first available cooperative tag if any are present
|
||||
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
|
||||
|
|
@ -44,7 +45,7 @@ test.describe('Admin board channels page', () => {
|
|||
await row.getByRole('button', { name: 'Edit' }).click()
|
||||
|
||||
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
|
||||
const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]')
|
||||
const nameInput = adminPage.locator('input[placeholder="e.g., coop-formation"]')
|
||||
await nameInput.fill(editedName)
|
||||
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ test.describe('Authentication flows', () => {
|
|||
test('logout clears auth', async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.locator('.admin-tag')).toBeVisible({ timeout: 15000 })
|
||||
|
||||
// Set up response listener BEFORE clicking to avoid race
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ test.describe('Board page', () => {
|
|||
|
||||
test('clicking New Post reveals the form', async ({ memberPage }) => {
|
||||
await memberPage.goto('/board')
|
||||
await memberPage.waitForLoadState('networkidle')
|
||||
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -40,6 +41,7 @@ test.describe('Board page', () => {
|
|||
|
||||
test('create, edit, and delete own post', async ({ memberPage }) => {
|
||||
await memberPage.goto('/board')
|
||||
await memberPage.waitForLoadState('networkidle')
|
||||
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -55,7 +57,7 @@ test.describe('Board page', () => {
|
|||
await memberPage.locator('#post-title').fill(originalTitle)
|
||||
await memberPage.locator('#post-seeking').fill('Playwright test seeking text')
|
||||
|
||||
await memberPage.getByRole('button', { name: 'Post' }).click()
|
||||
await memberPage.getByRole('button', { name: 'Post', exact: true }).click()
|
||||
|
||||
await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({
|
||||
timeout: 10000,
|
||||
|
|
@ -75,10 +77,10 @@ test.describe('Board page', () => {
|
|||
timeout: 10000,
|
||||
})
|
||||
|
||||
// --- Delete (confirm dialog) ---
|
||||
memberPage.once('dialog', (dialog) => dialog.accept())
|
||||
// --- Delete (in-card two-step confirm; not a native dialog) ---
|
||||
const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle })
|
||||
await editedCard.getByRole('button', { name: 'Delete' }).click()
|
||||
await editedCard.getByRole('button', { name: 'Confirm' }).click()
|
||||
|
||||
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
|
|
|
|||
|
|
@ -68,8 +68,12 @@ test.describe('Join page — member signup flow', () => {
|
|||
await page.locator('#join-name').fill('Test User')
|
||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||
|
||||
// Fill email too — now all fields are populated and button should be enabled
|
||||
// Fill email — agreement still unchecked, so still disabled
|
||||
await page.locator('#join-email').fill('incomplete-test@example.com')
|
||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||
|
||||
// Check the Community Guidelines agreement — now all required fields satisfied
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
})
|
||||
|
||||
|
|
@ -83,8 +87,9 @@ test.describe('Join page — member signup flow', () => {
|
|||
await page.locator('#join-name').fill('E2E Test User')
|
||||
await page.locator('#join-email').fill(uniqueEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
await page.locator('#join-contribution').click()
|
||||
await page.getByRole('option', { name: '$0/mo' }).click()
|
||||
// Contribution is now a numeric input with preset chips, not a select
|
||||
await page.locator('#join-contribution').fill('0')
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
|
||||
|
|
@ -93,8 +98,10 @@ test.describe('Join page — member signup flow', () => {
|
|||
|
||||
await page.locator('.form-submit').click()
|
||||
|
||||
// Free tier creates subscription then shows confirmation (step 3)
|
||||
await expect(page.locator('.success-box')).toBeVisible({ timeout: 15000 })
|
||||
// Free tier flips the SignupFlowOverlay into its success state
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('duplicate email shows error', async ({ page }) => {
|
||||
|
|
@ -109,12 +116,13 @@ test.describe('Join page — member signup flow', () => {
|
|||
await page.locator('#join-name').fill('Dup Test User')
|
||||
await page.locator('#join-email').fill(duplicateEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
await page.locator('#join-contribution').click()
|
||||
await page.getByRole('option', { name: '$0/mo' }).click()
|
||||
await page.locator('#join-contribution').fill('0')
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
await page.locator('.form-submit').click()
|
||||
|
||||
// Should show an error about the email already existing
|
||||
await expect(page.locator('.error-box')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('.error-box')).toContainText(/already/i)
|
||||
// Helcim 409 puts SignupFlowOverlay into its error state
|
||||
const overlayError = page.locator('.signup-flow-overlay .error-box')
|
||||
await expect(overlayError).toBeVisible({ timeout: 10000 })
|
||||
await expect(overlayError).toContainText(/already/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { test, expect } from './helpers/fixtures.js'
|
|||
test.describe('Member profile page', () => {
|
||||
test('profile page loads', async ({ adminPage }) => {
|
||||
await adminPage.goto('/member/profile')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
// Auth is checked client-side in onMounted — wait for profile form to render
|
||||
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
|
||||
await expect(adminPage.getByText('How you appear to other members')).toBeVisible()
|
||||
// Verify a stable structural section label, not transient marketing copy
|
||||
await expect(adminPage.getByText('Show in Member Directory')).toBeVisible()
|
||||
})
|
||||
|
||||
test('form fields are present', async ({ adminPage }) => {
|
||||
|
|
@ -24,6 +26,7 @@ test.describe('Member profile page', () => {
|
|||
|
||||
test('bio field accepts input', async ({ adminPage }) => {
|
||||
await adminPage.goto('/member/profile')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
|
||||
|
||||
const bio = adminPage.locator('textarea[placeholder*="Share your background"]')
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
// server/api/auth/login.post.js
|
||||
import { getRequestIP } from "h3";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
import { validateBody } from "../../utils/validateBody.js";
|
||||
import { emailSchema } from "../../utils/schemas.js";
|
||||
import { sendMagicLink } from "../../utils/magicLink.js";
|
||||
import { rateLimit } from "../../utils/rateLimit.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) || "unknown";
|
||||
if (!rateLimit(`auth:login:ip:${ip}`, { max: 5, windowMs: 3600_000 })) {
|
||||
throw createError({ statusCode: 429, statusMessage: "Too many login attempts" });
|
||||
}
|
||||
|
||||
await connectDB();
|
||||
|
||||
const { email } = await validateBody(event, emailSchema);
|
||||
const body = await validateBody(event, emailSchema);
|
||||
|
||||
if (!rateLimit(`auth:login:email:${body.email}`, { max: 3, windowMs: 3600_000 })) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: "Too many login attempts for this email",
|
||||
});
|
||||
}
|
||||
|
||||
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
||||
|
||||
try {
|
||||
await sendMagicLink(email);
|
||||
await sendMagicLink(body.email);
|
||||
return {
|
||||
success: true,
|
||||
message: GENERIC_MESSAGE,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
|
|||
contributionAmount: member.contributionAmount,
|
||||
billingCadence: member.billingCadence,
|
||||
helcimCustomerId: member.helcimCustomerId,
|
||||
helcimCustomerCode: member.helcimCustomerCode,
|
||||
nextBillingDate: member.nextBillingDate,
|
||||
membershipLevel: `${member.circle}-${member.contributionAmount}`,
|
||||
// Profile fields
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
// server/api/auth/verify.post.js
|
||||
import { getRequestIP } from 'h3'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../../models/member.js'
|
||||
import { validateBody } from '../../utils/validateBody.js'
|
||||
import { verifyMagicLinkSchema } from '../../utils/schemas.js'
|
||||
import { setAuthCookie } from '../../utils/auth.js'
|
||||
import { rateLimit } from '../../utils/rateLimit.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
|
||||
if (!rateLimit(`auth:verify:ip:${ip}`, { max: 5, windowMs: 3600_000 })) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too many verification attempts' })
|
||||
}
|
||||
|
||||
const { token } = await validateBody(event, verifyMagicLinkSchema)
|
||||
|
||||
const config = useRuntimeConfig(event)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
|||
circle: body.circle,
|
||||
contributionAmount: body.contributionAmount,
|
||||
helcimCustomerId: customerData.id,
|
||||
helcimCustomerCode: customerData.customerCode,
|
||||
status: 'pending_payment',
|
||||
'agreement.acceptedAt': new Date()
|
||||
}
|
||||
|
|
@ -75,6 +76,7 @@ export default defineEventHandler(async (event) => {
|
|||
circle: body.circle,
|
||||
contributionAmount: body.contributionAmount,
|
||||
helcimCustomerId: customerData.id,
|
||||
helcimCustomerCode: customerData.customerCode,
|
||||
status: 'pending_payment',
|
||||
agreement: { acceptedAt: new Date() }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ export default defineEventHandler(async (event) => {
|
|||
return { cardToken: null }
|
||||
}
|
||||
|
||||
const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId)
|
||||
const cards = Array.isArray(cardsResponse)
|
||||
? cardsResponse
|
||||
: (cardsResponse?.cards || cardsResponse?.data || [])
|
||||
const cards = await listHelcimCustomerCards(member.helcimCustomerId)
|
||||
|
||||
if (!cards.length) {
|
||||
return { cardToken: null }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
const customer = await getHelcimCustomer(member.helcimCustomerId)
|
||||
if (customer?.id) {
|
||||
if (!member.helcimCustomerCode && customer.customerCode) {
|
||||
await Member.findByIdAndUpdate(
|
||||
member._id,
|
||||
{ $set: { helcimCustomerCode: customer.customerCode } },
|
||||
{ runValidators: false }
|
||||
)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
customerId: customer.id,
|
||||
|
|
@ -49,10 +56,13 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
if (existingCustomer) {
|
||||
if (!member.helcimCustomerId) {
|
||||
if (!member.helcimCustomerId || !member.helcimCustomerCode) {
|
||||
await Member.findByIdAndUpdate(
|
||||
member._id,
|
||||
{ $set: { helcimCustomerId: existingCustomer.id } },
|
||||
{ $set: {
|
||||
helcimCustomerId: existingCustomer.id,
|
||||
helcimCustomerCode: existingCustomer.customerCode
|
||||
} },
|
||||
{ runValidators: false }
|
||||
)
|
||||
}
|
||||
|
|
@ -73,7 +83,10 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
await Member.findByIdAndUpdate(
|
||||
member._id,
|
||||
{ $set: { helcimCustomerId: customerData.id } },
|
||||
{ $set: {
|
||||
helcimCustomerId: customerData.id,
|
||||
helcimCustomerCode: customerData.customerCode
|
||||
} },
|
||||
{ runValidators: false }
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Member from '../../models/member.js'
|
||||
import Series from '../../models/series.js'
|
||||
import { loadPublicEvent } from '../../utils/loadEvent.js'
|
||||
import { loadPublicSeries } from '../../utils/loadSeries.js'
|
||||
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
|
||||
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
|
||||
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
||||
|
|
@ -10,10 +10,10 @@ export default defineEventHandler(async (event) => {
|
|||
const body = await validateBody(event, helcimInitializePaymentSchema)
|
||||
const metaType = body.metadata?.type
|
||||
|
||||
const isEventTicket = metaType === 'event_ticket'
|
||||
const isSeriesTicket = metaType === 'series_ticket'
|
||||
const isEventTicket = metaType === PAYMENT_METADATA_TYPES.EVENT_TICKET
|
||||
const isSeriesTicket = metaType === PAYMENT_METADATA_TYPES.SERIES_TICKET
|
||||
const isTicket = isEventTicket || isSeriesTicket
|
||||
const isMembershipSignup = metaType === 'membership_signup'
|
||||
const isMembershipSignup = metaType === PAYMENT_METADATA_TYPES.MEMBERSHIP_SIGNUP
|
||||
|
||||
if (!isTicket) {
|
||||
if (isMembershipSignup) {
|
||||
|
|
@ -55,14 +55,7 @@ export default defineEventHandler(async (event) => {
|
|||
if (!seriesId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' })
|
||||
}
|
||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId)
|
||||
const seriesQuery = isObjectId
|
||||
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
|
||||
: { $or: [{ id: seriesId }, { slug: seriesId }] }
|
||||
const series = await Series.findOne(seriesQuery)
|
||||
if (!series) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Series not found' })
|
||||
}
|
||||
const series = await loadPublicSeries(event, seriesId)
|
||||
const ticketInfo = calculateSeriesTicketPrice(series, accessMember)
|
||||
if (!ticketInfo) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' })
|
||||
|
|
|
|||
|
|
@ -90,26 +90,22 @@ export default defineEventHandler(async (event) => {
|
|||
await connectDB()
|
||||
const body = await validateBody(event, helcimSubscriptionSchema)
|
||||
|
||||
// Only send welcome email when a member transitions from pending_payment
|
||||
// to active for the first time — not on tier upgrades (active → active).
|
||||
const priorMember = await Member.findOne(
|
||||
{ helcimCustomerId: body.customerId },
|
||||
{ status: 1 }
|
||||
)
|
||||
const isFirstActivation = priorMember?.status === 'pending_payment'
|
||||
|
||||
// Check if payment is required
|
||||
if (!requiresPayment(body.contributionAmount)) {
|
||||
// For free tier, just update member status
|
||||
const member = await Member.findOneAndUpdate(
|
||||
// For free tier, atomically capture pre-update status alongside the write.
|
||||
// Welcome email only fires on pending_payment → active transitions, not
|
||||
// on tier upgrades (active → active).
|
||||
const preMember = await Member.findOneAndUpdate(
|
||||
{ helcimCustomerId: body.customerId },
|
||||
{
|
||||
status: 'active',
|
||||
contributionAmount: body.contributionAmount,
|
||||
subscriptionStartDate: new Date()
|
||||
},
|
||||
{ new: true }
|
||||
{ new: false, projection: { status: 1 } }
|
||||
)
|
||||
const isFirstActivation = preMember?.status === 'pending_payment'
|
||||
const member = await Member.findById(preMember._id)
|
||||
|
||||
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
||||
|
||||
|
|
@ -175,8 +171,10 @@ export default defineEventHandler(async (event) => {
|
|||
? new Date(subscription.nextBillingDate)
|
||||
: null
|
||||
|
||||
// Update member in database
|
||||
const member = await Member.findOneAndUpdate(
|
||||
// Atomically capture pre-update status alongside the write so we can
|
||||
// detect the pending_payment → active transition without a separate read
|
||||
// (which would race with concurrent webhooks/double-clicks).
|
||||
const preMember = await Member.findOneAndUpdate(
|
||||
{ helcimCustomerId: body.customerId },
|
||||
{ $set: {
|
||||
contributionAmount: body.contributionAmount,
|
||||
|
|
@ -190,8 +188,10 @@ export default defineEventHandler(async (event) => {
|
|||
? { nextBillingDate }
|
||||
: {}),
|
||||
} },
|
||||
{ new: true, runValidators: false }
|
||||
{ new: false, runValidators: false, projection: { status: 1 } }
|
||||
)
|
||||
const isFirstActivation = preMember?.status === 'pending_payment'
|
||||
const member = await Member.findById(preMember._id)
|
||||
|
||||
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,7 @@ export default defineEventHandler(async (event) => {
|
|||
const { cardToken } = body
|
||||
|
||||
// Step 3: verify the submitted token is attached to this member's customer
|
||||
const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId)
|
||||
const cards = Array.isArray(cardsResponse)
|
||||
? cardsResponse
|
||||
: (cardsResponse?.cards || cardsResponse?.data || [])
|
||||
const cards = await listHelcimCustomerCards(member.helcimCustomerId)
|
||||
|
||||
const matchingCard = cards.find((c) => c?.cardToken === cardToken)
|
||||
if (!matchingCard) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
|
|||
const cards = await listHelcimCustomerCards(body.customerId)
|
||||
|
||||
// Verify the card token exists for this customer
|
||||
const cardExists = Array.isArray(cards) && cards.some(card =>
|
||||
const cardExists = cards.some(card =>
|
||||
card.cardToken === body.cardToken
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
import Member from '../../models/member.js'
|
||||
import Payment from '../../models/payment.js'
|
||||
import { listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
||||
import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
const members = await Member.find(
|
||||
{ helcimCustomerId: { $exists: true, $ne: null } },
|
||||
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 }
|
||||
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 }
|
||||
).lean()
|
||||
|
||||
let txExamined = 0
|
||||
|
|
@ -65,37 +65,75 @@ export default defineEventHandler(async (event) => {
|
|||
let skipped = 0
|
||||
let memberErrors = 0
|
||||
|
||||
for (const member of members) {
|
||||
async function processMember(member) {
|
||||
// Opportunistic backfill: members predating the helcimCustomerCode field
|
||||
// get it filled in here so the daily cron acts as the migration. Only on
|
||||
// the missing path — no overwrite, no extra API call once populated.
|
||||
if (!member.helcimCustomerCode) {
|
||||
try {
|
||||
const customer = await getHelcimCustomer(member.helcimCustomerId)
|
||||
if (customer?.customerCode) {
|
||||
await Member.findByIdAndUpdate(
|
||||
member._id,
|
||||
{ $set: { helcimCustomerCode: customer.customerCode } },
|
||||
{ runValidators: false }
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
// Backfill is best-effort — never fail the reconcile run on it.
|
||||
console.warn(`[reconcile] customerCode backfill failed for member=${member._id}: ${err?.message || err}`)
|
||||
}
|
||||
}
|
||||
|
||||
let txs
|
||||
try {
|
||||
txs = await listTransactionsWithRetry(member.helcimCustomerId)
|
||||
} catch (err) {
|
||||
memberErrors++
|
||||
console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
|
||||
continue
|
||||
return { error: true }
|
||||
}
|
||||
|
||||
const result = { error: false, txExamined: 0, created: 0, existed: 0, skipped: 0 }
|
||||
|
||||
for (const tx of txs) {
|
||||
txExamined++
|
||||
result.txExamined++
|
||||
if (!RECONCILABLE_STATUSES.has(tx?.status)) {
|
||||
skipped++
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
const existing = await Payment.findOne({ helcimTransactionId: tx.id })
|
||||
if (existing) existed++
|
||||
else created++
|
||||
if (existing) result.existed++
|
||||
else result.created++
|
||||
continue
|
||||
}
|
||||
|
||||
// Note: deliberately NOT passing sendConfirmation — cron back-fills must
|
||||
// not re-send confirmation emails for transactions the member has already
|
||||
// been notified about (or that pre-date Mongo Payment tracking entirely).
|
||||
const result = await upsertPaymentFromHelcim(member, tx)
|
||||
if (result.created) created++
|
||||
else if (result.payment) existed++
|
||||
else skipped++
|
||||
const upsertResult = await upsertPaymentFromHelcim(member, tx)
|
||||
if (upsertResult.created) result.created++
|
||||
else if (upsertResult.payment) result.existed++
|
||||
else result.skipped++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 8
|
||||
for (let i = 0; i < members.length; i += CHUNK_SIZE) {
|
||||
const chunk = members.slice(i, i + CHUNK_SIZE)
|
||||
const results = await Promise.all(chunk.map((m) => processMember(m)))
|
||||
for (const r of results) {
|
||||
if (r.error) {
|
||||
memberErrors++
|
||||
continue
|
||||
}
|
||||
txExamined += r.txExamined
|
||||
created += r.created
|
||||
existed += r.existed
|
||||
skipped += r.skipped
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export default defineEventHandler(async (event) => {
|
|||
bio: body.motivation || undefined,
|
||||
status: body.contributionAmount === 0 ? 'active' : 'pending_payment',
|
||||
helcimCustomerId: helcimCustomer?.id,
|
||||
helcimCustomerCode: helcimCustomer?.customerCode,
|
||||
agreement: { acceptedAt: new Date() },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Event from "../../models/event.js";
|
||||
import Series from "../../models/series.js";
|
||||
import { loadPublicSeries } from "../../utils/loadSeries.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -15,16 +15,14 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Try to fetch the Series model first for full ticketing info
|
||||
// Build query conditions based on whether id looks like ObjectId or string
|
||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
|
||||
const seriesQuery = isObjectId
|
||||
? { $or: [{ _id: id }, { id: id }, { slug: id }] }
|
||||
: { $or: [{ id: id }, { slug: id }] };
|
||||
|
||||
const seriesModel = await Series.findOne(seriesQuery)
|
||||
.select("-registrations") // Don't expose registration details
|
||||
.lean();
|
||||
// Try to fetch the Series model first for full ticketing info.
|
||||
// Legacy series may exist only as event metadata (no Series doc), so we
|
||||
// fall through to the events-based path below when no Series doc matches.
|
||||
const seriesModel = await loadPublicSeries(event, id, {
|
||||
select: "-registrations", // Don't expose registration details
|
||||
lean: true,
|
||||
allowMissing: true,
|
||||
});
|
||||
|
||||
// Fetch all events in this series
|
||||
const events = await Event.find({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Series from "../../../../models/series.js";
|
||||
import Member from "../../../../models/member.js";
|
||||
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
|
||||
import {
|
||||
calculateSeriesTicketPrice,
|
||||
checkSeriesTicketAvailability,
|
||||
|
|
@ -13,20 +13,7 @@ export default defineEventHandler(async (event) => {
|
|||
const email = query.email;
|
||||
|
||||
// Fetch series
|
||||
// Build query conditions based on whether seriesId looks like ObjectId or string
|
||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
|
||||
const seriesQuery = isObjectId
|
||||
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
|
||||
: { $or: [{ id: seriesId }, { slug: seriesId }] };
|
||||
|
||||
const series = await Series.findOne(seriesQuery);
|
||||
|
||||
if (!series) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Series not found",
|
||||
});
|
||||
}
|
||||
const series = await loadPublicSeries(event, seriesId);
|
||||
|
||||
// Check if tickets are enabled
|
||||
if (!series.tickets?.enabled) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Series from "../../../../models/series.js";
|
||||
import Event from "../../../../models/event.js";
|
||||
import Member from "../../../../models/member.js";
|
||||
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
|
||||
import {
|
||||
validateSeriesTicketPurchase,
|
||||
calculateSeriesTicketPrice,
|
||||
|
|
@ -19,20 +19,7 @@ export default defineEventHandler(async (event) => {
|
|||
const { name, email, paymentId } = body;
|
||||
|
||||
// Fetch series
|
||||
// Build query conditions based on whether seriesId looks like ObjectId or string
|
||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
|
||||
const seriesQuery = isObjectId
|
||||
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
|
||||
: { $or: [{ id: seriesId }, { slug: seriesId }] };
|
||||
|
||||
const series = await Series.findOne(seriesQuery);
|
||||
|
||||
if (!series) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Series not found",
|
||||
});
|
||||
}
|
||||
const series = await loadPublicSeries(event, seriesId);
|
||||
|
||||
// Check membership — prefer JWT auth for accurate member pricing.
|
||||
// Only members with access (active or pending_payment) get member-tier
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
// Strict rate limit for auth endpoints
|
||||
const authLimiter = new RateLimiterMemory({
|
||||
points: 5, // 5 requests
|
||||
duration: 300, // per 5 minutes
|
||||
keyPrefix: 'rl_auth'
|
||||
})
|
||||
|
||||
// Moderate rate limit for payment endpoints
|
||||
const paymentLimiter = new RateLimiterMemory({
|
||||
points: 10,
|
||||
|
|
@ -35,7 +28,6 @@ function getClientIp(event) {
|
|||
|| 'unknown'
|
||||
}
|
||||
|
||||
const AUTH_PATHS = new Set(['/api/auth/login', '/api/auth/verify'])
|
||||
const PAYMENT_PREFIXES = ['/api/helcim/']
|
||||
const UPLOAD_PATHS = new Set(['/api/upload/image'])
|
||||
|
||||
|
|
@ -43,12 +35,15 @@ export default defineEventHandler(async (event) => {
|
|||
const path = getRequestURL(event).pathname
|
||||
if (!path.startsWith('/api/')) return
|
||||
|
||||
// Bypass rate limiting in test/dev opt-in mode so parallel E2E runs from a
|
||||
// single IP (127.0.0.1) do not exhaust the per-IP budget. Mirrors the gate
|
||||
// used by /api/dev/* endpoints — only set in development and by Playwright.
|
||||
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') return
|
||||
|
||||
const ip = getClientIp(event)
|
||||
|
||||
try {
|
||||
if (AUTH_PATHS.has(path)) {
|
||||
await authLimiter.consume(ip)
|
||||
} else if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
|
||||
if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
|
||||
await paymentLimiter.consume(ip)
|
||||
} else if (UPLOAD_PATHS.has(path)) {
|
||||
await uploadLimiter.consume(ip)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const memberSchema = new mongoose.Schema({
|
|||
default: "pending_payment",
|
||||
},
|
||||
helcimCustomerId: String,
|
||||
helcimCustomerCode: String,
|
||||
helcimSubscriptionId: String,
|
||||
billingCadence: {
|
||||
type: String,
|
||||
|
|
|
|||
|
|
@ -86,8 +86,10 @@ export const createHelcimCustomer = (payload) =>
|
|||
export const updateHelcimCustomer = (id, payload) =>
|
||||
helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' })
|
||||
|
||||
export const listHelcimCustomerCards = (id) =>
|
||||
helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' })
|
||||
export const listHelcimCustomerCards = async (id) => {
|
||||
const raw = await helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' })
|
||||
return Array.isArray(raw) ? raw : (raw?.cards || raw?.data || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a customer's default payment method by card token.
|
||||
|
|
|
|||
47
server/utils/loadSeries.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Series from '../models/series.js'
|
||||
import { connectDB } from './mongoose.js'
|
||||
|
||||
/**
|
||||
* Load a series by ObjectId, string id, or slug for public endpoints.
|
||||
* Series has three identifier fields (`_id`, `id`, `slug`); this helper
|
||||
* builds the same conditional `$or` query the call sites would otherwise
|
||||
* inline. No isVisible gate today (parity with existing call-site behavior).
|
||||
*
|
||||
* @param {Object} reqEvent - h3 event (reserved for future auth/cookie access)
|
||||
* @param {String} identifier - ObjectId string, string id, or slug
|
||||
* @param {Object} [options]
|
||||
* @param {Boolean} [options.lean] - apply .lean() to the query
|
||||
* @param {String} [options.select] - apply .select() to the query
|
||||
* @param {Boolean} [options.allowMissing] - return null instead of throwing 404 on miss
|
||||
* @returns {Promise<Object|null>} the series document, or null if allowMissing and not found
|
||||
*/
|
||||
export async function loadPublicSeries(reqEvent, identifier, options = {}) {
|
||||
if (!identifier) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Series identifier is required'
|
||||
})
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
const { lean = false, select = null, allowMissing = false } = options
|
||||
|
||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(identifier)
|
||||
const seriesQuery = isObjectId
|
||||
? { $or: [{ _id: identifier }, { id: identifier }, { slug: identifier }] }
|
||||
: { $or: [{ id: identifier }, { slug: identifier }] }
|
||||
|
||||
let query = Series.findOne(seriesQuery)
|
||||
if (select) query = query.select(select)
|
||||
if (lean) query = query.lean()
|
||||
|
||||
const series = await query
|
||||
|
||||
if (!series) {
|
||||
if (allowMissing) return null
|
||||
throw createError({ statusCode: 404, statusMessage: 'Series not found' })
|
||||
}
|
||||
|
||||
return series
|
||||
}
|
||||
15
server/utils/paymentTypes.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Metadata.type values accepted by /api/helcim/initialize-payment.
|
||||
// Shared by the Zod schema (PAYMENT_METADATA_TYPE_VALUES in z.enum) and the
|
||||
// route handler so server-side wire validation stays single-sourced. The client
|
||||
// composable intentionally uses inline string literals — server-side z.enum
|
||||
// rejects any drift as a 400.
|
||||
|
||||
export const PAYMENT_METADATA_TYPES = {
|
||||
EVENT_TICKET: 'event_ticket',
|
||||
SERIES_TICKET: 'series_ticket',
|
||||
SUBSCRIPTION: 'subscription',
|
||||
CARD_VERIFY: 'card_verify',
|
||||
MEMBERSHIP_SIGNUP: 'membership_signup'
|
||||
}
|
||||
|
||||
export const PAYMENT_METADATA_TYPE_VALUES = Object.values(PAYMENT_METADATA_TYPES)
|
||||
|
|
@ -4,6 +4,11 @@
|
|||
const buckets = new Map()
|
||||
|
||||
export function rateLimit(key, { max, windowMs }) {
|
||||
// Bypass in test/dev opt-in mode so parallel E2E runs from a single IP
|
||||
// (127.0.0.1) don't exhaust per-IP/email budgets. Mirrors the gate used by
|
||||
// /api/dev/* endpoints and server/middleware/03.rate-limit.js.
|
||||
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') return true
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Probabilistic sweep: ~1% of calls evict keys whose newest entry has fully
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as z from 'zod'
|
||||
import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js'
|
||||
import { PAYMENT_METADATA_TYPE_VALUES } from './paymentTypes.js'
|
||||
|
||||
export const emailSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email()
|
||||
|
|
@ -71,7 +72,7 @@ export const helcimInitializePaymentSchema = z.object({
|
|||
amount: z.number().min(0).optional(),
|
||||
customerCode: z.string().max(200).optional(),
|
||||
metadata: z.object({
|
||||
type: z.enum(['event_ticket', 'series_ticket', 'subscription', 'card_verify', 'membership_signup']).optional(),
|
||||
type: z.enum(PAYMENT_METADATA_TYPE_VALUES).optional(),
|
||||
eventTitle: z.string().max(500).optional(),
|
||||
eventId: z.string().max(200).optional(),
|
||||
seriesId: z.string().max(200).optional(),
|
||||
|
|
|
|||
145
tests/client/composables/useMemberPayment.test.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useMemberPayment } from '../../../app/composables/useMemberPayment.js'
|
||||
|
||||
// Stub Vue's ref/readonly as Nuxt auto-imports
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('readonly', (v) => v)
|
||||
|
||||
const memberData = ref(null)
|
||||
const checkMemberStatus = vi.fn()
|
||||
vi.stubGlobal('useAuth', () => ({ memberData, checkMemberStatus }))
|
||||
|
||||
const initializeHelcimPay = vi.fn()
|
||||
const verifyPayment = vi.fn()
|
||||
const cleanupHelcimPay = vi.fn()
|
||||
vi.stubGlobal('useHelcimPay', () => ({
|
||||
initializeHelcimPay,
|
||||
verifyPayment,
|
||||
cleanup: cleanupHelcimPay,
|
||||
}))
|
||||
|
||||
const $fetch = vi.fn()
|
||||
vi.stubGlobal('$fetch', $fetch)
|
||||
|
||||
describe('useMemberPayment.initiatePaymentSetup — shortcut path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
memberData.value = null
|
||||
$fetch.mockReset()
|
||||
})
|
||||
|
||||
it('skips getOrCreateCustomer when both helcim ids are cached AND a card is on file', async () => {
|
||||
memberData.value = {
|
||||
helcimCustomerId: 'cust-123',
|
||||
helcimCustomerCode: 'CST-ABC',
|
||||
contributionAmount: 15,
|
||||
}
|
||||
|
||||
$fetch.mockImplementation((path) => {
|
||||
if (path === '/api/helcim/existing-card') {
|
||||
return Promise.resolve({ cardToken: 'tok-xyz' })
|
||||
}
|
||||
if (path === '/api/helcim/subscription') {
|
||||
return Promise.resolve({ success: true })
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
|
||||
})
|
||||
|
||||
const { initiatePaymentSetup } = useMemberPayment()
|
||||
const result = await initiatePaymentSetup()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// The whole point: get-or-create-customer was NOT called.
|
||||
const calledPaths = $fetch.mock.calls.map((c) => c[0])
|
||||
expect(calledPaths).not.toContain('/api/helcim/get-or-create-customer')
|
||||
expect(calledPaths).not.toContain('/api/helcim/customer-code')
|
||||
|
||||
// We did call existing-card (once) and subscription (once).
|
||||
expect(calledPaths.filter((p) => p === '/api/helcim/existing-card')).toHaveLength(1)
|
||||
expect(calledPaths.filter((p) => p === '/api/helcim/subscription')).toHaveLength(1)
|
||||
|
||||
// HelcimPay modal not opened — card was already on file.
|
||||
expect(initializeHelcimPay).not.toHaveBeenCalled()
|
||||
expect(verifyPayment).not.toHaveBeenCalled()
|
||||
|
||||
// Subscription called with the cached id/code from memberData.
|
||||
const subscriptionCall = $fetch.mock.calls.find((c) => c[0] === '/api/helcim/subscription')
|
||||
expect(subscriptionCall[1].body).toMatchObject({
|
||||
customerId: 'cust-123',
|
||||
customerCode: 'CST-ABC',
|
||||
cardToken: 'tok-xyz',
|
||||
contributionAmount: 15,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls through to get-or-create-customer when helcimCustomerCode is missing', async () => {
|
||||
memberData.value = {
|
||||
helcimCustomerId: 'cust-123',
|
||||
// helcimCustomerCode missing — must NOT take shortcut
|
||||
contributionAmount: 15,
|
||||
}
|
||||
|
||||
$fetch.mockImplementation((path) => {
|
||||
if (path === '/api/helcim/customer-code') {
|
||||
return Promise.resolve({ customerId: 'cust-123', customerCode: 'CST-FRESH' })
|
||||
}
|
||||
if (path === '/api/helcim/existing-card') {
|
||||
return Promise.resolve({ cardToken: 'tok-xyz' })
|
||||
}
|
||||
if (path === '/api/helcim/subscription') {
|
||||
return Promise.resolve({ success: true })
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
|
||||
})
|
||||
|
||||
const { initiatePaymentSetup } = useMemberPayment()
|
||||
await initiatePaymentSetup()
|
||||
|
||||
const calledPaths = $fetch.mock.calls.map((c) => c[0])
|
||||
// Existing helcimCustomerId path -> /api/helcim/customer-code is called.
|
||||
expect(calledPaths).toContain('/api/helcim/customer-code')
|
||||
})
|
||||
|
||||
it('falls through to get-or-create-customer when no card is on file', async () => {
|
||||
memberData.value = {
|
||||
helcimCustomerId: 'cust-123',
|
||||
helcimCustomerCode: 'CST-ABC',
|
||||
contributionAmount: 15,
|
||||
}
|
||||
|
||||
initializeHelcimPay.mockResolvedValue(undefined)
|
||||
verifyPayment.mockResolvedValue({ success: true, cardToken: 'tok-new' })
|
||||
|
||||
let existingCardCalls = 0
|
||||
$fetch.mockImplementation((path) => {
|
||||
if (path === '/api/helcim/existing-card') {
|
||||
existingCardCalls++
|
||||
return Promise.resolve(null) // no card on file
|
||||
}
|
||||
if (path === '/api/helcim/customer-code') {
|
||||
return Promise.resolve({ customerId: 'cust-123', customerCode: 'CST-ABC' })
|
||||
}
|
||||
if (path === '/api/helcim/verify-payment') {
|
||||
return Promise.resolve({ success: true })
|
||||
}
|
||||
if (path === '/api/helcim/subscription') {
|
||||
return Promise.resolve({ success: true })
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
|
||||
})
|
||||
|
||||
const { initiatePaymentSetup } = useMemberPayment()
|
||||
await initiatePaymentSetup()
|
||||
|
||||
// Falls into the full flow — modal opens, verify runs.
|
||||
expect(initializeHelcimPay).toHaveBeenCalled()
|
||||
expect(verifyPayment).toHaveBeenCalled()
|
||||
|
||||
const calledPaths = $fetch.mock.calls.map((c) => c[0])
|
||||
expect(calledPaths).toContain('/api/helcim/customer-code')
|
||||
// existing-card was reused from the shortcut probe — should not refetch.
|
||||
expect(existingCardCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import loginHandler from '../../../server/api/auth/login.post.js'
|
||||
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() }
|
||||
}))
|
||||
|
|
@ -20,13 +25,10 @@ vi.mock('resend', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import loginHandler from '../../../server/api/auth/login.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
describe('auth login endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRateLimit()
|
||||
})
|
||||
|
||||
it('returns generic success message for existing member', async () => {
|
||||
|
|
@ -110,4 +112,92 @@ describe('auth login endpoint', () => {
|
|||
statusMessage: 'Validation failed'
|
||||
})
|
||||
})
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('allows up to 5 login attempts from a single IP', async () => {
|
||||
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' })
|
||||
|
||||
// 5 calls succeed (each with a unique email so we don't hit email limit)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
body: { email: `u${i}@example.com` },
|
||||
headers: { host: 'localhost:3000' },
|
||||
remoteAddress: '10.0.0.1'
|
||||
})
|
||||
const result = await loginHandler(event)
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rate-limits a single IP after 5 login attempts', async () => {
|
||||
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' })
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
body: { email: `u${i}@example.com` },
|
||||
headers: { host: 'localhost:3000' },
|
||||
remoteAddress: '10.0.0.1'
|
||||
})
|
||||
await loginHandler(event)
|
||||
}
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
body: { email: 'u6@example.com' },
|
||||
headers: { host: 'localhost:3000' },
|
||||
remoteAddress: '10.0.0.1'
|
||||
})
|
||||
await expect(loginHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 429
|
||||
})
|
||||
})
|
||||
|
||||
it('allows up to 3 login attempts for a single email', async () => {
|
||||
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' })
|
||||
|
||||
// 3 calls from different IPs succeed
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
body: { email: 'shared@example.com' },
|
||||
headers: { host: 'localhost:3000' },
|
||||
remoteAddress: `10.0.0.${i + 10}`
|
||||
})
|
||||
const result = await loginHandler(event)
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rate-limits a single email after 3 login attempts (different IPs)', async () => {
|
||||
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' })
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
body: { email: 'shared@example.com' },
|
||||
headers: { host: 'localhost:3000' },
|
||||
remoteAddress: `10.0.0.${i + 10}`
|
||||
})
|
||||
await loginHandler(event)
|
||||
}
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
body: { email: 'shared@example.com' },
|
||||
headers: { host: 'localhost:3000' },
|
||||
remoteAddress: '10.0.0.99'
|
||||
})
|
||||
await expect(loginHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 429
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
||||
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
|
|
@ -33,6 +34,7 @@ const baseMember = {
|
|||
describe('auth verify endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRateLimit()
|
||||
})
|
||||
|
||||
it('rejects missing token with 400', async () => {
|
||||
|
|
@ -302,4 +304,79 @@ describe('auth verify endpoint', () => {
|
|||
|
||||
expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' })
|
||||
})
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('allows up to 5 verify attempts from a single IP', async () => {
|
||||
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||
|
||||
// 5 calls reach jwt.verify (and fail with 401, but not 429)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'bad-token' },
|
||||
remoteAddress: '10.0.0.1'
|
||||
})
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401
|
||||
})
|
||||
}
|
||||
expect(jwt.verify).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('rate-limits a single IP after 5 verify attempts', async () => {
|
||||
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'bad-token' },
|
||||
remoteAddress: '10.0.0.1'
|
||||
})
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401
|
||||
})
|
||||
}
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'bad-token' },
|
||||
remoteAddress: '10.0.0.1'
|
||||
})
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 429
|
||||
})
|
||||
// Rate limit fires before jwt.verify on the 6th call
|
||||
expect(jwt.verify).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('does not block different IPs (per-IP keying)', async () => {
|
||||
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'bad-token' },
|
||||
remoteAddress: '10.0.0.1'
|
||||
})
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401
|
||||
})
|
||||
}
|
||||
|
||||
// A different IP should still be allowed.
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'bad-token' },
|
||||
remoteAddress: '10.0.0.2'
|
||||
})
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,49 +1,139 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
|
||||
import Event from '../../../server/models/event.js'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import { waitlistSchema, waitlistDeleteSchema } from '../../../server/utils/schemas.js'
|
||||
import waitlistPostHandler from '../../../server/api/events/[id]/waitlist.post.js'
|
||||
import waitlistDeleteHandler from '../../../server/api/events/[id]/waitlist.delete.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
describe('waitlist.post.js bypasses validators on event.save()', () => {
|
||||
const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8')
|
||||
vi.mock('../../../server/models/event.js', () => ({
|
||||
default: { findOne: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findOne: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
|
||||
it('calls eventData.save with validateBeforeSave: false', () => {
|
||||
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
|
||||
})
|
||||
vi.stubGlobal('waitlistSchema', waitlistSchema)
|
||||
vi.stubGlobal('waitlistDeleteSchema', waitlistDeleteSchema)
|
||||
|
||||
it('does not contain a bare eventData.save() call', () => {
|
||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
||||
})
|
||||
})
|
||||
// Override the global validateBody stub so the route actually parses against
|
||||
// the schema it passed in.
|
||||
vi.stubGlobal('validateBody', vi.fn(async (event, schema) => {
|
||||
const body = await readBody(event)
|
||||
return schema.parse(body)
|
||||
}))
|
||||
|
||||
describe('waitlist.delete.js bypasses validators on event.save()', () => {
|
||||
const source = readFileSync(resolve(eventsDir, 'waitlist.delete.js'), 'utf-8')
|
||||
|
||||
it('calls eventData.save with validateBeforeSave: false', () => {
|
||||
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
|
||||
})
|
||||
|
||||
it('does not contain a bare eventData.save() call', () => {
|
||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
||||
})
|
||||
})
|
||||
|
||||
// payment.post.js cases are handled by Fix #3 (file deletion).
|
||||
// If the file still exists, it should also pass the validators bypass.
|
||||
describe.skipIf(!existsSync(resolve(eventsDir, 'payment.post.js')))(
|
||||
'payment.post.js bypasses validators on event.save()',
|
||||
() => {
|
||||
const source = existsSync(resolve(eventsDir, 'payment.post.js'))
|
||||
? readFileSync(resolve(eventsDir, 'payment.post.js'), 'utf-8')
|
||||
: ''
|
||||
|
||||
it('has exactly two eventData.save({ validateBeforeSave: false }) calls', () => {
|
||||
const matches = source.match(/eventData\.save\(\{\s*validateBeforeSave:\s*false\s*\}\)/g) || []
|
||||
expect(matches.length).toBe(2)
|
||||
})
|
||||
|
||||
it('does not contain a bare eventData.save() call', () => {
|
||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
||||
})
|
||||
/**
|
||||
* Build a mock Event document whose `save()` simulates the legacy validator
|
||||
* problem we're protecting against: when called WITHOUT `validateBeforeSave:
|
||||
* false` it throws (mimicking a stale `location` validator failing on
|
||||
* unrelated writes). When called WITH `validateBeforeSave: false` it resolves
|
||||
* normally. The route is correct iff it bypasses validators.
|
||||
*/
|
||||
function makeMockEvent(overrides = {}) {
|
||||
const doc = {
|
||||
_id: 'event-1',
|
||||
slug: 'event-slug',
|
||||
tickets: {
|
||||
waitlist: {
|
||||
enabled: true,
|
||||
maxSize: 10,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
registrations: [],
|
||||
...overrides,
|
||||
}
|
||||
)
|
||||
doc.save = vi.fn(async (options) => {
|
||||
if (!options || options.validateBeforeSave !== false) {
|
||||
const err = new Error('Validation failed: location: legacy field invalid')
|
||||
err.name = 'ValidationError'
|
||||
throw err
|
||||
}
|
||||
return doc
|
||||
})
|
||||
return doc
|
||||
}
|
||||
|
||||
function buildPostEvent(body) {
|
||||
const ev = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/events/event-slug/waitlist',
|
||||
body,
|
||||
})
|
||||
ev.context = { params: { id: 'event-slug' } }
|
||||
return ev
|
||||
}
|
||||
|
||||
function buildDeleteEvent(body) {
|
||||
const ev = createMockEvent({
|
||||
method: 'DELETE',
|
||||
path: '/api/events/event-slug/waitlist',
|
||||
body,
|
||||
})
|
||||
ev.context = { params: { id: 'event-slug' } }
|
||||
return ev
|
||||
}
|
||||
|
||||
describe('POST /api/events/[id]/waitlist — bypasses save validators', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Member.findOne.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
|
||||
const mockEvent = makeMockEvent()
|
||||
Event.findOne.mockResolvedValue(mockEvent)
|
||||
|
||||
const result = await waitlistPostHandler(buildPostEvent({
|
||||
name: 'Waiter',
|
||||
email: 'wait@example.com',
|
||||
}))
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockEvent.save).toHaveBeenCalledTimes(1)
|
||||
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
|
||||
// Entry was actually appended.
|
||||
expect(mockEvent.tickets.waitlist.entries).toHaveLength(1)
|
||||
expect(mockEvent.tickets.waitlist.entries[0].email).toBe('wait@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/events/[id]/waitlist — bypasses save validators', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
|
||||
const mockEvent = makeMockEvent({
|
||||
tickets: {
|
||||
waitlist: {
|
||||
enabled: true,
|
||||
maxSize: 10,
|
||||
entries: [
|
||||
{
|
||||
name: 'Waiter',
|
||||
email: 'wait@example.com',
|
||||
membershipLevel: 'non-member',
|
||||
addedAt: new Date(),
|
||||
notified: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
Event.findOne.mockResolvedValue(mockEvent)
|
||||
|
||||
const result = await waitlistDeleteHandler(buildDeleteEvent({
|
||||
email: 'wait@example.com',
|
||||
}))
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockEvent.save).toHaveBeenCalledTimes(1)
|
||||
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
|
||||
// Entry was actually removed.
|
||||
expect(mockEvent.tickets.waitlist.entries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* Regression: `events/[id]/payment.post.js` was deleted because its
|
||||
* unauthenticated POST allowed any caller to spam-register an existing
|
||||
* member to any paid event by supplying their email. See
|
||||
* docs/superpowers/specs/2026-04-25-fix-3.md.
|
||||
*
|
||||
* With the route file gone, Nitro's filesystem router will not register
|
||||
* a handler at `/api/events/{id}/payment`, so a POST returns 404 — the
|
||||
* spam-register attack surface no longer exists at the network layer.
|
||||
*/
|
||||
describe('events/[id]/payment route deletion', () => {
|
||||
it('the payment.post.js route file no longer exists', () => {
|
||||
const routePath = resolve(
|
||||
import.meta.dirname,
|
||||
'../../../../server/api/events/[id]/payment.post.js'
|
||||
)
|
||||
expect(existsSync(routePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('the secure replacement at tickets/purchase.post.js still exists', () => {
|
||||
const replacementPath = resolve(
|
||||
import.meta.dirname,
|
||||
'../../../../server/api/events/[id]/tickets/purchase.post.js'
|
||||
)
|
||||
expect(existsSync(replacementPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -82,28 +82,6 @@ describe('helcim existing-card endpoint', () => {
|
|||
expect(result.cardToken).toBe('tok-b')
|
||||
})
|
||||
|
||||
it('unwraps a { cards: [...] } response envelope', async () => {
|
||||
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
|
||||
listHelcimCustomerCards.mockResolvedValue({
|
||||
cards: [{ cardToken: 'tok-only' }]
|
||||
})
|
||||
|
||||
const result = await existingCardHandler(newEvent())
|
||||
|
||||
expect(result.cardToken).toBe('tok-only')
|
||||
})
|
||||
|
||||
it('unwraps a { data: [...] } response envelope', async () => {
|
||||
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
|
||||
listHelcimCustomerCards.mockResolvedValue({
|
||||
data: [{ cardToken: 'tok-only' }]
|
||||
})
|
||||
|
||||
const result = await existingCardHandler(newEvent())
|
||||
|
||||
expect(result.cardToken).toBe('tok-only')
|
||||
})
|
||||
|
||||
it('returns { cardToken: null } if the resolved card has no cardToken', async () => {
|
||||
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
|
||||
listHelcimCustomerCards.mockResolvedValue([{ default: true }])
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|||
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
|
||||
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
||||
import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
|
||||
import { loadPublicSeries } from '../../../server/utils/loadSeries.js'
|
||||
import { PAYMENT_METADATA_TYPES } from '../../../server/utils/paymentTypes.js'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import Series from '../../../server/models/series.js'
|
||||
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
|
||||
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
|
@ -16,12 +17,15 @@ vi.mock('../../../server/utils/auth.js', () => ({
|
|||
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
|
||||
vi.mock('../../../server/utils/loadSeries.js', () => ({ loadPublicSeries: vi.fn() }))
|
||||
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
|
||||
vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } }))
|
||||
|
||||
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
||||
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
||||
|
||||
// PAYMENT_METADATA_TYPES is a Nitro auto-import from server/utils/paymentTypes.js
|
||||
vi.stubGlobal('PAYMENT_METADATA_TYPES', PAYMENT_METADATA_TYPES)
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
|
|
@ -30,7 +34,7 @@ describe('initialize-payment endpoint', () => {
|
|||
vi.clearAllMocks()
|
||||
getOptionalMember.mockResolvedValue(null)
|
||||
Member.findOne.mockResolvedValue(null)
|
||||
Series.findOne.mockResolvedValue(null)
|
||||
loadPublicSeries.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -184,13 +188,13 @@ describe('initialize-payment endpoint', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => {
|
||||
it('re-derives series_ticket price via loadPublicSeries + calculateSeriesTicketPrice', async () => {
|
||||
const body = {
|
||||
amount: 100, // tampered
|
||||
metadata: { type: 'series_ticket', seriesId: 'ser-x' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
Series.findOne.mockResolvedValue({
|
||||
loadPublicSeries.mockResolvedValue({
|
||||
_id: 'ser-x',
|
||||
title: 'Coop Foundations',
|
||||
tickets: { enabled: true, public: { available: true, price: 7500 } }
|
||||
|
|
@ -215,7 +219,7 @@ describe('initialize-payment endpoint', () => {
|
|||
expect(sentBody.amount).toBe(7500)
|
||||
expect(sentBody.paymentType).toBe('purchase')
|
||||
expect(result.amount).toBe(7500)
|
||||
expect(Series.findOne).toHaveBeenCalled()
|
||||
expect(loadPublicSeries).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses member pricing when metadata.email matches an active member', async () => {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import { requireAuth } from '../../../server/utils/auth.js'
|
|||
import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js'
|
||||
import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
||||
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
||||
import { sendWelcomeEmail } from '../../../server/utils/resend.js'
|
||||
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
|
||||
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn(), findById: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/auth.js', () => ({
|
||||
|
|
@ -41,8 +42,9 @@ vi.stubGlobal('helcimSubscriptionSchema', {})
|
|||
describe('helcim subscription endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: first activation from pending_payment
|
||||
Member.findOne.mockResolvedValue({ status: 'pending_payment' })
|
||||
// Default: pre-update doc reflects first activation from pending_payment.
|
||||
// findOneAndUpdate returns pre-update doc; findById returns post-update doc.
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-default', status: 'pending_payment' })
|
||||
})
|
||||
|
||||
it('requires auth', async () => {
|
||||
|
|
@ -77,7 +79,8 @@ describe('helcim subscription endpoint', () => {
|
|||
status: 'active',
|
||||
save: vi.fn()
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
|
|
@ -100,8 +103,9 @@ describe('helcim subscription endpoint', () => {
|
|||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ helcimCustomerId: 'cust-1' },
|
||||
expect.objectContaining({ status: 'active', contributionAmount: 0 }),
|
||||
{ new: true }
|
||||
{ new: false, projection: { status: 1 } }
|
||||
)
|
||||
expect(Member.findById).toHaveBeenCalledWith('member-1')
|
||||
expect(createHelcimSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -135,7 +139,8 @@ describe('helcim subscription endpoint', () => {
|
|||
contributionAmount: 15,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-2', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||
})
|
||||
|
|
@ -156,8 +161,9 @@ describe('helcim subscription endpoint', () => {
|
|||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ helcimCustomerId: 'cust-1' },
|
||||
{ $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) },
|
||||
{ new: true, runValidators: false }
|
||||
{ new: false, runValidators: false, projection: { status: 1 } }
|
||||
)
|
||||
expect(Member.findById).toHaveBeenCalledWith('member-2')
|
||||
})
|
||||
|
||||
it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => {
|
||||
|
|
@ -173,7 +179,8 @@ describe('helcim subscription endpoint', () => {
|
|||
contributionAmount: 15,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }]
|
||||
})
|
||||
|
|
@ -194,8 +201,9 @@ describe('helcim subscription endpoint', () => {
|
|||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ helcimCustomerId: 'cust-1' },
|
||||
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) },
|
||||
{ new: true, runValidators: false }
|
||||
{ new: false, runValidators: false, projection: { status: 1 } }
|
||||
)
|
||||
expect(Member.findById).toHaveBeenCalledWith('member-3')
|
||||
})
|
||||
|
||||
it('annual $50 tier recurringAmount is 600', async () => {
|
||||
|
|
@ -211,7 +219,8 @@ describe('helcim subscription endpoint', () => {
|
|||
contributionAmount: 50,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }]
|
||||
})
|
||||
|
|
@ -283,7 +292,8 @@ describe('helcim subscription endpoint', () => {
|
|||
contributionAmount: 15,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-9', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||
})
|
||||
|
|
@ -322,7 +332,8 @@ describe('helcim subscription endpoint', () => {
|
|||
contributionAmount: 15,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-10', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }]
|
||||
})
|
||||
|
|
@ -358,7 +369,8 @@ describe('helcim subscription endpoint', () => {
|
|||
contributionAmount: 15,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-11', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||
})
|
||||
|
|
@ -376,6 +388,120 @@ describe('helcim subscription endpoint', () => {
|
|||
expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('first activation (pending_payment → active) sends welcome email on free tier', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(false)
|
||||
|
||||
const mockMember = {
|
||||
_id: 'member-first-free',
|
||||
email: 'newbie@example.com',
|
||||
name: 'Newbie',
|
||||
circle: 'community',
|
||||
contributionAmount: 0,
|
||||
status: 'active',
|
||||
}
|
||||
// Pre-update status was pending_payment → first activation
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-free', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: { customerId: 'cust-first-free', contributionAmount: 0, customerCode: 'code-1' }
|
||||
})
|
||||
|
||||
await subscriptionHandler(event)
|
||||
|
||||
expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember)
|
||||
})
|
||||
|
||||
it('first activation (pending_payment → active) sends welcome email on paid tier', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(true)
|
||||
getHelcimPlanId.mockReturnValue('99999')
|
||||
|
||||
const mockMember = {
|
||||
_id: 'member-first-paid',
|
||||
email: 'newpaid@example.com',
|
||||
name: 'NewPaid',
|
||||
circle: 'founder',
|
||||
contributionAmount: 15,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-paid', status: 'pending_payment' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-first-paid', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: { customerId: 'cust-first-paid', contributionAmount: 15, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' }
|
||||
})
|
||||
|
||||
await subscriptionHandler(event)
|
||||
|
||||
expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember)
|
||||
})
|
||||
|
||||
it('already-active retry (active → active) does NOT send welcome email on free tier', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(false)
|
||||
|
||||
const mockMember = {
|
||||
_id: 'member-retry-free',
|
||||
email: 'existing@example.com',
|
||||
name: 'Existing',
|
||||
circle: 'community',
|
||||
contributionAmount: 0,
|
||||
status: 'active',
|
||||
}
|
||||
// Pre-update status was already active → tier upgrade, not first activation
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-free', status: 'active' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: { customerId: 'cust-retry-free', contributionAmount: 0, customerCode: 'code-1' }
|
||||
})
|
||||
|
||||
await subscriptionHandler(event)
|
||||
|
||||
expect(sendWelcomeEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('already-active retry (active → active) does NOT send welcome email on paid tier', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(true)
|
||||
getHelcimPlanId.mockReturnValue('99999')
|
||||
|
||||
const mockMember = {
|
||||
_id: 'member-retry-paid',
|
||||
email: 'upgrader@example.com',
|
||||
name: 'Upgrader',
|
||||
circle: 'founder',
|
||||
contributionAmount: 25,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-paid', status: 'active' })
|
||||
Member.findById.mockResolvedValue(mockMember)
|
||||
createHelcimSubscription.mockResolvedValue({
|
||||
data: [{ id: 'sub-retry', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: { customerId: 'cust-retry-paid', contributionAmount: 25, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' }
|
||||
})
|
||||
|
||||
await subscriptionHandler(event)
|
||||
|
||||
expect(sendWelcomeEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Helcim API failure returns 500 and does NOT activate member', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(true)
|
||||
|
|
|
|||
|
|
@ -2,19 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import Payment from '../../../server/models/payment.js'
|
||||
import { listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
||||
import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
||||
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
||||
import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { find: vi.fn() }
|
||||
default: { find: vi.fn(), findByIdAndUpdate: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/models/payment.js', () => ({
|
||||
default: { findOne: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/helcim.js', () => ({
|
||||
getHelcimCustomer: vi.fn(),
|
||||
listHelcimCustomerTransactions: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../server/utils/payments.js', () => ({
|
||||
|
|
@ -88,6 +89,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
_id: 'm1',
|
||||
email: 'a@example.com',
|
||||
helcimCustomerId: 'cust-1',
|
||||
helcimCustomerCode: 'CST-1',
|
||||
helcimSubscriptionId: 'sub-1',
|
||||
billingCadence: 'monthly'
|
||||
}
|
||||
|
|
@ -113,6 +115,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
{ helcimCustomerId: { $exists: true, $ne: null } },
|
||||
expect.objectContaining({
|
||||
helcimCustomerId: 1,
|
||||
helcimCustomerCode: 1,
|
||||
helcimSubscriptionId: 1,
|
||||
billingCadence: 1
|
||||
})
|
||||
|
|
@ -134,7 +137,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
|
||||
it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockResolvedValue([
|
||||
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' }
|
||||
|
|
@ -157,17 +160,17 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
|
||||
it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' },
|
||||
{ _id: 'm2', helcimCustomerId: 'cust-2' },
|
||||
{ _id: 'm3', helcimCustomerId: 'cust-3' }
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' },
|
||||
{ _id: 'm2', helcimCustomerId: 'cust-2', helcimCustomerCode: 'CST-2' },
|
||||
{ _id: 'm3', helcimCustomerId: 'cust-3', helcimCustomerCode: 'CST-3' }
|
||||
]))
|
||||
// m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try.
|
||||
listHelcimCustomerTransactions
|
||||
.mockResolvedValueOnce([{ id: 'tx1', status: 'paid', amount: 5 }])
|
||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
||||
.mockResolvedValueOnce([{ id: 'tx3', status: 'paid', amount: 7 }])
|
||||
// Keyed by customerCode so it works regardless of call order (chunked Promise.all).
|
||||
listHelcimCustomerTransactions.mockImplementation((customerCode) => {
|
||||
if (customerCode === 'cust-1') return Promise.resolve([{ id: 'tx1', status: 'paid', amount: 5 }])
|
||||
if (customerCode === 'cust-3') return Promise.resolve([{ id: 'tx3', status: 'paid', amount: 7 }])
|
||||
return Promise.reject(new Error('helcim 503'))
|
||||
})
|
||||
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } })
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
|
@ -191,7 +194,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => {
|
||||
vi.useFakeTimers()
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions
|
||||
.mockRejectedValueOnce(new Error('boom 1'))
|
||||
|
|
@ -219,7 +222,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
it('counts memberErrors when all 3 retry attempts fail', async () => {
|
||||
vi.useFakeTimers()
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503'))
|
||||
|
||||
|
|
@ -241,7 +244,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
|
||||
it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockResolvedValue([
|
||||
{ id: 'tx-existing', status: 'paid', amount: 10 },
|
||||
|
|
@ -266,4 +269,63 @@ describe('POST /api/internal/reconcile-payments', () => {
|
|||
existed: 1
|
||||
})
|
||||
})
|
||||
|
||||
describe('helcimCustomerCode backfill', () => {
|
||||
it('writes helcimCustomerCode when missing on the member doc', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' } // no helcimCustomerCode
|
||||
]))
|
||||
getHelcimCustomer.mockResolvedValue({ id: 'cust-1', customerCode: 'CST-NEW' })
|
||||
listHelcimCustomerTransactions.mockResolvedValue([])
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
await reconcileHandler(event)
|
||||
|
||||
expect(getHelcimCustomer).toHaveBeenCalledWith('cust-1')
|
||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'm1',
|
||||
{ $set: { helcimCustomerCode: 'CST-NEW' } },
|
||||
{ runValidators: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('skips backfill when helcimCustomerCode is already present', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-EXISTING' }
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockResolvedValue([])
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
await reconcileHandler(event)
|
||||
|
||||
expect(getHelcimCustomer).not.toHaveBeenCalled()
|
||||
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fail the run when getHelcimCustomer throws during backfill', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
]))
|
||||
getHelcimCustomer.mockRejectedValue(new Error('helcim 503'))
|
||||
listHelcimCustomerTransactions.mockResolvedValue([])
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
const result = await reconcileHandler(event)
|
||||
|
||||
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||
expect(result.memberErrors).toBe(0) // backfill failure is best-effort, not fatal
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,98 +1,177 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]')
|
||||
import Member from '../../../server/models/member.js'
|
||||
import Series from '../../../server/models/series.js'
|
||||
import Event from '../../../server/models/event.js'
|
||||
import {
|
||||
validateSeriesTicketPurchase,
|
||||
completeSeriesTicketPurchase,
|
||||
registerForAllSeriesEvents,
|
||||
hasMemberAccess,
|
||||
} from '../../../server/utils/tickets.js'
|
||||
import { sendSeriesPassConfirmation } from '../../../server/utils/resend.js'
|
||||
import { seriesTicketPurchaseSchema } from '../../../server/utils/schemas.js'
|
||||
import handler from '../../../server/api/series/[id]/tickets/purchase.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => {
|
||||
const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8')
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findOne: vi.fn(), findOneAndUpdate: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/models/series.js', () => ({
|
||||
default: { findOne: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/models/event.js', () => ({
|
||||
default: {
|
||||
find: vi.fn(() => ({ sort: vi.fn().mockResolvedValue([]) }))
|
||||
}
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/tickets.js', () => ({
|
||||
validateSeriesTicketPurchase: vi.fn(),
|
||||
calculateSeriesTicketPrice: vi.fn(),
|
||||
reserveSeriesTicket: vi.fn(),
|
||||
releaseSeriesTicket: vi.fn(),
|
||||
completeSeriesTicketPurchase: vi.fn().mockResolvedValue(undefined),
|
||||
registerForAllSeriesEvents: vi.fn().mockResolvedValue([]),
|
||||
hasMemberAccess: vi.fn(() => false),
|
||||
}))
|
||||
vi.mock('../../../server/utils/resend.js', () => ({
|
||||
sendSeriesPassConfirmation: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
it('uses validateBody with seriesTicketPurchaseSchema', () => {
|
||||
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
|
||||
// Auto-imports the handler relies on but the global setup doesn't stub.
|
||||
const setAuthCookieMock = vi.fn()
|
||||
vi.stubGlobal('setAuthCookie', setAuthCookieMock)
|
||||
vi.stubGlobal('seriesTicketPurchaseSchema', seriesTicketPurchaseSchema)
|
||||
|
||||
// Capture schema passed to validateBody so we can prove the route validates
|
||||
// against seriesTicketPurchaseSchema specifically.
|
||||
const validateBodyCalls = []
|
||||
const validateBodyMock = vi.fn(async (event, schema) => {
|
||||
validateBodyCalls.push(schema)
|
||||
// Mirror real behavior: parse body via the schema so invalid bodies still throw.
|
||||
const body = await readBody(event)
|
||||
return schema.parse(body)
|
||||
})
|
||||
vi.stubGlobal('validateBody', validateBodyMock)
|
||||
|
||||
function buildEvent(body) {
|
||||
const ev = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/series/series-1/tickets/purchase',
|
||||
body,
|
||||
})
|
||||
ev.context = { params: { id: 'series-1' } }
|
||||
return ev
|
||||
}
|
||||
|
||||
it('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => {
|
||||
// Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in
|
||||
// checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`.
|
||||
expect(source).toContain('findOneAndUpdate')
|
||||
expect(source).toContain('$setOnInsert')
|
||||
expect(source).toContain('status: "guest"')
|
||||
expect(source).toContain('upsert: true')
|
||||
expect(source).toContain('circle: "community"')
|
||||
expect(source).toContain('contributionAmount: 0')
|
||||
// ALWAYS-CREATE — must NOT gate on a createAccount flag
|
||||
expect(source).not.toContain('body.createAccount')
|
||||
})
|
||||
|
||||
it('Case 3 (idempotency): upsert pattern handles concurrent same-email registrations atomically', () => {
|
||||
// findOneAndUpdate with $setOnInsert + upsert:true is the idempotent pattern;
|
||||
// email has a unique index. No duplicate Member doc created on retry.
|
||||
expect(source).toMatch(/findOneAndUpdate\(\s*\{\s*email:/)
|
||||
expect(source).toContain('upsert: true')
|
||||
expect(source).toContain('new: true')
|
||||
expect(source).toContain('setDefaultsOnInsert: true')
|
||||
})
|
||||
|
||||
it('Case 4 (existing real member): does not auto-login real members entered via public form', () => {
|
||||
// Auto-login only for newly-created accounts and existing guests.
|
||||
// Real members (active/pending_payment) get requiresSignIn: true instead.
|
||||
expect(source).toContain('accountCreated || member.status === "guest"')
|
||||
expect(source).toContain('requiresSignIn = true')
|
||||
})
|
||||
|
||||
it('Case 5 (authenticated guest): sets auth cookie on signedIn:true response', () => {
|
||||
// setAuthCookie fires for both new accounts and returning guests.
|
||||
expect(source).toContain('setAuthCookie(event, member)')
|
||||
expect(source).toContain('signedIn = true')
|
||||
})
|
||||
|
||||
it('Case 6 (missing fields): relies on schema validation to reject missing name/email', () => {
|
||||
// No new validation logic added — existing seriesTicketPurchaseSchema
|
||||
// already requires name+email; validateBody throws 400 if missing.
|
||||
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
|
||||
})
|
||||
|
||||
it('includes accountCreated, signedIn, and requiresSignIn in response (parity with event endpoint)', () => {
|
||||
expect(source).toContain('accountCreated,')
|
||||
expect(source).toContain('signedIn,')
|
||||
expect(source).toContain('requiresSignIn,')
|
||||
})
|
||||
|
||||
it('still uses hasMemberAccess to gate member pricing (guest/suspended/cancelled treated as non-members)', () => {
|
||||
expect(source).toContain('hasMemberAccess(member)')
|
||||
})
|
||||
|
||||
it('preserves try/catch around requireAuth so unauthenticated callers fall through', () => {
|
||||
// Required for unauth guest-purchase flow to work at all.
|
||||
expect(source).toMatch(/try\s*\{[^}]*requireAuth\(event\)[^}]*\}\s*catch/s)
|
||||
})
|
||||
|
||||
it('does not block purchase when confirmation email fails', () => {
|
||||
const emailCallIndex = source.indexOf('await sendSeriesPassConfirmation')
|
||||
expect(emailCallIndex).toBeGreaterThan(-1)
|
||||
const afterEmail = source.slice(emailCallIndex)
|
||||
const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
|
||||
expect(catchBlock).not.toBeNull()
|
||||
expect(catchBlock[0]).toContain('console.error')
|
||||
})
|
||||
const baseSeries = () => ({
|
||||
_id: 'series-1',
|
||||
id: 'series-1',
|
||||
slug: 'series-slug',
|
||||
title: 'Test Series',
|
||||
description: 'desc',
|
||||
type: 'workshop',
|
||||
registrations: [],
|
||||
})
|
||||
|
||||
describe('SeriesPassPurchase.vue — client auth refresh (Fix #8)', () => {
|
||||
const source = readFileSync(
|
||||
resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => {
|
||||
expect(source).toContain('useAuth().checkMemberStatus()')
|
||||
expect(source).toMatch(/purchaseResponse\?\.signedIn/)
|
||||
describe('POST /api/series/[id]/tickets/purchase — guest upsert + auth cookie', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
validateBodyCalls.length = 0
|
||||
// Default: unauthenticated buyer
|
||||
globalThis.requireAuth = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('Unauthorized'), { statusCode: 401 })
|
||||
)
|
||||
Series.findOne.mockResolvedValue(baseSeries())
|
||||
Member.findOne.mockResolvedValue(null)
|
||||
validateSeriesTicketPurchase.mockReturnValue({
|
||||
valid: true,
|
||||
ticketInfo: {
|
||||
ticketType: 'public',
|
||||
price: 0,
|
||||
currency: 'CAD',
|
||||
isFree: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a one-line guest-account hint under the form (no checkbox)', () => {
|
||||
// Per ALWAYS-CREATE-GUEST decision: hint only, no UI control.
|
||||
expect(source).toMatch(/free guest account/i)
|
||||
// Make sure no checkbox was added by mistake.
|
||||
expect(source).not.toMatch(/createAccount/)
|
||||
expect(source).not.toMatch(/<input[^>]*type="checkbox"/i)
|
||||
it('upserts a guest Member with $setOnInsert + upsert:true when buyer has no account', async () => {
|
||||
Member.findOneAndUpdate.mockResolvedValue({
|
||||
_id: 'new-member-1',
|
||||
email: 'guest@example.com',
|
||||
status: 'guest',
|
||||
})
|
||||
|
||||
await handler(buildEvent({
|
||||
name: 'Guest Buyer',
|
||||
email: 'guest@example.com',
|
||||
ticketType: 'public',
|
||||
}))
|
||||
|
||||
expect(Member.findOneAndUpdate).toHaveBeenCalledTimes(1)
|
||||
const [filter, update, options] = Member.findOneAndUpdate.mock.calls[0]
|
||||
expect(filter).toEqual({ email: 'guest@example.com' })
|
||||
expect(update).toEqual({
|
||||
$setOnInsert: {
|
||||
email: 'guest@example.com',
|
||||
name: 'Guest Buyer',
|
||||
circle: 'community',
|
||||
contributionAmount: 0,
|
||||
status: 'guest',
|
||||
},
|
||||
})
|
||||
expect(options).toEqual({
|
||||
upsert: true,
|
||||
new: true,
|
||||
setDefaultsOnInsert: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('sets the auth cookie for newly-created guest accounts', async () => {
|
||||
const newMember = {
|
||||
_id: 'new-member-2',
|
||||
email: 'newbie@example.com',
|
||||
status: 'guest',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(newMember)
|
||||
|
||||
const result = await handler(buildEvent({
|
||||
name: 'Newbie',
|
||||
email: 'newbie@example.com',
|
||||
ticketType: 'public',
|
||||
}))
|
||||
|
||||
expect(setAuthCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(setAuthCookieMock.mock.calls[0][1]).toBe(newMember)
|
||||
expect(result.signedIn).toBe(true)
|
||||
expect(result.accountCreated).toBe(true)
|
||||
expect(result.requiresSignIn).toBe(false)
|
||||
})
|
||||
|
||||
it('validates input via seriesTicketPurchaseSchema', async () => {
|
||||
Member.findOneAndUpdate.mockResolvedValue({
|
||||
_id: 'm',
|
||||
email: 'a@b.com',
|
||||
status: 'guest',
|
||||
})
|
||||
|
||||
await handler(buildEvent({
|
||||
name: 'A',
|
||||
email: 'a@b.com',
|
||||
ticketType: 'public',
|
||||
}))
|
||||
|
||||
expect(validateBodyMock).toHaveBeenCalled()
|
||||
expect(validateBodyCalls[0]).toBe(seriesTicketPurchaseSchema)
|
||||
})
|
||||
|
||||
it('rejects invalid body (no name) via schema validation', async () => {
|
||||
await expect(
|
||||
handler(buildEvent({ email: 'a@b.com', ticketType: 'public' }))
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(Member.findOneAndUpdate).not.toHaveBeenCalled()
|
||||
expect(setAuthCookieMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,35 +18,6 @@ describe('rate-limit middleware', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('auth endpoint limiting (5 per 5 min)', () => {
|
||||
it('allows 5 requests then blocks the 6th', async () => {
|
||||
const ip = '10.0.1.1'
|
||||
|
||||
// First 5 should succeed
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
remoteAddress: ip
|
||||
})
|
||||
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
|
||||
}
|
||||
|
||||
// 6th should be rate limited
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
remoteAddress: ip
|
||||
})
|
||||
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
|
||||
statusCode: 429
|
||||
})
|
||||
|
||||
// Check Retry-After header was set
|
||||
expect(event._testSetHeaders['retry-after']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('payment endpoint limiting (10 per min)', () => {
|
||||
it('allows 10 requests then blocks the 11th', async () => {
|
||||
const ip = '10.0.2.1'
|
||||
|
|
@ -68,16 +39,19 @@ describe('rate-limit middleware', () => {
|
|||
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
|
||||
statusCode: 429
|
||||
})
|
||||
|
||||
// Check Retry-After header was set
|
||||
expect(event._testSetHeaders['retry-after']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IP isolation', () => {
|
||||
it('different IPs have separate rate limit counters', async () => {
|
||||
// Exhaust limit for IP A
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// Exhaust payment limit for IP A
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
remoteAddress: '10.0.3.1'
|
||||
})
|
||||
await rateLimitMiddleware(event)
|
||||
|
|
@ -86,7 +60,7 @@ describe('rate-limit middleware', () => {
|
|||
// IP B should still be able to make requests
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
remoteAddress: '10.0.3.2'
|
||||
})
|
||||
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import {
|
||||
listHelcimCustomerCards,
|
||||
listHelcimCustomerTransactions,
|
||||
updateHelcimCustomerDefaultPaymentMethod,
|
||||
updateHelcimSubscriptionPaymentMethod
|
||||
|
|
@ -25,6 +26,56 @@ function errResponse(status = 500, body = 'boom') {
|
|||
}
|
||||
}
|
||||
|
||||
describe('listHelcimCustomerCards', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
it('passes through a bare array response', async () => {
|
||||
const cards = [
|
||||
{ id: 1, cardToken: 'tok-a' },
|
||||
{ id: 2, cardToken: 'tok-b' }
|
||||
]
|
||||
mockFetch.mockResolvedValue(okResponse(cards))
|
||||
|
||||
const result = await listHelcimCustomerCards('2488717')
|
||||
|
||||
expect(result).toEqual(cards)
|
||||
})
|
||||
|
||||
it('unwraps a { cards: [...] } response envelope', async () => {
|
||||
const cards = [{ id: 1, cardToken: 'tok-a' }]
|
||||
mockFetch.mockResolvedValue(okResponse({ cards }))
|
||||
|
||||
const result = await listHelcimCustomerCards('2488717')
|
||||
|
||||
expect(result).toEqual(cards)
|
||||
})
|
||||
|
||||
it('unwraps a { data: [...] } response envelope', async () => {
|
||||
const cards = [{ id: 1, cardToken: 'tok-a' }]
|
||||
mockFetch.mockResolvedValue(okResponse({ data: cards }))
|
||||
|
||||
const result = await listHelcimCustomerCards('2488717')
|
||||
|
||||
expect(result).toEqual(cards)
|
||||
})
|
||||
|
||||
it('returns an empty array for null, undefined, or unrecognized object responses', async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(null))
|
||||
expect(await listHelcimCustomerCards('2488717')).toEqual([])
|
||||
|
||||
mockFetch.mockResolvedValueOnce(okResponse(undefined))
|
||||
expect(await listHelcimCustomerCards('2488717')).toEqual([])
|
||||
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ unexpected: 'shape' }))
|
||||
expect(await listHelcimCustomerCards('2488717')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('listHelcimCustomerTransactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||