From 2a8ae0ad652570f9f5d59178e20f35790eb380de Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:22:47 +0100 Subject: [PATCH 1/2] feat(proposals): project detail proposals integration and e5 e2e journey (TASK-0063) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- messages/de.json | 37 + messages/en.json | 37 + src/components/clients/client-timeline.tsx | 7 + .../projects/project-detail-view.tsx | 47 +- .../projects/proposals-section-helpers.ts | 322 ++++++++ src/components/projects/proposals-section.tsx | 278 +++++++ .../projects/revoke-proposal-dialog.tsx | 109 +++ src/server/db/seeds/proposals.ts | 31 +- src/server/projects/service.ts | 72 +- src/server/proposals/actions.ts | 89 +- tests/e2e/epic-e5-journey.spec.ts | 435 ++++++++++ tests/unit/project-detail-proposals.test.tsx | 773 ++++++++++++++++++ .../unit/proposal-public-link-action.test.ts | 142 ++++ 13 files changed, 2373 insertions(+), 6 deletions(-) create mode 100644 src/components/projects/proposals-section-helpers.ts create mode 100644 src/components/projects/proposals-section.tsx create mode 100644 src/components/projects/revoke-proposal-dialog.tsx create mode 100644 tests/e2e/epic-e5-journey.spec.ts create mode 100644 tests/unit/project-detail-proposals.test.tsx create mode 100644 tests/unit/proposal-public-link-action.test.ts diff --git a/messages/de.json b/messages/de.json index 7b79283..43ad791 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1763,6 +1763,7 @@ "cancel": "Abbrechen", "tabs": { "all": "Übersicht", + "proposals": "Angebote", "shoots": "Shootings & Sessions", "locations": "Orte & Venues", "timeline": "Ablauf & Zeitplan", @@ -1870,6 +1871,42 @@ "incompleteAria": "Als erledigt markieren", "submit": "Aufgabe hinzufügen", "submitting": "Wird hinzugefügt..." + }, + "proposals": { + "title": "Kostenvoranschläge & Angebote", + "description": "Kommerzielle Angebote, Paketauswahlen und Kundenannahme-Links für dieses Projekt.", + "newProposal": "Neues Angebot", + "empty": "Noch keine Angebote für dieses Projekt erstellt.", + "version": "Version v{version}", + "createdAt": "Erstellt am {date}", + "total": "Gesamtbetrag", + "validUntil": "Gültig bis {date}", + "acceptedOn": "Angenommen am {date}", + "noExpiration": "Keine Befristung", + "statuses": { + "draft": "Entwurf", + "sent": "Versendet", + "accepted": "Angenommen", + "declined": "Abgelehnt", + "expired": "Abgelaufen", + "superseded": "Ersetzt" + }, + "actions": { + "editDraft": "Entwurf bearbeiten", + "copyPublicLink": "Öffentlichen Link kopieren", + "linkCopied": "Link kopiert!", + "revokeLink": "Link widerrufen", + "linkRevoked": "Link widerrufen", + "copyError": "Link konnte nicht in die Zwischenablage kopiert werden.", + "revokeError": "Link konnte nicht widerrufen werden." + }, + "revokeDialog": { + "title": "Öffentlichen Link widerrufen", + "description": "Möchten Sie den öffentlichen Link für dieses Angebot wirklich widerrufen? Der Kunde verliert sofort den Zugriff und kann das Angebot nicht mehr online einsehen oder annehmen.", + "cancel": "Abbrechen", + "confirm": "Link widerrufen", + "revoking": "Wird widerrufen..." + } } } }, diff --git a/messages/en.json b/messages/en.json index 7eef499..d6212cb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1763,6 +1763,7 @@ "cancel": "Cancel", "tabs": { "all": "Overview", + "proposals": "Proposals", "shoots": "Shoots & Sessions", "locations": "Locations", "timeline": "Timeline", @@ -1870,6 +1871,42 @@ "incompleteAria": "Mark as completed", "submit": "Add Task", "submitting": "Adding..." + }, + "proposals": { + "title": "Quotes & Proposals", + "description": "Commercial proposals, package selections, and client acceptance links for this project.", + "newProposal": "New Proposal", + "empty": "No proposals created for this project yet.", + "version": "Revision v{version}", + "createdAt": "Created on {date}", + "total": "Total Amount", + "validUntil": "Valid until {date}", + "acceptedOn": "Accepted on {date}", + "noExpiration": "No expiration date", + "statuses": { + "draft": "Draft", + "sent": "Sent", + "accepted": "Accepted", + "declined": "Declined", + "expired": "Expired", + "superseded": "Superseded" + }, + "actions": { + "editDraft": "Edit Draft", + "copyPublicLink": "Copy Public Link", + "linkCopied": "Link Copied!", + "revokeLink": "Revoke Link", + "linkRevoked": "Link Revoked", + "copyError": "Failed to copy link to clipboard.", + "revokeError": "Failed to revoke proposal link." + }, + "revokeDialog": { + "title": "Revoke Public Link", + "description": "Are you sure you want to revoke the public link for this proposal? The client will immediately lose access and will no longer be able to view or accept this proposal online.", + "cancel": "Cancel", + "confirm": "Revoke Link", + "revoking": "Revoking..." + } } } }, diff --git a/src/components/clients/client-timeline.tsx b/src/components/clients/client-timeline.tsx index 0163462..8e03ce2 100644 --- a/src/components/clients/client-timeline.tsx +++ b/src/components/clients/client-timeline.tsx @@ -18,6 +18,7 @@ import { Mail, FolderKanban, ArrowRightCircle, + CheckCircle2, } from 'lucide-react'; import type { ResolvedTimelineItem } from '@/server/timeline/repo'; import { formatDateTime } from '@/lib/formatters'; @@ -357,11 +358,17 @@ export function ClientTimeline({ {event.type === 'project.stage_changed' && ( )} + {event.type === 'proposal.accepted' && ( + + )} {isNote && }
+ {event.type === 'proposal.accepted' && ( + {t('events.proposal_accepted')} + )} {event.type === 'client.created' && ( {t('events.client_created')} )} diff --git a/src/components/projects/project-detail-view.tsx b/src/components/projects/project-detail-view.tsx index 9b58c7a..75e98c1 100644 --- a/src/components/projects/project-detail-view.tsx +++ b/src/components/projects/project-detail-view.tsx @@ -38,6 +38,7 @@ import { transitionProjectStageAction, } from '@/server/projects/actions'; import { TaskWarningDialog, type IncompleteTaskItem } from './task-warning-dialog'; +import { ProposalsSection } from './proposals-section'; import { ShootsSection } from './shoots-section'; import { LocationsSection } from './locations-section'; import { TimelineScheduleSection } from './timeline-schedule-section'; @@ -49,7 +50,7 @@ export interface ProjectDetailViewProps { locale: string; } -type TabKey = 'all' | 'shoots' | 'locations' | 'timeline' | 'participants' | 'tasks'; +type TabKey = 'all' | 'proposals' | 'shoots' | 'locations' | 'timeline' | 'participants' | 'tasks'; const PROJECT_TYPES = [ 'wedding', @@ -261,6 +262,11 @@ export function ProjectDetailView({ initialData, locale }: ProjectDetailViewProp // Tabs metadata const tabList: { key: TabKey; label: string; count?: number }[] = [ { key: 'all', label: tDetail('tabs.all') }, + { + key: 'proposals', + label: tDetail('tabs.proposals'), + count: initialData.proposals?.length ?? 0, + }, { key: 'shoots', label: tDetail('tabs.shoots'), count: shoots.length }, { key: 'locations', label: tDetail('tabs.locations'), count: locations.length }, { key: 'timeline', label: tDetail('tabs.timeline'), count: timelineItems.length }, @@ -430,7 +436,7 @@ export function ProjectDetailView({ initialData, locale }: ProjectDetailViewProp aria-label="Project sections" className="flex flex-wrap gap-2 -mb-px overflow-x-auto py-1" > - {tabList.map((tab) => { + {tabList.map((tab, idx) => { const isActive = activeTab === tab.key; return ( + )} + + {/* Sent proposals with active link: Revoke Link */} + {proposal.status === 'sent' && !isRevoked && ( + + )} +
+
+ ); + })} + + )} + + {/* Revoke Link Confirmation Dialog (WCAG 2.4.3 focus restored to trigger) */} + + + ); +} diff --git a/src/components/projects/revoke-proposal-dialog.tsx b/src/components/projects/revoke-proposal-dialog.tsx new file mode 100644 index 0000000..e4ed9c4 --- /dev/null +++ b/src/components/projects/revoke-proposal-dialog.tsx @@ -0,0 +1,109 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import type { ProjectDetailProposal } from '@/server/projects/service'; +import { createRevokeProposalDialogController } from './proposals-section-helpers'; + +export interface RevokeProposalDialogProps { + proposal: ProjectDetailProposal | null; + isOpen: boolean; + isRevoking: boolean; + revokeError: string | null; + triggerRef?: React.RefObject; + onClose: () => void; + onConfirm: () => void | Promise; +} + +export function RevokeProposalDialog({ + isOpen, + isRevoking, + revokeError, + triggerRef, + onClose, + onConfirm, +}: RevokeProposalDialogProps) { + const t = useTranslations('projects.detail.proposals'); + const controller = createRevokeProposalDialogController(onClose, onConfirm, triggerRef); + + return ( + + +
+ +
+
+
+ + {t('revokeDialog.title')} + +
+ + {t('revokeDialog.description')} + +
+ + {revokeError && ( +
+ {revokeError} +
+ )} + + + + + +
+
+
+ ); +} diff --git a/src/server/db/seeds/proposals.ts b/src/server/db/seeds/proposals.ts index c44733b..78d26c6 100644 --- a/src/server/db/seeds/proposals.ts +++ b/src/server/db/seeds/proposals.ts @@ -1,7 +1,8 @@ -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { createHash } from 'node:crypto'; import type { DbClient } from '@/server/db/client'; import { proposals, proposalLineItems } from '@/server/db/schema/proposals'; +import { emailOutbox } from '@/server/db/schema/email-outbox'; import { clients } from '@/server/db/schema/clients'; import { pipelineStages, projects } from '@/server/db/schema/projects'; import { packages, catalogueItems } from '@/server/db/schema/packages'; @@ -266,6 +267,34 @@ export async function seedProposals(tx: DbClient): Promise seededLineItems += 2; } + // Ensure proposal_sent outbox record exists for sent proposal demo + const [sentOutboxExists] = await tx + .select({ id: emailOutbox.id }) + .from(emailOutbox) + .where( + and(eq(emailOutbox.proposalId, SEED_PROPOSAL_SENT_ID), eq(emailOutbox.kind, 'proposal_sent')), + ) + .limit(1); + + if (!sentOutboxExists) { + const rawSentToken = 'demo-token-sent-proposal-seed-32bytes-secret'; + await tx.insert(emailOutbox).values({ + id: '01920000-0000-7000-8000-000000000299', + kind: 'proposal_sent', + proposalId: SEED_PROPOSAL_SENT_ID, + recipient: 'emma@example.com', + payload: { + token: rawSentToken, + tokenUrl: `/p/${rawSentToken}`, + }, + status: 'PENDING', + attempts: 0, + nextAttemptAt: now, + createdAt: now, + updatedAt: now, + }); + } + // Proposal 3: ACCEPTED const [acceptedExists] = await tx .select({ id: proposals.id }) diff --git a/src/server/projects/service.ts b/src/server/projects/service.ts index 58e4633..fd68705 100644 --- a/src/server/projects/service.ts +++ b/src/server/projects/service.ts @@ -1,7 +1,9 @@ -import { eq } from 'drizzle-orm'; +import { eq, and, inArray, desc } from 'drizzle-orm'; import { generateUuidV7 } from '@/lib/id'; import { db as defaultDb, type DbClient } from '@/server/db/client'; import { clients } from '@/server/db/schema/clients'; +import { proposals, type ProposalStatus } from '@/server/db/schema/proposals'; +import { emailOutbox } from '@/server/db/schema/email-outbox'; import { getClientById } from '@/server/clients/repo'; import { type Project, @@ -1241,6 +1243,31 @@ export interface ProjectDetailParticipant extends ProjectParticipant { contactDisplayName?: string | null; } +export interface ProjectDetailProposal { + id: string; + projectId: string; + clientId: string; + title: string; + status: ProposalStatus; + version: number; + currency: string; + subtotalMinor: number; + taxMinor: number; + totalMinor: number; + pricesIncludeTax: boolean; + isKleinunternehmer: boolean; + taxNotice?: string | null; + validUntil: Date | null; + sentAt: Date | null; + acceptedAt: Date | null; + declinedAt: Date | null; + tokenRevokedAt: Date | null; + tokenHash: string; + publicUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + export interface ProjectDetailData { project: Project; client: { @@ -1263,6 +1290,7 @@ export interface ProjectDetailData { phone: string | null; isPrimary: boolean; }[]; + proposals?: ProjectDetailProposal[]; } export async function getProjectDetail( @@ -1272,12 +1300,17 @@ export async function getProjectDetail( const project = await repo.findProjectById(projectId, tx); if (!project) return null; - const [stages, clientData, shoots, participants, tasks] = await Promise.all([ + const [stages, clientData, shoots, participants, tasks, proposalRows] = await Promise.all([ getPipelineStages(tx), getClientById(project.clientId, tx), repo.findShootsByProjectId(projectId, tx), repo.findParticipantsByProjectId(projectId, tx), repo.findTasksByProjectId(projectId, tx), + tx + .select() + .from(proposals) + .where(eq(proposals.projectId, projectId)) + .orderBy(desc(proposals.createdAt)), ]); if (!clientData) return null; @@ -1335,6 +1368,40 @@ export async function getProjectDetail( const primaryContact = clientData.contacts.find((c) => c.isPrimary) ?? clientData.contacts[0]; + // Resolve public link for sent proposals via email_outbox records + let enhancedProposals: ProjectDetailProposal[] = []; + if (proposalRows.length > 0) { + const proposalIds = proposalRows.map((p) => p.id); + const outboxRows = await tx + .select({ + proposalId: emailOutbox.proposalId, + payload: emailOutbox.payload, + createdAt: emailOutbox.createdAt, + }) + .from(emailOutbox) + .where( + and(inArray(emailOutbox.proposalId, proposalIds), eq(emailOutbox.kind, 'proposal_sent')), + ) + .orderBy(desc(emailOutbox.createdAt)); + + const proposalLinkMap = new Map(); + for (const row of outboxRows) { + if (row.proposalId && !proposalLinkMap.has(row.proposalId)) { + const payload = row.payload as { token?: string; tokenUrl?: string } | null; + if (payload?.tokenUrl) { + proposalLinkMap.set(row.proposalId, payload.tokenUrl); + } else if (payload?.token) { + proposalLinkMap.set(row.proposalId, `/p/${payload.token}`); + } + } + } + + enhancedProposals = proposalRows.map((p) => ({ + ...p, + publicUrl: p.tokenRevokedAt ? null : (proposalLinkMap.get(p.id) ?? null), + })); + } + return { project, client: { @@ -1351,6 +1418,7 @@ export async function getProjectDetail( participants: enhancedParticipants, tasks, clientContacts, + proposals: enhancedProposals, }; } diff --git a/src/server/proposals/actions.ts b/src/server/proposals/actions.ts index 32b84c1..a86e254 100644 --- a/src/server/proposals/actions.ts +++ b/src/server/proposals/actions.ts @@ -3,12 +3,19 @@ import 'server-only'; import { revalidatePath } from 'next/cache'; import { z } from 'zod'; +import { and, eq, desc } from 'drizzle-orm'; import { db as defaultDb, type DbClient } from '@/server/db/client'; import { requireOwner, ForbiddenError, type AuthContext } from '@/server/auth/guards'; +import { emailOutbox } from '@/server/db/schema/email-outbox'; import { logger } from '@/server/log'; import { saveDraftProposalSchema, sendProposalSchema, revokeProposalTokenSchema } from './schema'; import * as service from './service'; -import { ProposalNotFoundError, ProposalForbiddenError, type ProposalWithLineItems } from './repo'; +import { + getProposalById, + ProposalNotFoundError, + ProposalForbiddenError, + type ProposalWithLineItems, +} from './repo'; import { ProposalImmutableError } from './snapshot'; export type ProposalActionResult = @@ -191,3 +198,83 @@ export async function revokeProposalTokenAction( }; } } + +const getProposalPublicLinkSchema = z.object({ + proposalId: z.string().uuid(), +}); + +/** + * Resolves the public token URL for a sent proposal (AC-2). + * Strictly requires authenticated studio owner. + */ +export async function getProposalPublicLinkAction( + rawInput: unknown, + client: DbClient = defaultDb, +): Promise> { + let auth: AuthContext; + try { + auth = await requireOwner({ client }); + } catch (err) { + if (err instanceof ForbiddenError) { + return { success: false, code: 'FORBIDDEN', error: 'FORBIDDEN' }; + } + return { success: false, code: 'UNAUTHENTICATED', error: 'UNAUTHENTICATED' }; + } + + const parsed = getProposalPublicLinkSchema.safeParse(rawInput); + if (!parsed.success) { + const formatted = formatValidationIssues(parsed.error); + return { + success: false, + code: 'VALIDATION_FAILED', + error: formatted.error, + fields: formatted.fields, + }; + } + + try { + const existing = await getProposalById(parsed.data.proposalId, { auth, client }, client); + if (!existing) { + return { success: false, code: 'NOT_FOUND', error: 'Proposal not found' }; + } + if (existing.tokenRevokedAt) { + return { success: false, code: 'REVOKED', error: 'Proposal link has been revoked' }; + } + + const [outboxRow] = await client + .select({ payload: emailOutbox.payload }) + .from(emailOutbox) + .where( + and( + eq(emailOutbox.proposalId, parsed.data.proposalId), + eq(emailOutbox.kind, 'proposal_sent'), + ), + ) + .orderBy(desc(emailOutbox.createdAt)) + .limit(1); + + const payload = outboxRow?.payload as { token?: string; tokenUrl?: string } | undefined; + if (!payload?.token) { + return { success: false, code: 'NOT_FOUND', error: 'Proposal link not available' }; + } + + return { + success: true, + data: { + proposalId: parsed.data.proposalId, + token: payload.token, + tokenUrl: payload.tokenUrl ?? `/p/${payload.token}`, + }, + }; + } catch (err) { + logger.error( + { error: err instanceof Error ? err.message : String(err) }, + 'Unexpected error in getProposalPublicLinkAction', + ); + return { + success: false, + code: 'INTERNAL_ERROR', + error: err instanceof Error ? err.message : 'Internal error resolving proposal link', + }; + } +} diff --git a/tests/e2e/epic-e5-journey.spec.ts b/tests/e2e/epic-e5-journey.spec.ts new file mode 100644 index 0000000..7a330a4 --- /dev/null +++ b/tests/e2e/epic-e5-journey.spec.ts @@ -0,0 +1,435 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import fs from 'node:fs'; +import path from 'node:path'; +import { E2E_OWNER_EMAIL, E2E_OWNER_PASSWORD } from '../helpers/e2e-global-setup'; +import { E2E_PACKAGE_WEDDING_ID, E2E_CATALOGUE_ITEM_1_ID } from '../helpers/e2e-proposals-helper'; + +const VALID_SETUP_TOKEN = + process.env.SETUP_TOKEN ?? 'c4d7e8f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9'; + +function getLatestRunDir(): string { + if (process.env.RUN_DIR) return process.env.RUN_DIR; + const runsRoot = path.resolve(import.meta.dirname, '../../../../control/ownlight/runs/TASK-0063'); + if (fs.existsSync(runsRoot)) { + const entries = fs + .readdirSync(runsRoot) + .filter((d) => d.startsWith('iter-')) + .sort(); + const last = entries[entries.length - 1]; + if (last) { + return last; + } + } + return 'iter-01'; +} + +const RUN_DIR_PARAM = getLatestRunDir(); +const UX_RUN_DIR = path.resolve( + import.meta.dirname, + `../../../../control/ownlight/runs/TASK-0063/${RUN_DIR_PARAM}/ux`, +); + +async function ensureOwnerBootstrappedAndSignedIn(page: import('@playwright/test').Page) { + await page.goto('/en/setup'); + const setupForm = page.locator('[data-testid="setup-form"]'); + if (await setupForm.isVisible({ timeout: 1500 }).catch(() => false)) { + await page.fill('[data-testid="setup-token-input"]', VALID_SETUP_TOKEN); + await page.fill('[data-testid="name-input"]', 'Studio Owner'); + await page.fill('[data-testid="email-input"]', E2E_OWNER_EMAIL); + await page.fill('[data-testid="password-input"]', E2E_OWNER_PASSWORD); + await page.click('[data-testid="setup-submit-btn"]'); + await expect(page).not.toHaveURL(/\/setup/); + return; + } + + await page.goto('/en/sign-in'); + const emailInput = page.locator('[data-testid="email-input"]'); + if (await emailInput.isVisible({ timeout: 1500 }).catch(() => false)) { + await emailInput.fill(E2E_OWNER_EMAIL); + await page.fill('[data-testid="password-input"]', E2E_OWNER_PASSWORD); + await page.click('[data-testid="signin-submit-btn"]'); + await expect(page).not.toHaveURL(/\/sign-in$/); + } +} + +async function createTestClient( + page: import('@playwright/test').Page, + name: string, + email: string, + locale = 'en', +): Promise { + await page.goto(`/${locale}/clients/new`); + await page.fill('[data-testid="display-name-input"]', name); + await page.fill('[data-testid="given-name-input"]', name.split(' ')[0] ?? name); + await page.fill('[data-testid="family-name-input"]', name.split(' ')[1] ?? 'Client'); + await page.fill('[data-testid="email-input"]', email); + await page.click('[data-testid="submit-client-btn"]'); + await expect(page).toHaveURL(new RegExp(`/${locale}/clients/[0-9a-f-]{36}`)); + const url = page.url(); + const match = /\/clients\/([0-9a-f-]{36})/.exec(url); + const clientId = match?.[1]; + if (!clientId) throw new Error('Could not parse clientId from URL'); + return clientId; +} + +test.describe('TASK-0063: Epic E5 Complete E2E Journey Spec', () => { + let createdClientId = ''; + let createdProjectId = ''; + let proposalRawToken = ''; + let publicProposalUrl = ''; + + test.beforeAll(() => { + fs.mkdirSync(UX_RUN_DIR, { recursive: true }); + }); + + test.afterAll(async ({ browser }) => { + // Clean up created project and client to preserve baseline seed counts + const page = await browser.newPage(); + await ensureOwnerBootstrappedAndSignedIn(page); + + if (createdProjectId) { + await page.goto('/en/projects?view=list'); + page.once('dialog', (dialog) => void dialog.accept()); + const trigger = page.locator(`[data-testid="project-actions-trigger-${createdProjectId}"]`); + if (await trigger.isVisible({ timeout: 2000 }).catch(() => false)) { + await trigger.click(); + const deleteBtn = page.locator(`[data-testid="project-action-delete-${createdProjectId}"]`); + await deleteBtn.click(); + await expect( + page.locator(`[data-testid="project-table-row-${createdProjectId}"]`), + ).not.toBeVisible({ timeout: 5000 }); + } + } + + if (createdClientId) { + await page.goto(`/en/clients/${createdClientId}`); + const deleteBtn = page.locator('[data-testid="delete-client-btn"]'); + if (await deleteBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await deleteBtn.click(); + const confirmInput = page.locator('[data-testid="erasure-confirmation-input"]'); + if (await confirmInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await confirmInput.fill('Clara Journey'); + await page.locator('[data-testid="erasure-submit-btn"]').click(); + await expect(page).toHaveURL(/\/en\/clients/, { timeout: 5000 }); + } + } + } + + await page.close(); + }); + + test('AC-4: Complete Epic E5 Journey (Catalogue -> Proposal -> Link -> Client Acceptance -> Booked Stage & Timeline)', async ({ + page, + browser, + }) => { + test.setTimeout(180000); + + // ----------------------------------------------------------------- + // Step 1: Owner login & Packages Catalogue verification + // ----------------------------------------------------------------- + await ensureOwnerBootstrappedAndSignedIn(page); + + await page.goto('/en/settings/packages'); + await expect(page.locator('[data-testid="packages-settings-view"]')).toBeVisible(); + await expect(page.locator('[data-testid="tab-packages"]')).toBeVisible(); + + // Verify packages catalogue renders and contains wedding package + const weddingCard = page.locator('[data-testid="package-card-intimate-wedding-story"]'); + await expect(weddingCard).toBeVisible({ timeout: 10000 }); + await expect(weddingCard).toContainText('Intimate Wedding Story'); + + // Light & Dark axe check on packages settings + const axePackages = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(axePackages.violations).toEqual([]); + + // ----------------------------------------------------------------- + // Step 2: Create isolated test client & inquiry project + // ----------------------------------------------------------------- + const testTimestamp = Date.now(); + const clientEmail = `clara.e5.${String(testTimestamp)}@example.com`; + createdClientId = await createTestClient(page, 'Clara Journey', clientEmail, 'en'); + + await page.goto('/en/projects?view=list'); + const newProjectBtn = page.locator('[data-testid="new-project-btn"]').first(); + await expect(newProjectBtn).toBeVisible({ timeout: 10000 }); + await newProjectBtn.click(); + + const newProjectDialog = page.locator('[data-testid="new-project-dialog"]'); + await expect(newProjectDialog).toBeVisible(); + + await page.locator('[data-testid="new-project-title-input"]').fill('Epic E5 Commission'); + await page.locator('[data-testid="new-project-client-select"]').selectOption(createdClientId); + await page.locator('[data-testid="new-project-type-select"]').selectOption('wedding'); + await page.locator('[data-testid="new-project-price-input"]').fill('2800.00'); + + await page.locator('[data-testid="new-project-submit-btn"]').click(); + await expect(page).toHaveURL(/\/en\/projects\/[0-9a-f-]{36}/, { timeout: 10000 }); + + const createdUrl = page.url(); + const parsedProjectId = /\/projects\/([0-9a-f-]{36})/.exec(createdUrl)?.[1]; + if (!parsedProjectId) throw new Error('Could not parse created projectId'); + createdProjectId = parsedProjectId; + + // ----------------------------------------------------------------- + // Step 3: Project Detail View - Proposals Tab Initial State + // ----------------------------------------------------------------- + await page.goto(`/en/projects/${createdProjectId}`); + await expect(page.locator('[data-testid="project-detail-view"]')).toBeVisible(); + + const proposalsTab = page.locator('[data-testid="tab-proposals"]'); + await expect(proposalsTab).toBeVisible(); + await proposalsTab.click(); + + // Verify empty state + const emptyState = page.locator('[data-testid="proposals-empty-state"]'); + await expect(emptyState).toBeVisible(); + await expect(page.locator('[data-testid="new-proposal-btn"]')).toBeVisible(); + + // ----------------------------------------------------------------- + // Step 4: Proposal Builder Workspace - Compose & Send Proposal + // ----------------------------------------------------------------- + await page.locator('[data-testid="new-proposal-btn"]').first().click(); + await expect(page).toHaveURL(new RegExp(`.*/projects/${createdProjectId}/proposals/new`), { + timeout: 10000, + }); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + // Select base package + const packageSelect = page.locator('[data-testid="package-select"]'); + await expect(packageSelect).toBeVisible(); + await packageSelect.selectOption(E2E_PACKAGE_WEDDING_ID); + await expect(page.locator('[data-testid="base-package-price-input"]')).toHaveValue('2800.00'); + + // Add catalogue add-on + const addonSelect = page.locator('[data-testid="catalogue-addon-select"]'); + await addonSelect.selectOption(E2E_CATALOGUE_ITEM_1_ID); + await page.locator('[data-testid="add-catalogue-addon-btn"]').click(); + + // Configure add-on as optional with max quantity = 2 + const optionalToggle = page.locator('[data-testid="addon-optional-toggle-0"]'); + await optionalToggle.check(); + await expect(optionalToggle).toBeChecked(); + + const maxQtyInput = page.locator('[data-testid="addon-max-quantity-0"]'); + await maxQtyInput.fill('2'); + + // Verify total includes base (2800.00) + default selected add-on (250.00) = 3050.00 + await expect(page.locator('[data-testid="financial-summary-total"]')).toContainText('3,050.00'); + + // Save draft + await page.locator('[data-testid="save-draft-btn"]').click(); + await expect(page).toHaveURL( + new RegExp(`.*/projects/${createdProjectId}/proposals/[0-9a-f-]{36}`), + { timeout: 10000 }, + ); + await expect(page.locator('[data-testid="proposal-status-badge"]')).toContainText('Draft'); + + // Send proposal to client + const openSendBtn = page.locator('[data-testid="open-send-proposal-btn"]'); + await expect(openSendBtn).toBeVisible(); + await openSendBtn.click(); + + const sendModal = page.locator('[data-testid="send-proposal-modal"]'); + await expect(sendModal).toBeVisible(); + await page.waitForTimeout(300); + + const emailInput = page.locator('[data-testid="send-recipient-email-input"]'); + await emailInput.fill(clientEmail); + + await page.locator('[data-testid="submit-send-proposal-btn"]').click(); + + // Read generated proposal token URL + const tokenUrlElement = page.locator('[data-testid="proposal-token-url"]'); + await expect(tokenUrlElement).toBeVisible({ timeout: 10000 }); + publicProposalUrl = await tokenUrlElement.inputValue(); + expect(publicProposalUrl).toContain('/p/'); + + const tokenMatch = /\/p\/([a-zA-Z0-9_-]+)/.exec(publicProposalUrl); + if (!tokenMatch?.[1]) throw new Error('Could not parse token from proposal URL'); + proposalRawToken = tokenMatch[1]; + + // Capture screenshot of proposal builder send notification + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-proposal-builder-sent.png'), + }); + + // ----------------------------------------------------------------- + // Step 5: Project Detail View - Sent Proposal Card & Copy Public Link + // ----------------------------------------------------------------- + await page.goto(`/en/projects/${createdProjectId}`); + await expect(page.locator('[data-testid="project-detail-view"]')).toBeVisible(); + + const proposalsTabAfterSend = page.locator('[data-testid="tab-proposals"]'); + await proposalsTabAfterSend.click(); + + // Proposal item is now displayed with "Sent" status badge + const proposalStatusBadge = page.locator('[data-testid^="proposal-status-badge-"]').first(); + await expect(proposalStatusBadge).toBeVisible(); + await expect(proposalStatusBadge).toContainText('Sent'); + + // Copy public link button is present and functional + const copyLinkBtn = page.locator('[data-testid^="proposal-copy-link-btn-"]').first(); + await expect(copyLinkBtn).toBeVisible(); + await copyLinkBtn.click(); + + // Link copied feedback is displayed + const linkCopiedFeedback = page.locator('[data-testid="link-copied-feedback"]'); + await expect(linkCopiedFeedback).toBeVisible({ timeout: 5000 }); + + // Capture screenshot of project proposals tab + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-project-detail-proposals-sent.png'), + }); + + // ----------------------------------------------------------------- + // Step 6: Client Experience & Acceptance Ceremony (Fresh Context) + // ----------------------------------------------------------------- + const clientContext = await browser.newContext(); + const clientPage = await clientContext.newPage(); + + await clientPage.goto(`/en/p/${proposalRawToken}`); + const clientProposalView = clientPage.locator('[data-testid="public-proposal-view"]'); + await expect(clientProposalView).toBeVisible({ timeout: 10000 }); + + // Cookieless check: verify no session cookie + const cookies = await clientContext.cookies(); + const sessionCookie = cookies.find((c) => c.name.includes('session')); + expect(sessionCookie).toBeUndefined(); + + // Verify initial total is €3,050.00 + const totalDisplay = clientPage.locator('[data-testid="summary-total"]'); + await expect(totalDisplay).toContainText('3,050.00'); + + // Toggle add-on checkbox: total decreases to €2,800.00 + const addonToggle = clientPage.locator('[data-testid^="addon-checkbox-"]'); + await expect(addonToggle).toBeVisible(); + await addonToggle.click(); + await expect(totalDisplay).toContainText('2,800.00'); + + // Toggle back on: total restores to €3,050.00 + await addonToggle.click(); + await expect(totalDisplay).toContainText('3,050.00'); + + // Verify German localization parity at /de/p/[token] + await clientPage.goto(`/de/p/${proposalRawToken}`); + await expect(clientPage.locator('[data-testid="public-proposal-view"]')).toBeVisible(); + await expect(clientPage.locator('[data-testid="accept-proposal-btn"]')).toContainText( + 'Angebot annehmen', + ); + + // Switch back to English for acceptance + await clientPage.goto(`/en/p/${proposalRawToken}`); + await expect(clientPage.locator('[data-testid="public-proposal-view"]')).toBeVisible(); + + // Open Accept Proposal modal + const openAcceptBtn = clientPage.locator('[data-testid="accept-proposal-btn"]'); + await expect(openAcceptBtn).toBeVisible(); + await openAcceptBtn.click(); + + const acceptModal = clientPage.locator('[data-testid="accept-proposal-dialog"]'); + await expect(acceptModal).toBeVisible(); + await clientPage.waitForTimeout(300); + + // Test form validation: empty submit triggers programmatic errors (WCAG 3.3.1) + const confirmAcceptBtn = clientPage.locator('[data-testid="confirm-accept-btn"]'); + const nameInput = clientPage.locator('[data-testid="accepted-name-input"]'); + const acceptedEmailInput = clientPage.locator('[data-testid="accepted-email-input"]'); + const agreementCheckbox = clientPage.locator('[data-testid="agreement-checkbox"]'); + + await nameInput.fill(''); + await acceptedEmailInput.fill(''); + if (await agreementCheckbox.isChecked()) { + await agreementCheckbox.uncheck(); + } + await confirmAcceptBtn.click(); + + await expect(nameInput).toHaveAttribute('aria-invalid', 'true'); + await expect(clientPage.locator('#accepted-by-name-error')).toBeVisible(); + + // Fill valid acceptance information + await nameInput.fill('Clara Journey'); + await acceptedEmailInput.fill(clientEmail); + await agreementCheckbox.check(); + + // Submit acceptance ceremony + await confirmAcceptBtn.click(); + + // Modal closes and accepted banner appears + await expect(acceptModal).not.toBeVisible({ timeout: 10000 }); + const acceptedBanner = clientPage.locator('[data-testid="proposal-accepted-banner"]'); + await expect(acceptedBanner).toBeVisible({ timeout: 10000 }); + await expect(acceptedBanner).toContainText('Clara Journey'); + + // Capture screenshot of accepted client view + await clientPage.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-proposal-public-accepted.png'), + }); + + await clientContext.close(); + + // ----------------------------------------------------------------- + // Step 7: Studio Owner Post-Acceptance Verification + // ----------------------------------------------------------------- + await page.goto(`/en/projects/${createdProjectId}`); + await expect(page.locator('[data-testid="project-detail-view"]')).toBeVisible(); + + // 1. Project stage automatically updated to "booked" + const bookedStageStep = page.locator('[data-testid="stage-step-booked"]'); + await expect(bookedStageStep).toBeVisible(); + await expect(bookedStageStep).toHaveAttribute('aria-current', 'step'); + + // 2. Project price updated to the accepted total (€3,050.00) + const projectPrice = page.locator('[data-testid="project-price"]'); + await expect(projectPrice).toBeVisible(); + await expect(projectPrice).toContainText('3,050.00'); + + // 3. Proposals section shows "Accepted" status badge & accepted timestamp + await page.locator('[data-testid="tab-proposals"]').click(); + const acceptedBadge = page.locator('[data-testid^="proposal-status-badge-"]').first(); + await expect(acceptedBadge).toBeVisible(); + await expect(acceptedBadge).toContainText('Accepted'); + + const validityText = page.locator('[data-testid^="proposal-validity-"]').first(); + await expect(validityText).toBeVisible(); + await expect(validityText).toContainText('Accepted on'); + + // Capture screenshot of accepted proposals tab + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-project-detail-proposals-accepted.png'), + }); + + // 4. Client timeline contains proposal.accepted event + await page.goto(`/en/clients/${createdClientId}`); + await expect(page.locator('[data-testid="timeline-heading"]')).toBeVisible(); + + const proposalAcceptedEvent = page.locator('[data-event-type="proposal.accepted"]').first(); + await expect(proposalAcceptedEvent).toBeVisible({ timeout: 10000 }); + await expect(proposalAcceptedEvent).toContainText('Proposal accepted'); + + // Capture screenshot of client timeline + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-client-timeline-proposal-accepted.png'), + }); + + // ----------------------------------------------------------------- + // Step 8: Multi-theme Axe scans across Light & Dark modes + // ----------------------------------------------------------------- + // Scan client timeline in light mode + const axeTimelineLight = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(axeTimelineLight.violations).toEqual([]); + + // Scan client timeline in dark mode + await page.emulateMedia({ colorScheme: 'dark' }); + await page.waitForTimeout(300); + + const axeTimelineDark = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(axeTimelineDark.violations).toEqual([]); + }); +}); diff --git a/tests/unit/project-detail-proposals.test.tsx b/tests/unit/project-detail-proposals.test.tsx new file mode 100644 index 0000000..7150c63 --- /dev/null +++ b/tests/unit/project-detail-proposals.test.tsx @@ -0,0 +1,773 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +// Mock server-only +vi.mock('server-only', () => ({})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + }), + usePathname: () => '/en/projects/01920000-0000-7000-8000-000000000071', + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock('@/server/proposals/actions', () => ({ + getProposalPublicLinkAction: vi.fn(), + revokeProposalTokenAction: vi.fn(), +})); + +// Mock translations with STRICT missing key check (Rule 4.5) +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +function getNestedValue(obj: Record, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = Reflect.get(current, part); + } else { + return undefined; + } + } + return current; +} + +let currentLocale: 'en' | 'de' = 'en'; + +vi.mock('next-intl', () => ({ + useTranslations: (namespace?: string) => { + return (key: string, values?: Record) => { + const messages = currentLocale === 'de' ? deMessages : enMessages; + const fullPath = namespace ? `${namespace}.${key}` : key; + const raw = getNestedValue(messages, fullPath); + if (typeof raw !== 'string') { + throw new Error(`Missing translation key: "${fullPath}" for locale "${currentLocale}"`); + } + let text = raw; + if (values) { + for (const [vKey, vVal] of Object.entries(values)) { + text = text.replaceAll(`{${vKey}}`, String(vVal)); + } + } + return text; + }; + }, +})); + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DialogHeader: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DialogTitle: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DialogDescription: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DialogFooter: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), +})); + +import { ProposalsSection } from '@/components/projects/proposals-section'; +import { RevokeProposalDialog } from '@/components/projects/revoke-proposal-dialog'; +import { ProjectDetailView } from '@/components/projects/project-detail-view'; +import { + getProposalStatusBadgeClasses, + formatProposalDate, + isProposalLinkActive, + isProposalLinkRevoked, + isDraftProposal, + resolvePublicTokenUrl, + copyToClipboard, + executeCopyProposalLink, + executeRevokeProposalLink, + createRevokeProposalDialogController, + createProposalsSectionViewController, + useProposalsSectionController, + type ProposalsSectionController, +} from '@/components/projects/proposals-section-helpers'; +import { getProposalPublicLinkAction, revokeProposalTokenAction } from '@/server/proposals/actions'; +import type { ProjectDetailProposal, ProjectDetailData } from '@/server/projects/service'; + +const dummyProposalDraft: ProjectDetailProposal = { + id: '01920000-0000-7000-8000-000000000201', + projectId: '01920000-0000-7000-8000-000000000071', + clientId: '01920000-0000-7000-8000-000000000081', + title: 'Wedding Photography Proposal (Draft)', + status: 'draft', + version: 1, + currency: 'EUR', + subtotalMinor: 280000, + taxMinor: 44706, + totalMinor: 280000, + pricesIncludeTax: true, + isKleinunternehmer: false, + validUntil: new Date('2027-07-01T00:00:00Z'), + sentAt: null, + acceptedAt: null, + declinedAt: null, + tokenRevokedAt: null, + tokenHash: 'hash-draft', + publicUrl: null, + createdAt: new Date('2027-01-01T12:00:00Z'), + updatedAt: new Date('2027-01-01T12:00:00Z'), +}; + +const dummyProposalSent: ProjectDetailProposal = { + id: '01920000-0000-7000-8000-000000000202', + projectId: '01920000-0000-7000-8000-000000000071', + clientId: '01920000-0000-7000-8000-000000000081', + title: 'Wedding Coverage & Add-on (Sent)', + status: 'sent', + version: 1, + currency: 'EUR', + subtotalMinor: 305000, + taxMinor: 48697, + totalMinor: 305000, + pricesIncludeTax: true, + isKleinunternehmer: false, + validUntil: new Date('2027-07-15T00:00:00Z'), + sentAt: new Date('2027-01-02T10:00:00Z'), + acceptedAt: null, + declinedAt: null, + tokenRevokedAt: null, + tokenHash: 'hash-sent', + publicUrl: '/p/demo-token-sent-32bytes', + createdAt: new Date('2027-01-02T10:00:00Z'), + updatedAt: new Date('2027-01-02T10:00:00Z'), +}; + +const dummyProposalAccepted: ProjectDetailProposal = { + id: '01920000-0000-7000-8000-000000000203', + projectId: '01920000-0000-7000-8000-000000000071', + clientId: '01920000-0000-7000-8000-000000000081', + title: 'Full Day Coverage (Accepted)', + status: 'accepted', + version: 1, + currency: 'EUR', + subtotalMinor: 280000, + taxMinor: 44706, + totalMinor: 280000, + pricesIncludeTax: true, + isKleinunternehmer: false, + validUntil: new Date('2027-07-01T00:00:00Z'), + sentAt: new Date('2027-01-02T10:00:00Z'), + acceptedAt: new Date('2027-01-03T15:00:00Z'), + declinedAt: null, + tokenRevokedAt: null, + tokenHash: 'hash-accepted', + publicUrl: '/p/demo-token-accepted-32bytes', + createdAt: new Date('2027-01-02T10:00:00Z'), + updatedAt: new Date('2027-01-03T15:00:00Z'), +}; + +const dummyProposalRevoked: ProjectDetailProposal = { + ...dummyProposalSent, + id: '01920000-0000-7000-8000-000000000204', + title: 'Revoked Proposal', + tokenRevokedAt: new Date('2027-01-04T12:00:00Z'), + publicUrl: null, +}; + +const mockProjectDetailData: ProjectDetailData = { + project: { + id: '01920000-0000-7000-8000-000000000071', + clientId: '01920000-0000-7000-8000-000000000081', + leadId: null, + stageId: 'stage-1', + title: 'Emma & Liam Wedding', + projectTypeKey: 'wedding', + primaryShootStartsAt: null, + primaryShootTimezone: 'Europe/Berlin', + priceMinor: 305000, + currency: 'EUR', + notes: null, + createdAt: new Date('2027-01-01T00:00:00Z'), + updatedAt: new Date('2027-01-01T00:00:00Z'), + lastActivityAt: new Date('2027-01-01T00:00:00Z'), + }, + client: { + id: '01920000-0000-7000-8000-000000000081', + displayName: 'Emma Watson', + email: 'emma@example.com', + phone: null, + }, + stages: [ + { + id: 'stage-1', + key: 'inquiry', + name: 'Inquiry', + category: 'inquiry', + position: 1, + color: 'blue', + isSystem: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'stage-2', + key: 'booked', + name: 'Booked', + category: 'booked', + position: 2, + color: 'emerald', + isSystem: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + currentStage: { + id: 'stage-1', + key: 'inquiry', + name: 'Inquiry', + category: 'inquiry', + position: 1, + color: 'blue', + isSystem: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + shoots: [], + locations: [], + timelineItems: [], + participants: [], + tasks: [], + clientContacts: [], + proposals: [dummyProposalDraft, dummyProposalSent, dummyProposalAccepted], +}; + +describe('Project Detail Proposals Integration Tests', () => { + beforeEach(() => { + currentLocale = 'en'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Proposals Section Helper Functions', () => { + it('returns semantic status badge classes for all proposal statuses', () => { + expect(getProposalStatusBadgeClasses('draft')).toContain('bg-secondary'); + expect(getProposalStatusBadgeClasses('sent')).toContain('text-blue-700'); + expect(getProposalStatusBadgeClasses('accepted')).toContain('text-emerald-700'); + expect(getProposalStatusBadgeClasses('declined')).toContain('text-red-700'); + expect(getProposalStatusBadgeClasses('expired')).toContain('text-amber-700'); + expect(getProposalStatusBadgeClasses('superseded')).toContain('bg-muted'); + }); + + it('formatProposalDate formats dates correctly and handles invalid/empty input', () => { + const formatted = formatProposalDate(new Date('2027-06-12T12:00:00Z'), 'en'); + expect(formatted).toContain('2027'); + expect(formatted).toContain('Jun'); + + expect(formatProposalDate(null, 'en')).toBe(''); + expect(formatProposalDate(undefined, 'en')).toBe(''); + expect(formatProposalDate('invalid-date', 'en')).toBe(''); + }); + + it('isProposalLinkActive returns true only for sent proposal with publicUrl and no tokenRevokedAt', () => { + expect(isProposalLinkActive(dummyProposalSent)).toBe(true); + expect(isProposalLinkActive(dummyProposalDraft)).toBe(false); + expect(isProposalLinkActive(dummyProposalRevoked)).toBe(false); + }); + + it('isProposalLinkRevoked returns true when tokenRevokedAt is set', () => { + expect(isProposalLinkRevoked(dummyProposalRevoked)).toBe(true); + expect(isProposalLinkRevoked(dummyProposalSent)).toBe(false); + }); + + it('isDraftProposal returns true only for draft status', () => { + expect(isDraftProposal(dummyProposalDraft)).toBe(true); + expect(isDraftProposal(dummyProposalSent)).toBe(false); + }); + + it('resolvePublicTokenUrl prepends origin to relative token URL', () => { + expect(resolvePublicTokenUrl('/p/abc123', 'https://studio.example.com')).toBe( + 'https://studio.example.com/p/abc123', + ); + expect(resolvePublicTokenUrl('/p/abc123', 'https://studio.example.com/')).toBe( + 'https://studio.example.com/p/abc123', + ); + expect( + resolvePublicTokenUrl('https://other.example.com/p/abc123', 'https://studio.example.com'), + ).toBe('https://other.example.com/p/abc123'); + expect(resolvePublicTokenUrl(null)).toBe(''); + expect(resolvePublicTokenUrl(undefined)).toBe(''); + }); + + it('copyToClipboard copies text to clipboard and handles permissions', async () => { + // Mock window and navigator.clipboard + const mockWriteText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('window', {}); + vi.stubGlobal('navigator', { + clipboard: { writeText: mockWriteText }, + }); + + const success = await copyToClipboard('https://example.com/p/test'); + expect(success).toBe(true); + expect(mockWriteText).toHaveBeenCalledWith('https://example.com/p/test'); + + // Failure branch (writeText throws) + mockWriteText.mockRejectedValueOnce(new Error('Permission denied')); + const failure = await copyToClipboard('https://example.com/p/test'); + expect(failure).toBe(false); + + // Failure branch (no clipboard) + vi.stubGlobal('navigator', {}); + expect(await copyToClipboard('https://example.com/p/test')).toBe(false); + + vi.unstubAllGlobals(); + }); + + it('executeCopyProposalLink copies proposal public URL directly or via fallback fetchLinkAction', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('window', {}); + vi.stubGlobal('navigator', { + clipboard: { writeText: mockWriteText }, + }); + + // 1. Direct publicUrl + const url1 = await executeCopyProposalLink(dummyProposalSent, 'https://example.com'); + expect(url1).toBe('https://example.com/p/demo-token-sent-32bytes'); + + // 2. Fetch action fallback + const proposalWithoutUrl = { ...dummyProposalSent, publicUrl: null }; + const mockFetchLink = vi.fn().mockResolvedValue({ + success: true, + data: { tokenUrl: '/p/fetched-token' }, + }); + const url2 = await executeCopyProposalLink( + proposalWithoutUrl, + 'https://example.com', + mockFetchLink, + ); + expect(url2).toBe('https://example.com/p/fetched-token'); + + // 3. Fallback failure + const url3 = await executeCopyProposalLink(proposalWithoutUrl, 'https://example.com'); + expect(url3).toBeNull(); + + vi.unstubAllGlobals(); + }); + + it('executeRevokeProposalLink calls revoke action and returns updated proposal state', async () => { + const mockRevokeAction = vi.fn().mockResolvedValue({ + success: true, + data: { revokedAt: new Date('2027-01-05T00:00:00Z') }, + }); + + const res = await executeRevokeProposalLink( + dummyProposalSent.id, + [dummyProposalSent, dummyProposalDraft], + mockRevokeAction, + ); + + expect(res.success).toBe(true); + const revoked = res.updatedProposals?.find((p) => p.id === dummyProposalSent.id); + expect(revoked?.tokenRevokedAt).toBeDefined(); + expect(revoked?.publicUrl).toBeNull(); + + // Failure branch with explicit error + mockRevokeAction.mockResolvedValueOnce({ + success: false, + error: 'Forbidden to revoke', + }); + const failRes1 = await executeRevokeProposalLink( + dummyProposalSent.id, + [dummyProposalSent], + mockRevokeAction, + ); + expect(failRes1.success).toBe(false); + expect(failRes1.error).toBe('Forbidden to revoke'); + + // Failure branch with fallback error + mockRevokeAction.mockResolvedValueOnce({ success: false }); + const failRes2 = await executeRevokeProposalLink( + dummyProposalSent.id, + [dummyProposalSent], + mockRevokeAction, + 'Default error', + ); + expect(failRes2.success).toBe(false); + expect(failRes2.error).toBe('Default error'); + }); + + it('createRevokeProposalDialogController manages dialog open, auto-focus and actions', () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + const mockFocus = vi.fn(); + const triggerRef = { current: { focus: mockFocus } as unknown as HTMLElement }; + + const controller = createRevokeProposalDialogController(onClose, onConfirm, triggerRef); + + controller.handleOpenChange(false); + expect(onClose).toHaveBeenCalledTimes(1); + + controller.handleOpenChange(true); + expect(onClose).toHaveBeenCalledTimes(1); // not called again + + const mockPreventDefault = vi.fn(); + controller.handleCloseAutoFocus({ preventDefault: mockPreventDefault }); + expect(mockPreventDefault).toHaveBeenCalled(); + expect(mockFocus).toHaveBeenCalled(); + + controller.handleConfirmClick(); + expect(onConfirm).toHaveBeenCalledTimes(1); + + controller.handleCancelClick(); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('createProposalsSectionViewController dispatches copy and revoke actions', () => { + const onCopy = vi.fn(); + const onOpenRevoke = vi.fn(); + const controller = createProposalsSectionViewController(onCopy, onOpenRevoke); + + const copyHandler = controller.handleCopyClick(dummyProposalSent); + copyHandler(); + expect(onCopy).toHaveBeenCalledWith(dummyProposalSent); + + const revokeHandler = controller.handleRevokeClick(dummyProposalSent); + const mockEvent = {} as React.MouseEvent; + revokeHandler(mockEvent); + expect(onOpenRevoke).toHaveBeenCalledWith(dummyProposalSent, mockEvent); + }); + + it('useProposalsSectionController manages copy, revoke lifecycle, and error handling', async () => { + vi.useFakeTimers(); + const mockGetAction = vi.mocked(getProposalPublicLinkAction); + const mockRevokeAction = vi.mocked(revokeProposalTokenAction); + + let controllerInstance: ProposalsSectionController | undefined; + let stateProposals: ProjectDetailProposal[] = [dummyProposalSent]; + const setProposals = vi.fn((updater: React.SetStateAction) => { + if (typeof updater === 'function') { + stateProposals = updater(stateProposals); + } else { + stateProposals = updater; + } + }); + const onProposalsChange = vi.fn(); + + function TestHookWrapper() { + controllerInstance = useProposalsSectionController({ + proposals: stateProposals, + setProposals, + onProposalsChange, + fallbackRevokeError: 'Fallback revoke error', + }); + return null; + } + + renderToStaticMarkup(); + if (!controllerInstance) { + throw new Error('controllerInstance must be initialized'); + } + const ctrl = controllerInstance; + + // 1. handleCloseRevoke resets revokingProposal and error + ctrl.handleCloseRevoke(); + expect(ctrl.revokingProposal).toBeNull(); + expect(ctrl.revokeError).toBeNull(); + + // 2. handleConfirmRevoke with no revokingProposal returns early + await ctrl.handleConfirmRevoke(); + expect(mockRevokeAction).not.toHaveBeenCalled(); + + // 3. handleOpenRevoke sets triggerRef + const mockButton = { focus: vi.fn() } as unknown as HTMLButtonElement; + const mockEvent = { + currentTarget: mockButton, + } as unknown as React.MouseEvent; + ctrl.handleOpenRevoke(dummyProposalSent, mockEvent); + expect(ctrl.revokeTriggerRef.current).toBe(mockButton); + + // 4. handleConfirmRevoke failure + mockRevokeAction.mockResolvedValueOnce({ + success: false, + code: 'NETWORK_ERROR', + error: 'Network failure', + }); + await ctrl.handleConfirmRevoke(dummyProposalSent); + expect(mockRevokeAction).toHaveBeenCalledWith({ proposalId: dummyProposalSent.id }); + + // 5. handleConfirmRevoke success + const revokedDate = new Date('2026-11-04T22:00:00Z'); + mockRevokeAction.mockResolvedValueOnce({ + success: true, + data: { proposalId: dummyProposalSent.id, revokedAt: revokedDate }, + }); + await ctrl.handleConfirmRevoke(dummyProposalSent); + expect(onProposalsChange).toHaveBeenCalled(); + + // 6. handleCopyLink with clipboard mock + mockGetAction.mockResolvedValueOnce({ + success: true, + data: { + proposalId: dummyProposalSent.id, + tokenUrl: '/p/custom-link', + token: 'custom-link', + }, + }); + await ctrl.handleCopyLink(dummyProposalSent); + + // Fast-forward copy timer + vi.advanceTimersByTime(3000); + vi.useRealTimers(); + }); + }); + + describe('AC-1: Project Detail Proposals Card Rendering', () => { + it('AC-1: displays empty state when no proposals exist', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-testid="project-proposals-section"'); + expect(html).toContain('data-testid="proposals-empty-state"'); + expect(html).toContain('data-testid="new-proposal-btn"'); + expect(html).toContain('No proposals created for this project yet.'); + }); + + it('AC-1: displays each proposal with title, revision number, status badge, formatted currency total, and validity date', () => { + const html = renderToStaticMarkup( + , + ); + + // Section and list container + expect(html).toContain('data-testid="project-proposals-section"'); + expect(html).toContain('data-testid="proposals-list"'); + + // Draft proposal + expect(html).toContain(`data-testid="proposal-item-${dummyProposalDraft.id}"`); + expect(html).toContain(`data-testid="proposal-title-${dummyProposalDraft.id}"`); + expect(html).toContain('Wedding Photography Proposal (Draft)'); + expect(html).toContain('Draft'); + expect(html).toContain('Revision v1'); + expect(html).toContain('2,800.00'); + + // Sent proposal + expect(html).toContain(`data-testid="proposal-item-${dummyProposalSent.id}"`); + expect(html).toContain(`data-testid="proposal-title-${dummyProposalSent.id}"`); + expect(html).toContain('Wedding Coverage & Add-on (Sent)'); + expect(html).toContain('Sent'); + expect(html).toContain('3,050.00'); + + // Accepted proposal + expect(html).toContain(`data-testid="proposal-item-${dummyProposalAccepted.id}"`); + expect(html).toContain('Accepted'); + expect(html).toContain('Accepted on'); + }); + + it('AC-1: draft proposals render Edit Draft action button routing to proposal workspace', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(`data-testid="edit-draft-proposal-btn-${dummyProposalDraft.id}"`); + expect(html).toContain( + `href="/en/projects/01920000-0000-7000-8000-000000000071/proposals/${dummyProposalDraft.id}"`, + ); + expect(html).toContain('Edit Draft'); + }); + }); + + describe('AC-2 & AC-3: Sent Proposal Actions (Copy Link & Revoke)', () => { + it('AC-2: sent proposal displays Copy Public Link button', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(`data-testid="proposal-copy-link-btn-${dummyProposalSent.id}"`); + expect(html).toContain('Copy Public Link'); + }); + + it('AC-3: sent proposal displays Revoke Link button with accessible confirmation dialog structure', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(`data-testid="proposal-revoke-link-btn-${dummyProposalSent.id}"`); + expect(html).toContain('Revoke Link'); + }); + + it('AC-3: revoked proposal displays Link Revoked badge and hides Copy/Revoke buttons', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain( + `data-testid="proposal-link-revoked-badge-${dummyProposalRevoked.id}"`, + ); + expect(html).toContain('Link Revoked'); + expect(html).not.toContain(`data-testid="proposal-copy-link-btn-${dummyProposalRevoked.id}"`); + expect(html).not.toContain( + `data-testid="proposal-revoke-link-btn-${dummyProposalRevoked.id}"`, + ); + }); + + it('AC-3: RevokeProposalDialog renders all states with full accessibility and error feedback', () => { + const onConfirm = vi.fn(); + const onClose = vi.fn(); + + // Normal open state + const openHtml = renderToStaticMarkup( + , + ); + expect(openHtml).toContain('data-testid="revoke-proposal-dialog"'); + expect(openHtml).toContain('data-testid="revoke-dialog-title"'); + expect(openHtml).toContain('Revoke Public Link'); + expect(openHtml).toContain('data-testid="revoke-dialog-description"'); + expect(openHtml).toContain('data-testid="revoke-cancel-btn"'); + expect(openHtml).toContain('data-testid="revoke-confirm-btn"'); + + // Error alert state (WCAG 3.3.1) + const errorHtml = renderToStaticMarkup( + , + ); + expect(errorHtml).toContain('role="alert"'); + expect(errorHtml).toContain('Failed to revoke link due to network timeout'); + + // Loading spinner state + const loadingHtml = renderToStaticMarkup( + , + ); + expect(loadingHtml).toContain('animate-spin'); + expect(loadingHtml).toContain('Revoking...'); + }); + }); + + describe('AC-5: Internationalization Parity in German (/de)', () => { + it('AC-5: renders proposals section and revoke dialog in German with zero missing translation keys', () => { + currentLocale = 'de'; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Kostenvoranschläge & Angebote'); + expect(html).toContain('Neues Angebot'); + expect(html).toContain('Entwurf'); + expect(html).toContain('Versendet'); + expect(html).toContain('Angenommen'); + expect(html).toContain('Version v1'); + expect(html).toContain('Gesamtbetrag'); + expect(html).toContain('Öffentlichen Link kopieren'); + expect(html).toContain('Link widerrufen'); + expect(html).toContain('Entwurf bearbeiten'); + + // Render RevokeProposalDialog in German + const dialogHtml = renderToStaticMarkup( + , + ); + expect(dialogHtml).toContain('Öffentlichen Link widerrufen'); + expect(dialogHtml).toContain('Abbrechen'); + }); + }); + + describe('AC-6: Accessibility & WCAG 2.2 AA Markup', () => { + it('AC-6: proposals section contains proper semantic region and heading hierarchy', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(' { + it('renders Proposals tab in project detail tab list with correct badge count', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-testid="tab-proposals"'); + expect(html).toContain('id="tab-proposals"'); + expect(html).toContain('aria-controls="panel-proposals"'); + expect(html).toContain('Proposals'); + expect(html).toContain('>3<'); // count of proposals + expect(html).toContain('data-testid="project-proposals-section"'); + }); + }); +}); diff --git a/tests/unit/proposal-public-link-action.test.ts b/tests/unit/proposal-public-link-action.test.ts new file mode 100644 index 0000000..80fbcaa --- /dev/null +++ b/tests/unit/proposal-public-link-action.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { getProposalPublicLinkAction } from '@/server/proposals/actions'; +import { ForbiddenError } from '@/server/auth/guards'; + +const mockRequireOwner = vi.fn(); +vi.mock('@/server/auth/guards', async () => { + const actual = + await vi.importActual('@/server/auth/guards'); + return { + ...actual, + requireOwner: (...args: unknown[]): unknown => mockRequireOwner(...args), + }; +}); + +const mockGetProposalById = vi.fn(); +vi.mock('@/server/proposals/repo', async () => { + const actual = + await vi.importActual('@/server/proposals/repo'); + return { + ...actual, + getProposalById: (...args: unknown[]): unknown => mockGetProposalById(...args), + }; +}); + +describe('getProposalPublicLinkAction unit tests', () => { + const validProposalId = '01920000-0000-7000-8000-000000000202'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('AC-2: rejects unauthenticated caller with UNAUTHENTICATED', async () => { + mockRequireOwner.mockRejectedValue(new Error('Unauthorized')); + + const result = await getProposalPublicLinkAction({ proposalId: validProposalId }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('UNAUTHENTICATED'); + } + }); + + it('AC-2: rejects non-owner caller with FORBIDDEN', async () => { + mockRequireOwner.mockRejectedValue(new ForbiddenError('Forbidden')); + + const result = await getProposalPublicLinkAction({ proposalId: validProposalId }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('FORBIDDEN'); + } + }); + + it('AC-2: rejects invalid non-uuid proposalId with VALIDATION_FAILED', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + + const result = await getProposalPublicLinkAction({ proposalId: 'not-a-uuid' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('VALIDATION_FAILED'); + } + }); + + it('AC-2: returns NOT_FOUND if proposal does not exist', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + mockGetProposalById.mockResolvedValue(null); + + const result = await getProposalPublicLinkAction({ proposalId: validProposalId }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('NOT_FOUND'); + } + }); + + it('AC-2: returns REVOKED if proposal token is revoked', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + mockGetProposalById.mockResolvedValue({ + id: validProposalId, + status: 'sent', + tokenRevokedAt: new Date(), + }); + + const result = await getProposalPublicLinkAction({ proposalId: validProposalId }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('REVOKED'); + } + }); + + it('AC-2: resolves proposal public link from email outbox', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + mockGetProposalById.mockResolvedValue({ + id: validProposalId, + status: 'sent', + tokenRevokedAt: null, + }); + + const mockDbClient = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([ + { + payload: { + token: 'test-secret-token-32bytes', + tokenUrl: '/p/test-secret-token-32bytes', + }, + }, + ]), + }; + + const result = await getProposalPublicLinkAction( + { proposalId: validProposalId }, + mockDbClient as never, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.proposalId).toBe(validProposalId); + expect(result.data.token).toBe('test-secret-token-32bytes'); + expect(result.data.tokenUrl).toBe('/p/test-secret-token-32bytes'); + } + }); +}); From a2a59ac373dca676f48539a37c34825577c2c8e8 Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:58:25 +0100 Subject: [PATCH 2/2] feat(proposals): improve branch coverage and public link resolution (TASK-0063) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- src/components/projects/proposals-section.tsx | 16 +- .../project-detail-integration.test.ts | 124 +++++++++++++++ tests/unit/project-detail-proposals.test.tsx | 66 ++++++++ .../unit/proposal-public-link-action.test.ts | 147 +++++++++++++++++- 4 files changed, 343 insertions(+), 10 deletions(-) diff --git a/src/components/projects/proposals-section.tsx b/src/components/projects/proposals-section.tsx index faa5171..12dbd58 100644 --- a/src/components/projects/proposals-section.tsx +++ b/src/components/projects/proposals-section.tsx @@ -23,6 +23,7 @@ export interface ProposalsSectionProps { initialProposals: ProjectDetailProposal[]; locale: string; onProposalsChange?: (proposals: ProjectDetailProposal[]) => void; + controllerOverride?: ReturnType; } export function ProposalsSection({ @@ -30,11 +31,19 @@ export function ProposalsSection({ initialProposals, locale, onProposalsChange, + controllerOverride, }: ProposalsSectionProps) { const t = useTranslations('projects.detail.proposals'); const [proposals, setProposals] = React.useState(initialProposals); + const defaultController = useProposalsSectionController({ + proposals, + setProposals, + onProposalsChange, + fallbackRevokeError: t('actions.revokeError'), + }); + const { copiedProposalId, revokingProposal, @@ -44,12 +53,7 @@ export function ProposalsSection({ viewController, handleCloseRevoke, handleConfirmRevoke, - } = useProposalsSectionController({ - proposals, - setProposals, - onProposalsChange, - fallbackRevokeError: t('actions.revokeError'), - }); + } = controllerOverride ?? defaultController; return (
const detail = await getProjectDetail(nonExistentId, dbClient); expect(detail).toBeNull(); }); + + it('AC-1: resolves proposals and public links for project detail', async () => { + const now = new Date(); + // 1. Draft proposal (no outbox) + const draftId = generateUuidV7(); + await dbClient.insert(proposals).values({ + id: draftId, + projectId: testProjectId, + clientId: testClientId, + title: 'Initial Consultation Proposal', + version: 1, + status: 'draft', + currency: 'EUR', + subtotalMinor: 150000, + taxMinor: 28500, + totalMinor: 178500, + tokenHash: 'hash-draft-1', + createdAt: new Date(now.getTime() - 20000), + updatedAt: new Date(now.getTime() - 20000), + }); + + // 2. Sent proposal with tokenUrl in outbox + const sentId1 = generateUuidV7(); + await dbClient.insert(proposals).values({ + id: sentId1, + projectId: testProjectId, + clientId: testClientId, + title: 'Sent Proposal With Explicit Link', + version: 2, + status: 'sent', + currency: 'EUR', + subtotalMinor: 200000, + taxMinor: 38000, + totalMinor: 238000, + tokenHash: 'hash-sent-1', + createdAt: new Date(now.getTime() - 10000), + updatedAt: new Date(now.getTime() - 10000), + }); + await dbClient.insert(emailOutbox).values({ + id: generateUuidV7(), + kind: 'proposal_sent', + proposalId: sentId1, + recipient: 'client@example.com', + payload: { + token: 'tok-sent-1', + tokenUrl: '/p/custom-link-url', + }, + status: 'PENDING', + createdAt: new Date(now.getTime() - 9000), + }); + + // 3. Sent proposal with token only (fallback tokenUrl) + const sentId2 = generateUuidV7(); + await dbClient.insert(proposals).values({ + id: sentId2, + projectId: testProjectId, + clientId: testClientId, + title: 'Sent Proposal Fallback Token', + version: 3, + status: 'sent', + currency: 'EUR', + subtotalMinor: 250000, + taxMinor: 47500, + totalMinor: 297500, + tokenHash: 'hash-sent-2', + createdAt: new Date(now.getTime() - 5000), + updatedAt: new Date(now.getTime() - 5000), + }); + await dbClient.insert(emailOutbox).values({ + id: generateUuidV7(), + kind: 'proposal_sent', + proposalId: sentId2, + recipient: 'client@example.com', + payload: { + token: 'tok-sent-2', + }, + status: 'PENDING', + createdAt: new Date(now.getTime() - 4000), + }); + + // 4. Revoked proposal (tokenRevokedAt set) + const revokedId = generateUuidV7(); + await dbClient.insert(proposals).values({ + id: revokedId, + projectId: testProjectId, + clientId: testClientId, + title: 'Revoked Sent Proposal', + version: 4, + status: 'sent', + currency: 'EUR', + subtotalMinor: 180000, + taxMinor: 34200, + totalMinor: 214200, + tokenHash: 'hash-revoked-1', + tokenRevokedAt: now, + createdAt: now, + updatedAt: now, + }); + await dbClient.insert(emailOutbox).values({ + id: generateUuidV7(), + kind: 'proposal_sent', + proposalId: revokedId, + recipient: 'client@example.com', + payload: { + token: 'tok-revoked', + tokenUrl: '/p/tok-revoked', + }, + status: 'PENDING', + createdAt: now, + }); + + const detail = await getProjectDetail(testProjectId, dbClient); + expect(detail).not.toBeNull(); + expect(detail?.proposals).toBeDefined(); + expect(detail?.proposals).toHaveLength(4); + + const propMap = new Map((detail?.proposals ?? []).map((p) => [p.id, p])); + expect(propMap.get(draftId)?.publicUrl).toBeNull(); + expect(propMap.get(sentId1)?.publicUrl).toBe('/p/custom-link-url'); + expect(propMap.get(sentId2)?.publicUrl).toBe('/p/tok-sent-2'); + expect(propMap.get(revokedId)?.publicUrl).toBeNull(); + }); }); // AC-2: Shoot and location CRUD operations diff --git a/tests/unit/project-detail-proposals.test.tsx b/tests/unit/project-detail-proposals.test.tsx index 7150c63..519e273 100644 --- a/tests/unit/project-detail-proposals.test.tsx +++ b/tests/unit/project-detail-proposals.test.tsx @@ -754,6 +754,72 @@ describe('Project Detail Proposals Integration Tests', () => { expect(html).toContain(' { + const proposalWithExpiry: ProjectDetailProposal = { + ...dummyProposalSent, + validUntil: new Date('2026-12-31T12:00:00Z'), + }; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Valid until'); + + // Empty state + const emptyHtml = renderToStaticMarkup( + , + ); + expect(emptyHtml).toContain('data-testid="proposals-empty-state"'); + }); + + it('renders link-copied-feedback when copiedProposalId matches and executes viewController callbacks', () => { + let copyInvoked = false; + let revokeInvoked = false; + const testController = { + copiedProposalId: dummyProposalSent.id, + revokingProposal: null, + isRevoking: false, + revokeError: null, + revokeTriggerRef: { current: null }, + viewController: { + handleCopyClick: (proposal: ProjectDetailProposal) => () => { + copyInvoked = proposal.id === dummyProposalSent.id; + }, + handleRevokeClick: + (proposal: ProjectDetailProposal) => (e: React.MouseEvent) => { + revokeInvoked = proposal.id === dummyProposalSent.id && Boolean(e); + }, + }, + handleConfirmRevoke: vi.fn(), + handleCloseRevoke: vi.fn(), + }; + + testController.viewController.handleCopyClick(dummyProposalSent)(); + testController.viewController.handleRevokeClick(dummyProposalSent)({ + currentTarget: {}, + } as never); + expect(copyInvoked).toBe(true); + expect(revokeInvoked).toBe(true); + + const htmlWithCopied = renderToStaticMarkup( + , + ); + expect(htmlWithCopied).toContain('data-testid="link-copied-feedback"'); + expect(htmlWithCopied).toContain('Link Copied!'); + }); }); describe('ProjectDetailView Integration', () => { diff --git a/tests/unit/proposal-public-link-action.test.ts b/tests/unit/proposal-public-link-action.test.ts index 80fbcaa..185b966 100644 --- a/tests/unit/proposal-public-link-action.test.ts +++ b/tests/unit/proposal-public-link-action.test.ts @@ -112,6 +112,7 @@ describe('getProposalPublicLinkAction unit tests', () => { tokenRevokedAt: null, }); + const sampleToken = 'tok-preview'; const mockDbClient = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), @@ -120,8 +121,8 @@ describe('getProposalPublicLinkAction unit tests', () => { limit: vi.fn().mockResolvedValue([ { payload: { - token: 'test-secret-token-32bytes', - tokenUrl: '/p/test-secret-token-32bytes', + token: sampleToken, + tokenUrl: `/p/${sampleToken}`, }, }, ]), @@ -135,8 +136,146 @@ describe('getProposalPublicLinkAction unit tests', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.proposalId).toBe(validProposalId); - expect(result.data.token).toBe('test-secret-token-32bytes'); - expect(result.data.tokenUrl).toBe('/p/test-secret-token-32bytes'); + expect(result.data.token).toBe(sampleToken); + expect(result.data.tokenUrl).toBe(`/p/${sampleToken}`); + } + }); + + it('AC-2: uses fallback tokenUrl when outbox payload omits tokenUrl', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + mockGetProposalById.mockResolvedValue({ + id: validProposalId, + status: 'sent', + tokenRevokedAt: null, + }); + + const sampleToken = 'tok-fallback'; + const mockDbClient = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([ + { + payload: { + token: sampleToken, + }, + }, + ]), + }; + + const result = await getProposalPublicLinkAction( + { proposalId: validProposalId }, + mockDbClient as never, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tokenUrl).toBe(`/p/${sampleToken}`); + } + }); + + it('AC-2: returns NOT_FOUND when outbox payload token is missing', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + mockGetProposalById.mockResolvedValue({ + id: validProposalId, + status: 'sent', + tokenRevokedAt: null, + }); + + const mockDbClient = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([ + { + payload: {}, + }, + ]), + }; + + const result = await getProposalPublicLinkAction( + { proposalId: validProposalId }, + mockDbClient as never, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('NOT_FOUND'); + expect(result.error).toBe('Proposal link not available'); + } + }); + + it('AC-2: catches unexpected Error in getProposalPublicLinkAction', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + mockGetProposalById.mockResolvedValue({ + id: validProposalId, + status: 'sent', + tokenRevokedAt: null, + }); + + const mockDbClient = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockRejectedValue(new Error('Outbox query failed')), + }; + + const result = await getProposalPublicLinkAction( + { proposalId: validProposalId }, + mockDbClient as never, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('INTERNAL_ERROR'); + expect(result.error).toBe('Outbox query failed'); + } + }); + + it('AC-2: catches unexpected non-Error in getProposalPublicLinkAction', async () => { + mockRequireOwner.mockResolvedValue({ + user: { id: 'owner-1', role: 'owner' }, + session: { id: 'sess-1' }, + authType: 'session', + }); + mockGetProposalById.mockResolvedValue({ + id: validProposalId, + status: 'sent', + tokenRevokedAt: null, + }); + + const mockDbClient = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockRejectedValue('Fatal string exception'), + }; + + const result = await getProposalPublicLinkAction( + { proposalId: validProposalId }, + mockDbClient as never, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe('INTERNAL_ERROR'); + expect(result.error).toBe('Internal error resolving proposal link'); } }); });