Skip to content

Commit bb02cea

Browse files
committed
feat: added tests
1 parent 71395eb commit bb02cea

7 files changed

Lines changed: 226 additions & 34 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"lint": "npm run typecheck",
1717
"typecheck": "tsc --noEmit",
1818
"knip": "node --import tsx ./node_modules/knip/bin/knip.js",
19+
"test": "node --import tsx --test tests/*.test.ts",
1920
"generate": "payload generate:types"
2021
},
2122
"dependencies": {

src/components/SubscriptionConfigurator.tsx

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { SubscriptionConfigData } from "@/lib/cms"
44
import { formatEuroCurrency } from "@/lib/formatters"
55
import type { AppLocale } from "@/lib/i18n"
6+
import { calculateSubscriptionPrice, clampToRange, formatDiscountBadge, getPaymentPeriodDiscount, getPaymentPeriodSuffix, type PaymentPeriod, type UsageRange } from "@/lib/subscriptionCalculator"
67
import { useDesktopPinnedPosition } from "@/hooks/useDesktopPinnedPosition"
78
import { cn } from "@/lib/utils"
89
import { BuyMenu } from "@/components/BuyMenu"
@@ -17,7 +18,6 @@ import { Card } from "./ui/Card"
1718

1819
type DeploymentMode = "self-hosted" | "cloud"
1920
type CustomerType = "b2b" | "b2c"
20-
type PaymentPeriod = "monthly" | "quarterly" | "yearly"
2121
type OptionAccent = "aqua" | "yellow" | "pink" | "blue" | "brand"
2222
type SubscriptionSelection = {
2323
deployment: DeploymentMode
@@ -26,12 +26,6 @@ type SubscriptionSelection = {
2626
workflowExecutions: number
2727
aiTokens: number
2828
}
29-
type UsageRange = {
30-
min: number
31-
max: number
32-
step: number
33-
}
34-
3529
export interface SubscriptionIcons {
3630
featureOverview: ReactNode[]
3731
deployment: {
@@ -182,29 +176,6 @@ function AdditionalFeatureCard({ title, description, active, onClick, icon, form
182176
)
183177
}
184178

185-
function clampToRange(value: number, range: UsageRange) {
186-
return Math.min(Math.max(value, range.min), range.max)
187-
}
188-
189-
function formatDiscountBadge(discount: number, locale: AppLocale) {
190-
return new Intl.NumberFormat(locale === "de" ? "de-DE" : "en-US", {
191-
style: "percent",
192-
maximumFractionDigits: 0,
193-
}).format(discount)
194-
}
195-
196-
function getPaymentPeriodDiscount(period: PaymentPeriod, paymentPeriod: SubscriptionConfigData["paymentPeriod"]) {
197-
if (period === "quarterly") return paymentPeriod.quarterlyDiscount
198-
if (period === "yearly") return paymentPeriod.yearlyDiscount
199-
return 0
200-
}
201-
202-
function getPaymentPeriodSuffix(period: PaymentPeriod, paymentPeriod: SubscriptionConfigData["paymentPeriod"]) {
203-
if (period === "quarterly") return paymentPeriod.quarterlyPeriodSuffix
204-
if (period === "yearly") return paymentPeriod.yearlyPeriodSuffix
205-
return paymentPeriod.monthlyPeriodSuffix
206-
}
207-
208179
export function SubscriptionConfigurator({ locale, content, icons }: { locale: AppLocale; content: SubscriptionConfigData; icons: SubscriptionIcons }) {
209180
const workflowExecutions = content.workflowExecutions
210181
const aiTokens = content.aiTokens
@@ -226,13 +197,17 @@ export function SubscriptionConfigurator({ locale, content, icons }: { locale: A
226197
const [selectedFeatures, setSelectedFeatures] = useState<Set<number>>(new Set())
227198
const { wrapperRef: desktopWrapperRef, containerRef: desktopContainerRef } = useDesktopPinnedPosition<HTMLDivElement, HTMLDivElement>(96)
228199

229-
const workflowExecutionPrice = content.workflowExecutionPriceFactor * selection.workflowExecutions
230-
const aiTokenPrice = content.aiTokenPriceFactor * selection.aiTokens
231200
const additionalFeaturesPrice = Array.from(selectedFeatures).reduce((acc, idx) => acc + (content.additionalFeatures?.[idx]?.price ?? 0), 0)
232201
const paymentPeriodDiscount = getPaymentPeriodDiscount(selection.paymentPeriod, content.paymentPeriod)
233202
const paymentPeriodSuffix = getPaymentPeriodSuffix(selection.paymentPeriod, content.paymentPeriod)
234-
const totalBeforeDiscount = workflowExecutionPrice + aiTokenPrice + additionalFeaturesPrice
235-
const totalPrice = totalBeforeDiscount * (1 - paymentPeriodDiscount)
203+
const { totalPrice } = calculateSubscriptionPrice({
204+
additionalFeaturesPrice,
205+
aiTokenPriceFactor: content.aiTokenPriceFactor,
206+
aiTokens: selection.aiTokens,
207+
discount: paymentPeriodDiscount,
208+
workflowExecutionPriceFactor: content.workflowExecutionPriceFactor,
209+
workflowExecutions: selection.workflowExecutions,
210+
})
236211
const paymentPeriodSwitchOptions: SwitchOption<PaymentPeriod>[] = paymentPeriodOptions.map((option) => {
237212
const discount = getPaymentPeriodDiscount(option.value, content.paymentPeriod)
238213

src/lib/subscriptionCalculator.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { SubscriptionConfigData } from "@/lib/cms"
2+
import type { AppLocale } from "@/lib/i18n"
3+
4+
export type PaymentPeriod = "monthly" | "quarterly" | "yearly"
5+
6+
export type UsageRange = {
7+
min: number
8+
max: number
9+
step: number
10+
}
11+
12+
export function clampToRange(value: number, range: UsageRange) {
13+
return Math.min(Math.max(value, range.min), range.max)
14+
}
15+
16+
export function formatDiscountBadge(discount: number, locale: AppLocale) {
17+
return new Intl.NumberFormat(locale === "de" ? "de-DE" : "en-US", {
18+
style: "percent",
19+
maximumFractionDigits: 0,
20+
}).format(discount)
21+
}
22+
23+
export function getPaymentPeriodDiscount(period: PaymentPeriod, paymentPeriod: SubscriptionConfigData["paymentPeriod"]) {
24+
if (period === "quarterly") return paymentPeriod.quarterlyDiscount
25+
if (period === "yearly") return paymentPeriod.yearlyDiscount
26+
return 0
27+
}
28+
29+
export function getPaymentPeriodSuffix(period: PaymentPeriod, paymentPeriod: SubscriptionConfigData["paymentPeriod"]) {
30+
if (period === "quarterly") return paymentPeriod.quarterlyPeriodSuffix
31+
if (period === "yearly") return paymentPeriod.yearlyPeriodSuffix
32+
return paymentPeriod.monthlyPeriodSuffix
33+
}
34+
35+
export function calculateSubscriptionPrice({
36+
additionalFeaturesPrice = 0,
37+
aiTokenPriceFactor,
38+
aiTokens,
39+
discount = 0,
40+
workflowExecutionPriceFactor,
41+
workflowExecutions,
42+
}: {
43+
additionalFeaturesPrice?: number
44+
aiTokenPriceFactor: number
45+
aiTokens: number
46+
discount?: number
47+
workflowExecutionPriceFactor: number
48+
workflowExecutions: number
49+
}) {
50+
const workflowExecutionPrice = workflowExecutionPriceFactor * workflowExecutions
51+
const aiTokenPrice = aiTokenPriceFactor * aiTokens
52+
const totalBeforeDiscount = workflowExecutionPrice + aiTokenPrice + additionalFeaturesPrice
53+
54+
return {
55+
aiTokenPrice,
56+
totalBeforeDiscount,
57+
totalPrice: totalBeforeDiscount * (1 - discount),
58+
workflowExecutionPrice,
59+
}
60+
}

tests/i18n.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { getLocaleFromPath, isSupportedLocale, localizeHref } from "@/lib/i18n"
4+
5+
test("detects supported locales", () => {
6+
assert.equal(isSupportedLocale("en"), true)
7+
assert.equal(isSupportedLocale("de"), true)
8+
assert.equal(isSupportedLocale("fr"), false)
9+
})
10+
11+
test("gets locale from path with fallback", () => {
12+
assert.equal(getLocaleFromPath("/de/features"), "de")
13+
assert.equal(getLocaleFromPath("/en"), "en")
14+
assert.equal(getLocaleFromPath("/features"), "en")
15+
assert.equal(getLocaleFromPath("/"), "en")
16+
})
17+
18+
test("localizes internal hrefs only once", () => {
19+
assert.equal(localizeHref("/features", "de"), "/de/features")
20+
assert.equal(localizeHref("/", "de"), "/de")
21+
assert.equal(localizeHref("/en/features", "de"), "/en/features")
22+
assert.equal(localizeHref("https://example.com", "de"), "https://example.com")
23+
})

tests/media.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { getMediaUrl } from "@/lib/media"
4+
5+
test("returns empty string for missing media URL", () => {
6+
assert.equal(getMediaUrl(null), "")
7+
assert.equal(getMediaUrl(undefined), "")
8+
})
9+
10+
test("keeps relative media URLs unchanged", () => {
11+
assert.equal(getMediaUrl("/api/media/file/example.png"), "/api/media/file/example.png")
12+
})
13+
14+
test("normalizes local Payload media URLs to relative paths", () => {
15+
assert.equal(getMediaUrl("http://localhost:3000/api/media/file/example.png?size=small"), "/api/media/file/example.png?size=small")
16+
assert.equal(getMediaUrl("http://127.0.0.1:3000/api/media/file/example.png"), "/api/media/file/example.png")
17+
})
18+
19+
test("normalizes configured app media URLs to relative paths", () => {
20+
const previousAppUrl = process.env.NEXT_PUBLIC_APP_URL
21+
process.env.NEXT_PUBLIC_APP_URL = "https://codezero.build"
22+
23+
try {
24+
assert.equal(getMediaUrl("https://codezero.build/api/media/file/example.png"), "/api/media/file/example.png")
25+
assert.equal(getMediaUrl("https://cdn.example.com/api/media/file/example.png"), "https://cdn.example.com/api/media/file/example.png")
26+
} finally {
27+
process.env.NEXT_PUBLIC_APP_URL = previousAppUrl
28+
}
29+
})

tests/smtp.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { createRateLimitChecker, escapeHtml, getClientIdentifier, getRateLimitConfig } from "@/lib/smtp"
4+
5+
test("escapes html-sensitive characters", () => {
6+
assert.equal(escapeHtml(`<a href="x">It's ok & safe</a>`), "&lt;a href=&quot;x&quot;&gt;It&#039;s ok &amp; safe&lt;/a&gt;")
7+
})
8+
9+
test("uses forwarded IP before real IP", () => {
10+
const request = new Request("https://example.com", {
11+
headers: {
12+
"x-forwarded-for": "203.0.113.10, 203.0.113.11",
13+
"x-real-ip": "198.51.100.1",
14+
},
15+
})
16+
17+
assert.equal(getClientIdentifier(request), "203.0.113.10")
18+
})
19+
20+
test("falls back invalid rate limit env values", () => {
21+
const previousMax = process.env.TEST_RATE_LIMIT_MAX
22+
const previousWindow = process.env.TEST_RATE_LIMIT_WINDOW
23+
process.env.TEST_RATE_LIMIT_MAX = "-1"
24+
process.env.TEST_RATE_LIMIT_WINDOW = "nope"
25+
26+
try {
27+
assert.deepEqual(getRateLimitConfig("TEST_RATE_LIMIT_MAX", "TEST_RATE_LIMIT_WINDOW", 3, 20), {
28+
max: 3,
29+
windowMs: 20_000,
30+
})
31+
} finally {
32+
process.env.TEST_RATE_LIMIT_MAX = previousMax
33+
process.env.TEST_RATE_LIMIT_WINDOW = previousWindow
34+
}
35+
})
36+
37+
test("blocks requests after configured max within window", () => {
38+
const checkRateLimit = createRateLimitChecker({ max: 2, windowMs: 60_000 })
39+
40+
assert.deepEqual(checkRateLimit("client"), { allowed: true, retryAfterSeconds: 0 })
41+
assert.deepEqual(checkRateLimit("client"), { allowed: true, retryAfterSeconds: 0 })
42+
43+
const blocked = checkRateLimit("client")
44+
assert.equal(blocked.allowed, false)
45+
assert.equal(blocked.retryAfterSeconds > 0, true)
46+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { calculateSubscriptionPrice, clampToRange, formatDiscountBadge, getPaymentPeriodDiscount, getPaymentPeriodSuffix } from "@/lib/subscriptionCalculator"
4+
5+
const paymentPeriod = {
6+
description: "Choose how often to pay.",
7+
label: "Payment period",
8+
monthlyPeriodSuffix: "per month",
9+
monthlyText: "Monthly",
10+
quarterlyDiscount: 0.1,
11+
quarterlyPeriodSuffix: "per quarter",
12+
quarterlyText: "Quarterly",
13+
yearlyDiscount: 0.2,
14+
yearlyPeriodSuffix: "per year",
15+
yearlyText: "Yearly",
16+
}
17+
18+
test("clamps usage values to configured range", () => {
19+
const range = { min: 100, max: 1_000, step: 100 }
20+
21+
assert.equal(clampToRange(50, range), 100)
22+
assert.equal(clampToRange(500, range), 500)
23+
assert.equal(clampToRange(2_000, range), 1_000)
24+
})
25+
26+
test("resolves payment period discounts and suffixes", () => {
27+
assert.equal(getPaymentPeriodDiscount("monthly", paymentPeriod), 0)
28+
assert.equal(getPaymentPeriodDiscount("quarterly", paymentPeriod), 0.1)
29+
assert.equal(getPaymentPeriodDiscount("yearly", paymentPeriod), 0.2)
30+
31+
assert.equal(getPaymentPeriodSuffix("monthly", paymentPeriod), "per month")
32+
assert.equal(getPaymentPeriodSuffix("quarterly", paymentPeriod), "per quarter")
33+
assert.equal(getPaymentPeriodSuffix("yearly", paymentPeriod), "per year")
34+
})
35+
36+
test("formats discount badges by locale", () => {
37+
assert.equal(formatDiscountBadge(0.2, "en"), "20%")
38+
assert.equal(formatDiscountBadge(0.2, "de"), "20\u00a0%")
39+
})
40+
41+
test("calculates subscription totals before and after discount", () => {
42+
assert.deepEqual(
43+
calculateSubscriptionPrice({
44+
additionalFeaturesPrice: 25,
45+
aiTokenPriceFactor: 0.00001,
46+
aiTokens: 1_000_000,
47+
discount: 0.2,
48+
workflowExecutionPriceFactor: 0.01,
49+
workflowExecutions: 1_000,
50+
}),
51+
{
52+
aiTokenPrice: 10,
53+
totalBeforeDiscount: 45,
54+
totalPrice: 36,
55+
workflowExecutionPrice: 10,
56+
}
57+
)
58+
})

0 commit comments

Comments
 (0)