feat(sprint-6): Phase 6 — Notifications (WebSocket) + PWA
- 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:
@@ -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) {
|
||||
|
||||
@@ -59,9 +59,9 @@ test.describe("Group 1: Login Form Interactions", () => {
|
||||
expect(page.url()).toContain("/login")
|
||||
|
||||
// The email input should fail native HTML5 validation
|
||||
const isInvalid = await page.locator('input[id="email"]').evaluate(
|
||||
(el: HTMLInputElement) => !el.checkValidity()
|
||||
)
|
||||
const isInvalid = await page
|
||||
.locator('input[id="email"]')
|
||||
.evaluate((el: HTMLInputElement) => !el.checkValidity())
|
||||
expect(isInvalid).toBe(true)
|
||||
})
|
||||
|
||||
@@ -192,7 +192,9 @@ test.describe("Group 3: Navigation & Layout", () => {
|
||||
const url = page.url()
|
||||
// If redirected to login — that's also acceptable behavior for protected routes
|
||||
const is404OrLogin = url.includes("/login") || url.includes("not-found")
|
||||
expect(is404OrLogin || (await page.locator("body").textContent()) !== "").toBe(true)
|
||||
expect(
|
||||
is404OrLogin || (await page.locator("body").textContent()) !== ""
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("3.3 - Responsive: mobile viewport (375px)", async ({ page }) => {
|
||||
@@ -611,9 +613,7 @@ test.describe("Group 11: Accessibility Basics", () => {
|
||||
await expect(emailInput).toHaveAttribute("autoComplete", "email")
|
||||
})
|
||||
|
||||
test("11.5 - Form submit button is keyboard-accessible", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("11.5 - Form submit button is keyboard-accessible", async ({ page }) => {
|
||||
await page.goto("/login", { waitUntil: "domcontentloaded" })
|
||||
await page.waitForSelector('input[id="email"]', { timeout: 15000 })
|
||||
|
||||
@@ -683,7 +683,8 @@ test.describe("Group 12: Error States & Edge Cases", () => {
|
||||
|
||||
// Page should not crash — wait and verify it's still functional
|
||||
await page.waitForTimeout(3000)
|
||||
const isStillOnLogin = page.url().includes("/login") || page.url().includes("/dashboard")
|
||||
const isStillOnLogin =
|
||||
page.url().includes("/login") || page.url().includes("/dashboard")
|
||||
expect(isStillOnLogin).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ const MOCK_USER = {
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS"
|
||||
)
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
|
||||
@@ -20,15 +20,27 @@ const SCREENSHOT_DIR = path.join(__dirname, "..", "docs", "screenshots")
|
||||
// Pages accessible without auth
|
||||
const PUBLIC_PAGES = [
|
||||
{ route: "/login", name: "01-login", title: "Admin Login" },
|
||||
{ route: "/portal-login", name: "02-portal-login", title: "Member Portal Login" },
|
||||
{
|
||||
route: "/portal-login",
|
||||
name: "02-portal-login",
|
||||
title: "Member Portal Login",
|
||||
},
|
||||
]
|
||||
|
||||
// Admin pages (require auth session)
|
||||
const ADMIN_PAGES = [
|
||||
{ route: "/dashboard", name: "03-dashboard", title: "Club Dashboard" },
|
||||
{ route: "/members", name: "04-members", title: "Member Management" },
|
||||
{ route: "/distributions", name: "05-distributions", title: "Distribution History" },
|
||||
{ route: "/distributions/new", name: "06-distribution-new", title: "New Distribution (Multi-Step)" },
|
||||
{
|
||||
route: "/distributions",
|
||||
name: "05-distributions",
|
||||
title: "Distribution History",
|
||||
},
|
||||
{
|
||||
route: "/distributions/new",
|
||||
name: "06-distribution-new",
|
||||
title: "New Distribution (Multi-Step)",
|
||||
},
|
||||
{ route: "/stock", name: "07-stock", title: "Stock & Batch Management" },
|
||||
{ route: "/stock/new", name: "08-stock-new", title: "Add New Batch" },
|
||||
{ route: "/reports", name: "09-reports", title: "Compliance Reports" },
|
||||
@@ -36,9 +48,21 @@ const ADMIN_PAGES = [
|
||||
|
||||
// Portal pages (no admin auth needed per middleware)
|
||||
const PORTAL_PAGES = [
|
||||
{ route: "/portal/dashboard", name: "10-portal-dashboard", title: "Member Quota Overview" },
|
||||
{ route: "/portal/history", name: "11-portal-history", title: "My Distribution History" },
|
||||
{ route: "/portal/profile", name: "12-portal-profile", title: "Profile & Settings" },
|
||||
{
|
||||
route: "/portal/dashboard",
|
||||
name: "10-portal-dashboard",
|
||||
title: "Member Quota Overview",
|
||||
},
|
||||
{
|
||||
route: "/portal/history",
|
||||
name: "11-portal-history",
|
||||
title: "My Distribution History",
|
||||
},
|
||||
{
|
||||
route: "/portal/profile",
|
||||
name: "12-portal-profile",
|
||||
title: "Profile & Settings",
|
||||
},
|
||||
]
|
||||
|
||||
async function setTheme(page: Page, theme: "dark" | "light") {
|
||||
@@ -76,7 +100,12 @@ test.describe("CannaManage Screenshot Tour", () => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
test("capture all pages in dark and light mode", async ({ page }) => {
|
||||
const results: { name: string; title: string; dark: string; light: string }[] = []
|
||||
const results: {
|
||||
name: string
|
||||
title: string
|
||||
dark: string
|
||||
light: string
|
||||
}[] = []
|
||||
|
||||
// --- PUBLIC PAGES ---
|
||||
for (const p of PUBLIC_PAGES) {
|
||||
@@ -102,13 +131,19 @@ test.describe("CannaManage Screenshot Tour", () => {
|
||||
}
|
||||
|
||||
// Check if we got authenticated (redirected to dashboard)
|
||||
const isAuthenticated = page.url().includes("/dashboard") || page.url().includes("/login")
|
||||
const isAuthenticated =
|
||||
page.url().includes("/dashboard") || page.url().includes("/login")
|
||||
|
||||
if (page.url().includes("/dashboard")) {
|
||||
// --- ADMIN PAGES (authenticated) ---
|
||||
for (const p of ADMIN_PAGES) {
|
||||
const dark = await capturePageScreenshot(page, p.route, p.name, "dark")
|
||||
const light = await capturePageScreenshot(page, p.route, p.name, "light")
|
||||
const light = await capturePageScreenshot(
|
||||
page,
|
||||
p.route,
|
||||
p.name,
|
||||
"light"
|
||||
)
|
||||
results.push({ name: p.name, title: p.title, dark, light })
|
||||
}
|
||||
} else {
|
||||
@@ -116,7 +151,12 @@ test.describe("CannaManage Screenshot Tour", () => {
|
||||
console.log("⚠️ Auth failed — admin pages will show login redirect")
|
||||
for (const p of ADMIN_PAGES) {
|
||||
const dark = await capturePageScreenshot(page, p.route, p.name, "dark")
|
||||
results.push({ name: p.name, title: `${p.title} (auth required)`, dark, light: dark })
|
||||
results.push({
|
||||
name: p.name,
|
||||
title: `${p.title} (auth required)`,
|
||||
dark,
|
||||
light: dark,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +187,9 @@ test.describe("CannaManage Screenshot Tour", () => {
|
||||
}
|
||||
fs.writeFileSync(path.join(docsDir, "visual-tour.md"), md)
|
||||
|
||||
console.log(`\n✅ Screenshot tour complete! ${results.length} pages captured.`)
|
||||
console.log(
|
||||
`\n✅ Screenshot tour complete! ${results.length} pages captured.`
|
||||
)
|
||||
console.log(`📄 Markdown: docs/visual-tour.md`)
|
||||
console.log(`📸 Screenshots: docs/screenshots/`)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,9 @@ test.describe("Staff Management", () => {
|
||||
|
||||
// If redirected to login, that's expected without a running backend
|
||||
if (page.url().includes("/login")) {
|
||||
console.log(" ℹ️ Redirected to login (no session) — expected without backend")
|
||||
console.log(
|
||||
" ℹ️ Redirected to login (no session) — expected without backend"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,7 +55,9 @@ test.describe("Staff Management", () => {
|
||||
|
||||
// Sheet/dialog should open with form fields
|
||||
const hasEmailField =
|
||||
(await page.locator('input[type="email"], input[name="email"]').count()) > 0
|
||||
(await page
|
||||
.locator('input[type="email"], input[name="email"]')
|
||||
.count()) > 0
|
||||
expect(hasEmailField).toBe(true)
|
||||
}
|
||||
})
|
||||
@@ -69,7 +73,8 @@ test.describe("Staff Management", () => {
|
||||
|
||||
// Check for table or list structure
|
||||
const hasTable = (await page.locator("table").count()) > 0
|
||||
const hasList = (await page.locator('[role="list"], [data-testid*="staff"]').count()) > 0
|
||||
const hasList =
|
||||
(await page.locator('[role="list"], [data-testid*="staff"]').count()) > 0
|
||||
|
||||
expect(hasTable || hasList).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user