feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
test.describe("Holiday Calendar Editor", () => {
|
||||
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => {
|
||||
const suffix = Date.now().toString();
|
||||
const calendarName = `E2E City Calendar ${suffix}`;
|
||||
const holidayName = `E2E Local Holiday ${suffix}`;
|
||||
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/admin/vacations");
|
||||
|
||||
await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible();
|
||||
|
||||
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
|
||||
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
|
||||
await page.getByTestId("holiday-calendar-country-select").selectOption({ label: "Germany (DE)" });
|
||||
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
|
||||
await page.getByTestId("holiday-calendar-create-button").click();
|
||||
|
||||
await expect(page.getByTestId(/holiday-calendar-row-/).filter({ hasText: calendarName }).first()).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
|
||||
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
|
||||
|
||||
await page.getByTestId("holiday-entry-date-input").fill("2026-08-08");
|
||||
await page.getByTestId("holiday-entry-name-input").fill(holidayName);
|
||||
await page.getByTestId("holiday-entry-source-input").fill("E2E");
|
||||
await page.getByTestId("holiday-entry-create-button").click();
|
||||
|
||||
await expect(page.getByText(holidayName).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId("holiday-preview-year-input").fill("2026");
|
||||
await expect(page.getByTestId("holiday-preview-table")).toContainText(holidayName);
|
||||
await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-08-08");
|
||||
|
||||
await page.getByTestId("holiday-entry-date-input").fill("2026-08-08");
|
||||
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
|
||||
await page.getByTestId("holiday-entry-create-button").click();
|
||||
|
||||
await expect(page.getByText("A holiday entry for this calendar and date already exists")).toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByTestId(/holiday-entry-delete-/).first().click();
|
||||
await expect(page.getByText(holidayName).first()).not.toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByTestId("holiday-calendar-delete-button").click();
|
||||
await expect(page.getByRole("heading", { name: calendarName })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(currentDir, "../../..");
|
||||
const webRoot = resolve(currentDir, "..");
|
||||
const webEnvLocal = resolve(webRoot, ".env.local");
|
||||
const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup");
|
||||
const webDistDir = ".next-e2e";
|
||||
const webDistDirPath = resolve(webRoot, webDistDir);
|
||||
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
||||
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
||||
const composeProjectName = `capakraken-e2e-${process.pid}`;
|
||||
const managedEnvKeys = [
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"NEXTAUTH_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"AUTH_SECRET",
|
||||
"E2E_TEST_MODE",
|
||||
"NODE_ENV",
|
||||
"PORT",
|
||||
];
|
||||
const e2eComposePrefix = "capakraken-e2e-";
|
||||
|
||||
function dockerComposeArgs(...args) {
|
||||
return ["compose", "-p", composeProjectName, ...args];
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
const env = {};
|
||||
|
||||
try {
|
||||
const contents = readFileSync(filePath, "utf8");
|
||||
for (const rawLine of contents.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf("=");
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const rawValue = line.slice(separatorIndex + 1).trim();
|
||||
const quoted =
|
||||
(rawValue.startsWith("\"") && rawValue.endsWith("\"")) ||
|
||||
(rawValue.startsWith("'") && rawValue.endsWith("'"));
|
||||
|
||||
env[key] = quoted ? rawValue.slice(1, -1) : rawValue;
|
||||
}
|
||||
} catch {
|
||||
// Keep local runs working even when no workspace .env is present.
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function applyEnv(env) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function writeManagedWebEnv(rootEnv) {
|
||||
if (existsSync(webEnvBackup)) {
|
||||
rmSync(webEnvBackup, { force: true });
|
||||
}
|
||||
|
||||
if (existsSync(webEnvLocal)) {
|
||||
renameSync(webEnvLocal, webEnvBackup);
|
||||
}
|
||||
|
||||
const contents = managedEnvKeys
|
||||
.map((key) => {
|
||||
const value = rootEnv[key] ?? process.env[key];
|
||||
return value ? `${key}=${value}` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
writeFileSync(webEnvLocal, `${contents}\n`, "utf8");
|
||||
}
|
||||
|
||||
function restoreWebEnv() {
|
||||
if (existsSync(webEnvLocal)) {
|
||||
rmSync(webEnvLocal, { force: true });
|
||||
}
|
||||
|
||||
if (existsSync(webEnvBackup)) {
|
||||
renameSync(webEnvBackup, webEnvLocal);
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args, cwd) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", rejectPromise);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
|
||||
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runQuiet(command, args, cwd) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
child.on("error", rejectPromise);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
|
||||
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runCapture(command, args, cwd) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on("error", rejectPromise);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
rejectPromise(
|
||||
new Error(
|
||||
`${command} ${args.join(" ")} exited with code ${code ?? "null"}${stderr ? `: ${stderr.trim()}` : ""}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupStaleE2eArtifacts() {
|
||||
try {
|
||||
const containerOutput = await runCapture("docker", ["ps", "-a", "--format", "{{.Names}}"], workspaceRoot);
|
||||
const staleContainers = containerOutput
|
||||
.split(/\r?\n/u)
|
||||
.map((value) => value.trim())
|
||||
.filter((name) => name.startsWith(e2eComposePrefix));
|
||||
|
||||
if (staleContainers.length > 0) {
|
||||
await runQuiet("docker", ["rm", "-f", ...staleContainers], workspaceRoot);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
|
||||
try {
|
||||
const networkOutput = await runCapture("docker", ["network", "ls", "--format", "{{.Name}}"], workspaceRoot);
|
||||
const staleNetworks = networkOutput
|
||||
.split(/\r?\n/u)
|
||||
.map((value) => value.trim())
|
||||
.filter((name) => name.startsWith(e2eComposePrefix));
|
||||
|
||||
if (staleNetworks.length > 0) {
|
||||
await runQuiet("docker", ["network", "rm", ...staleNetworks], workspaceRoot);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureE2eDatabaseContainer() {
|
||||
try {
|
||||
await runQuiet("docker", dockerComposeArgs("rm", "-sf", "postgres-test"), workspaceRoot);
|
||||
} catch {
|
||||
// No previous test container to remove.
|
||||
}
|
||||
|
||||
await run("docker", dockerComposeArgs("--profile", "test", "up", "-d", "--force-recreate", "postgres-test"), workspaceRoot);
|
||||
|
||||
const maxAttempts = 30;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
await runQuiet(
|
||||
"docker",
|
||||
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
|
||||
workspaceRoot,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseDatabaseName(databaseUrl) {
|
||||
const parsed = new URL(databaseUrl);
|
||||
return parsed.pathname.replace(/^\/+/u, "");
|
||||
}
|
||||
|
||||
async function canBindPort(port) {
|
||||
return new Promise((resolvePromise) => {
|
||||
const server = createServer();
|
||||
|
||||
server.once("error", () => {
|
||||
resolvePromise(false);
|
||||
});
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolvePromise(true));
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAvailablePort(preferredPort) {
|
||||
const candidates = [
|
||||
preferredPort,
|
||||
...Array.from({ length: 50 }, (_, index) => preferredPort + index + 1),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await canBindPort(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No free host port available for postgres-test near ${preferredPort}.`);
|
||||
}
|
||||
|
||||
function replaceDatabasePort(databaseUrl, port) {
|
||||
const parsed = new URL(databaseUrl);
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
let cleanedUpComposeProject = false;
|
||||
|
||||
async function cleanupComposeProject() {
|
||||
if (cleanedUpComposeProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanedUpComposeProject = true;
|
||||
|
||||
try {
|
||||
await runQuiet("docker", dockerComposeArgs("down", "--remove-orphans"), workspaceRoot);
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
const rootEnv = loadEnvFile(resolve(workspaceRoot, ".env"));
|
||||
applyEnv(rootEnv);
|
||||
let playwrightDatabaseUrl = process.env.PLAYWRIGHT_DATABASE_URL ?? process.env.DATABASE_URL_TEST;
|
||||
|
||||
if (!playwrightDatabaseUrl) {
|
||||
throw new Error("PLAYWRIGHT_DATABASE_URL or DATABASE_URL_TEST must be configured for E2E runs.");
|
||||
}
|
||||
|
||||
const requestedTestDbPort = Number(new URL(playwrightDatabaseUrl).port || "5434");
|
||||
const selectedTestDbPort = await selectAvailablePort(requestedTestDbPort);
|
||||
playwrightDatabaseUrl = replaceDatabasePort(playwrightDatabaseUrl, selectedTestDbPort);
|
||||
|
||||
const playwrightDatabaseName = parseDatabaseName(playwrightDatabaseUrl);
|
||||
|
||||
if (!/(^|_)(test|e2e|ci)$/u.test(playwrightDatabaseName)) {
|
||||
throw new Error(
|
||||
`Refusing to run E2E destructive setup against non-test database '${playwrightDatabaseName}'. Set PLAYWRIGHT_DATABASE_URL to an isolated test database.`,
|
||||
);
|
||||
}
|
||||
|
||||
process.env.DATABASE_URL = playwrightDatabaseUrl;
|
||||
process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
|
||||
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
||||
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
|
||||
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
|
||||
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
|
||||
process.env.PORT = e2ePort;
|
||||
process.env.NEXTAUTH_URL = e2eBaseUrl;
|
||||
process.env.AUTH_URL = e2eBaseUrl;
|
||||
process.env.NEXT_DIST_DIR = webDistDir;
|
||||
process.env.E2E_TEST_MODE = "true";
|
||||
writeManagedWebEnv(rootEnv);
|
||||
|
||||
try {
|
||||
await cleanupStaleE2eArtifacts();
|
||||
await ensureE2eDatabaseContainer();
|
||||
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot);
|
||||
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot);
|
||||
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot);
|
||||
rmSync(webDistDirPath, { recursive: true, force: true });
|
||||
|
||||
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
|
||||
cwd: webRoot,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
restoreWebEnv();
|
||||
void cleanupComposeProject();
|
||||
server.kill(signal);
|
||||
});
|
||||
}
|
||||
|
||||
server.on("exit", async (code) => {
|
||||
restoreWebEnv();
|
||||
await cleanupComposeProject();
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
} catch (error) {
|
||||
restoreWebEnv();
|
||||
await cleanupComposeProject();
|
||||
throw error;
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
test.describe("Timeline", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
@@ -7,11 +15,7 @@ test.describe("Timeline", () => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
||||
});
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/timeline");
|
||||
});
|
||||
|
||||
@@ -87,8 +91,13 @@ test.describe("Timeline", () => {
|
||||
.first();
|
||||
const allocationPopoverField = page.getByText("Hours / day");
|
||||
|
||||
const resourceHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
|
||||
await resourceHoverTarget.hover({ position: { x: 120, y: 20 } });
|
||||
const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first();
|
||||
const resourceHoverBox = await resourceHoverTarget.boundingBox();
|
||||
expect(resourceHoverBox).not.toBeNull();
|
||||
if (!resourceHoverBox) {
|
||||
throw new Error("Expected a resource timeline row canvas to be available");
|
||||
}
|
||||
await page.mouse.move(resourceHoverBox.x + 120, resourceHoverBox.y + 20);
|
||||
await expect(heatmapTooltip).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -109,8 +118,19 @@ test.describe("Timeline", () => {
|
||||
await expect(page.getByText(/projects/)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const projectHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
|
||||
await projectHoverTarget.hover({ position: { x: 120, y: 20 } });
|
||||
const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first();
|
||||
const projectHoverBox = await projectHoverTarget.boundingBox();
|
||||
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
|
||||
const projectAllocationBox = await projectAllocation.boundingBox();
|
||||
expect(projectHoverBox).not.toBeNull();
|
||||
expect(projectAllocationBox).not.toBeNull();
|
||||
if (!projectHoverBox) {
|
||||
throw new Error("Expected a project timeline row canvas to be available");
|
||||
}
|
||||
if (!projectAllocationBox) {
|
||||
throw new Error("Expected a project allocation block to be available");
|
||||
}
|
||||
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
|
||||
await expect(heatmapTooltip).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -118,8 +138,48 @@ test.describe("Timeline", () => {
|
||||
})
|
||||
.toBe("rgba(3, 7, 18, 0.96)");
|
||||
|
||||
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
|
||||
await projectAllocation.click({ button: "right" });
|
||||
await expect(allocationPopoverField).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows resolved holiday overlays in the resource timeline and exposes the holiday name in the tooltip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/timeline?startDate=2026-04-01&days=14&eids=bruce.banner", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const holidayBlock = row.locator(
|
||||
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||||
).first();
|
||||
await expect(holidayBlock).toBeVisible();
|
||||
|
||||
const rowBox = await row.boundingBox();
|
||||
const holidayBox = await holidayBlock.boundingBox();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(holidayBox).not.toBeNull();
|
||||
|
||||
if (!rowBox || !holidayBox) {
|
||||
throw new Error("Expected timeline row and holiday block bounding boxes to be available");
|
||||
}
|
||||
|
||||
await row.hover({
|
||||
position: {
|
||||
x: holidayBox.x - rowBox.x + holidayBox.width / 2,
|
||||
y: holidayBox.y - rowBox.y + Math.min(holidayBox.height / 2, rowBox.height - 4),
|
||||
},
|
||||
});
|
||||
|
||||
const holidayTooltip = page
|
||||
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
||||
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
|
||||
.first();
|
||||
|
||||
await expect(holidayTooltip).toBeVisible();
|
||||
await expect(holidayTooltip).toContainText("Karfreitag");
|
||||
await expect(holidayTooltip).toContainText("3 April 2026");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
async function fillDisplayDate(page: Page, label: RegExp, value: string) {
|
||||
const [year, month, day] = value.split("-");
|
||||
await page.getByLabel(label).fill(`${day}/${month}/${year}`);
|
||||
}
|
||||
|
||||
test.describe("Vacations", () => {
|
||||
test.describe("My Vacations (self-service)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/vacations/my");
|
||||
});
|
||||
|
||||
@@ -23,25 +32,19 @@ test.describe("Vacations", () => {
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("request vacation modal opens", async ({ page }) => {
|
||||
test("request vacation is blocked without linked resource", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
||||
await reqBtn.click();
|
||||
// Modal should show vacation form
|
||||
await expect(reqBtn).toBeDisabled();
|
||||
await expect(
|
||||
page.locator("text=Request Vacation").or(page.locator("text=Vacation Type")),
|
||||
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Vacation Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/vacations");
|
||||
});
|
||||
|
||||
@@ -62,12 +65,59 @@ test.describe("Vacations", () => {
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("filter chips are visible on list tab", async ({ page }) => {
|
||||
test("filter controls are visible on list tab", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Status filter options should be visible
|
||||
const filters = page.getByRole("combobox");
|
||||
|
||||
await expect(filters).toHaveCount(3);
|
||||
await expect(filters.nth(0)).toHaveValue("ALL");
|
||||
await expect(filters.nth(1)).toHaveValue("ALL");
|
||||
await expect(filters.nth(2)).toHaveValue("");
|
||||
});
|
||||
|
||||
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByRole("button", { name: /request vacation/i }).click();
|
||||
|
||||
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
|
||||
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
||||
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
||||
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
||||
await fillDisplayDate(page, /end date/i, "2026-01-06");
|
||||
|
||||
await expect(page.getByTestId("vacation-preview-card")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
||||
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
||||
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
||||
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
|
||||
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
||||
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Admin Holiday Calendar", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/admin/vacations");
|
||||
});
|
||||
|
||||
test("seeded holiday calendars can be selected and previewed", async ({ page }) => {
|
||||
await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible({ timeout: 10000 });
|
||||
const germanyCalendarRow = page
|
||||
.getByTestId(/holiday-calendar-row-/)
|
||||
.filter({ hasText: "Referenzfeiertage Deutschland 2026-2027" })
|
||||
.first();
|
||||
|
||||
await expect(germanyCalendarRow).toBeVisible({ timeout: 10000 });
|
||||
await germanyCalendarRow.click();
|
||||
|
||||
await expect(
|
||||
page.locator("button", { hasText: /All|Pending|Approved/i }).first(),
|
||||
page.getByRole("heading", { name: "Referenzfeiertage Deutschland 2026-2027" }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByTestId("holiday-preview-year-input").fill("2026");
|
||||
await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-01-01");
|
||||
await expect(page.getByTestId("holiday-preview-table")).toContainText("Neujahr");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user