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,609 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getQuotaStatus,
getQuotaResources,
archiveResources,
unarchiveResource,
getOverageDetail,
QuotaStatus,
QuotaResourcesResponse,
ArchiveResponse,
QuotaOverageDetail,
} from '../quota';
import apiClient from '../client';
describe('quota API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getQuotaStatus', () => {
it('fetches quota status from API', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [
{
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
},
],
usage: {
resources: {
current: 15,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 3,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 8,
limit: 20,
display_name: 'Services',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(apiClient.get).toHaveBeenCalledWith('/quota/status/');
expect(result).toEqual(mockQuotaStatus);
expect(result.active_overages).toHaveLength(1);
expect(result.usage.resources.current).toBe(15);
});
it('returns empty active_overages when no overages exist', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(result.active_overages).toHaveLength(0);
expect(result.usage.resources.current).toBeLessThan(result.usage.resources.limit);
});
it('handles multiple quota types in usage', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 2,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 15,
limit: 20,
display_name: 'Services',
},
customers: {
current: 100,
limit: 500,
display_name: 'Customers',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(Object.keys(result.usage)).toHaveLength(4);
expect(result.usage).toHaveProperty('resources');
expect(result.usage).toHaveProperty('staff');
expect(result.usage).toHaveProperty('services');
expect(result.usage).toHaveProperty('customers');
});
});
describe('getQuotaResources', () => {
it('fetches resources for a specific quota type', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Conference Room A',
type: 'room',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Conference Room B',
type: 'room',
created_at: '2025-01-02T11:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/resources/');
expect(result).toEqual(mockResourcesResponse);
expect(result.quota_type).toBe('resources');
expect(result.resources).toHaveLength(2);
});
it('fetches staff members for staff quota type', async () => {
const mockStaffResponse: QuotaResourcesResponse = {
quota_type: 'staff',
resources: [
{
id: 10,
name: 'John Doe',
email: 'john@example.com',
role: 'staff',
created_at: '2025-01-15T09:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 11,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'manager',
created_at: '2025-01-16T09:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaffResponse });
const result = await getQuotaResources('staff');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/staff/');
expect(result.quota_type).toBe('staff');
expect(result.resources[0]).toHaveProperty('email');
expect(result.resources[0]).toHaveProperty('role');
});
it('fetches services for services quota type', async () => {
const mockServicesResponse: QuotaResourcesResponse = {
quota_type: 'services',
resources: [
{
id: 20,
name: 'Haircut',
duration: 30,
price: '25.00',
created_at: '2025-02-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 21,
name: 'Color Treatment',
duration: 90,
price: '75.00',
created_at: '2025-02-02T10:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServicesResponse });
const result = await getQuotaResources('services');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/services/');
expect(result.quota_type).toBe('services');
expect(result.resources[0]).toHaveProperty('duration');
expect(result.resources[0]).toHaveProperty('price');
});
it('includes archived resources', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Active Resource',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Archived Resource',
created_at: '2024-12-01T10:00:00Z',
is_archived: true,
archived_at: '2025-12-01T15:30:00Z',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(2);
expect(result.resources[0].is_archived).toBe(false);
expect(result.resources[1].is_archived).toBe(true);
expect(result.resources[1].archived_at).toBe('2025-12-01T15:30:00Z');
});
it('handles empty resources list', async () => {
const mockEmptyResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(0);
expect(result.quota_type).toBe('resources');
});
});
describe('archiveResources', () => {
it('archives multiple resources successfully', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 3,
current_usage: 7,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2, 3]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [1, 2, 3],
});
expect(result).toEqual(mockArchiveResponse);
expect(result.archived_count).toBe(3);
expect(result.is_resolved).toBe(true);
});
it('archives single resource', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 1,
current_usage: 9,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('staff', [5]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'staff',
resource_ids: [5],
});
expect(result.archived_count).toBe(1);
});
it('indicates overage is still not resolved after archiving', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 2,
current_usage: 12,
limit: 10,
is_resolved: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2]);
expect(result.is_resolved).toBe(false);
expect(result.current_usage).toBeGreaterThan(result.limit);
});
it('handles archiving with different quota types', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 5,
current_usage: 15,
limit: 20,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
await archiveResources('services', [10, 11, 12, 13, 14]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'services',
resource_ids: [10, 11, 12, 13, 14],
});
});
it('handles empty resource_ids array', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 0,
current_usage: 10,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', []);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [],
});
expect(result.archived_count).toBe(0);
});
});
describe('unarchiveResource', () => {
it('unarchives a resource successfully', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'resources',
resource_id: 5,
});
expect(result).toEqual(mockUnarchiveResponse);
expect(result.success).toBe(true);
expect(result.resource_id).toBe(5);
});
it('unarchives staff member', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 10,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('staff', 10);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'staff',
resource_id: 10,
});
expect(result.success).toBe(true);
});
it('unarchives service', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 20,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('services', 20);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'services',
resource_id: 20,
});
expect(result.resource_id).toBe(20);
});
it('handles unsuccessful unarchive', async () => {
const mockUnarchiveResponse = {
success: false,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(result.success).toBe(false);
});
});
describe('getOverageDetail', () => {
it('fetches detailed overage information', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: '2025-12-07T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(1);
expect(apiClient.get).toHaveBeenCalledWith('/quota/overages/1/');
expect(result).toEqual(mockOverageDetail);
expect(result.status).toBe('active');
expect(result.overage_amount).toBe(5);
});
it('includes sent email timestamps', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 2,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 8,
allowed_limit: 5,
overage_amount: 3,
days_remaining: 3,
grace_period_ends_at: '2025-12-10T00:00:00Z',
status: 'active',
created_at: '2025-11-30T10:00:00Z',
initial_email_sent_at: '2025-11-30T10:05:00Z',
week_reminder_sent_at: '2025-12-03T09:00:00Z',
day_reminder_sent_at: '2025-12-06T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(2);
expect(result.initial_email_sent_at).toBe('2025-11-30T10:05:00Z');
expect(result.week_reminder_sent_at).toBe('2025-12-03T09:00:00Z');
expect(result.day_reminder_sent_at).toBe('2025-12-06T09:00:00Z');
});
it('includes archived resource IDs', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 3,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 10,
allowed_limit: 10,
overage_amount: 0,
days_remaining: 5,
grace_period_ends_at: '2025-12-12T00:00:00Z',
status: 'resolved',
created_at: '2025-12-01T10:00:00Z',
initial_email_sent_at: '2025-12-01T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [1, 3, 5, 7],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(3);
expect(result.archived_resource_ids).toHaveLength(4);
expect(result.archived_resource_ids).toEqual([1, 3, 5, 7]);
expect(result.status).toBe('resolved');
});
it('handles resolved overage with zero overage_amount', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 4,
quota_type: 'services',
display_name: 'Services',
current_usage: 18,
allowed_limit: 20,
overage_amount: 0,
days_remaining: 0,
grace_period_ends_at: '2025-12-05T00:00:00Z',
status: 'resolved',
created_at: '2025-11-25T10:00:00Z',
initial_email_sent_at: '2025-11-25T10:05:00Z',
week_reminder_sent_at: '2025-11-28T09:00:00Z',
day_reminder_sent_at: null,
archived_resource_ids: [20, 21],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(4);
expect(result.overage_amount).toBe(0);
expect(result.status).toBe('resolved');
expect(result.current_usage).toBeLessThanOrEqual(result.allowed_limit);
});
it('handles expired overage', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 5,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 0,
grace_period_ends_at: '2025-12-06T00:00:00Z',
status: 'expired',
created_at: '2025-11-20T10:00:00Z',
initial_email_sent_at: '2025-11-20T10:05:00Z',
week_reminder_sent_at: '2025-11-27T09:00:00Z',
day_reminder_sent_at: '2025-12-05T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(5);
expect(result.status).toBe('expired');
expect(result.days_remaining).toBe(0);
expect(result.overage_amount).toBeGreaterThan(0);
});
it('handles null email timestamps when no reminders sent', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 6,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 6,
allowed_limit: 5,
overage_amount: 1,
days_remaining: 14,
grace_period_ends_at: '2025-12-21T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: null,
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(6);
expect(result.initial_email_sent_at).toBeNull();
expect(result.week_reminder_sent_at).toBeNull();
expect(result.day_reminder_sent_at).toBeNull();
});
});
});