Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
596 lines
22 KiB
TypeScript
596 lines
22 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
import { execSync } from 'child_process';
|
||
import { join, dirname } from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = dirname(__filename);
|
||
|
||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||
|
||
const ADMIN_EMAIL = 'e2e-schedule-admin@example.com';
|
||
const ADMIN_PASSWORD = 'ScheduleTest123';
|
||
const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com';
|
||
const TEACHER_PASSWORD = 'ScheduleTeacher123';
|
||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||
|
||
const projectRoot = join(__dirname, '../..');
|
||
const composeFile = join(projectRoot, 'compose.yaml');
|
||
|
||
function runSql(sql: string) {
|
||
execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
);
|
||
}
|
||
|
||
function clearCache() {
|
||
try {
|
||
execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
);
|
||
} catch {
|
||
// Cache pool may not exist
|
||
}
|
||
}
|
||
|
||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||
const output = execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||
`require "/app/vendor/autoload.php"; ` +
|
||
`$t="${TENANT_ID}"; ` +
|
||
`$dns="6ba7b810-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||
`$ay="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||
`echo Ramsey\\Uuid\\Uuid::uuid5($dns,"school-$t")->toString()."\\n"; ` +
|
||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||
`echo Ramsey\\Uuid\\Uuid::uuid5($ay,"$t:$s-$e")->toString();` +
|
||
`' 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
).trim();
|
||
const [schoolId, academicYearId] = output.split('\n');
|
||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||
}
|
||
|
||
function cleanupScheduleData() {
|
||
try {
|
||
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Table may not exist yet
|
||
}
|
||
}
|
||
|
||
function seedTeacherAssignments() {
|
||
const { academicYearId } = resolveDeterministicIds();
|
||
try {
|
||
// Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid
|
||
runSql(
|
||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||
`ON CONFLICT DO NOTHING`
|
||
);
|
||
} catch {
|
||
// Table may not exist
|
||
}
|
||
}
|
||
|
||
function cleanupCalendarEntries() {
|
||
try {
|
||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Table may not exist
|
||
}
|
||
}
|
||
|
||
function seedBlockedDate(date: string, label: string, type: string) {
|
||
const { academicYearId } = resolveDeterministicIds();
|
||
runSql(
|
||
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, created_at) ` +
|
||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', '${type}', '${date}', '${date}', '${label}', NOW()) ` +
|
||
`ON CONFLICT DO NOTHING`
|
||
);
|
||
}
|
||
|
||
function getWeekdayInCurrentWeek(isoDay: number): string {
|
||
const now = new Date();
|
||
const monday = new Date(now);
|
||
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
|
||
const target = new Date(monday);
|
||
target.setDate(monday.getDate() + (isoDay - 1));
|
||
const y = target.getFullYear();
|
||
const m = String(target.getMonth() + 1).padStart(2, '0');
|
||
const d = String(target.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||
await page.goto(`${ALPHA_URL}/login`);
|
||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||
await page.getByRole('button', { name: /se connecter/i }).click();
|
||
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
||
}
|
||
|
||
async function waitForScheduleReady(page: import('@playwright/test').Page) {
|
||
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({
|
||
timeout: 15000
|
||
});
|
||
// Wait for either the grid or the empty state to appear
|
||
await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({
|
||
timeout: 15000
|
||
});
|
||
}
|
||
|
||
async function chooseScopeAndEdit(page: import('@playwright/test').Page) {
|
||
// Scope modal appears - choose "this occurrence"
|
||
const scopeModal = page.getByRole('dialog');
|
||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||
await scopeModal.getByText('Cette occurrence uniquement').click();
|
||
}
|
||
|
||
async function fillSlotForm(
|
||
dialog: import('@playwright/test').Locator,
|
||
options: {
|
||
className?: string;
|
||
dayValue?: string;
|
||
startTime?: string;
|
||
endTime?: string;
|
||
room?: string;
|
||
} = {}
|
||
) {
|
||
const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options;
|
||
|
||
if (className) {
|
||
await dialog.locator('#slot-class').selectOption({ label: className });
|
||
}
|
||
// Wait for assignments to load, then select subject first (filters teachers)
|
||
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
|
||
await expect(subjectOptions.first()).toBeAttached({ timeout: 10000 });
|
||
await dialog.locator('#slot-subject').selectOption({ index: 1 });
|
||
// After subject selection, wait for teacher dropdown to be filtered
|
||
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
|
||
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
|
||
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
|
||
await dialog.locator('#slot-day').selectOption(dayValue);
|
||
await dialog.locator('#slot-start').fill(startTime);
|
||
await dialog.locator('#slot-end').fill(endTime);
|
||
if (room) {
|
||
await dialog.locator('#slot-room').fill(room);
|
||
}
|
||
}
|
||
|
||
test.describe('Schedule Management - Modification & Conflicts & Calendar (Story 4.1)', () => {
|
||
// Tests share database state (same tenant, users, slots) so they must run sequentially
|
||
test.describe.configure({ mode: 'serial' });
|
||
|
||
test.beforeAll(async () => {
|
||
// Create admin user
|
||
execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
);
|
||
|
||
// Create teacher user
|
||
execSync(
|
||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||
{ encoding: 'utf-8' }
|
||
);
|
||
|
||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||
|
||
// Clean up stale test data (e.g. from previous runs with wrong school_id)
|
||
try {
|
||
runSql(`DELETE FROM schedule_slots WHERE class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
|
||
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
|
||
runSql(`DELETE FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}'`);
|
||
runSql(`DELETE FROM subjects WHERE code IN ('E2SMATH','E2SFRA') AND tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Tables may not exist
|
||
}
|
||
|
||
// Create test classes
|
||
runSql(
|
||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW())`
|
||
);
|
||
runSql(
|
||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-5A', '5ème', 'active', NOW(), NOW())`
|
||
);
|
||
|
||
// Create test subjects
|
||
runSql(
|
||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW())`
|
||
);
|
||
runSql(
|
||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2SFRA', '#ef4444', 'active', NOW(), NOW())`
|
||
);
|
||
|
||
cleanupScheduleData();
|
||
cleanupCalendarEntries();
|
||
clearCache();
|
||
});
|
||
|
||
test.beforeEach(async () => {
|
||
cleanupScheduleData();
|
||
cleanupCalendarEntries();
|
||
try {
|
||
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
|
||
} catch {
|
||
// Table may not exist
|
||
}
|
||
seedTeacherAssignments();
|
||
clearCache();
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC3: Slot Modification & Deletion
|
||
// ==========================================================================
|
||
test.describe('AC3: Slot Modification & Deletion', () => {
|
||
test('clicking a slot opens scope modal then edit modal', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// First create a slot
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog);
|
||
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Click on the created slot — scope modal should appear
|
||
const slotCard = page.locator('.slot-card').first();
|
||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||
await slotCard.click();
|
||
|
||
await chooseScopeAndEdit(page);
|
||
|
||
// Edit modal should appear
|
||
const editDialog = page.getByRole('dialog');
|
||
await expect(editDialog).toBeVisible({ timeout: 10000 });
|
||
await expect(
|
||
editDialog.getByRole('heading', { name: /modifier le créneau/i })
|
||
).toBeVisible();
|
||
});
|
||
|
||
test('can delete a slot via edit modal', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Create a slot
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
let dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, { dayValue: '2', startTime: '14:00', endTime: '15:00' });
|
||
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Click on the slot — scope modal then edit modal
|
||
const slotCard = page.locator('.slot-card').first();
|
||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||
await slotCard.click();
|
||
|
||
await chooseScopeAndEdit(page);
|
||
|
||
dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Click delete button — opens scope modal again for delete scope
|
||
await dialog.getByRole('button', { name: /supprimer/i }).click();
|
||
|
||
// Scope modal appears for delete action
|
||
const scopeModal = page.locator('.modal-scope');
|
||
await expect(scopeModal).toBeVisible({ timeout: 10000 });
|
||
await scopeModal.getByText('Cette occurrence uniquement').click();
|
||
|
||
// Confirmation modal should appear
|
||
const deleteModal = page.getByRole('alertdialog');
|
||
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
||
|
||
// Confirm deletion
|
||
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
|
||
|
||
// Modal should close and slot should disappear
|
||
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
||
await expect(page.locator('.slot-card')).not.toBeVisible({ timeout: 10000 });
|
||
});
|
||
|
||
test('can modify a slot and see updated data in grid', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Create initial slot with room
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
let dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, { room: 'A101' });
|
||
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Verify initial slot with room A101
|
||
const slotCard = page.locator('.slot-card').first();
|
||
await expect(slotCard).toBeVisible({ timeout: 10000 });
|
||
await expect(slotCard.getByText('A101')).toBeVisible();
|
||
|
||
// Click to open scope modal then edit modal
|
||
await slotCard.click();
|
||
|
||
await chooseScopeAndEdit(page);
|
||
|
||
dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
await expect(
|
||
dialog.getByRole('heading', { name: /modifier le créneau/i })
|
||
).toBeVisible();
|
||
|
||
// Change room to B202
|
||
await dialog.locator('#slot-room').clear();
|
||
await dialog.locator('#slot-room').fill('B202');
|
||
|
||
// Submit modification
|
||
await dialog.getByRole('button', { name: /modifier/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Verify success and updated data
|
||
await expect(page.getByText('Créneau modifié.')).toBeVisible({ timeout: 5000 });
|
||
const updatedSlot = page.locator('.slot-card').first();
|
||
await expect(updatedSlot.getByText('B202')).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC4: Conflict Detection
|
||
// ==========================================================================
|
||
test.describe('AC4: Conflict Detection', () => {
|
||
test('displays conflict warning when creating slot with same teacher at overlapping time', async ({
|
||
page
|
||
}) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Step 1: Create first slot (class 6A, Wednesday 10:00-11:00)
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
let dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, { dayValue: '3', startTime: '10:00', endTime: '11:00' });
|
||
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
|
||
|
||
// Step 2: Create conflicting slot with DIFFERENT class but SAME teacher at same time
|
||
const timeCell2 = page.locator('.time-cell').first();
|
||
await timeCell2.click();
|
||
|
||
dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, {
|
||
className: 'E2E-Schedule-5A',
|
||
dayValue: '3',
|
||
startTime: '10:00',
|
||
endTime: '11:00'
|
||
});
|
||
|
||
// Submit - should trigger conflict detection
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
|
||
// Conflict warning should appear inside the dialog
|
||
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
|
||
await expect(dialog.getByText(/conflits détectés/i)).toBeVisible();
|
||
|
||
// Force checkbox should be available
|
||
await expect(dialog.getByText(/forcer la création/i)).toBeVisible();
|
||
|
||
// Dialog should still be open (not closed)
|
||
await expect(dialog).toBeVisible();
|
||
});
|
||
|
||
test('can force creation despite detected conflict', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Step 1: Create first slot (class 6A, Thursday 14:00-15:00)
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
let dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, { dayValue: '4', startTime: '14:00', endTime: '15:00' });
|
||
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
|
||
|
||
// Step 2: Create conflicting slot with different class, same teacher, same time
|
||
const timeCell2 = page.locator('.time-cell').first();
|
||
await timeCell2.click();
|
||
|
||
dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
await fillSlotForm(dialog, {
|
||
className: 'E2E-Schedule-5A',
|
||
dayValue: '4',
|
||
startTime: '14:00',
|
||
endTime: '15:00'
|
||
});
|
||
|
||
// First submit - triggers conflict warning
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
|
||
|
||
// Check force checkbox
|
||
await dialog.locator('.force-checkbox input[type="checkbox"]').check();
|
||
|
||
// Submit again with force enabled
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
|
||
// Modal should close - slot created despite conflict
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
|
||
// Success message should appear
|
||
await expect(page.getByText('Créneau créé.')).toBeVisible({ timeout: 15000 });
|
||
});
|
||
});
|
||
|
||
// ==========================================================================
|
||
// AC5: Calendar Respect (Blocked Days)
|
||
// ==========================================================================
|
||
test.describe('AC5: Calendar Respect', () => {
|
||
test('time validation prevents end before start', async ({ page }) => {
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
|
||
await waitForScheduleReady(page);
|
||
|
||
// Open creation modal
|
||
const timeCell = page.locator('.time-cell').first();
|
||
await timeCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
|
||
// Set end time before start time
|
||
await dialog.locator('#slot-start').fill('10:00');
|
||
await dialog.locator('#slot-end').fill('09:00');
|
||
|
||
// Error message should appear
|
||
await expect(
|
||
dialog.getByText(/l'heure de fin doit être après/i)
|
||
).toBeVisible();
|
||
|
||
// Submit should be disabled
|
||
await expect(dialog.getByRole('button', { name: /créer/i })).toBeDisabled();
|
||
});
|
||
|
||
test('blocked day is visually marked in the grid', async ({ page }) => {
|
||
// Seed a holiday on Wednesday of current week
|
||
const wednesdayDate = getWeekdayInCurrentWeek(3);
|
||
seedBlockedDate(wednesdayDate, 'Jour férié test', 'holiday');
|
||
clearCache();
|
||
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Wait for the blocked date badge to appear — confirms API data is loaded
|
||
await expect(page.getByText('Jour férié test')).toBeVisible({ timeout: 20000 });
|
||
|
||
// The third day-column (Wednesday) should have the blocked class
|
||
const dayColumns = page.locator('.day-column');
|
||
await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 5000 });
|
||
});
|
||
|
||
test('cannot create a slot on a blocked day', async ({ page }) => {
|
||
// Seed a vacation on Tuesday of current week
|
||
const tuesdayDate = getWeekdayInCurrentWeek(2);
|
||
seedBlockedDate(tuesdayDate, 'Vacances test', 'vacation');
|
||
clearCache();
|
||
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Tuesday column should be blocked
|
||
const dayColumns = page.locator('.day-column');
|
||
await expect(dayColumns.nth(1)).toHaveClass(/day-blocked/, { timeout: 10000 });
|
||
|
||
// Attempt to click a time cell in the blocked day — dialog should NOT open
|
||
// Use dispatchEvent to bypass pointer-events: none
|
||
await dayColumns.nth(1).locator('.time-cell').first().dispatchEvent('click');
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).not.toBeVisible({ timeout: 3000 });
|
||
});
|
||
|
||
test('recurring slots do not appear on vacation days in next week (AC4)', async ({
|
||
page
|
||
}) => {
|
||
// Compute next week Thursday (use local format to avoid UTC shift)
|
||
const thisThursday = new Date(getWeekdayInCurrentWeek(4) + 'T00:00:00');
|
||
const nextThursday = new Date(thisThursday);
|
||
nextThursday.setDate(thisThursday.getDate() + 7);
|
||
const y = nextThursday.getFullYear();
|
||
const m = String(nextThursday.getMonth() + 1).padStart(2, '0');
|
||
const d = String(nextThursday.getDate()).padStart(2, '0');
|
||
const nextThursdayStr = `${y}-${m}-${d}`;
|
||
|
||
// Clean calendar entries and seed a vacation on next Thursday only
|
||
cleanupCalendarEntries();
|
||
seedBlockedDate(nextThursdayStr, 'Vacances AC4', 'vacation');
|
||
clearCache();
|
||
|
||
await loginAsAdmin(page);
|
||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||
await waitForScheduleReady(page);
|
||
|
||
// Create a Thursday slot (if none exists) so we have a recurring slot on Thursday
|
||
const dayColumns = page.locator('.day-column');
|
||
// Check if Thursday column (index 3) already has a slot
|
||
const thursdaySlots = dayColumns.nth(3).locator('.slot-card');
|
||
const existingCount = await thursdaySlots.count();
|
||
|
||
if (existingCount === 0) {
|
||
// Create a slot on Thursday
|
||
const thursdayCell = dayColumns.nth(3).locator('.time-cell').first();
|
||
await thursdayCell.click();
|
||
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||
await fillSlotForm(dialog, { dayValue: '4', startTime: '09:00', endTime: '10:00' });
|
||
await dialog.getByRole('button', { name: /créer/i }).click();
|
||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||
await waitForScheduleReady(page);
|
||
}
|
||
|
||
// Verify slot is visible on current week's Thursday
|
||
await expect(dayColumns.nth(3).locator('.slot-card').first()).toBeVisible({
|
||
timeout: 10000
|
||
});
|
||
|
||
// Navigate to next week
|
||
await page.getByRole('button', { name: 'Semaine suivante' }).click();
|
||
await waitForScheduleReady(page);
|
||
|
||
// Next week Thursday should be blocked
|
||
await expect(dayColumns.nth(3)).toHaveClass(/day-blocked/, { timeout: 10000 });
|
||
|
||
// No slot card should appear on the blocked Thursday
|
||
await expect(dayColumns.nth(3).locator('.slot-card')).toHaveCount(0);
|
||
});
|
||
});
|
||
});
|