Files
lisilou-portfolio/tests/api.spec.js
T
jhodgkin bfe374206c
Deploy to Dev / Deploy & Smoke Test (push) Successful in 24s
Add Google Calendar availability date picker (issue #3)
- api/google-calendar.js: service account JWT auth + freebusy query
  using only Node.js built-in crypto (no new npm deps). In-memory cache:
  5-min busy dates, 1-hr OAuth token. Gracefully returns empty busy list
  when GOOGLE_CALENDAR_ID / GOOGLE_SERVICE_ACCOUNT_JSON not configured.
- api/server.js: GET /api/availability?year=YYYY&month=MM endpoint
  with 5-min Cache-Control; errors return { busy:[], configured:false }
  so the UI always works even if Calendar is unavailable.
- src/index.html: replace plain <input type="date"> in step 1 with a
  custom month-grid calendar (dpInit/dpRender/dpNav/dpSelectDate).
  Hidden #session-date input carries the value for the rest of the wizard.
  Past dates and busy dates are visually blocked; month nav pre-fetches.
- tests/booking.spec.js: 4 new calendar UI tests + updated openAndPickDate
  helper to click a rendered day cell.
- tests/api.spec.js: 2 new availability endpoint tests.

Setup: set GOOGLE_CALENDAR_ID and GOOGLE_SERVICE_ACCOUNT_JSON in api/.env.
See api/google-calendar.js header for step-by-step instructions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 00:10:31 -06:00

213 lines
7.9 KiB
JavaScript

// @ts-check
const { test, expect } = require('@playwright/test');
const ADMIN_SECRET = process.env.ADMIN_SECRET || '9yPuNfYjy5kisRKrowhmH42PTeEgH';
const authHeaders = { Authorization: `Bearer ${ADMIN_SECRET}` };
test.describe('Booking API — public routes', () => {
test('GET /api/health → {ok:true}', async ({ request }) => {
const res = await request.get('/api/health');
expect(res.status()).toBe(200);
expect((await res.json()).ok).toBe(true);
});
test('POST /api/bookings creates a booking and returns id', async ({ request }) => {
const res = await request.post('/api/bookings', {
data: {
client_name: 'API Test',
client_email: `api-test-${Date.now()}@test.lisilou`,
client_phone: '555-000-0002',
session_date: '2099-11-15',
session_type: 'individual',
session_length: 'mini',
location: 'studio',
},
});
expect(res.status()).toBe(201);
const json = await res.json();
expect(typeof json.id).toBe('number');
expect(json.id).toBeGreaterThan(0);
});
test('POST /api/bookings with payment_status=pending_confirmation accepted', async ({ request }) => {
const res = await request.post('/api/bookings', {
data: {
client_name: 'Payment Test',
client_email: `pay-test-${Date.now()}@test.lisilou`,
session_date: '2099-12-01',
session_type: 'couple',
session_length: 'full',
location: 'downtown',
payment_status: 'pending_confirmation',
},
});
expect(res.status()).toBe(201);
expect((await res.json()).id).toBeGreaterThan(0);
});
test('POST /api/bookings with missing required fields returns 400', async ({ request }) => {
const res = await request.post('/api/bookings', {
data: { client_name: 'Incomplete' },
});
expect(res.status()).toBe(400);
});
test('GET /api/bookings/99999/contract returns 404 for unknown booking', async ({ request }) => {
const res = await request.get('/api/bookings/99999/contract');
expect(res.status()).toBe(404);
});
test('GET /api/contracts/template returns 404 (no template on file yet)', async ({ request }) => {
const res = await request.get('/api/contracts/template');
// Either 404 (no template uploaded) or 200 (uploaded) — both are valid; just not 500
expect(res.status()).not.toBe(500);
});
test('GET /api/availability returns busy array for valid month', async ({ request }) => {
const now = new Date();
const res = await request.get(`/api/availability?year=${now.getFullYear()}&month=${now.getMonth() + 1}`);
expect(res.status()).toBe(200);
const json = await res.json();
expect(Array.isArray(json.busy)).toBe(true);
expect(typeof json.configured).toBe('boolean');
// Each busy entry must be a valid YYYY-MM-DD string
for (const d of json.busy) {
expect(d).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
});
test('GET /api/availability rejects missing params', async ({ request }) => {
const res = await request.get('/api/availability');
expect(res.status()).toBe(400);
});
});
test.describe('Admin API — authentication', () => {
test('GET /api/admin/stats without token returns 401 or 503', async ({ request }) => {
const res = await request.get('/api/admin/stats');
expect([401, 503]).toContain(res.status());
});
test('GET /api/admin/stats with wrong token returns 401', async ({ request }) => {
const res = await request.get('/api/admin/stats', {
headers: { Authorization: 'Bearer wrongtoken' },
});
expect(res.status()).toBe(401);
});
test('GET /api/admin/stats with correct token returns stats', async ({ request }) => {
const res = await request.get('/api/admin/stats', { headers: authHeaders });
expect(res.status()).toBe(200);
const json = await res.json();
expect(typeof json.upcoming).toBe('number');
expect(typeof json.pending_payment).toBe('number');
expect(typeof json.confirmed_payment).toBe('number');
expect(Array.isArray(json.nextUpcoming)).toBe(true);
});
});
test.describe('Admin API — bookings CRUD', () => {
let createdId;
test('GET /api/admin/bookings returns array', async ({ request }) => {
const res = await request.get('/api/admin/bookings', { headers: authHeaders });
expect(res.status()).toBe(200);
const json = await res.json();
expect(Array.isArray(json)).toBe(true);
});
test('GET /api/admin/bookings filters by payment_status', async ({ request }) => {
const res = await request.get('/api/admin/bookings?payment_status=pending', { headers: authHeaders });
expect(res.status()).toBe(200);
const json = await res.json();
for (const b of json) {
expect(b.payment_status).toBe('pending');
}
});
test('GET /api/admin/bookings?search= filters by name', async ({ request }) => {
const res = await request.get('/api/admin/bookings?search=Smoke+Test', { headers: authHeaders });
expect(res.status()).toBe(200);
const json = await res.json();
for (const b of json) {
const matches = b.client_name?.includes('Smoke') || b.client_email?.includes('Smoke');
expect(matches).toBeTruthy();
}
});
test('PATCH /api/admin/bookings/:id updates payment_status', async ({ request }) => {
// Create a booking to patch
const createRes = await request.post('/api/bookings', {
data: {
client_name: 'Admin Patch Test',
client_email: `patch-${Date.now()}@test.lisilou`,
session_date: '2099-10-10',
session_type: 'family',
session_length: 'full',
location: 'park',
payment_status: 'pending_confirmation',
},
});
createdId = (await createRes.json()).id;
const patchRes = await request.patch(`/api/admin/bookings/${createdId}`, {
headers: authHeaders,
data: { payment_status: 'confirmed' },
});
expect(patchRes.status()).toBe(200);
const updated = await patchRes.json();
expect(updated.payment_status).toBe('confirmed');
});
test('PATCH /api/admin/bookings/:id updates notes', async ({ request }) => {
// Reuse createdId from previous test or create new
const createRes = await request.post('/api/bookings', {
data: {
client_name: 'Notes Test',
client_email: `notes-${Date.now()}@test.lisilou`,
session_date: '2099-09-09',
session_type: 'individual',
session_length: 'mini',
location: 'studio',
},
});
const id = (await createRes.json()).id;
const patchRes = await request.patch(`/api/admin/bookings/${id}`, {
headers: authHeaders,
data: { notes: 'Playwright test note' },
});
expect(patchRes.status()).toBe(200);
expect((await patchRes.json()).notes).toBe('Playwright test note');
});
test('GET /api/admin/bookings/:id returns single booking', async ({ request }) => {
const listRes = await request.get('/api/admin/bookings', { headers: authHeaders });
const bookings = await listRes.json();
if (!bookings.length) return; // skip if empty DB
const id = bookings[0].id;
const res = await request.get(`/api/admin/bookings/${id}`, { headers: authHeaders });
expect(res.status()).toBe(200);
const b = await res.json();
expect(b.id).toBe(id);
expect(b).toHaveProperty('client_name');
expect(b).toHaveProperty('session_date');
});
test('GET /api/admin/bookings/99999 returns 404', async ({ request }) => {
const res = await request.get('/api/admin/bookings/99999', { headers: authHeaders });
expect(res.status()).toBe(404);
});
test('GET /api/admin/payments/export returns CSV', async ({ request }) => {
const res = await request.get('/api/admin/payments/export', { headers: authHeaders });
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('text/csv');
const body = await res.text();
expect(body).toContain('client_name');
expect(body).toContain('session_date');
expect(body).toContain('payment_status');
});
});