Files
accounted/lib/support/__tests__/submit-feedback.test.ts
T
Jakob Wennberg 52ec3ce497 feat(support): open PostHog tickets from the existing support dialog (#1239)
Enables PostHog Support through the Direct API (posthog.conversations),
restoring the second channel Recapt used to provide, but as a real ticket
linked to the person and their session replay instead of a black hole.

The in-app WIDGET stays off on purpose. It is a third-party floating chat
bubble, which is exactly what Recapt was: it would sit next to the
Assistenten FAB (which already has a hide_assistant_fab preference
because users wanted it gone), cannot follow the locked design system,
and its copy is not ours to keep Swedish. The conversations API gives the
same tickets from components/ui/support-link.tsx, which is already
on-design, Swedish and reachable from 8 surfaces.

A ticket is explicitly NOT treated as delivery. submitFeedback returns ok
only when the Resend email actually went out, even if the ticket opened.
Recapt's precise failure mode was reporting success on its own channel
while /api/support/contact was dead, and nobody is watching PostHog at
02:00. Tests pin that: ticket-only is ok:false.

Identity verification uses posthog.setIdentity(distinctId, hash) at
runtime rather than the identity_distinct_id/identity_hash init options
PostHog's settings page documents. init runs from
instrumentation-client.ts app-wide, before the user is known and
including logged-out pages, and PostHog fixes init values for the
session. setIdentity is a real method on the SDK (verified typed in
posthog-js 1.407.3), so the hash applies from AnalyticsIdentify once the
dashboard layout knows who the user is. Without the key it is skipped and
tickets fall back to browser-scoped with email recovery, which is the
normal state off hosted.

POSTHOG_SECRET_API_KEY is server-only, no NEXT_PUBLIC_ prefix: it signs
identity hashes AND authenticates external API requests, so unlike the
phc_ project token it is a real credential. Only the derived per-user
HMAC crosses to the browser.

Compliance: support free text is declared as its own data category
(user.content.support) in .compliance/ropa.yaml and named on the privacy
page. Analytics events still carry no message body (the breadcrumb sends
only the subject); a ticket carries what the user wrote, because that is
the point. Keeping the purposes separate is what stops the privacy page
drifting the way the Recapt row did.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:23:32 +02:00

184 lines
6.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { submitFeedback } from '@/lib/support/submit-feedback'
// posthog-js is browser-only and irrelevant to delivery: stub it so the
// analytics breadcrumb can be asserted without initialising the real SDK.
const captureMock = vi.fn()
const sendMessageMock = vi.fn()
const isAvailableMock = vi.fn(() => true)
vi.mock('posthog-js', () => ({
default: {
capture: (...a: unknown[]) => captureMock(...a),
conversations: {
isAvailable: () => isAvailableMock(),
sendMessage: (...a: unknown[]) => sendMessageMock(...a),
},
},
}))
describe('submitFeedback', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
captureMock.mockClear()
sendMessageMock.mockClear()
isAvailableMock.mockReturnValue(true)
// Analytics on by default so the breadcrumb path is exercised.
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test')
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
function stubFetchOk() {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchSpy)
return fetchSpy
}
it('delivers over email and reports the email channel', async () => {
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email', 'ticket'])
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ subject: 'Hjälpsida', message: 'Hjälp tack' }),
})
)
})
// Recapt used to mask a failing email endpoint by reporting success on its
// own channel. Email is now the only delivery path, so its failure must
// surface to the user instead of being swallowed.
it('reports failure when the email endpoint rejects', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: 'Mailtjänsten är inte konfigurerad' }),
})
)
const result = await submitFeedback({ message: 'msg' })
// A ticket may still open, but delivery failed, so ok stays false.
expect(result.ok).toBe(false)
expect(result.channels).not.toContain('email')
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
})
it('reports failure when fetch itself throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(false)
expect(result.channels).not.toContain('email')
expect(result.error).toBe('Network down')
})
it('records a PostHog breadcrumb WITHOUT the message body', async () => {
stubFetchOk()
await submitFeedback({ subject: 'Hjälpsida', message: 'känslig text om mitt bolag' })
expect(captureMock).toHaveBeenCalledWith('support_feedback_submitted', {
subject: 'Hjälpsida',
delivered: true,
})
// Free text is user content: it must never ride along as an event property.
expect(JSON.stringify(captureMock.mock.calls)).not.toContain('känslig text')
})
it('marks the breadcrumb undelivered when email failed', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }))
await submitFeedback({ message: 'msg' })
expect(captureMock).toHaveBeenCalledWith(
'support_feedback_submitted',
expect.objectContaining({ delivered: false })
)
})
it('skips the breadcrumb entirely when analytics is off (self-hosted)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(captureMock).not.toHaveBeenCalled()
})
it('does not let a throwing analytics SDK break delivery', async () => {
captureMock.mockImplementationOnce(() => {
throw new Error('posthog boom')
})
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toContain('email')
})
describe('PostHog Support ticket channel', () => {
it('opens a ticket carrying the message body, unlike the analytics breadcrumb', async () => {
stubFetchOk()
await submitFeedback({ subject: 'Moms', message: 'Jag fastnar på ruta 05' })
expect(sendMessageMock).toHaveBeenCalledWith('[Moms]\n\nJag fastnar på ruta 05')
})
it('omits the subject prefix when there is no subject', async () => {
stubFetchOk()
await submitFeedback({ message: 'bara text' })
expect(sendMessageMock).toHaveBeenCalledWith('bara text')
})
// A ticket alone is NOT delivery: nobody is watching PostHog at 02:00, and
// Recapt masking a dead email endpoint is the exact bug we removed.
it('does not report success when only the ticket worked', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) }))
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(false)
expect(result.channels).toEqual(['ticket'])
expect(result.error).toBe('down')
})
it('still delivers by email when conversations are unavailable', async () => {
isAvailableMock.mockReturnValue(false)
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
expect(sendMessageMock).not.toHaveBeenCalled()
})
it('still delivers by email when sendMessage throws', async () => {
sendMessageMock.mockRejectedValueOnce(new Error('conversations boom'))
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
it('opens no ticket when analytics is off (self-hosted)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.channels).toEqual(['email'])
expect(sendMessageMock).not.toHaveBeenCalled()
})
})
})