feat(sprint-6): Phase 6 — Notifications (WebSocket) + PWA
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- WebSocket: Spring STOMP + SockJS, NotificationService, persistent notifications table
- NotificationController: GET/PUT endpoints for notification management
- Frontend: notification bell with unread badge, dropdown panel, real-time via STOMP
- PWA: manifest.json, service worker (manual sw.js), offline page, install prompt
- PWA icons (192+512), dark theme colors, standalone display
- Full i18n (de/en) for notifications and PWA
- Flyway V10 migration for notifications table
- spring-boot-starter-websocket dependency added
This commit is contained in:
Patrick Plate
2026-06-12 23:02:44 +02:00
parent 076fd6f9b3
commit 599514c0db
39 changed files with 6684 additions and 3217 deletions
@@ -23,13 +23,41 @@ const SCREENSHOT_DIR = path.join(__dirname, "..", "docs", "screenshots")
// Admin pages to visit after login
const ADMIN_PAGES = [
{ route: "/dashboard", name: "03-dashboard-dark", waitFor: "h1, h2, [data-testid]" },
{ route: "/members", name: "04-members-dark", waitFor: "h1, h2, table, [data-testid]" },
{ route: "/distributions", name: "05-distributions-dark", waitFor: "h1, h2, table, [data-testid]" },
{ route: "/distributions/new", name: "06-distribution-new-dark", waitFor: "h1, h2, form, [data-testid]" },
{ route: "/stock", name: "07-stock-dark", waitFor: "h1, h2, table, [data-testid]" },
{ route: "/stock/new", name: "08-stock-new-dark", waitFor: "h1, h2, form, [data-testid]" },
{ route: "/reports", name: "09-reports-dark", waitFor: "h1, h2, [data-testid]" },
{
route: "/dashboard",
name: "03-dashboard-dark",
waitFor: "h1, h2, [data-testid]",
},
{
route: "/members",
name: "04-members-dark",
waitFor: "h1, h2, table, [data-testid]",
},
{
route: "/distributions",
name: "05-distributions-dark",
waitFor: "h1, h2, table, [data-testid]",
},
{
route: "/distributions/new",
name: "06-distribution-new-dark",
waitFor: "h1, h2, form, [data-testid]",
},
{
route: "/stock",
name: "07-stock-dark",
waitFor: "h1, h2, table, [data-testid]",
},
{
route: "/stock/new",
name: "08-stock-new-dark",
waitFor: "h1, h2, form, [data-testid]",
},
{
route: "/reports",
name: "09-reports-dark",
waitFor: "h1, h2, [data-testid]",
},
]
// Error patterns to check for on each page
@@ -129,8 +157,14 @@ test.describe("Authenticated Admin Tour", () => {
console.log(" ⚠️ Form login didn't redirect. Checking for errors...")
const pageText = await page.locator("body").innerText()
if (pageText.includes("Ungültige") || pageText.includes("invalid") || pageText.includes("Fehler")) {
console.log(" ❌ Auth error on page. Trying cookie-based session bypass...")
if (
pageText.includes("Ungültige") ||
pageText.includes("invalid") ||
pageText.includes("Fehler")
) {
console.log(
" ❌ Auth error on page. Trying cookie-based session bypass..."
)
}
// Alternative: directly inject a NextAuth session token cookie
@@ -146,15 +180,18 @@ test.describe("Authenticated Admin Tour", () => {
console.log(` Using CSRF token: ${csrfToken?.substring(0, 20)}...`)
const signInResponse = await page.request.post("/api/auth/callback/credentials", {
form: {
email: "admin@gruener-daumen.de",
password: "test123",
csrfToken: csrfToken,
callbackUrl: "/dashboard",
json: "true",
},
})
const signInResponse = await page.request.post(
"/api/auth/callback/credentials",
{
form: {
email: "admin@gruener-daumen.de",
password: "test123",
csrfToken: csrfToken,
callbackUrl: "/dashboard",
json: "true",
},
}
)
// The response sets cookies — apply them
const cookies = signInResponse.headers()["set-cookie"]
@@ -171,7 +208,9 @@ test.describe("Authenticated Admin Tour", () => {
if (afterDirectUrl.includes("/login")) {
// Still redirected to login — one more approach: use page.request to get the session
console.log(" ⚠️ Still on login. Trying page.context().storageState approach...")
console.log(
" ⚠️ Still on login. Trying page.context().storageState approach..."
)
// Last resort: go through the full browser-based flow with longer waits
await page.goto("/login", { waitUntil: "domcontentloaded" })
@@ -181,7 +220,9 @@ test.describe("Authenticated Admin Tour", () => {
// Click and immediately wait for URL change
await Promise.all([
page.waitForURL("**/dashboard**", { timeout: 15_000 }).catch(() => null),
page
.waitForURL("**/dashboard**", { timeout: 15_000 })
.catch(() => null),
submitButton.click(),
])
@@ -190,7 +231,9 @@ test.describe("Authenticated Admin Tour", () => {
console.log(` Final URL: ${finalUrl}`)
if (finalUrl.includes("/login")) {
console.log(" ❌ All login attempts failed. Test will capture login-state screenshots.")
console.log(
" ❌ All login attempts failed. Test will capture login-state screenshots."
)
// We'll still screenshot whatever renders
}
}
@@ -215,14 +258,23 @@ test.describe("Authenticated Admin Tour", () => {
// Check if we were redirected to login (auth failed)
if (page.url().includes("/login")) {
console.log(` ⚠️ Redirected to /login — no session for ${adminPage.route}`)
allErrors.push(`${adminPage.name}: Redirected to login (no valid session)`)
console.log(
` ⚠️ Redirected to /login no session for ${adminPage.route}`
)
allErrors.push(
`${adminPage.name}: Redirected to login (no valid session)`
)
} else {
// Wait for content to appear
try {
await page.locator(adminPage.waitFor).first().waitFor({ timeout: 8000 })
await page
.locator(adminPage.waitFor)
.first()
.waitFor({ timeout: 8000 })
} catch {
console.log(` ⚠️ Content selector "${adminPage.waitFor}" not found within timeout`)
console.log(
` ⚠️ Content selector "${adminPage.waitFor}" not found within timeout`
)
}
// Check for errors
@@ -241,7 +293,9 @@ test.describe("Authenticated Admin Tour", () => {
// STEP 3: Summary
// ============================================
console.log("\n📊 Results:")
console.log(` Screenshots captured: ${captured.length}/${ADMIN_PAGES.length}`)
console.log(
` Screenshots captured: ${captured.length}/${ADMIN_PAGES.length}`
)
console.log(` Errors found: ${allErrors.length}`)
if (allErrors.length > 0) {
@@ -254,7 +308,9 @@ test.describe("Authenticated Admin Tour", () => {
// The test passes as long as screenshots were taken (even with login issues)
// but we assert no ERROR patterns on the actual rendered pages
const criticalErrors = allErrors.filter(
(e) => !e.includes("Redirected to login") && !e.includes("not found within timeout")
(e) =>
!e.includes("Redirected to login") &&
!e.includes("not found within timeout")
)
if (criticalErrors.length > 0) {