feat: Add comprehensive test suite and misc improvements

- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-08 02:36:46 -05:00
parent c220612214
commit 8dc2248f1f
145 changed files with 77947 additions and 1048 deletions

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
hexToHSL,
hslToHex,
generateColorPalette,
applyColorPalette,
applyBrandColors,
defaultColorPalette,
} from '../colorUtils';
describe('colorUtils', () => {
describe('hexToHSL', () => {
it('converts pure red correctly', () => {
const result = hexToHSL('#ff0000');
expect(result.h).toBeCloseTo(0, 0);
expect(result.s).toBeCloseTo(100, 0);
expect(result.l).toBeCloseTo(50, 0);
});
it('converts pure green correctly', () => {
const result = hexToHSL('#00ff00');
expect(result.h).toBeCloseTo(120, 0);
expect(result.s).toBeCloseTo(100, 0);
expect(result.l).toBeCloseTo(50, 0);
});
it('converts pure blue correctly', () => {
const result = hexToHSL('#0000ff');
expect(result.h).toBeCloseTo(240, 0);
expect(result.s).toBeCloseTo(100, 0);
expect(result.l).toBeCloseTo(50, 0);
});
it('converts white correctly', () => {
const result = hexToHSL('#ffffff');
expect(result.l).toBeCloseTo(100, 0);
});
it('converts black correctly', () => {
const result = hexToHSL('#000000');
expect(result.l).toBeCloseTo(0, 0);
});
it('handles hex without hash', () => {
const result = hexToHSL('ff0000');
expect(result.h).toBeCloseTo(0, 0);
});
it('returns zeros for invalid hex', () => {
const result = hexToHSL('invalid');
expect(result).toEqual({ h: 0, s: 0, l: 0 });
});
it('converts gray correctly (no saturation)', () => {
const result = hexToHSL('#808080');
expect(result.s).toBeCloseTo(0, 0);
expect(result.l).toBeCloseTo(50, 0);
});
});
describe('hslToHex', () => {
it('converts red HSL to hex', () => {
const result = hslToHex(0, 100, 50);
expect(result.toLowerCase()).toBe('#ff0000');
});
it('converts green HSL to hex', () => {
const result = hslToHex(120, 100, 50);
expect(result.toLowerCase()).toBe('#00ff00');
});
it('converts blue HSL to hex', () => {
const result = hslToHex(240, 100, 50);
expect(result.toLowerCase()).toBe('#0000ff');
});
it('converts white correctly', () => {
const result = hslToHex(0, 0, 100);
expect(result.toLowerCase()).toBe('#ffffff');
});
it('converts black correctly', () => {
const result = hslToHex(0, 0, 0);
expect(result.toLowerCase()).toBe('#000000');
});
it('handles cyan (h=180)', () => {
const result = hslToHex(180, 100, 50);
expect(result.toLowerCase()).toBe('#00ffff');
});
it('handles magenta (h=300)', () => {
const result = hslToHex(300, 100, 50);
expect(result.toLowerCase()).toBe('#ff00ff');
});
it('handles yellow (h=60)', () => {
const result = hslToHex(60, 100, 50);
expect(result.toLowerCase()).toBe('#ffff00');
});
});
describe('generateColorPalette', () => {
it('generates a palette with all shade keys', () => {
const palette = generateColorPalette('#3b82f6');
expect(palette).toHaveProperty('50');
expect(palette).toHaveProperty('100');
expect(palette).toHaveProperty('200');
expect(palette).toHaveProperty('300');
expect(palette).toHaveProperty('400');
expect(palette).toHaveProperty('500');
expect(palette).toHaveProperty('600');
expect(palette).toHaveProperty('700');
expect(palette).toHaveProperty('800');
expect(palette).toHaveProperty('900');
});
it('uses the base color for shade 600', () => {
const baseColor = '#3b82f6';
const palette = generateColorPalette(baseColor);
expect(palette['600']).toBe(baseColor);
});
it('generates lighter shades for lower numbers', () => {
const palette = generateColorPalette('#3b82f6');
const hsl50 = hexToHSL(palette['50']);
const hsl500 = hexToHSL(palette['500']);
expect(hsl50.l).toBeGreaterThan(hsl500.l);
});
it('generates darker shades for higher numbers', () => {
const palette = generateColorPalette('#3b82f6');
const hsl500 = hexToHSL(palette['500']);
const hsl900 = hexToHSL(palette['900']);
expect(hsl900.l).toBeLessThan(hsl500.l);
});
});
describe('applyColorPalette', () => {
beforeEach(() => {
// Reset any CSS custom properties
document.documentElement.style.cssText = '';
});
it('sets CSS custom properties for each shade', () => {
const palette = { '500': '#3b82f6', '600': '#2563eb' };
applyColorPalette(palette);
expect(document.documentElement.style.getPropertyValue('--color-brand-500')).toBe('#3b82f6');
expect(document.documentElement.style.getPropertyValue('--color-brand-600')).toBe('#2563eb');
});
});
describe('applyBrandColors', () => {
beforeEach(() => {
document.documentElement.style.cssText = '';
});
it('applies primary color palette', () => {
applyBrandColors('#3b82f6');
expect(document.documentElement.style.getPropertyValue('--color-brand-600')).toBe('#3b82f6');
});
it('sets secondary color to primary when not provided', () => {
applyBrandColors('#3b82f6');
expect(document.documentElement.style.getPropertyValue('--color-brand-secondary')).toBe('#3b82f6');
});
it('sets secondary color when provided', () => {
applyBrandColors('#3b82f6', '#10b981');
expect(document.documentElement.style.getPropertyValue('--color-brand-secondary')).toBe('#10b981');
});
});
describe('defaultColorPalette', () => {
it('has all required shades', () => {
expect(Object.keys(defaultColorPalette)).toHaveLength(10);
expect(defaultColorPalette).toHaveProperty('50');
expect(defaultColorPalette).toHaveProperty('900');
});
it('contains valid hex colors', () => {
Object.values(defaultColorPalette).forEach((color) => {
expect(color).toMatch(/^#[0-9a-fA-F]{6}$/);
});
});
});
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setCookie, getCookie, deleteCookie } from '../cookies';
import * as domain from '../domain';
// Mock the domain module
vi.mock('../domain', () => ({
getCookieDomain: vi.fn(),
}));
describe('cookies', () => {
beforeEach(() => {
// Clear all cookies before each test
document.cookie.split(';').forEach((c) => {
document.cookie = c
.replace(/^ +/, '')
.replace(/=.*/, '=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/');
});
vi.clearAllMocks();
});
describe('setCookie', () => {
it('sets a cookie with the correct name and value', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('test_cookie', 'test_value');
expect(document.cookie).toContain('test_cookie=test_value');
});
it('sets cookie without domain attribute for localhost', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('test_cookie', 'test_value');
// Verify cookie is set (domain attribute not included for localhost)
expect(getCookie('test_cookie')).toBe('test_value');
});
it('includes domain attribute for non-localhost domains', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('.lvh.me');
// In jsdom, cookies with domain attributes may not be readable
// We verify that setCookie doesn't throw and the function is called correctly
expect(() => setCookie('test_cookie', 'test_value')).not.toThrow();
expect(domain.getCookieDomain).toHaveBeenCalled();
});
it('sets expiration based on days parameter', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('test_cookie', 'test_value', 1);
expect(getCookie('test_cookie')).toBe('test_value');
});
it('defaults to 7 days expiration', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('test_cookie', 'test_value');
expect(getCookie('test_cookie')).toBe('test_value');
});
});
describe('getCookie', () => {
it('returns null when cookie does not exist', () => {
expect(getCookie('nonexistent')).toBeNull();
});
it('returns the correct value for an existing cookie', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('my_cookie', 'my_value');
expect(getCookie('my_cookie')).toBe('my_value');
});
it('handles multiple cookies correctly', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('cookie1', 'value1');
setCookie('cookie2', 'value2');
setCookie('cookie3', 'value3');
expect(getCookie('cookie1')).toBe('value1');
expect(getCookie('cookie2')).toBe('value2');
expect(getCookie('cookie3')).toBe('value3');
});
it('handles cookies with special characters in values', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('special', 'value%20with%20spaces');
expect(getCookie('special')).toBe('value%20with%20spaces');
});
it('handles whitespace in cookie string', () => {
// Set a cookie with potential whitespace
document.cookie = ' spaced_cookie = spaced_value';
// The getCookie function should handle leading whitespace
expect(getCookie('spaced_cookie')).toBeTruthy();
});
it('does not return partial matches', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('access_token', 'token123');
expect(getCookie('access')).toBeNull();
expect(getCookie('token')).toBeNull();
expect(getCookie('access_token')).toBe('token123');
});
});
describe('deleteCookie', () => {
it('removes an existing cookie', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('to_delete', 'value');
expect(getCookie('to_delete')).toBe('value');
deleteCookie('to_delete');
expect(getCookie('to_delete')).toBeNull();
});
it('does not throw when deleting non-existent cookie', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
expect(() => deleteCookie('nonexistent')).not.toThrow();
});
it('uses correct domain attribute when deleting', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('.lvh.me');
setCookie('domain_cookie', 'value');
deleteCookie('domain_cookie');
expect(getCookie('domain_cookie')).toBeNull();
});
it('deletes only the specified cookie', () => {
vi.mocked(domain.getCookieDomain).mockReturnValue('localhost');
setCookie('keep1', 'value1');
setCookie('delete_me', 'value2');
setCookie('keep2', 'value3');
deleteCookie('delete_me');
expect(getCookie('keep1')).toBe('value1');
expect(getCookie('delete_me')).toBeNull();
expect(getCookie('keep2')).toBe('value3');
});
});
});

