Merge feature/inbox-1: Anfragen-Posteingang UI (list/triage/assign/draft/send) [god-QA pending]
# Conflicts: # gerbil-manager-web/e2e/mock-data.ts # gerbil-manager-web/src/App.tsx
This commit is contained in:
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang — Liste/Filter, Triage, KI-Entwurf (inkl.
|
||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||
*/
|
||||
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const ta = de.pages.anfragen
|
||||
const td = ta.detail
|
||||
|
||||
test.describe('Anfragen', () => {
|
||||
test('Liste zeigt Anfragen neueste zuerst + Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.requests)
|
||||
await expect(page.getByRole('heading', { name: ta.title })).toBeVisible()
|
||||
|
||||
// Neueste zuerst: Anna (05.06.) vor Ben (04.06.) vor Clara (01.06.)
|
||||
const cards = page.locator('.anfrage-card')
|
||||
await expect(cards).toHaveCount(3)
|
||||
await expect(cards.nth(0)).toContainText('Anna Albrecht')
|
||||
await expect(cards.nth(2)).toContainText('clara@example.com') // ohne Anzeigename → Adresse
|
||||
|
||||
// Status-Badge auf der Karte
|
||||
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||
|
||||
// Filter: nur Beantwortet
|
||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||
await expect(cards).toHaveCount(1)
|
||||
await expect(cards.first()).toContainText('Danke!')
|
||||
})
|
||||
|
||||
test('Sync ohne Gmail-Konfiguration zeigt deutschen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.mailConfigured = false
|
||||
await page.goto('/anfragen')
|
||||
await page.getByRole('button', { name: ta.sync }).click()
|
||||
await expect(page.getByText(ta.mailNotConfigured)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detail: Triage — Abnehmer zuordnen setzt Status auf Zugeordnet', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await expect(page.getByRole('heading', { name: 'Anfrage: Pärchen zur Abgabe?' })).toBeVisible()
|
||||
await expect(page.getByText('anna@example.de', { exact: false })).toBeVisible()
|
||||
|
||||
await page.getByLabel(td.assignContact).selectOption({ label: 'Zoohandlung Meier' })
|
||||
await expect(page.locator('.anfrage-badge--assigned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
|
||||
// Verwerfen (mit Bestätigung) → Status Verworfen, Zuordnung bleibt
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.abandon }).click()
|
||||
await expect(page.locator('.anfrage-badge--abandoned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
})
|
||||
|
||||
test('KI-Entwurf füllt das Antwortfeld; Senden markiert als Beantwortet', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
const textarea = page.locator('.anfrage-reply__text')
|
||||
await expect(textarea).toHaveValue(/vielen Dank für deine Anfrage/)
|
||||
|
||||
// Entwurf bearbeiten, dann senden (bestätigt) → Beantwortet + Hinweis
|
||||
await textarea.fill('Hallo Anna, ja — die beiden suchen noch ein Zuhause!')
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sent)).toBeVisible()
|
||||
await expect(page.locator('.anfrage-badge--answered')).toBeVisible()
|
||||
})
|
||||
|
||||
test('KI ohne Schlüssel: 503 wird zum freundlichen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.aiConfigured = false
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
await expect(page.getByText(td.aiKeyMissing)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Senden ohne Text zeigt Hinweis statt Versand', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sendEmptyBody)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -135,6 +135,53 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
if (method === 'PUT') return json(route, 204)
|
||||
}
|
||||
|
||||
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
|
||||
if (path === '/requests/sync' && method === 'POST') {
|
||||
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
|
||||
}
|
||||
let rm = path.match(/^\/requests\/([^/]+)\/draft$/)
|
||||
if (rm && method === 'POST') {
|
||||
if (!db.aiConfigured) {
|
||||
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === rm![1])
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße`
|
||||
return json(route, 200, r)
|
||||
}
|
||||
rm = path.match(/^\/requests\/([^/]+)\/send$/)
|
||||
if (rm && method === 'POST') {
|
||||
if (!db.mailConfigured) {
|
||||
return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' })
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === rm![1])
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
r.status = 'Answered'
|
||||
r.answeredAt = '2026-06-06T10:00:00Z'
|
||||
return json(route, 200, r)
|
||||
}
|
||||
rm = path.match(/^\/requests(?:\/([^/]+))?$/)
|
||||
if (rm) {
|
||||
const reqId = rm[1] ? decodeURIComponent(rm[1]) : null
|
||||
if (!reqId && method === 'GET') {
|
||||
return json(route, 200, pagedResponse(db.requests as unknown as Row[], url))
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === reqId)
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'GET') return json(route, 200, r)
|
||||
if (method === 'PUT') {
|
||||
// Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen.
|
||||
const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null }
|
||||
r.assignedContactId = body.assignedContactId ?? null
|
||||
if (body.status) {
|
||||
r.status = body.status as typeof r.status
|
||||
if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z'
|
||||
}
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
||||
// generischen /gerbils/:id-Route abfangen.
|
||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* FEAT-2/FEAT-6-Flüsse.
|
||||
*/
|
||||
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
||||
import type { InboxRequest } from '../src/api/requests'
|
||||
|
||||
export interface HealthRecordRow {
|
||||
id: string
|
||||
@@ -52,6 +53,11 @@ export interface MockDb {
|
||||
healthRecords: HealthRecordRow[]
|
||||
weightRecords: WeightRecordRow[]
|
||||
pages: MockPage[]
|
||||
// INBOX-1: Anfragen-Posteingang. Die Flags steuern die Fehlerpfade des Mocks
|
||||
// (Specs können sie pro Test umlegen): KI-Entwurf 503 / Sync+Send Mail-Fehler.
|
||||
requests: InboxRequest[]
|
||||
aiConfigured: boolean
|
||||
mailConfigured: boolean
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -190,5 +196,64 @@ export function seedDb(): MockDb {
|
||||
{ id: 'page-kontakt', slug: 'kontakt', title: 'Kontakt', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
]
|
||||
|
||||
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords, pages }
|
||||
// INBOX-1: Anfragen (neueste zuerst nach receivedAt sortierbar)
|
||||
const requests: InboxRequest[] = [
|
||||
{
|
||||
id: 'req-anna',
|
||||
gmailMessageId: '<anna-1@mail.example>',
|
||||
threadId: 'thr-1',
|
||||
fromAddress: 'anna@example.de',
|
||||
fromName: 'Anna Albrecht',
|
||||
subject: 'Anfrage: Pärchen zur Abgabe?',
|
||||
bodyText:
|
||||
'Hallo,\n\nich habe euer Inserat gesehen — sind die beiden Agouti-Jungs noch zu haben?\n\nViele Grüße\nAnna',
|
||||
receivedAt: '2026-06-05T18:30:00Z',
|
||||
status: 'New',
|
||||
assignedContactId: null,
|
||||
draftReply: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'req-ben',
|
||||
gmailMessageId: '<ben-1@mail.example>',
|
||||
threadId: 'thr-2',
|
||||
fromAddress: 'ben@example.org',
|
||||
fromName: 'Ben Berger',
|
||||
subject: 'Frage zur Haltung',
|
||||
bodyText: 'Guten Tag, was für ein Becken empfehlt ihr für zwei Rennmäuse?',
|
||||
receivedAt: '2026-06-04T09:00:00Z',
|
||||
status: 'InProgress',
|
||||
assignedContactId: null,
|
||||
draftReply: 'Hallo Ben, wir empfehlen mindestens 100×50 cm …',
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'req-clara',
|
||||
gmailMessageId: '<clara-1@mail.example>',
|
||||
threadId: null,
|
||||
fromAddress: 'clara@example.com',
|
||||
fromName: null,
|
||||
subject: 'Danke!',
|
||||
bodyText: 'Die beiden sind super angekommen — danke nochmal!',
|
||||
receivedAt: '2026-06-01T12:00:00Z',
|
||||
status: 'Answered',
|
||||
assignedContactId: 'con-meier',
|
||||
draftReply: null,
|
||||
answeredAt: '2026-06-02T08:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
gerbils,
|
||||
litters,
|
||||
enclosures,
|
||||
contacts,
|
||||
colorVarieties,
|
||||
healthRecords,
|
||||
weightRecords,
|
||||
pages,
|
||||
requests,
|
||||
aiConfigured: true,
|
||||
mailConfigured: true,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user