Files
GerbilManager/gerbil-manager-web/e2e/tickets.spec.ts
Gulum 750619d3d7 feat(tickets): Foto-/Datei-Anhänge an Tickets
- Anhänge im Melde-Fenster (beim Erstellen) und direkt an bestehenden Tickets:
  Vorschaubilder, Öffnen im neuen Tab, Entfernen.
- Bytes liegen in eigener Tabelle (FeedbackAttachment, lose FeedbackId ohne FK →
  übersteht den Ingest-Wipe); GET /feedback liefert nur Metadaten (id/Name/Typ/Größe),
  die Bytes über /feedback/attachments/{id}. Größenlimit 10 MB.
- Endpoints: POST /feedback/{id}/attachments (base64), GET /feedback/attachments/{id}
  (Bytes), DELETE /feedback/attachments/{id}.

Migration FeedbackAttachments. Tests: 262 Backend grün (+Upload/Serve/Delete +Validierung),
e2e Tickets Desktop+Phone grün (+Foto-Upload), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:24:02 +02:00

336 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte.
* Erreichbar über Hilfe → „Meine Tickets". Drei gefilterte Verlinkungen (Filter-Chips):
* „Rückfragen" (im Dialog), „Offen", „Geschlossen" (zeigt den Changelog/fixNote).
* Pro Ticket: gelöst markieren / wieder öffnen, Nachricht bearbeiten, antworten, löschen,
* Frage/Antwort-Verlauf (thread). Das interne `agentContext` wird NIE gerendert.
*/
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
const tt = de.feedback.tickets
/** In die gewünschte gefilterte Ansicht wechseln (Filter-Chip klicken). */
async function selectView(page: import('@playwright/test').Page, label: string) {
await page.locator('.tickets-filter').filter({ hasText: label }).click()
}
test.describe('Meine Tickets', () => {
test.beforeEach(() => skipUnlessMock())
test('über Hilfe erreichbar, Default-Ansicht „Offen" zeigt offenes 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()
// Default = „Offen": offenes Ticket + dessen Nachricht sichtbar.
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible()
})
test('drei gefilterte Verlinkungen filtern korrekt, Anzahl-Badges stimmen', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Filter-Chips vorhanden, jeweils mit Anzahl-Badge (1 pro Gruppe in den Seed-Daten).
const dialog = page.locator('.tickets-filter').filter({ hasText: tt.filters.dialog })
const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
await expect(dialog.locator('.tickets-filter__count')).toHaveText('1')
// „Offen" = Open + Answered → das offene + das beantwortete Ticket = 2.
await expect(open.locator('.tickets-filter__count')).toHaveText('2')
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
// „Offen": nur das offene Ticket.
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toHaveCount(0)
// „Rückfragen": nur das Dialog-Ticket (NeedsInfo).
await selectView(page, tt.filters.dialog)
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toBeVisible()
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toHaveCount(0)
// „Geschlossen": nur das gelöste Ticket.
await selectView(page, tt.filters.closed)
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible()
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
})
test('geschlossenes Ticket zeigt den Changelog (fixNote) + Datum', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.getByText(tt.changelogLabel)).toBeVisible()
await expect(
card.getByText('Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.'),
).toBeVisible()
// Datum „Gelöst am" in der Meta-Leiste.
await expect(card.getByText(tt.resolvedLabel)).toBeVisible()
})
test('agentContext wird NIE in der Züchterin-UI gerendert (in allen Ansichten)', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
// In jeder Ansicht prüfen: der interne Kontext-Text taucht nirgends auf.
for (const view of [tt.filters.open, tt.filters.dialog, tt.filters.closed]) {
await selectView(page, view)
await expect(page.getByText('GEHEIM-AGENT-CONTEXT', { exact: false })).toHaveCount(0)
}
})
test('Verlauf (thread) früherer Runden wird angezeigt', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card.getByText(tt.threadLabel)).toBeVisible()
// Frühere Runde (Frage + Antwort) sichtbar.
await expect(card.getByText('Wurde der Wurf im Mai oder Juni geboren?')).toBeVisible()
await expect(card.getByText('Im Juni.')).toBeVisible()
// Aktuelle offene Rückfrage ebenfalls sichtbar.
await expect(card.getByText('Welches Geburtsdatum ist korrekt')).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()
// Das Ticket verlässt die „Offen"-Ansicht — die Ansicht weicht automatisch auf
// eine nicht-leere Gruppe aus. Das gelöste Ticket findet sich unter „Geschlossen".
await selectView(page, tt.filters.closed)
const resolvedCard = page
.locator('.ticket-card')
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
await expect(resolvedCard.locator('.ticket-badge--resolved')).toBeVisible()
await expect(resolvedCard.getByRole('button', { name: tt.reopen })).toBeVisible()
})
test('Nachricht eines Tickets bearbeiten', async ({ page }) => {
await page.goto('/hilfe/tickets')
// „Offen"-Ansicht (Default): das offene Ticket über den Datensatz-Namen adressieren.
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('beantwortetes Ticket: Erst-Text gesperrt, nur die neueste Antwort ist änderbar', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
// „Offen"-Ansicht (Default) zeigt auch beantwortete Tickets (Answered).
const card = page.locator('.ticket-card').filter({ hasText: 'Karlsson ist Blau' })
await expect(card).toBeVisible()
// Erst-Text ist gesperrt: kein „Bearbeiten"-Button mehr, sobald es eine Antwort gibt.
await expect(card.getByRole('button', { name: tt.edit })).toHaveCount(0)
// Die neueste Antwort ist dagegen noch änderbar (KI hat noch nicht reagiert → Answered).
await expect(card.getByRole('button', { name: tt.amendAnswer })).toBeVisible()
})
test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card).toBeVisible()
// Rückfrage ist sichtbar; Status-Badge „Rückfrage offen".
await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible()
await expect(card.getByText(tt.statusNeedsInfo, { exact: true })).toBeVisible()
// Antworten.
await card.locator('.ticket-card__answer-form textarea').fill('Korrekt ist der 2. Juni.')
await card.getByRole('button', { name: tt.answerButton }).click()
// Antwort wird angezeigt + Badge wechselt auf „Beantwortet". Das Ticket verlässt die
// Rückfragen-Liste und steht jetzt unter „Offen" (Ansicht wechselt automatisch dorthin,
// da die Rückfragen-Gruppe nun leer ist).
await expect(card.getByText('Korrekt ist der 2. Juni.')).toBeVisible()
await expect(card.locator('.ticket-badge--answered')).toBeVisible()
await expect(card.getByText(tt.statusAnswered, { exact: true })).toBeVisible()
const openFilter = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
await expect(openFilter).toHaveClass(/tickets-filter--active/)
})
test('Rückfrage rendert Markdown und ist (offen) nicht bearbeitbar', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card).toBeVisible()
// Markdown: **fett** → <strong>, „- "-Aufzählung → <li>.
await expect(card.locator('.ticket-card__question-text strong').first()).toBeVisible()
await expect(card.locator('.ticket-card__question-text li')).toHaveCount(2)
// Offene Rückfrage (NeedsInfo): kein „Bearbeiten"-Button, dafür ein Antwort-Feld.
await expect(card.getByRole('button', { name: tt.edit })).toHaveCount(0)
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
})
test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
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()
// Aus „Geschlossen" verschwunden …
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toHaveCount(0)
// … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button.
await selectView(page, tt.filters.deleted)
const trashed = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(trashed).toBeVisible()
await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible()
await expect(trashed.getByText('endgültig gelöscht')).toBeVisible()
// Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück).
await trashed.getByRole('button', { name: tt.restore }).click()
await expect(
page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }),
).toHaveCount(0)
await selectView(page, tt.filters.closed)
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
})
test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
// „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null).
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await card.getByRole('button', { name: tt.reopen }).click()
// Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am".
await selectView(page, tt.filters.dialog)
const reopened = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
})
test('Volltextsuche filtert die Ticket-Liste', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const search = page.getByPlaceholder(tt.searchPlaceholder)
await search.fill('Krümel')
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
await search.fill('xyzgibtsnicht')
await expect(page.getByText(tt.emptySearch)).toBeVisible()
})
test('Kategorie-Filter zeigt nur Tickets der gewählten Kategorie', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
// Das Krümel-Ticket hat die Kategorie „Stammbaum" → als Chip sichtbar.
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.locator('.ticket-card__category')).toHaveText('Stammbaum')
// Über das Kategorie-Menü filtern.
await page.getByLabel(tt.categoryFilterLabel).selectOption('Stammbaum')
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
})
test('👍 auf gelöstem Ticket speichert Rückmeldung; 👎 öffnet es wieder', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.getByText(tt.helpfulQuestion)).toBeVisible()
// 👍 → Dank-Text, kein Wiederöffnen.
await card.getByRole('button', { name: tt.helpfulYes }).click()
await expect(card.getByText(tt.helpfulThanks)).toBeVisible()
})
test('👎 auf gelöstem Ticket öffnet es als Rückfrage wieder', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await card.getByRole('button', { name: tt.helpfulNo }).click()
// Verlässt „Geschlossen", landet in „Rückfragen" mit Bitte um Infos.
await selectView(page, tt.filters.dialog)
const reopened = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible()
})
test('Foto an ein Ticket anhängen erscheint als Vorschaubild', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Offenes Ticket (Fridolin) — Anhang-Bereich mit „Foto anhängen".
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
await expect(card.getByText(tt.attachmentsLabel, { exact: true })).toBeVisible()
// 1×1-PNG als Datei hochladen.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
)
await card.locator('input[type="file"]').setInputFiles({
name: 'maus.png',
mimeType: 'image/png',
buffer: png,
})
// Nach dem Upload erscheint ein Vorschaubild im Anhang-Raster.
await expect(card.locator('.ticket-card__attachment img')).toHaveCount(1)
})
})