diff --git a/.env.example b/.env.example index 300181a98b..c944ae71c3 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,12 @@ EMAIL_SES_REGION= # Format (no quotes): Your App EMAIL_FROM= +# Product name shown inside transactional emails — the sign-in, invite, welcome, +# and password-reset subjects and headings. Defaults to "Quackback". Set this if +# you run under your own brand so those emails say your name, not ours. This is +# the product name, separate from each workspace's own name. +# EMAIL_BRAND_NAME=Your App + # ============================================================================ # Inbound email (optional) — conversation email channel # ============================================================================ diff --git a/.env.prod.example b/.env.prod.example index 9d5729fd8e..a9bc3bea57 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -52,6 +52,9 @@ EMAIL_SMTP_PORT=587 EMAIL_SMTP_USER= EMAIL_SMTP_PASS= EMAIL_FROM=Quackback +# Product name shown inside transactional emails (subjects and headings). +# Defaults to "Quackback"; set it to run those emails under your own brand. +# EMAIL_BRAND_NAME=Quackback # Or use the Amazon SES API instead of SMTP. All three are required: the # region is the one the sending identity is verified in, and there is no # default because a verified identity is regional. diff --git a/SELF-IMPROVE.md b/SELF-IMPROVE.md index 52f350a35e..f2ed9d97b4 100644 --- a/SELF-IMPROVE.md +++ b/SELF-IMPROVE.md @@ -5,7 +5,7 @@ when the same thing bites again and re-sort the list by counter, descending. Entries that have actually been fixed move to **Resolved** at the end, with what fixed them — they are the record of what the counters bought. -## 7x — Test suites are flaky under parallel load +## 8x — Test suites are flaky under parallel load `principals/__tests__/seat-usage.db.test.ts` and `tickets/__tests__/ticket-convergence-1b.test.ts` each fail intermittently when @@ -90,6 +90,19 @@ Raising `hookTimeout` to match is the obvious fix; not done inside the back-merge, for the reason above -- a change to shared test infrastructure needs its own run to be falsifiable. +Eighth hit, on the back-merge of upstream #506–#520: a run over 345 suites with +coverage ended on one red test, `policy/module-state/__tests__/module-state.test.ts` +(`Test timed out in 20000ms` on the scanner walk), and the same file passed alone in +under a second of test time. Another scanner-shaped suite that walks the source tree +and lives close to the 20s ceiling under contention; nothing in the change touched it. +The re-run of the same set, capped at six workers, went red on a different file +instead: `jobs/__tests__/runner.test.ts` ("reaps a stranded lease and prunes an aged +terminal row in one pass") saw `pruned` come back 0 for a row it had just aged +400 days, and passed three times in a row alone. `job-queue.test.ts` and +`worker.test.ts` prune the same shared `job_queue` table from their own workers, +so whichever process prunes first takes the other's row and its count. Same +shape as `seat-usage`: a database-wide count asserted across parallel suites. + ## 1x — A line that is only an arrow function passed as a JSX prop reads as uncovered until the handler actually fires Filling a diff-coverage hole for `onEdit={() => onEdit(row)}`-shaped lines diff --git a/apps/web/e2e/tests/admin/settings-tags.spec.ts b/apps/web/e2e/tests/admin/settings-tags.spec.ts index e062b5a9e0..d4ea19e3e1 100644 --- a/apps/web/e2e/tests/admin/settings-tags.spec.ts +++ b/apps/web/e2e/tests/admin/settings-tags.spec.ts @@ -73,6 +73,11 @@ test.describe('Admin Tags Settings', () => { // Color section label await expect(dialog.getByText('Color')).toBeVisible() + // Portal visibility switch, on by default for new tags + const portalSwitch = dialog.getByRole('switch', { name: /show on portal/i }) + await expect(portalSwitch).toBeVisible() + await expect(portalSwitch).toHaveAttribute('aria-checked', 'true') + // Create and Cancel buttons await expect(dialog.getByRole('button', { name: /cancel/i })).toBeVisible() await expect(dialog.getByRole('button', { name: /create tag/i })).toBeVisible() diff --git a/apps/web/src/components/admin/settings/imports/__tests__/import-csv.test.tsx b/apps/web/src/components/admin/settings/imports/__tests__/import-csv.test.tsx index d619c56d1b..0553c0bfa2 100644 --- a/apps/web/src/components/admin/settings/imports/__tests__/import-csv.test.tsx +++ b/apps/web/src/components/admin/settings/imports/__tests__/import-csv.test.tsx @@ -115,6 +115,12 @@ afterEach(() => { }) describe('', () => { + it('explains how author_email and author_name are applied', () => { + renderCsv() + expect(screen.getByText(/Every row needs author_email or author_name/)).toBeTruthy() + expect(screen.getByText(/name-only contact/)).toBeTruthy() + }) + it('walks upload -> dry-run review -> commit -> done', async () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url diff --git a/apps/web/src/components/admin/settings/imports/import-csv.tsx b/apps/web/src/components/admin/settings/imports/import-csv.tsx index 17927cc497..aeafe789be 100644 --- a/apps/web/src/components/admin/settings/imports/import-csv.tsx +++ b/apps/web/src/components/admin/settings/imports/import-csv.tsx @@ -202,7 +202,8 @@ export function ImportCsv() { Must use the template columns — title and content are required. Up to 10MB / 10,000 - rows. + rows. Every row needs author_email or author_name: email matches or creates a person; + name without an email creates a name-only contact. ({ + createPostTagFn: (...args: unknown[]) => mockCreate(...args), + updatePostTagFn: (...args: unknown[]) => mockUpdate(...args), + deletePostTagFn: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ + useRouter: () => ({ invalidate: vi.fn() }), +})) + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { TagList } from '../tag-list' + +const PUBLIC_TAG = { + id: 'post_tag_public', + name: 'Bug', + color: '#ef4444', + description: 'Broken behaviour', + aiPrompt: null, + isPublic: true, + createdAt: new Date('2026-01-01'), + deletedAt: null, +} as PostTag + +const INTERNAL_TAG = { + ...PUBLIC_TAG, + id: 'post_tag_internal', + name: 'Churn risk', + description: null, + isPublic: false, +} as PostTag + +beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockImplementation(async ({ data }) => ({ + ...PUBLIC_TAG, + id: 'post_tag_new', + ...data, + })) + mockUpdate.mockImplementation(async ({ data }) => ({ ...PUBLIC_TAG, ...data })) +}) + +function portalSwitch() { + return screen.getByRole('switch', { name: /show on portal/i }) +} + +describe(' — portal visibility', () => { + it('marks internal tags in the list and leaves public tags unmarked', () => { + render() + + const internalRow = screen.getByText('Churn risk').closest('div')! + expect(within(internalRow).getByText('Internal')).toBeTruthy() + + const publicRow = screen.getByText('Bug').closest('div')! + expect(within(publicRow).queryByText('Internal')).toBeNull() + }) + + it('defaults a new tag to public and sends isPublic on create', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /add new tag/i })) + expect(portalSwitch()).toHaveAttribute('aria-checked', 'true') + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Design' } }) + fireEvent.click(screen.getByRole('button', { name: /create tag/i })) + + await waitFor(() => + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ name: 'Design', isPublic: true }), + }) + ) + }) + + it('lets an admin create an internal tag by turning the switch off', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /add new tag/i })) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Churn risk' } }) + fireEvent.click(portalSwitch()) + expect(portalSwitch()).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(screen.getByRole('button', { name: /create tag/i })) + + await waitFor(() => + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ name: 'Churn risk', isPublic: false }), + }) + ) + }) + + it('reflects the saved flag when editing and sends the toggled value', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /edit tag/i })) + expect(portalSwitch()).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(portalSwitch()) + fireEvent.click(screen.getByRole('button', { name: /save changes/i })) + + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: expect.objectContaining({ id: 'post_tag_internal', isPublic: true }), + }) + ) + }) +}) diff --git a/apps/web/src/components/admin/settings/tags/tag-list.tsx b/apps/web/src/components/admin/settings/tags/tag-list.tsx index b21544b812..af299ba689 100644 --- a/apps/web/src/components/admin/settings/tags/tag-list.tsx +++ b/apps/web/src/components/admin/settings/tags/tag-list.tsx @@ -1,9 +1,16 @@ import { useState, useEffect, useTransition } from 'react' import { useRouter } from '@tanstack/react-router' import { toast } from 'sonner' -import { PlusIcon, TrashIcon, PencilSquareIcon, ArrowPathIcon } from '@heroicons/react/24/solid' +import { + PlusIcon, + TrashIcon, + PencilSquareIcon, + ArrowPathIcon, + EyeSlashIcon, +} from '@heroicons/react/24/solid' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { Dialog, DialogContent, @@ -164,6 +171,7 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) { const [name, setName] = useState('') const [description, setDescription] = useState('') const [color, setColor] = useState('#6b7280') + const [isPublic, setIsPublic] = useState(true) const [error, setError] = useState(null) const [isSaving, setIsSaving] = useState(false) @@ -175,10 +183,12 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) { setName(tag.name) setDescription(tag.description ?? '') setColor(tag.color) + setIsPublic(tag.isPublic) } else { setName('') setDescription('') setColor(randomColor()) + setIsPublic(true) } setError(null) } @@ -211,6 +221,7 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) { name: trimmedName, color, description: description.trim() || null, + isPublic, }, }) } else { @@ -219,6 +230,7 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) { name: trimmedName, color, description: description.trim() || undefined, + isPublic, }, }) } @@ -280,6 +292,17 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) { +
+
+ +

