Skip to content
Merged
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: 10 additions & 6 deletions .github/workflows/web-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@ name: Web CI

on:
push:
branches: [master, main]
branches: [master, main, feat/**]
pull_request:
branches: [master, main]
workflow_dispatch:

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
submodules: false

- name: Install pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.0

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
11 changes: 9 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "echo 'lint handled at root'",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@packages/validators": "workspace:*",
"@clerk/nextjs": "^7.5.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
Expand All @@ -29,13 +32,17 @@
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.39.4",
"eslint-config-next": "16.2.7",
"eslint-plugin-react": "^7.37.5",
"jsdom": "^24.0.0",
"tailwindcss": "^4",
"typescript": "^6"
"typescript": "^6",
"vitest": "^4.1.8"
}
}
215 changes: 215 additions & 0 deletions apps/web/src/api/__tests__/rosterApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as rosterApi from '@/api/rosterApi';

describe('rosterApi', () => {
beforeEach(() => {
vi.resetAllMocks();
});

describe('fetchCurrentRoster', () => {
it('calls GET /api/roster with tenantId and weekStart', async () => {
const mockResponse = { roster: { id: 'r1' }, shifts: [] };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01');

expect(global.fetch).toHaveBeenCalledWith(
'/api/roster?tenantId=tenant-1&weekStart=2026-01-01'
);
expect(result).toEqual(mockResponse);
});

it('throws on non-ok response', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
});

await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01'))
.rejects.toThrow('Not found');
});
});

describe('fetchProfiles', () => {
it('calls GET /api/profiles', async () => {
const mockResponse = { profiles: [{ id: 'p1' }] };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.fetchProfiles();

expect(global.fetch).toHaveBeenCalledWith('/api/profiles');
expect(result).toEqual(mockResponse);
});
});

describe('publishRoster', () => {
it('calls POST /api/roster with publish action', async () => {
const mockResponse = { roster: { id: 'r1', status: 'published' } };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.publishRoster('roster-1');

expect(global.fetch).toHaveBeenCalledWith('/api/roster', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'publish', rosterId: 'roster-1' }),
});
expect(result).toEqual(mockResponse);
});
});

describe('unpublishRoster', () => {
it('calls POST /api/roster with unpublish action', async () => {
const mockResponse = { roster: { id: 'r1', status: 'draft' } };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.unpublishRoster('roster-1');

expect(global.fetch).toHaveBeenCalledWith('/api/roster', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'unpublish', rosterId: 'roster-1' }),
});
expect(result).toEqual(mockResponse);
});
});

describe('copyForwardRoster', () => {
it('calls POST /api/roster with copy-forward action', async () => {
const mockResponse = { roster: { id: 'r2' } };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.copyForwardRoster('tenant-1', '2026-01-01', 'roster-1');

expect(global.fetch).toHaveBeenCalledWith('/api/roster', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'copy-forward',
tenantId: 'tenant-1',
weekStart: '2026-01-01',
rosterId: 'roster-1',
}),
});
expect(result).toEqual(mockResponse);
});
});

describe('createShift', () => {
it('calls POST /api/roster with create-shift action', async () => {
const mockResponse = { success: true };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.createShift({
action: 'create-shift',
rosterId: 'r1',
profileId: 'p1',
startTime: '2026-01-01T09:00:00Z',
endTime: '2026-01-01T17:00:00Z',
});

expect(global.fetch).toHaveBeenCalledWith('/api/roster', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'create-shift',
rosterId: 'r1',
profileId: 'p1',
startTime: '2026-01-01T09:00:00Z',
endTime: '2026-01-01T17:00:00Z',
}),
});
expect(result).toEqual(mockResponse);
});
});

describe('updateShift', () => {
it('calls POST /api/roster with update-shift action', async () => {
const mockResponse = { success: true };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.updateShift({
action: 'update-shift',
shiftId: 's1',
profileId: 'p1',
startTime: '2026-01-01T10:00:00Z',
endTime: '2026-01-01T18:00:00Z',
});

expect(global.fetch).toHaveBeenCalledWith('/api/roster', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'update-shift',
shiftId: 's1',
profileId: 'p1',
startTime: '2026-01-01T10:00:00Z',
endTime: '2026-01-01T18:00:00Z',
}),
});
expect(result).toEqual(mockResponse);
});
});

describe('saveShifts', () => {
it('calls POST /api/roster with save-shifts action', async () => {
const mockResponse = { success: true };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const shifts = [
{ id: 's1', profile_id: 'p1', start_time: '2026-01-01T09:00:00Z', end_time: '2026-01-01T17:00:00Z' },
];
const result = await rosterApi.saveShifts({ action: 'save-shifts', shifts });

expect(global.fetch).toHaveBeenCalledWith('/api/roster', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save-shifts', shifts }),
});
expect(result).toEqual(mockResponse);
});
});

describe('error handling', () => {
it('throws on network error', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));

await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01'))
.rejects.toThrow('Network error');
});

it('throws with server error message', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'Internal server error' }),
});

await expect(rosterApi.publishRoster('roster-1'))
.rejects.toThrow('Internal server error');
});
});
});
Loading
Loading