From b3a4159c5e09a91edd21f5e8bacd511a4e429d20 Mon Sep 17 00:00:00 2001 From: chev Date: Wed, 8 Jul 2026 18:25:31 -0500 Subject: [PATCH 1/3] build: add bun test script and types --- bun.lock | 5 +++++ package.json | 2 ++ 2 files changed, 7 insertions(+) diff --git a/bun.lock b/bun.lock index 0211e3c..f9d1eec 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@storybook/nextjs": "^8.5.3", "@storybook/react": "^8.5.3", "@storybook/test": "^8.5.3", + "@types/bun": "^1.3.14", "@types/country-list": "^2.1.4", "@types/node": "^20", "@types/react": "^19", @@ -687,6 +688,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ=="], "@types/country-list": ["@types/country-list@2.1.4", "", {}, "sha512-c4orjm0wT+BRt6Ynf2/QRRL+iMX4SdLT11jAjKqkJ7mQr/DLOmdz7qR8i04j9Y7wFSoP70cXJc18FN4YGNgGaA=="], @@ -865,6 +868,8 @@ "builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/package.json b/package.json index a793c66..f109593 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check:all": "biome check --linter-enabled=true --formatter-enabled=true && bun scripts/check-i18n.ts && bun run typecheck", "check:i18n": "bun scripts/check-i18n.ts", "typecheck": "tsc --noEmit", + "test": "bun test", "db:generate": "bun --env-file=.env.local drizzle-kit generate", "db:migrate": "bun --env-file=.env.local drizzle-kit migrate", "db:studio": "bun --env-file=.env.local drizzle-kit studio", @@ -56,6 +57,7 @@ "@storybook/nextjs": "^8.5.3", "@storybook/react": "^8.5.3", "@storybook/test": "^8.5.3", + "@types/bun": "^1.3.14", "@types/country-list": "^2.1.4", "@types/node": "^20", "@types/react": "^19", From dc17b24365299b302ed6676fbbf50bfd2d794c4b Mon Sep 17 00:00:00 2001 From: chev Date: Wed, 8 Jul 2026 18:25:33 -0500 Subject: [PATCH 2/3] test: add core product test suite --- src/constants/languages.test.ts | 38 ++++++ .../Discovery/discoverySearch.test.ts | 114 ++++++++++++++++++ src/features/Profile/bumpProfile.test.ts | 39 ++++++ src/features/Profile/schema.test.ts | 78 ++++++++++++ src/features/Settings/schema.test.ts | 57 +++++++++ src/lib/auth-session.test.ts | 82 +++++++++++++ src/middleware.test.ts | 57 +++++++++ 7 files changed, 465 insertions(+) create mode 100644 src/constants/languages.test.ts create mode 100644 src/features/Discovery/discoverySearch.test.ts create mode 100644 src/features/Profile/bumpProfile.test.ts create mode 100644 src/features/Profile/schema.test.ts create mode 100644 src/features/Settings/schema.test.ts create mode 100644 src/lib/auth-session.test.ts create mode 100644 src/middleware.test.ts diff --git a/src/constants/languages.test.ts b/src/constants/languages.test.ts new file mode 100644 index 0000000..27b65aa --- /dev/null +++ b/src/constants/languages.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'bun:test'; +import { + getLanguageName, + isValidLanguageCode, + languageOptions, +} from './languages'; + +describe('language switching labels', () => { + it('localizes language names per app locale', () => { + expect(getLanguageName('en', 'en')).toBe('English'); + expect(getLanguageName('en', 'ja')).toBe('英語'); + expect(getLanguageName('ja', 'en')).toBe('Japanese'); + expect(getLanguageName('ja', 'ja')).toBe('日本語'); + }); + + it('passes unknown values through unchanged', () => { + expect(getLanguageName('English', 'en')).toBe('English'); + }); + + it('falls back to english names for unsupported locales', () => { + expect(getLanguageName('en', 'fr')).toBe('English'); + }); + + it('sorts localized options alphabetically for each locale', () => { + for (const locale of ['en', 'ja']) { + const labels = languageOptions(locale).map((option) => option.label); + + expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b))); + } + }); + + it('validates language codes strictly', () => { + expect(isValidLanguageCode('en')).toBe(true); + expect(isValidLanguageCode('EN')).toBe(false); + expect(isValidLanguageCode('xx')).toBe(false); + expect(isValidLanguageCode('eng')).toBe(false); + }); +}); diff --git a/src/features/Discovery/discoverySearch.test.ts b/src/features/Discovery/discoverySearch.test.ts new file mode 100644 index 0000000..c6d8be6 --- /dev/null +++ b/src/features/Discovery/discoverySearch.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'bun:test'; +import { applyDiscoverySearch } from './discoverySearch'; +import { applyDiscoverySort } from './discoverySort'; +import type { DiscoveryProfile } from './ProfileCard'; + +const profile = (overrides: Partial): DiscoveryProfile => ({ + id: 'profile-1', + displayName: 'Yuki', + discordUsername: 'yuki_lang', + primaryLanguage: 'ja', + targetLanguages: [{ language: 'en', level: 'advanced' }], + interests: [], + ...overrides, +}); + +const yuki = profile({ + id: 'yuki', + displayName: 'Yuki', + interests: ['Anime'], + about: 'Preparing for the IELTS exam.', +}); +const carlos = profile({ + id: 'carlos', + displayName: 'Carlos', + discordUsername: 'carlos_ba', + primaryLanguage: 'es', + targetLanguages: [{ language: 'en', level: 'intermediate' }], + interests: ['Football'], +}); + +describe('applyDiscoverySearch', () => { + it('returns everything for an empty query', () => { + expect(applyDiscoverySearch([yuki, carlos], ' ', 'en')).toHaveLength(2); + }); + + it('matches display names case-insensitively', () => { + expect(applyDiscoverySearch([yuki, carlos], 'CARLOS', 'en')).toEqual([ + carlos, + ]); + }); + + it('matches localized language names', () => { + expect(applyDiscoverySearch([yuki, carlos], 'spanish', 'en')).toEqual([ + carlos, + ]); + expect(applyDiscoverySearch([yuki, carlos], 'スペイン語', 'ja')).toEqual([ + carlos, + ]); + }); + + it('matches interests and bio text', () => { + expect(applyDiscoverySearch([yuki, carlos], 'football', 'en')).toEqual([ + carlos, + ]); + expect(applyDiscoverySearch([yuki, carlos], 'ielts', 'en')).toEqual([yuki]); + }); + + it('returns nothing for an unmatched query', () => { + expect(applyDiscoverySearch([yuki, carlos], 'zzzz', 'en')).toHaveLength(0); + }); +}); + +describe('applyDiscoverySort', () => { + const recentlyBumped = profile({ + id: 'recent', + displayName: 'Recent', + bumpedMinutesAgo: 5, + }); + const staleBump = profile({ + id: 'stale', + displayName: 'Stale', + bumpedMinutesAgo: 900, + }); + const neverBumped = profile({ id: 'never', displayName: 'Never' }); + + it('sorts by most recent bump by default order', () => { + const sorted = applyDiscoverySort( + [neverBumped, staleBump, recentlyBumped], + 'bumped-desc', + ); + + expect(sorted.map((entry) => entry.id)).toEqual([ + 'recent', + 'stale', + 'never', + ]); + }); + + it('sorts by name in both directions', () => { + const byName = applyDiscoverySort([staleBump, recentlyBumped], 'name-asc'); + + expect(byName.map((entry) => entry.displayName)).toEqual([ + 'Recent', + 'Stale', + ]); + + const reversed = applyDiscoverySort( + [recentlyBumped, staleBump], + 'name-desc', + ); + + expect(reversed.map((entry) => entry.displayName)).toEqual([ + 'Stale', + 'Recent', + ]); + }); + + it('does not mutate the input array', () => { + const input = [staleBump, recentlyBumped]; + applyDiscoverySort(input, 'bumped-desc'); + + expect(input.map((entry) => entry.id)).toEqual(['stale', 'recent']); + }); +}); diff --git a/src/features/Profile/bumpProfile.test.ts b/src/features/Profile/bumpProfile.test.ts new file mode 100644 index 0000000..11f92da --- /dev/null +++ b/src/features/Profile/bumpProfile.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test'; +import { + FREE_BUMP_COOLDOWN_MS, + getBumpCooldown, + PREMIUM_BUMP_COOLDOWN_MS, +} from './bumpProfile'; + +describe('getBumpCooldown', () => { + const now = new Date('2026-07-08T12:00:00Z'); + + it('allows an immediate bump when never bumped', () => { + const { remainingMs, nextBumpAt } = getBumpCooldown(null, false, now); + + expect(remainingMs).toBe(0); + expect(nextBumpAt).toEqual(now); + }); + + it('applies the free cooldown', () => { + const lastBumpedAt = new Date(now.getTime() - 60 * 60 * 1000); + const { remainingMs } = getBumpCooldown(lastBumpedAt, false, now); + + expect(remainingMs).toBe(FREE_BUMP_COOLDOWN_MS - 60 * 60 * 1000); + }); + + it('applies the shorter premium cooldown', () => { + const lastBumpedAt = new Date(now.getTime() - 60 * 60 * 1000); + const { remainingMs } = getBumpCooldown(lastBumpedAt, true, now); + + expect(remainingMs).toBe(PREMIUM_BUMP_COOLDOWN_MS - 60 * 60 * 1000); + expect(PREMIUM_BUMP_COOLDOWN_MS).toBeLessThan(FREE_BUMP_COOLDOWN_MS); + }); + + it('never returns a negative remaining time', () => { + const lastBumpedAt = new Date(now.getTime() - 10 * 60 * 60 * 1000); + const { remainingMs } = getBumpCooldown(lastBumpedAt, false, now); + + expect(remainingMs).toBe(0); + }); +}); diff --git a/src/features/Profile/schema.test.ts b/src/features/Profile/schema.test.ts new file mode 100644 index 0000000..172bea7 --- /dev/null +++ b/src/features/Profile/schema.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'bun:test'; +import type { ProfileFormValues } from './schema'; +import { profileSchema } from './schema'; + +const validProfile: ProfileFormValues = { + isPublic: true, + allowAnonymousCopy: true, + displayTimezone: true, + displayAvailability: true, + primaryLanguage: 'ja', + targetLanguages: [{ language: 'en', level: 'intermediate' }], + bio: 'I am looking for a patient English partner.', + tags: ['Anime', 'Cooking'], + country: 'JP', + timezone: 'Asia/Tokyo', +}; + +const issueMessages = (values: unknown) => { + const result = profileSchema.safeParse(values); + return result.success + ? [] + : result.error.issues.map((issue) => issue.message); +}; + +describe('profileSchema', () => { + it('accepts a valid profile', () => { + expect(profileSchema.safeParse(validProfile).success).toBe(true); + }); + + it('requires a known primary language', () => { + expect(issueMessages({ ...validProfile, primaryLanguage: '' })).toContain( + 'primaryLanguageRequired', + ); + expect(issueMessages({ ...validProfile, primaryLanguage: 'xx' })).toContain( + 'primaryLanguageInvalid', + ); + }); + + it('requires at least one valid target language', () => { + expect(issueMessages({ ...validProfile, targetLanguages: [] })).toContain( + 'targetLanguageRequired', + ); + expect( + issueMessages({ + ...validProfile, + targetLanguages: [ + { language: 'en', level: 'intermediate' }, + { language: 'en', level: 'beginner' }, + ], + }), + ).toContain('duplicateLanguage'); + }); + + it('enforces bio length limits', () => { + expect(issueMessages({ ...validProfile, bio: 'short' })).toContain( + 'bioTooShort', + ); + expect(issueMessages({ ...validProfile, bio: 'a'.repeat(501) })).toContain( + 'bioTooLong', + ); + }); + + it('enforces tag rules', () => { + expect(issueMessages({ ...validProfile, tags: ['a'] })).toContain( + 'tagTooShort', + ); + expect( + issueMessages({ ...validProfile, tags: ['anime', 'Anime'] }), + ).toContain('duplicateTag'); + }); + + it('rejects invalid timezones', () => { + expect( + profileSchema.safeParse({ ...validProfile, timezone: 'Not/AZone' }) + .success, + ).toBe(false); + }); +}); diff --git a/src/features/Settings/schema.test.ts b/src/features/Settings/schema.test.ts new file mode 100644 index 0000000..44b9822 --- /dev/null +++ b/src/features/Settings/schema.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test'; +import type { SettingsFormValues } from './schema'; +import { settingsSchema } from './schema'; + +const validSettings: SettingsFormValues = { + isPublic: true, + allowAnonymousCopy: true, + displayTimezone: true, + activityStatus: true, + pushNotifications: true, + matchAlert: true, + profileInteractionAlert: true, + profileViewAlert: false, + productAnalytics: true, + theme: 'dark', + applicationLanguage: 'en', + timeFormat: '24hr', + email: 'user@example.com', +}; + +describe('settingsSchema', () => { + it('accepts valid settings', () => { + expect(settingsSchema.safeParse(validSettings).success).toBe(true); + }); + + it('rejects an invalid email with the localized message key', () => { + const result = settingsSchema.safeParse({ + ...validSettings, + email: 'not-an-email', + }); + + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.error.issues.map((issue) => issue.message)).toContain( + 'emailInvalid', + ); + } + }); + + it('rejects unknown theme and time format values', () => { + expect( + settingsSchema.safeParse({ ...validSettings, theme: 'sepia' }).success, + ).toBe(false); + expect( + settingsSchema.safeParse({ ...validSettings, timeFormat: '48hr' }) + .success, + ).toBe(false); + }); + + it('requires an application language', () => { + expect( + settingsSchema.safeParse({ ...validSettings, applicationLanguage: '' }) + .success, + ).toBe(false); + }); +}); diff --git a/src/lib/auth-session.test.ts b/src/lib/auth-session.test.ts new file mode 100644 index 0000000..3e7783a --- /dev/null +++ b/src/lib/auth-session.test.ts @@ -0,0 +1,82 @@ +import { beforeAll, describe, expect, it } from 'bun:test'; +import { + createOAuthStateCookieValue, + createSessionCookieValue, + readOAuthStateFromCookieValue, + readSessionFromCookieValue, +} from './auth-session'; + +const user = { + id: '123456789', + name: 'Test User', + username: 'testuser', + email: 'test@example.com', +}; + +beforeAll(() => { + process.env.AUTH_SECRET = 'test-secret'; +}); + +describe('session cookies', () => { + it('round-trips a signed session', async () => { + const value = await createSessionCookieValue(user); + + expect(value).not.toBeNull(); + + const restored = await readSessionFromCookieValue(value as string); + + expect(restored).toEqual(user); + }); + + it('rejects a tampered payload', async () => { + const value = (await createSessionCookieValue(user)) as string; + const [payload, signature] = value.split('.'); + const forged = btoa( + JSON.stringify({ + user: { ...user, id: 'attacker' }, + expiresAt: Date.now() + 60_000, + }), + ) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); + + expect(await readSessionFromCookieValue(`${forged}.${signature}`)).toBe( + null, + ); + expect(await readSessionFromCookieValue(`${payload}.AAAA`)).toBe(null); + expect(await readSessionFromCookieValue('garbage')).toBe(null); + }); + + it('rejects an expired session', async () => { + const originalNow = Date.now; + Date.now = () => originalNow() - 40 * 24 * 60 * 60 * 1000; + const value = (await createSessionCookieValue(user)) as string; + Date.now = originalNow; + + expect(await readSessionFromCookieValue(value)).toBe(null); + }); + + it('returns null without an auth secret', async () => { + const secret = process.env.AUTH_SECRET; + delete process.env.AUTH_SECRET; + delete process.env.DISCORD_CLIENT_SECRET; + + expect(await createSessionCookieValue(user)).toBe(null); + + process.env.AUTH_SECRET = secret; + }); +}); + +describe('oauth state cookies', () => { + it('round-trips the redirect target', async () => { + const value = await createOAuthStateCookieValue({ + nonce: 'abc123', + redirectTo: '/en/profile', + }); + + const restored = await readOAuthStateFromCookieValue(value as string); + + expect(restored).toEqual({ nonce: 'abc123', redirectTo: '/en/profile' }); + }); +}); diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 0000000..b740d8a --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,57 @@ +import { beforeAll, describe, expect, it } from 'bun:test'; +import { NextRequest } from 'next/server'; +import { + AUTH_SESSION_COOKIE, + createSessionCookieValue, +} from './lib/auth-session'; +import middleware from './middleware'; + +const requestFor = (path: string, sessionCookie?: string) => { + const request = new NextRequest(new URL(`http://localhost:3000${path}`)); + + if (sessionCookie) { + request.cookies.set(AUTH_SESSION_COOKIE, sessionCookie); + } + + return request; +}; + +beforeAll(() => { + process.env.AUTH_SECRET = 'test-secret'; +}); + +describe('route guards', () => { + for (const route of ['/en/profile', '/en/settings', '/en/onboarding']) { + it(`redirects logged-out visitors away from ${route}`, async () => { + const response = await middleware(requestFor(route)); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://localhost:3000/en'); + }); + } + + it('redirects when the session cookie is invalid', async () => { + const response = await middleware( + requestFor('/en/settings', 'not-a-valid-session'), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://localhost:3000/en'); + }); + + it('lets a valid session through to protected routes', async () => { + const cookie = (await createSessionCookieValue({ + id: '123', + name: 'Test User', + })) as string; + const response = await middleware(requestFor('/en/settings', cookie)); + + expect(response.headers.get('location')).toBe(null); + }); + + it('does not guard public routes', async () => { + const response = await middleware(requestFor('/en')); + + expect(response.headers.get('location')).toBe(null); + }); +}); From 9d0532647f5f876c701d0becbb4b8bc04b334815 Mon Sep 17 00:00:00 2001 From: chev Date: Wed, 8 Jul 2026 18:25:34 -0500 Subject: [PATCH 3/3] ci: run checks and tests on pull requests --- .github/workflows/tests.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..72c2df7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,16 @@ +name: Tests + +on: + pull_request: + push: + branches: [develop, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run check:all + - run: bun test