- 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>
336 lines
15 KiB
TypeScript
336 lines
15 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|