+ Customers can see this tag on posts and filter by it in the public portal. Turn off to + keep it internal to your team. +

+
+ +
+ {error &&

{error}

} @@ -365,7 +388,7 @@ export function TagList({ initialTags }: TagListProps) {
@@ -403,6 +426,16 @@ export function TagList({ initialTags }: TagListProps) { {/* Name */} {tag.name} + {!tag.isPublic && ( + + + Internal + + )} + {/* Description */} {tag.description ?? ''} diff --git a/apps/web/src/components/portal/__tests__/portal-access-gate.intl.test.tsx b/apps/web/src/components/portal/__tests__/portal-access-gate.intl.test.tsx index cdb44a0c49..c89532e532 100644 --- a/apps/web/src/components/portal/__tests__/portal-access-gate.intl.test.tsx +++ b/apps/web/src/components/portal/__tests__/portal-access-gate.intl.test.tsx @@ -9,7 +9,7 @@ vi.mock('@tanstack/react-router', () => ({ // GateCard invalidates portal queries on sign-out. vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn(), removeQueries: vi.fn() }), })) // GateCard listens for cross-tab auth broadcasts. diff --git a/apps/web/src/components/portal/__tests__/portal-access-gate.signin-bridge.test.tsx b/apps/web/src/components/portal/__tests__/portal-access-gate.signin-bridge.test.tsx index a5fdac1f60..30529e6d34 100644 --- a/apps/web/src/components/portal/__tests__/portal-access-gate.signin-bridge.test.tsx +++ b/apps/web/src/components/portal/__tests__/portal-access-gate.signin-bridge.test.tsx @@ -20,7 +20,7 @@ vi.mock('@tanstack/react-router', () => ({ })) vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn(), removeQueries: vi.fn() }), })) // GateCard subscribes without an `enabled` flag. diff --git a/apps/web/src/components/portal/__tests__/portal-access-gate.test.tsx b/apps/web/src/components/portal/__tests__/portal-access-gate.test.tsx index 7c5014d9be..2946156d26 100644 --- a/apps/web/src/components/portal/__tests__/portal-access-gate.test.tsx +++ b/apps/web/src/components/portal/__tests__/portal-access-gate.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { render, screen, act } from '@testing-library/react' +import { render, screen, act, fireEvent } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' const navigate = vi.fn() @@ -18,8 +18,10 @@ vi.mock('@/lib/client/hooks/use-auth-broadcast', () => ({ postAuthSuccess: vi.fn(), })) +const invalidateQueries = vi.fn().mockResolvedValue(undefined) +const removeQueries = vi.fn() vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useQueryClient: () => ({ invalidateQueries, removeQueries }), })) vi.mock('@/lib/client/auth-client', () => ({ signOut: vi.fn() })) @@ -37,6 +39,8 @@ vi.mock('@/lib/client/post-auth-navigation', () => ({ navigateAfterAuth: vi.fn() import { PortalAccessGate } from '../portal-access-gate' import { navigateAfterAuth } from '@/lib/client/post-auth-navigation' +import { signOut } from '@/lib/client/auth-client' +import { VIEWER_SCOPED_PORTAL_QUERY_KEYS } from '@/lib/client/queries/portal' const baseProps = { reason: 'unauthenticated' as const, @@ -52,6 +56,9 @@ const baseProps = { beforeEach(() => { navigate.mockClear() invalidate.mockClear() + invalidateQueries.mockClear() + removeQueries.mockClear() + vi.mocked(signOut).mockClear() vi.mocked(navigateAfterAuth).mockClear() broadcastOnSuccess = undefined formProps = {} @@ -70,6 +77,26 @@ describe('PortalAccessGate — inline auth form', () => { expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument() }) + it('signing out from the unauthorized screen drops the viewer-scoped portal caches before the loaders re-run', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /sign out/i })) + }) + + expect(signOut).toHaveBeenCalledTimes(1) + const removedKeys = removeQueries.mock.calls.map( + (call) => (call as unknown as [{ queryKey: unknown[] }])[0].queryKey + ) + expect(removedKeys).toEqual(expect.arrayContaining([...VIEWER_SCOPED_PORTAL_QUERY_KEYS])) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['votedPosts'] }) + expect(invalidate).toHaveBeenCalledTimes(1) + // The caches must be gone before the loaders re-run, or ensureQueryData + // hands the next viewer the previous session's payload. + const lastRemoval = Math.max(...removeQueries.mock.invocationCallOrder) + expect(lastRemoval).toBeLessThan(invalidate.mock.invocationCallOrder[0]) + }) + it('seeds the form mode from autoOpenSignin', () => { render() expect(formProps.mode).toBe('signup') diff --git a/apps/web/src/components/portal/__tests__/portal-access-gate.two-factor-abandon.test.tsx b/apps/web/src/components/portal/__tests__/portal-access-gate.two-factor-abandon.test.tsx index 52c5dfec36..573f96a417 100644 --- a/apps/web/src/components/portal/__tests__/portal-access-gate.two-factor-abandon.test.tsx +++ b/apps/web/src/components/portal/__tests__/portal-access-gate.two-factor-abandon.test.tsx @@ -10,7 +10,7 @@ vi.mock('@tanstack/react-router', async (orig) => ({ })) vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn(), removeQueries: vi.fn() }), })) // Hoisted so the spy exists when the (hoisted) mock factory runs. diff --git a/apps/web/src/components/portal/portal-access-gate.tsx b/apps/web/src/components/portal/portal-access-gate.tsx index 594b2bd125..6ef12d26ce 100644 --- a/apps/web/src/components/portal/portal-access-gate.tsx +++ b/apps/web/src/components/portal/portal-access-gate.tsx @@ -18,6 +18,7 @@ import { useQueryClient } from '@tanstack/react-query' import { FormattedMessage } from 'react-intl' import { toast } from 'sonner' import { escapeInlineStyle } from '@/lib/shared/safe-inline-content' +import { removeViewerScopedPortalQueries } from '@/lib/client/queries/portal' import { ArrowPathIcon, ChevronUpIcon, @@ -407,6 +408,8 @@ function GateCard({ useAuthBroadcast({ onSuccess: () => { setSigningIn(true) + // Anything cached while gated was fetched as the previous viewer. + removeViewerScopedPortalQueries(queryClient) if (safeCallback) { // Team surfaces full-navigate (re-bootstrap the admin shell); a // portal-local destination invalidates so the gate clears, then routes. @@ -436,8 +439,8 @@ function GateCard({ setSigningOut(true) try { await signOut() + removeViewerScopedPortalQueries(queryClient) await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['portal', 'post'] }), queryClient.invalidateQueries({ queryKey: ['votedPosts'] }), router.invalidate(), ]) diff --git a/apps/web/src/components/public/__tests__/comment-form.test.tsx b/apps/web/src/components/public/__tests__/comment-form.test.tsx new file mode 100644 index 0000000000..77e6c93a91 --- /dev/null +++ b/apps/web/src/components/public/__tests__/comment-form.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +/** + * Upstream #520 ("per-tag portal visibility") made the default comment + * composer drop the viewer-scoped react-query caches when the commenter + * signs out, so a team member's internal-tag view cannot leak into the next + * anonymous visitor's read of the same thread. + * + * Contract for this suite: + * + * V1 A signed-in, non-anonymous commenter sees "Posting as " and a + * "sign out" control, and the wording comes from the catalogue rather than + * the English text baked into the source — proven under German, since an + * English render looks identical whether or not the catalogue was + * consulted. + * V2 Clicking "sign out" calls `signOut` with a success callback, and once + * that callback runs, every viewer-scoped portal query is gone from the + * cache while an unrelated query is untouched. + * V3 After signing out, the router is told to invalidate, so the surrounding + * loaders rebuild against the fresh (now anonymous) session instead of + * serving back the team-scoped data they already hold. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderInGerman } from '@/test/render-with-intl' +import type { PostId } from '@quackback/ids' + +interface SignOutOptions { + fetchOptions: { onSuccess: () => void } +} + +// vi.hoisted so these are ready before the vi.mock factories below run. +const { mockSignOut, mockInvalidate, mockRouteContext } = vi.hoisted(() => ({ + mockSignOut: vi.fn((options: SignOutOptions) => { + options.fetchOptions.onSuccess() + return Promise.resolve() + }), + mockInvalidate: vi.fn(), + mockRouteContext: vi.fn(), +})) + +vi.mock('@/lib/client/auth-client', () => ({ + signOut: mockSignOut, +})) + +vi.mock('@tanstack/react-router', () => ({ + useRouter: () => ({ invalidate: mockInvalidate }), + useRouteContext: () => mockRouteContext(), +})) + +vi.mock('@/lib/client/hooks/use-auth-broadcast', () => ({ + useAuthBroadcast: () => {}, +})) + +vi.mock('@/components/ui/rich-text-editor', () => ({ + RichTextEditor: () =>