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:
188
frontend/src/utils/__tests__/colorUtils.test.ts
Normal file
188
frontend/src/utils/__tests__/colorUtils.test.ts
Normal 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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
152
frontend/src/utils/__tests__/cookies.test.ts
Normal file
152
frontend/src/utils/__tests__/cookies.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
381
frontend/src/utils/__tests__/dateUtils.test.ts
Normal file
381
frontend/src/utils/__tests__/dateUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
239
frontend/src/utils/__tests__/domain.test.ts
Normal file
239
frontend/src/utils/__tests__/domain.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
211
frontend/src/utils/__tests__/quotaUtils.test.ts
Normal file
211
frontend/src/utils/__tests__/quotaUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user