View File

@@ -0,0 +1,381 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
toUTC,
toUTCFromTimezone,
convertTimezoneToUTC,
fromUTC,
convertUTCToTimezone,
getDisplayTimezone,
getUserTimezone,
formatForDisplay,
formatTimeForDisplay,
formatDateForDisplay,
formatForDateTimeInput,
formatLocalDate,
parseLocalDate,
formatLocalDateTime,
getTodayInTimezone,
isToday,
isSameDay,
startOfDay,
endOfDay,
getTimezoneAbbreviation,
formatTimezoneDisplay,
} from '../dateUtils';
describe('dateUtils', () => {
describe('toUTC', () => {
it('converts Date to ISO string', () => {
const date = new Date('2024-12-08T14:00:00Z');
expect(toUTC(date)).toBe('2024-12-08T14:00:00.000Z');
});
it('preserves milliseconds', () => {
const date = new Date('2024-12-08T14:00:00.123Z');
expect(toUTC(date)).toBe('2024-12-08T14:00:00.123Z');
});
});
describe('formatLocalDate', () => {
it('formats date as YYYY-MM-DD', () => {
const date = new Date(2024, 11, 8); // Dec 8, 2024
expect(formatLocalDate(date)).toBe('2024-12-08');
});
it('pads single-digit months', () => {
const date = new Date(2024, 0, 15); // Jan 15, 2024
expect(formatLocalDate(date)).toBe('2024-01-15');
});
it('pads single-digit days', () => {
const date = new Date(2024, 11, 5); // Dec 5, 2024
expect(formatLocalDate(date)).toBe('2024-12-05');
});
});
describe('parseLocalDate', () => {
it('parses YYYY-MM-DD to local Date', () => {
const date = parseLocalDate('2024-12-08');
expect(date.getFullYear()).toBe(2024);
expect(date.getMonth()).toBe(11); // December (0-indexed)
expect(date.getDate()).toBe(8);
});
it('creates date at midnight', () => {
const date = parseLocalDate('2024-06-15');
expect(date.getHours()).toBe(0);
expect(date.getMinutes()).toBe(0);
});
});
describe('formatLocalDateTime', () => {
it('formats Date as YYYY-MM-DDTHH:MM', () => {
const date = new Date(2024, 11, 8, 14, 30);
expect(formatLocalDateTime(date)).toBe('2024-12-08T14:30');
});
it('pads hours and minutes', () => {
const date = new Date(2024, 0, 5, 9, 5);
expect(formatLocalDateTime(date)).toBe('2024-01-05T09:05');
});
});
describe('isSameDay', () => {
it('returns true for same day', () => {
const date1 = new Date(2024, 11, 8, 10, 0);
const date2 = new Date(2024, 11, 8, 22, 30);
expect(isSameDay(date1, date2)).toBe(true);
});
it('returns false for different days', () => {
const date1 = new Date(2024, 11, 8);
const date2 = new Date(2024, 11, 9);
expect(isSameDay(date1, date2)).toBe(false);
});
it('returns false for different months', () => {
const date1 = new Date(2024, 10, 8);
const date2 = new Date(2024, 11, 8);
expect(isSameDay(date1, date2)).toBe(false);
});
it('returns false for different years', () => {
const date1 = new Date(2023, 11, 8);
const date2 = new Date(2024, 11, 8);
expect(isSameDay(date1, date2)).toBe(false);
});
});
describe('startOfDay', () => {
it('sets time to midnight', () => {
const date = new Date(2024, 11, 8, 14, 30, 45, 123);
const result = startOfDay(date);
expect(result.getHours()).toBe(0);
expect(result.getMinutes()).toBe(0);
expect(result.getSeconds()).toBe(0);
expect(result.getMilliseconds()).toBe(0);
});
it('preserves the date', () => {
const date = new Date(2024, 11, 8, 14, 30);
const result = startOfDay(date);
expect(result.getFullYear()).toBe(2024);
expect(result.getMonth()).toBe(11);
expect(result.getDate()).toBe(8);
});
it('does not mutate original date', () => {
const date = new Date(2024, 11, 8, 14, 30);
startOfDay(date);
expect(date.getHours()).toBe(14);
});
});
describe('endOfDay', () => {
it('sets time to 23:59:59.999', () => {
const date = new Date(2024, 11, 8, 14, 30);
const result = endOfDay(date);
expect(result.getHours()).toBe(23);
expect(result.getMinutes()).toBe(59);
expect(result.getSeconds()).toBe(59);
expect(result.getMilliseconds()).toBe(999);
});
it('preserves the date', () => {
const date = new Date(2024, 11, 8, 14, 30);
const result = endOfDay(date);
expect(result.getFullYear()).toBe(2024);
expect(result.getMonth()).toBe(11);
expect(result.getDate()).toBe(8);
});
it('does not mutate original date', () => {
const date = new Date(2024, 11, 8, 14, 30);
endOfDay(date);
expect(date.getHours()).toBe(14);
});
});
describe('getDisplayTimezone', () => {
it('returns business timezone when provided', () => {
expect(getDisplayTimezone('America/Denver')).toBe('America/Denver');
});
it('returns business timezone for empty string', () => {
// Empty string is falsy, so should use browser timezone
const result = getDisplayTimezone('');
expect(result).toBeTruthy();
// Should be the browser's timezone
});
it('returns browser timezone when null', () => {
const result = getDisplayTimezone(null);
expect(result).toBeTruthy();
});
it('returns browser timezone when undefined', () => {
const result = getDisplayTimezone(undefined);
expect(result).toBeTruthy();
});
});
describe('getUserTimezone', () => {
it('returns a valid IANA timezone', () => {
const tz = getUserTimezone();
expect(tz).toBeTruthy();
expect(typeof tz).toBe('string');
// Should contain a slash (e.g., America/New_York)
expect(tz).toMatch(/\//);
});
});
describe('formatForDisplay', () => {
it('formats UTC string with timezone', () => {
const result = formatForDisplay('2024-12-08T19:00:00Z', 'America/Denver');
// Should be Dec 8, 2024, 12:00 PM in Mountain Time
expect(result).toContain('Dec');
expect(result).toContain('8');
expect(result).toContain('2024');
});
it('accepts custom options', () => {
const result = formatForDisplay('2024-12-08T19:00:00Z', 'UTC', {
year: undefined,
month: 'long',
});
expect(result).toContain('December');
});
});
describe('formatTimeForDisplay', () => {
it('formats only time portion', () => {
const result = formatTimeForDisplay('2024-12-08T19:00:00Z', 'UTC');
expect(result).toMatch(/\d{1,2}:\d{2}\s*(AM|PM)/);
});
it('respects timezone', () => {
// 19:00 UTC should be 12:00 PM in Denver (MST, -7)
const result = formatTimeForDisplay('2024-12-08T19:00:00Z', 'America/Denver');
expect(result).toContain('12:00');
expect(result).toContain('PM');
});
});
describe('formatDateForDisplay', () => {
it('formats only date portion', () => {
const result = formatDateForDisplay('2024-12-08T19:00:00Z', 'UTC');
expect(result).toContain('Dec');
expect(result).toContain('8');
expect(result).toContain('2024');
});
it('does not include time', () => {
const result = formatDateForDisplay('2024-12-08T19:00:00Z', 'UTC');
expect(result).not.toMatch(/\d{1,2}:\d{2}/);
});
it('accepts custom options', () => {
const result = formatDateForDisplay('2024-12-08T19:00:00Z', 'UTC', {
weekday: 'long',
});
expect(result).toContain('Sunday');
});
});
describe('getTodayInTimezone', () => {
it('returns YYYY-MM-DD format', () => {
const result = getTodayInTimezone('UTC');
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('respects timezone', () => {
// At certain times, today in UTC vs Tokyo might differ
const utc = getTodayInTimezone('UTC');
const tokyo = getTodayInTimezone('Asia/Tokyo');
// Both should be valid dates
expect(utc).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(tokyo).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe('isToday', () => {
it('returns true for today', () => {
const today = getTodayInTimezone('UTC');
expect(isToday(today, 'UTC')).toBe(true);
});
it('returns false for yesterday', () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = formatLocalDate(yesterday);
expect(isToday(yesterdayStr, 'UTC')).toBe(false);
});
});
describe('getTimezoneAbbreviation', () => {
it('returns abbreviation for known timezone', () => {
// Note: Abbreviation may vary by date (DST)
const abbr = getTimezoneAbbreviation('America/New_York');
expect(['EST', 'EDT']).toContain(abbr);
});
it('returns abbreviation for UTC', () => {
const abbr = getTimezoneAbbreviation('UTC');
expect(abbr).toBe('UTC');
});
it('uses provided date for DST calculation', () => {
// Winter date (should be MST)
const winter = new Date('2024-01-15');
const winterAbbr = getTimezoneAbbreviation('America/Denver', winter);
expect(winterAbbr).toBe('MST');
// Summer date (should be MDT)
const summer = new Date('2024-07-15');
const summerAbbr = getTimezoneAbbreviation('America/Denver', summer);
expect(summerAbbr).toBe('MDT');
});
});
describe('formatTimezoneDisplay', () => {
it('formats timezone with city name and abbreviation', () => {
const result = formatTimezoneDisplay('America/Denver');
expect(result).toContain('Denver');
expect(result).toMatch(/\(M[SD]T\)/);
});
it('handles underscores in city names', () => {
const result = formatTimezoneDisplay('America/New_York');
expect(result).toContain('New York');
});
it('handles UTC', () => {
const result = formatTimezoneDisplay('UTC');
expect(result).toContain('UTC');
});
});
describe('convertUTCToTimezone', () => {
it('converts UTC date to target timezone', () => {
const utcDate = new Date('2024-12-08T19:00:00Z');
const result = convertUTCToTimezone(utcDate, 'America/Denver');
// 19:00 UTC = 12:00 MST
expect(result.getHours()).toBe(12);
expect(result.getMinutes()).toBe(0);
});
it('handles date crossing', () => {
// Late UTC time might be previous day in western timezones
const utcDate = new Date('2024-12-08T02:00:00Z');
const result = convertUTCToTimezone(utcDate, 'America/Los_Angeles');
// 02:00 UTC = 18:00 PST (previous day)
expect(result.getDate()).toBe(7);
});
});
describe('fromUTC', () => {
it('converts UTC string to date in business timezone', () => {
const result = fromUTC('2024-12-08T19:00:00Z', 'America/Denver');
expect(result.getHours()).toBe(12); // MST
});
it('uses local timezone when business timezone is null', () => {
const result = fromUTC('2024-12-08T19:00:00Z', null);
// Should return a valid date
expect(result instanceof Date).toBe(true);
});
});
describe('formatForDateTimeInput', () => {
it('returns datetime-local format', () => {
const result = formatForDateTimeInput('2024-12-08T19:00:00Z', 'UTC');
expect(result).toBe('2024-12-08T19:00');
});
it('respects timezone conversion', () => {
const result = formatForDateTimeInput('2024-12-08T19:00:00Z', 'America/Denver');
// 19:00 UTC = 12:00 MST
expect(result).toBe('2024-12-08T12:00');
});
});
describe('toUTCFromTimezone', () => {
it('converts date and time in timezone to UTC', () => {
const date = new Date(2024, 11, 8); // Dec 8, 2024
const result = toUTCFromTimezone(date, '12:00', 'America/Denver');
// 12:00 MST = 19:00 UTC
const resultDate = new Date(result);
expect(resultDate.getUTCHours()).toBe(19);
});
});
describe('convertTimezoneToUTC', () => {
it('converts timezone date to UTC', () => {
// Create a date representing 12:00 in Denver
const localDate = new Date(2024, 11, 8, 12, 0, 0);
const result = convertTimezoneToUTC(localDate, 'America/Denver');
// 12:00 MST = 19:00 UTC
expect(result.getUTCHours()).toBe(19);
});
});
});

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
getBaseDomain,
getCurrentSubdomain,
isRootDomain,
isPlatformDomain,
isBusinessSubdomain,
buildSubdomainUrl,
getCookieDomain,
getWebSocketUrl,
} from '../domain';
// Helper to mock window.location
const mockLocation = (hostname: string, protocol = 'https:', port = '') => {
Object.defineProperty(window, 'location', {
value: {
hostname,
protocol,
port,
},
writable: true,
});
};
describe('domain utilities', () => {
describe('getBaseDomain', () => {
it('returns localhost for localhost', () => {
mockLocation('localhost');
expect(getBaseDomain()).toBe('localhost');
});
it('returns localhost for 127.0.0.1', () => {
mockLocation('127.0.0.1');
expect(getBaseDomain()).toBe('localhost');
});
it('returns base domain for two-part hostname', () => {
mockLocation('lvh.me');
expect(getBaseDomain()).toBe('lvh.me');
});
it('extracts base domain from subdomain', () => {
mockLocation('platform.lvh.me');
expect(getBaseDomain()).toBe('lvh.me');
});
it('extracts base domain from production subdomain', () => {
mockLocation('demo.smoothschedule.com');
expect(getBaseDomain()).toBe('smoothschedule.com');
});
it('handles deeply nested subdomains', () => {
mockLocation('api.v2.smoothschedule.com');
expect(getBaseDomain()).toBe('smoothschedule.com');
});
});
describe('getCurrentSubdomain', () => {
it('returns null for localhost', () => {
mockLocation('localhost');
expect(getCurrentSubdomain()).toBeNull();
});
it('returns null for 127.0.0.1', () => {
mockLocation('127.0.0.1');
expect(getCurrentSubdomain()).toBeNull();
});
it('returns null for root domain', () => {
mockLocation('lvh.me');
expect(getCurrentSubdomain()).toBeNull();
});
it('returns subdomain for platform.lvh.me', () => {
mockLocation('platform.lvh.me');
expect(getCurrentSubdomain()).toBe('platform');
});
it('returns subdomain for demo.smoothschedule.com', () => {
mockLocation('demo.smoothschedule.com');
expect(getCurrentSubdomain()).toBe('demo');
});
it('returns first part for deeply nested subdomains', () => {
mockLocation('api.v2.smoothschedule.com');
expect(getCurrentSubdomain()).toBe('api');
});
});
describe('isRootDomain', () => {
it('returns true for localhost', () => {
mockLocation('localhost');
expect(isRootDomain()).toBe(true);
});
it('returns true for 127.0.0.1', () => {
mockLocation('127.0.0.1');
expect(isRootDomain()).toBe(true);
});
it('returns true for two-part domain', () => {
mockLocation('lvh.me');
expect(isRootDomain()).toBe(true);
});
it('returns false for subdomain', () => {
mockLocation('platform.lvh.me');
expect(isRootDomain()).toBe(false);
});
});
describe('isPlatformDomain', () => {
it('returns true for platform subdomain', () => {
mockLocation('platform.lvh.me');
expect(isPlatformDomain()).toBe(true);
});
it('returns true for platform.smoothschedule.com', () => {
mockLocation('platform.smoothschedule.com');
expect(isPlatformDomain()).toBe(true);
});
it('returns false for other subdomains', () => {
mockLocation('demo.lvh.me');
expect(isPlatformDomain()).toBe(false);
});
it('returns false for root domain', () => {
mockLocation('lvh.me');
expect(isPlatformDomain()).toBe(false);
});
it('returns false for localhost', () => {
mockLocation('localhost');
expect(isPlatformDomain()).toBe(false);
});
});
describe('isBusinessSubdomain', () => {
it('returns true for business subdomain', () => {
mockLocation('demo.lvh.me');
expect(isBusinessSubdomain()).toBe(true);
});
it('returns true for custom business subdomain', () => {
mockLocation('acme-corp.smoothschedule.com');
expect(isBusinessSubdomain()).toBe(true);
});
it('returns false for platform subdomain', () => {
mockLocation('platform.lvh.me');
expect(isBusinessSubdomain()).toBe(false);
});
it('returns false for api subdomain', () => {
mockLocation('api.lvh.me');
expect(isBusinessSubdomain()).toBe(false);
});
it('returns false for root domain', () => {
mockLocation('lvh.me');
expect(isBusinessSubdomain()).toBe(false);
});
it('returns false for localhost', () => {
mockLocation('localhost');
expect(isBusinessSubdomain()).toBe(false);
});
});
describe('buildSubdomainUrl', () => {
it('builds URL with subdomain in dev', () => {
mockLocation('lvh.me', 'http:', '5173');
const url = buildSubdomainUrl('platform');
expect(url).toBe('http://platform.lvh.me:5173/');
});
it('builds URL with custom path', () => {
mockLocation('lvh.me', 'http:', '5173');
const url = buildSubdomainUrl('demo', '/dashboard');
expect(url).toBe('http://demo.lvh.me:5173/dashboard');
});
it('builds root URL when subdomain is null', () => {
mockLocation('lvh.me', 'http:', '5173');
const url = buildSubdomainUrl(null);
expect(url).toBe('http://lvh.me:5173/');
});
it('builds production URL without port', () => {
mockLocation('smoothschedule.com', 'https:', '');
const url = buildSubdomainUrl('demo');
expect(url).toBe('https://demo.smoothschedule.com/');
});
});
describe('getCookieDomain', () => {
it('returns localhost for localhost', () => {
mockLocation('localhost');
expect(getCookieDomain()).toBe('localhost');
});
it('returns dotted domain for lvh.me', () => {
mockLocation('platform.lvh.me');
expect(getCookieDomain()).toBe('.lvh.me');
});
it('returns dotted domain for production', () => {
mockLocation('demo.smoothschedule.com');
expect(getCookieDomain()).toBe('.smoothschedule.com');
});
});
describe('getWebSocketUrl', () => {
it('returns ws URL for development http', () => {
mockLocation('lvh.me', 'http:', '5173');
const url = getWebSocketUrl('calendar');
expect(url).toBe('ws://lvh.me:8000/ws/calendar');
});
it('returns wss URL for https', () => {
mockLocation('smoothschedule.com', 'https:', '');
const url = getWebSocketUrl('calendar');
expect(url).toBe('wss://smoothschedule.com/ws/calendar');
});
it('includes port 8000 for localhost', () => {
mockLocation('localhost', 'http:', '5173');
const url = getWebSocketUrl('');
expect(url).toBe('ws://localhost:8000/ws/');
});
it('excludes port for production', () => {
mockLocation('smoothschedule.com', 'https:', '');
const url = getWebSocketUrl('events');
expect(url).not.toContain(':8000');
});
});
});

View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest';
import {
getOverQuotaResourceIds,
getOverQuotaServiceIds,
isResourceOverQuota,
isServiceOverQuota,
} from '../quotaUtils';
import { Resource, Service, QuotaOverage } from '../../types';
// Helper to create mock resources
const createResource = (id: string, createdAt: string, isArchived = false): Resource => ({
id,
name: `Resource ${id}`,
type: 'STAFF',
is_archived_by_quota: isArchived,
created_at: createdAt,
} as Resource);
// Helper to create mock services
const createService = (id: string, createdAt: string, isArchived = false): Service => ({
id,
name: `Service ${id}`,
is_archived_by_quota: isArchived,
created_at: createdAt,
} as Service);
// Helper to create quota overage
const createOverage = (quotaType: string, amount: number): QuotaOverage => ({
id: 1,
quota_type: quotaType,
overage_amount: amount,
grace_period_ends_at: '2024-12-31T00:00:00Z',
will_auto_archive: true,
} as QuotaOverage);
describe('quotaUtils', () => {
describe('getOverQuotaResourceIds', () => {
it('returns empty set when no overages', () => {
const resources = [createResource('1', '2024-01-01')];
const result = getOverQuotaResourceIds(resources, []);
expect(result.size).toBe(0);
});
it('returns empty set when overages is undefined', () => {
const resources = [createResource('1', '2024-01-01')];
const result = getOverQuotaResourceIds(resources, undefined);
expect(result.size).toBe(0);
});
it('returns empty set when no MAX_RESOURCES overage', () => {
const resources = [createResource('1', '2024-01-01')];
const overages = [createOverage('MAX_SERVICES', 1)];
const result = getOverQuotaResourceIds(resources, overages);
expect(result.size).toBe(0);
});
it('identifies oldest resources as over quota', () => {
const resources = [
createResource('3', '2024-03-01'), // newest
createResource('1', '2024-01-01'), // oldest - over quota
createResource('2', '2024-02-01'), // middle - over quota
];
const overages = [createOverage('MAX_RESOURCES', 2)];
const result = getOverQuotaResourceIds(resources, overages);
expect(result.size).toBe(2);
expect(result.has('1')).toBe(true); // oldest
expect(result.has('2')).toBe(true); // second oldest
expect(result.has('3')).toBe(false); // newest - not over quota
});
it('excludes already archived resources from consideration', () => {
const resources = [
createResource('1', '2024-01-01', true), // archived - excluded
createResource('2', '2024-02-01'), // oldest active - over quota
createResource('3', '2024-03-01'), // safe
];
const overages = [createOverage('MAX_RESOURCES', 1)];
const result = getOverQuotaResourceIds(resources, overages);
expect(result.size).toBe(1);
expect(result.has('1')).toBe(false); // already archived
expect(result.has('2')).toBe(true); // oldest active
expect(result.has('3')).toBe(false); // safe
});
it('handles overage larger than resource count', () => {
const resources = [createResource('1', '2024-01-01')];
const overages = [createOverage('MAX_RESOURCES', 5)];
const result = getOverQuotaResourceIds(resources, overages);
expect(result.size).toBe(1);
expect(result.has('1')).toBe(true);
});
it('handles resources without created_at', () => {
const resources = [
{ id: '1', name: 'R1', type: 'STAFF', is_archived_by_quota: false } as Resource,
createResource('2', '2024-02-01'),
];
const overages = [createOverage('MAX_RESOURCES', 1)];
const result = getOverQuotaResourceIds(resources, overages);
// Resource without date should be sorted first (timestamp 0)
expect(result.has('1')).toBe(true);
});
});
describe('getOverQuotaServiceIds', () => {
it('returns empty set when no overages', () => {
const services = [createService('1', '2024-01-01')];
const result = getOverQuotaServiceIds(services, []);
expect(result.size).toBe(0);
});
it('returns empty set when no MAX_SERVICES overage', () => {
const services = [createService('1', '2024-01-01')];
const overages = [createOverage('MAX_RESOURCES', 1)];
const result = getOverQuotaServiceIds(services, overages);
expect(result.size).toBe(0);
});
it('identifies oldest services as over quota', () => {
const services = [
createService('3', '2024-03-01'),
createService('1', '2024-01-01'),
createService('2', '2024-02-01'),
];
const overages = [createOverage('MAX_SERVICES', 2)];
const result = getOverQuotaServiceIds(services, overages);
expect(result.size).toBe(2);
expect(result.has('1')).toBe(true);
expect(result.has('2')).toBe(true);
expect(result.has('3')).toBe(false);
});
it('excludes already archived services', () => {
const services = [
createService('1', '2024-01-01', true),
createService('2', '2024-02-01'),
createService('3', '2024-03-01'),
];
const overages = [createOverage('MAX_SERVICES', 1)];
const result = getOverQuotaServiceIds(services, overages);
expect(result.has('1')).toBe(false);
expect(result.has('2')).toBe(true);
});
});
describe('isResourceOverQuota', () => {
it('returns true for over-quota resource', () => {
const resources = [
createResource('1', '2024-01-01'),
createResource('2', '2024-02-01'),
];
const overages = [createOverage('MAX_RESOURCES', 1)];
expect(isResourceOverQuota('1', resources, overages)).toBe(true);
});
it('returns false for safe resource', () => {
const resources = [
createResource('1', '2024-01-01'),
createResource('2', '2024-02-01'),
];
const overages = [createOverage('MAX_RESOURCES', 1)];
expect(isResourceOverQuota('2', resources, overages)).toBe(false);
});
it('returns false when no overages', () => {
const resources = [createResource('1', '2024-01-01')];
expect(isResourceOverQuota('1', resources, undefined)).toBe(false);
});
});
describe('isServiceOverQuota', () => {
it('returns true for over-quota service', () => {
const services = [
createService('1', '2024-01-01'),
createService('2', '2024-02-01'),
];
const overages = [createOverage('MAX_SERVICES', 1)];
expect(isServiceOverQuota('1', services, overages)).toBe(true);
});
it('returns false for safe service', () => {
const services = [
createService('1', '2024-01-01'),
createService('2', '2024-02-01'),
];
const overages = [createOverage('MAX_SERVICES', 1)];
expect(isServiceOverQuota('2', services, overages)).toBe(false);
});
it('returns false when no overages', () => {
const services = [createService('1', '2024-01-01')];
expect(isServiceOverQuota('1', services, [])).toBe(false);
});
});
});