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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions src/constants/languages.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
114 changes: 114 additions & 0 deletions src/features/Discovery/discoverySearch.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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']);
});
});
39 changes: 39 additions & 0 deletions src/features/Profile/bumpProfile.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
78 changes: 78 additions & 0 deletions src/features/Profile/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
57 changes: 57 additions & 0 deletions src/features/Settings/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading