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
10 changes: 5 additions & 5 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ services:
DECKEL_DATABASE_URL: "postgres://deckel:deckel@postgres:5432/deckel_test?sslmode=disable"
DECKEL_LISTEN_ADDR: ":8080"
DECKEL_ADMIN_GROUP: "role:admin"
DECKEL_KIOSK_GROUP: "role:kiosk"
DECKEL_STATIC_DIR: "/app/static"
DECKEL_TEMPLATE_DIR: "/app/templates"
DECKEL_ORGANIZATION: "K4-Bar"
Expand All @@ -19,6 +20,8 @@ services:
depends_on:
postgres:
condition: service_healthy
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 10s
Expand Down Expand Up @@ -64,15 +67,14 @@ services:

oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
extra_hosts:
- "localhost:host-gateway"
network_mode: host
command:
- --provider=keycloak-oidc
- --client-id=deckel
- --client-secret=test-client-secret
- --oidc-issuer-url=http://localhost:8181/realms/deckel-test
- --redirect-url=http://localhost:4181/oauth2/callback
- --upstream=http://app:8080
- --upstream=http://localhost:8080
- --http-address=0.0.0.0:4181
- --cookie-secret=super-secret-32-character-string
- --cookie-secure=false
Expand All @@ -82,8 +84,6 @@ services:
- --email-domain=*
- --skip-provider-button=true
- --backend-logout-url=http://localhost:8181/realms/deckel-test/protocol/openid-connect/logout?id_token_hint={id_token}
ports:
- "4181:4181"
depends_on:
keycloak:
condition: service_healthy
Expand Down
10 changes: 5 additions & 5 deletions docs/use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ Hier sind die Anwendungsfälle, die diese Software implementiert, niedergeschrie

- [x] Karte ansehen: Ein Benutzer öffnet die Getränkekarte. Er sieht alle Kategorien mit deren Getränken und den für seinen Status (Barteamer/Helfer) gültigen Preisen.
- [x] Getränk bestellen: Ein Benutzer klickt auf ein Getränk, wählt im Modal die Menge (1 bis Maximalwert aus Einstellungen) und bestätigt die Bestellung. Das Guthaben verringert sich um den Gesamtbetrag und eine Erfolgsmeldung erscheint.
- [ ] Bestellung bei niedrigem Guthaben: Ein Benutzer bestellt ein Getränk und unterschreitet dabei das Warnlimit. Die Bestellung wird durchgeführt, aber es erscheint eine Warnung ("Ausgabelimit erreicht — bitte bald einzahlen!").
- [ ] Bestellung bei Ausgabelimit: Ein Benutzer hat das harte Ausgabelimit erreicht. Die Getränke auf der Karte sind ausgegraut und nicht klickbar. Bestellungen werden serverseitig abgelehnt.
- [ ] Bestellung bei deaktiviertem Limit: Ein Benutzer, dessen Ausgabelimit vom Admin aufgehoben wurde, kann trotz negativem Guthaben weiterhin bestellen.
- [x] Bestellung bei niedrigem Guthaben: Ein Benutzer bestellt ein Getränk und unterschreitet dabei das Warnlimit. Die Bestellung wird durchgeführt, aber es erscheint eine Warnung ("Ausgabelimit erreicht — bitte bald einzahlen!").
- [x] Bestellung bei Ausgabelimit: Ein Benutzer hat das harte Ausgabelimit erreicht. Die Getränke auf der Karte sind ausgegraut und nicht klickbar. Bestellungen werden serverseitig abgelehnt.
- [x] Bestellung bei deaktiviertem Limit: Ein Benutzer, dessen Ausgabelimit vom Admin aufgehoben wurde, kann trotz negativem Guthaben weiterhin bestellen.

## Eigene Buchung

Expand Down Expand Up @@ -92,7 +92,7 @@ Hier sind die Anwendungsfälle, die diese Software implementiert, niedergeschrie

- [ ] Kiosk-Anmeldung: Ein Kiosk-Benutzer meldet sich an. Er wird automatisch zur Kiosk-Ansicht weitergeleitet.
- [ ] Getränk am Kiosk bestellen: Am Kiosk wird ein Getränk ausgewählt, dann ein Benutzer. Eine Bestätigungsseite zeigt Getränk, Benutzer, Preis und Guthaben. Nach Bestätigung wird die Buchung erstellt.
- [ ] Kiosk-Bestellung bei niedrigem Guthaben: Eine Bestellung wird für einen Benutzer mit niedrigem Guthaben am Kiosk durchgeführt. Die Buchung wird erstellt, aber eine Warnung erscheint.
- [ ] Kiosk-Bestellung bei Ausgabelimit: Am Kiosk wird ein Benutzer mit erreichtem Ausgabelimit ausgewählt. Die Bestätigung ist nicht möglich, ein Hinweis erscheint.
- [x] Kiosk-Bestellung bei niedrigem Guthaben: Eine Bestellung wird für einen Benutzer mit niedrigem Guthaben am Kiosk durchgeführt. Die Buchung wird erstellt, aber eine Warnung erscheint.
- [x] Kiosk-Bestellung bei Ausgabelimit: Am Kiosk wird ein Benutzer mit erreichtem Ausgabelimit ausgewählt. Die Bestätigung ist nicht möglich, ein Hinweis erscheint.
- [ ] Kiosk-Stornierung: Am Kiosk wird eine selbst erstellte Buchung innerhalb des Zeitfensters storniert. Die Transaktion wird rückgängig gemacht.
- [ ] Kiosk-Stornierung fremder Buchungen: Am Kiosk kann nur eine vom Kiosk erstellte Buchung storniert werden, nicht die Buchungen der Benutzer selbst.
31 changes: 31 additions & 0 deletions e2e/admin-kiosk.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';

test.describe('Admin Kiosk User Management', () => {

test('Kiosk-Benutzer in Liste — shows Kiosk badge and dashes for balance/actions', async ({ page }) => {
await page.goto('/admin/users');

const kioskRow = page.locator('tr', { hasText: 'testkiosk' });
await expect(kioskRow).toBeVisible();

// Should show "Kiosk" badge
await expect(kioskRow.locator('.badge', { hasText: 'Kiosk' })).toBeVisible();

// Balance column should show "—" (em dash), not a monetary value
const balanceCell = kioskRow.locator('td').nth(2);
await expect(balanceCell).toContainText('—');

// Barteamer column should show "—"
const barteamerCell = kioskRow.locator('td').nth(3);
await expect(barteamerCell).toContainText('—');

// Limit-Override column should show "—"
const limitCell = kioskRow.locator('td').nth(5);
await expect(limitCell).toContainText('—');

// Actions column should show "—" (no deposit button)
const actionsCell = kioskRow.locator('td').nth(6);
await expect(actionsCell).toContainText('—');
});

});
10 changes: 10 additions & 0 deletions e2e/auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';

const userFile = path.join(__dirname, '.auth/user.json');
const adminFile = path.join(__dirname, '.auth/admin.json');
const kioskFile = path.join(__dirname, '.auth/kiosk.json');

setup('authenticate as regular user', async ({ page }) => {
await page.goto('/');
Expand All @@ -21,3 +22,12 @@ setup('authenticate as admin', async ({ page }) => {
await page.waitForURL('**/');
await page.context().storageState({ path: adminFile });
});

setup('authenticate as kiosk', async ({ page }) => {
await page.goto('/');
await page.locator('#username').fill('testkiosk');
await page.locator('#password').fill('testpass');
await page.locator('#kc-login').click();
await page.waitForURL('**/kiosk');
await page.context().storageState({ path: kioskFile });
});
41 changes: 41 additions & 0 deletions e2e/kiosk-exclusion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';

test.describe('Kiosk User Exclusion', () => {

test('Kiosk-Anmeldung — kiosk user is redirected to /kiosk', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/kiosk/);
});

test('Kiosk-Layout — no header stats (balance/rank) shown', async ({ page }) => {
await page.goto('/kiosk');

// Kiosk layout should NOT contain header-stats
await expect(page.locator('#header-stats')).not.toBeVisible();
await expect(page.locator('text=Guthaben:')).not.toBeVisible();
await expect(page.locator('text=Platz')).not.toBeVisible();
});

test('Kiosk-Menü — sees menu items for ordering', async ({ page }) => {
await page.goto('/kiosk');

// Should see item categories and items
await expect(page.getByText('Getränke')).toBeVisible();
await expect(page.getByText('Bier')).toBeVisible();
});

test('Kiosk-Benutzerauswahl — kiosk user not in target list', async ({ page }) => {
await page.goto('/kiosk');

// Click on an item to get to user selection
const bierLink = page.locator('a', { hasText: 'Bier' });
await bierLink.click();

// Should see user selection page with regular users
await expect(page.getByText('Test User', { exact: false })).toBeVisible({ timeout: 5000 });

// Kiosk user itself should NOT appear in the list
await expect(page.getByText('Test Kiosk', { exact: false })).not.toBeVisible();
});

});
122 changes: 122 additions & 0 deletions e2e/kiosk-spending-limits.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { test, expect, BrowserContext } from '@playwright/test';
import path from 'path';

// Helper: create an admin browser context for setup operations.
async function adminContext(browser: any): Promise<BrowserContext> {
return browser.newContext({
storageState: path.join(__dirname, '.auth/admin.json'),
});
}

// Helper: update spending limit settings via admin page.
async function setSpendingLimit(ctx: BrowserContext, baseURL: string, limitEur: string, enabled: boolean) {
const page = await ctx.newPage();
await page.goto(`${baseURL}/admin/settings`);
await page.locator('input[name="hard_spending_limit"]').fill(limitEur);
const checkbox = page.locator('input[name="hard_limit_enabled"]');
if (enabled) {
await checkbox.check();
} else {
await checkbox.uncheck();
}
await page.locator('button[type="submit"]').click();
await expect(page.locator('.alert-success')).toBeVisible();
await page.close();
}

// Helper: update warning limit via admin page.
async function setWarningLimit(ctx: BrowserContext, baseURL: string, limitEur: string) {
const page = await ctx.newPage();
await page.goto(`${baseURL}/admin/settings`);
await page.locator('input[name="warning_limit"]').fill(limitEur);
await page.locator('button[type="submit"]').click();
await expect(page.locator('.alert-success')).toBeVisible();
await page.close();
}

// Helper: place a kiosk order for a user. Returns after the success overlay is shown.
async function kioskOrder(page: any, itemName: string, userName: string, quantity: number) {
await page.goto('/kiosk');
await page.locator('a.card', { hasText: itemName }).click();
await page.locator('.user-card', { hasText: userName }).click();

// Adjust quantity if needed
if (quantity > 1) {
for (let i = 1; i < quantity; i++) {
await page.locator('#qty-plus').click();
}
}

await page.locator('#confirm-btn').click();
// Wait for success overlay
await expect(page.getByText('Bestellung gebucht!')).toBeVisible();
// Wait for redirect back to kiosk menu
await page.waitForURL('**/kiosk');
}

test.describe('Kiosk Spending Limits', () => {

// Note: These tests run after user-tests and admin-tests.
// The admin-users test deposits 10 EUR for testuser, so the balance
// is positive at this point. We drain it via kiosk orders first.

test('Kiosk-Bestellung bei Ausgabelimit — blocked user cannot order via kiosk', async ({ page, browser }) => {
const baseURL = 'http://localhost:4181';
const admin = await adminContext(browser);

// Ensure hard limit is disabled first so we can place orders freely
await setSpendingLimit(admin, baseURL, '20', false);

// Place kiosk orders to drain Test User's balance into negative territory.
// Order 5x Bier (5 * 2 EUR = 10 EUR) to ensure balance goes well below zero.
await kioskOrder(page, 'Bier', 'Test User', 5);

// Now enable a low hard limit (1 EUR). User should be at/below -1 EUR.
await setSpendingLimit(admin, baseURL, '1', true);

// Navigate to kiosk, select an item
await page.goto('/kiosk');
await page.locator('a.card', { hasText: 'Bier' }).click();

// On user selection page, Test User should show "Limit erreicht" badge
const userCard = page.locator('.user-card', { hasText: 'Test User' });
await expect(userCard.locator('.badge')).toContainText('Limit erreicht');

// Click on blocked user
await userCard.click();

// Confirm page should show error alert and disabled button
await expect(page.locator('.alert-error')).toBeVisible();
await expect(page.locator('.alert-error')).toContainText('Ausgabelimit');
await expect(page.locator('#confirm-btn')).toBeDisabled();

// Restore settings
await setSpendingLimit(admin, baseURL, '20', true);
await admin.close();
});

test('Kiosk-Bestellung bei niedrigem Guthaben — warning shown on confirm', async ({ page, browser }) => {
const baseURL = 'http://localhost:4181';
const admin = await adminContext(browser);

// Set warning limit to 0 so any negative balance triggers warning.
// Set hard limit high enough to not block.
await setWarningLimit(admin, baseURL, '0');
await setSpendingLimit(admin, baseURL, '100', true);

// Navigate kiosk flow: select item, then user
await page.goto('/kiosk');
await page.locator('a.card', { hasText: 'Bier' }).click();
await page.locator('.user-card', { hasText: 'Test User' }).click();

// Should see low balance warning on confirm page (user has negative balance)
await expect(page.locator('.alert-warning')).toBeVisible();
await expect(page.locator('.alert-warning')).toContainText('Niedriges Guthaben');

// Restore settings
await setWarningLimit(admin, baseURL, '-10');
await setSpendingLimit(admin, baseURL, '20', true);
await admin.close();
});

});
11 changes: 10 additions & 1 deletion e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default defineConfig({
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'user-tests',
testIgnore: /admin-/,
testIgnore: /admin-|kiosk-/,
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
Expand All @@ -30,5 +30,14 @@ export default defineConfig({
},
dependencies: ['setup'],
},
{
name: 'kiosk-tests',
testMatch: /kiosk-/,
use: {
...devices['Desktop Chrome'],
storageState: '.auth/kiosk.json',
},
dependencies: ['setup'],
},
],
});
27 changes: 27 additions & 0 deletions e2e/rank-exclusion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test';

// This test runs as a regular user (user-tests project) to verify
// that kiosk users are not counted in the leaderboard rank total.

test.describe('Kiosk Rank Exclusion', () => {

test('Rangliste — kiosk user not counted in rank total', async ({ page }) => {
await page.goto('/');

// Wait for header stats to load (loaded lazily via hx-get)
const headerStats = page.locator('#header-stats');
await expect(headerStats).toContainText('Platz', { timeout: 10000 });

// Extract the rank text "Platz X von Y"
const rankText = await headerStats.textContent();
const match = rankText?.match(/Platz\s+(\d+)\s+von\s+(\d+)/);
expect(match).toBeTruthy();

const total = parseInt(match![2]);

// With testuser and testadmin as regular users, and testkiosk excluded,
// the total should be 2 (not 3)
expect(total).toBe(2);
});

});
Loading
Loading