feat(hilfe): Eigene Tickets — Fehlerberichte ansehen, bearbeiten, löschen, lösen
Unter Hilfe (/hilfe/tickets) eine Verwaltung der eingereichten „Fehler melden"-
Berichte: Liste (neueste zuerst) mit Status-Badge (Offen / ✓ Gelöst), Kontext-
Label, betroffenem Objekt, Nachricht und Datum. Pro Ticket: als gelöst markieren/
wieder öffnen, Nachricht inline bearbeiten, löschen (mit Bestätigung).
Backend: Feedback um Status ("Open"/"Resolved", Default Open) + nullable ResolvedAt
erweitert (weiterhin FK-frei → übersteht Ingest-Wipe); Migration AddFeedbackStatus.
Endpoints: GET /feedback (neueste zuerst, inkl. Status), PUT /feedback/{id}
(Nachricht/Status; leer→400, fehlt→404; lösen setzt/öffnen löscht ResolvedAt),
DELETE /feedback/{id} (fehlt→404).
Tests: FeedbackEndpointTests (CRUD + Status-Lebenszyklus + übersteht Ingest),
e2e tickets.spec.ts; dotnet 215, vitest 129, playwright grün.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,17 +25,17 @@ test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg',
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
// Der Bericht wurde mit Debug-Kontext gespeichert.
|
||||
// Der Bericht wurde mit Debug-Kontext gespeichert (zuletzt eingereichter Eintrag;
|
||||
// die Mock-DB ist mit Beispiel-Tickets vorbefüllt).
|
||||
expect(mockDb).not.toBeNull()
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Der Farbschlag stimmt nicht.')
|
||||
expect(report).toMatchObject({
|
||||
message: 'Der Farbschlag stimmt nicht.',
|
||||
context: 'gerbil-detail',
|
||||
gerbilId: 'kruemel',
|
||||
})
|
||||
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
|
||||
expect(typeof reports[0].clientTimestamp).toBe('string')
|
||||
expect(report!.url).toContain('/rennmaeuse/kruemel')
|
||||
expect(typeof report!.clientTimestamp).toBe('string')
|
||||
})
|
||||
|
||||
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
|
||||
@@ -51,9 +51,8 @@ test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wur
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Die Wurfstärke ist falsch.')
|
||||
expect(report).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
|
||||
})
|
||||
|
||||
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
|
||||
@@ -69,9 +68,8 @@ test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Die Adresse stimmt nicht.')
|
||||
expect(report).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
|
||||
})
|
||||
|
||||
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
|
||||
@@ -97,9 +95,8 @@ test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Stammbaum-Bug.')
|
||||
expect(report).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
|
||||
})
|
||||
|
||||
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
|
||||
|
||||
@@ -407,6 +407,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
...body,
|
||||
userAgent: request.headers()['user-agent'] ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'Open',
|
||||
resolvedAt: null,
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
@@ -416,6 +418,32 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
// FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE.
|
||||
const fm = path.match(/^\/feedback\/([^/]+)$/)
|
||||
if (fm) {
|
||||
const fid = decodeURIComponent(fm[1])
|
||||
const idx = db.feedback.findIndex((f) => f.id === fid)
|
||||
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'PUT') {
|
||||
const body = request.postDataJSON() as { message?: string; status?: string }
|
||||
const row = db.feedback[idx]
|
||||
if (typeof body.message === 'string') {
|
||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||
row.message = body.message.trim()
|
||||
}
|
||||
if (typeof body.status === 'string') {
|
||||
const resolved = body.status.toLowerCase() === 'resolved'
|
||||
row.status = resolved ? 'Resolved' : 'Open'
|
||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
||||
}
|
||||
return json(route, 200, row)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
db.feedback.splice(idx, 1)
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
|
||||
@@ -391,6 +391,39 @@ export function seedDb(): MockDb {
|
||||
],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
feedback: [],
|
||||
// FEEDBACK-TICKETS: ein offenes + ein gelöstes Ticket (GET liefert neueste zuerst →
|
||||
// db.feedback wird im Mock umgekehrt; das offene steht hier zuletzt = erscheint oben).
|
||||
feedback: [
|
||||
{
|
||||
id: 'feedback-resolved',
|
||||
message: 'Der Stammbaum zeigt den falschen Vater bei Krümel.',
|
||||
context: 'stammbaum',
|
||||
gerbilId: 'kruemel',
|
||||
litterId: null,
|
||||
contactId: null,
|
||||
entityName: 'Krümel',
|
||||
url: 'http://localhost:5173/rennmaeuse/kruemel/stammbaum',
|
||||
clientTimestamp: '2026-06-10T09:00:00Z',
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-10T09:00:00Z',
|
||||
status: 'Resolved',
|
||||
resolvedAt: '2026-06-12T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'feedback-open',
|
||||
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',
|
||||
context: 'gerbil-detail',
|
||||
gerbilId: 'fridolin',
|
||||
litterId: null,
|
||||
contactId: null,
|
||||
entityName: 'Fridolin',
|
||||
url: 'http://localhost:5173/rennmaeuse/fridolin',
|
||||
clientTimestamp: '2026-06-15T11:30:00Z',
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-15T11:30:00Z',
|
||||
status: 'Open',
|
||||
resolvedAt: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
75
gerbil-manager-web/e2e/tickets.spec.ts
Normal file
75
gerbil-manager-web/e2e/tickets.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte.
|
||||
* Erreichbar über Hilfe → „Meine Tickets". Liste mit Status-Badges (Offen/Gelöst),
|
||||
* gelöst markieren / wieder öffnen, Nachricht bearbeiten, löschen.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
|
||||
|
||||
const tt = de.feedback.tickets
|
||||
|
||||
test.describe('Meine Tickets', () => {
|
||||
test.beforeEach(() => skipUnlessMock())
|
||||
|
||||
test('über Hilfe erreichbar, zeigt offenes und gelöstes Ticket mit Badge', async ({ page }) => {
|
||||
await gotoSection(page, de.nav.help)
|
||||
await page.getByRole('link', { name: tt.link }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: tt.title })).toBeVisible()
|
||||
|
||||
// Offenes Ticket (Badge „Offen") + dessen Nachricht.
|
||||
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
|
||||
await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible()
|
||||
|
||||
// Gelöstes Ticket (Badge mit Häkchen + „Gelöst").
|
||||
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible()
|
||||
await expect(page.getByText(tt.statusResolved, { exact: false }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket als gelöst markieren', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
const openCard = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
|
||||
await expect(openCard.getByText(tt.statusOpen, { exact: true })).toBeVisible()
|
||||
|
||||
await openCard.getByRole('button', { name: tt.markResolved }).click()
|
||||
|
||||
// Badge wechselt auf „Gelöst", Aktion bietet jetzt „Wieder öffnen".
|
||||
// Auf das Badge (.ticket-badge) zielen — „Gelöst" steht sonst auch im Meta-Label „Gelöst am".
|
||||
await expect(openCard.locator('.ticket-badge--resolved')).toBeVisible()
|
||||
await expect(openCard.getByRole('button', { name: tt.reopen })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Nachricht eines Tickets bearbeiten', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
// Karte über den Datensatz-Namen (bleibt auch im Bearbeiten-Modus sichtbar)
|
||||
// adressieren — die Nachricht selbst wird beim Bearbeiten durch das Textfeld ersetzt.
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(card.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
|
||||
await card.getByRole('button', { name: tt.edit }).click()
|
||||
|
||||
const textarea = card.locator('textarea')
|
||||
await textarea.fill('Korrigierte Beschreibung des Fehlers.')
|
||||
await card.getByRole('button', { name: tt.save }).click()
|
||||
|
||||
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket löschen', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
acceptNextDialog(page)
|
||||
await card.getByRole('button', { name: tt.delete }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user