@prenta/admin 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
  5. package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
  8. package/dist/__tests__/lib/seo-service-errors.test.js +3 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  10. package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  12. package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
  13. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  14. package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
  15. package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
  16. package/dist/components/seo/FaqProposalPreview.js +62 -0
  17. package/dist/components/seo/FaqProposalPreview.js.map +1 -0
  18. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixPanel.js +7 -2
  20. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  21. package/dist/lib/seo-service.d.ts +61 -2
  22. package/dist/lib/seo-service.d.ts.map +1 -1
  23. package/dist/lib/seo-service.js +9 -2
  24. package/dist/lib/seo-service.js.map +1 -1
  25. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  26. package/dist/views/seo/ProposalsTab.js +160 -7
  27. package/dist/views/seo/ProposalsTab.js.map +1 -1
  28. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
  29. package/dist/views/settings/SeoAutopilotCard.js +6 -0
  30. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
  33. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
  34. package/src/__tests__/lib/seo-service-errors.test.ts +4 -0
  35. package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
  36. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
  37. package/src/components/seo/FaqProposalPreview.tsx +134 -0
  38. package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
  39. package/src/lib/seo-service.ts +57 -1
  40. package/src/views/seo/ProposalsTab.tsx +279 -13
  41. package/src/views/settings/SeoAutopilotCard.tsx +6 -0
@@ -0,0 +1,137 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import {
5
+ FAQ_MIN_SELECTED,
6
+ FaqProposalPreview,
7
+ faqDraftBlocker,
8
+ faqSignalLine,
9
+ type FaqProposalPreviewProps,
10
+ } from '../../components/seo/FaqProposalPreview.js'
11
+
12
+ const items = [
13
+ {
14
+ question: 'How long does it take?',
15
+ answer: 'Three to five days.',
16
+ sourceQuote: 'take three to five days',
17
+ },
18
+ { question: 'Can I finance?', answer: 'Yes, zero down.', sourceQuote: 'zero down' },
19
+ { question: 'Warranty?', answer: '25 years.', sourceQuote: '25-year' },
20
+ ]
21
+ const signals = {
22
+ geo: { referralVisits: 14, retrievalHits: 0, engines: ['chatgpt'] },
23
+ queries: [{ query: 'how long does it take', impressions: 120, clicks: 3 }],
24
+ }
25
+
26
+ function setup(selected = [true, true, true], over: Partial<FaqProposalPreviewProps> = {}) {
27
+ const onToggle = vi.fn()
28
+ const onHeadingChange = vi.fn()
29
+ render(
30
+ <FaqProposalPreview
31
+ heading="Questions"
32
+ items={items}
33
+ signals={signals}
34
+ selected={selected}
35
+ onToggle={onToggle}
36
+ headingValue="Questions"
37
+ onHeadingChange={onHeadingChange}
38
+ {...over}
39
+ />,
40
+ )
41
+ return { onToggle, onHeadingChange }
42
+ }
43
+
44
+ describe('FaqProposalPreview', () => {
45
+ it('renders every Q&A with its source quote and a labelled checkbox', () => {
46
+ setup()
47
+ expect(screen.getAllByRole('checkbox')).toHaveLength(3)
48
+ expect(screen.getByRole('checkbox', { name: /How long does it take\?/ })).toHaveProperty(
49
+ 'checked',
50
+ true,
51
+ )
52
+ expect(screen.getByText('Three to five days.')).toBeTruthy()
53
+ expect(screen.getByText(/take three to five days/)).toBeTruthy()
54
+ })
55
+
56
+ it('shows the signal line', () => {
57
+ setup()
58
+ expect(screen.getByText(/Cited by chatgpt 14×/)).toBeTruthy()
59
+ expect(screen.getByText(/"how long does it take"/)).toBeTruthy()
60
+ })
61
+
62
+ it('calls onToggle with the index and onHeadingChange with the value', () => {
63
+ const { onToggle, onHeadingChange } = setup()
64
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
65
+ expect(onToggle).toHaveBeenCalledWith(2)
66
+ fireEvent.change(screen.getByLabelText('Section heading'), {
67
+ target: { value: 'Top questions' },
68
+ })
69
+ expect(onHeadingChange).toHaveBeenCalledWith('Top questions')
70
+ })
71
+
72
+ it('warns when fewer than two items are selected', () => {
73
+ setup([true, false, false])
74
+ expect(screen.getByRole('status').textContent).toBe(
75
+ `Select at least ${FAQ_MIN_SELECTED} questions to approve (1 selected).`,
76
+ )
77
+ })
78
+
79
+ it('reports how many questions will be added when the draft is approvable', () => {
80
+ setup([true, true, false])
81
+ expect(screen.getByRole('status').textContent).toBe('2 of 3 questions will be added.')
82
+ })
83
+
84
+ it('treats a blank heading as "use the drafted heading", not a blocker', () => {
85
+ setup([true, true, true], { headingValue: ' ' })
86
+ expect(screen.getByRole('status').textContent).toBe('3 of 3 questions will be added.')
87
+ expect(screen.getByLabelText('Section heading')).toHaveProperty('placeholder', 'Questions')
88
+ })
89
+
90
+ it('renders no source line for an item without a quote', () => {
91
+ setup([true, true], {
92
+ items: [
93
+ { question: 'Q1?', answer: 'A1.', sourceQuote: '' },
94
+ { question: 'Q2?', answer: 'A2.' },
95
+ ],
96
+ })
97
+ expect(screen.queryByText(/^Source:/)).toBeNull()
98
+ expect(screen.queryByText(/“”/)).toBeNull()
99
+ })
100
+
101
+ it('omits the signal line when there are no signals', () => {
102
+ setup([true, true, true], { signals: null })
103
+ expect(screen.queryByText(/Cited by|Fetched by|asked/)).toBeNull()
104
+ })
105
+
106
+ it('disables the heading field and every checkbox when disabled', () => {
107
+ setup([true, true, true], { disabled: true })
108
+ expect(screen.getByLabelText('Section heading')).toHaveProperty('disabled', true)
109
+ for (const box of screen.getAllByRole('checkbox')) expect(box).toHaveProperty('disabled', true)
110
+ })
111
+ })
112
+
113
+ describe('faqSignalLine', () => {
114
+ it('prefers referral citations, then retrieval fetches, then the top query', () => {
115
+ expect(faqSignalLine(signals)).toBe(
116
+ 'Cited by chatgpt 14× in 28 days · asked "how long does it take" 120× on Google',
117
+ )
118
+ expect(
119
+ faqSignalLine({ geo: { referralVisits: 0, retrievalHits: 40, engines: [] }, queries: [] }),
120
+ ).toBe('Fetched by AI engines 40× in 28 days')
121
+ expect(
122
+ faqSignalLine({ geo: null, queries: [{ query: 'why', impressions: 9, clicks: 0 }] }),
123
+ ).toBe('asked "why" 9× on Google')
124
+ expect(faqSignalLine({ geo: null, queries: [] })).toBeNull()
125
+ expect(faqSignalLine(null)).toBeNull()
126
+ })
127
+ })
128
+
129
+ describe('faqDraftBlocker', () => {
130
+ it('blocks only on too few selected items', () => {
131
+ expect(faqDraftBlocker([true, false, false])).toBe(
132
+ `Select at least ${FAQ_MIN_SELECTED} questions to approve (1 selected).`,
133
+ )
134
+ expect(faqDraftBlocker([false, false, false])).toMatch(/\(0 selected\)/)
135
+ expect(faqDraftBlocker([true, true, false])).toBeNull()
136
+ })
137
+ })
@@ -27,21 +27,45 @@ const orphanIssue: SeoIssue = {
27
27
  resolvedAt: null,
28
28
  }
29
29
 
30
+ const faqSchemaIssue: SeoIssue = {
31
+ ...orphanIssue,
32
+ id: 'iss-faq-schema',
33
+ entityTitle: 'Pricing',
34
+ url: '/pricing',
35
+ category: 'schema',
36
+ type: 'faq-schema-missing',
37
+ title: 'FAQ section without FAQPage schema',
38
+ fixActionType: 'edit-schema',
39
+ }
40
+
41
+ const fetchSuggestionMock = vi.fn(async (issueId: string) => ({
42
+ suggestion:
43
+ issueId === faqSchemaIssue.id
44
+ ? {
45
+ autoFixable: true,
46
+ source: 'deterministic',
47
+ // Metadata/schema fixes carry no strategy — only redirects do.
48
+ fingerprint: 'fp-faq',
49
+ patch: { structuredData: {} },
50
+ changes: [],
51
+ justification: 'Adds a FAQPage node.',
52
+ }
53
+ : {
54
+ autoFixable: true,
55
+ source: 'deterministic',
56
+ fixStrategy: 'insert-link',
57
+ fingerprint: 'fp-insert',
58
+ patch: {},
59
+ changes: [],
60
+ justification: 'Add an inbound link from a related page.',
61
+ },
62
+ }))
63
+
30
64
  vi.mock('../../lib/seo-service.js', async (importOriginal) => {
31
65
  const actual = await importOriginal<typeof import('../../lib/seo-service.js')>()
32
66
  return {
33
67
  ...actual,
34
- fetchSeoIssueFixSuggestion: vi.fn(async () => ({
35
- suggestion: {
36
- autoFixable: true,
37
- source: 'deterministic',
38
- fixStrategy: 'insert-link',
39
- fingerprint: 'fp-insert',
40
- patch: {},
41
- changes: [],
42
- justification: 'Add an inbound link from a related page.',
43
- },
44
- })),
68
+ fetchSeoIssueFixSuggestion: (issueId: string) => fetchSuggestionMock(issueId),
45
69
  applySeoIssueFix: vi.fn(async () => ({})),
46
70
  fetchSeoContentBrief: vi.fn(async () => ({})),
47
71
  fetchSeoContentResearch: vi.fn(async () => ({})),
@@ -62,4 +86,11 @@ describe('SeoIssueFixPanel insert-link copy', () => {
62
86
  expect(await screen.findByRole('button', { name: 'Approve link' })).toBeTruthy()
63
87
  expect(screen.queryByRole('button', { name: 'Approve redirect' })).toBeNull()
64
88
  })
89
+
90
+ it('labels a strategy-less schema fix (faq-schema-missing) Approve fix, not Approve redirect', async () => {
91
+ render(<SeoIssueFixPanel issue={faqSchemaIssue} expanded />)
92
+
93
+ expect(await screen.findByRole('button', { name: 'Approve fix' })).toBeTruthy()
94
+ expect(screen.queryByRole('button', { name: 'Approve redirect' })).toBeNull()
95
+ })
65
96
  })
@@ -220,6 +220,10 @@ describe('inline SEO fix issue types', () => {
220
220
  it('treats orphan-page as an inline Approve issue', () => {
221
221
  expect(isInlineSeoFixIssue('orphan-page')).toBe(true)
222
222
  })
223
+
224
+ it('treats faq-schema-missing as an inline Approve issue (mirrors core DETERMINISTIC_FIX_TYPES)', () => {
225
+ expect(isInlineSeoFixIssue('faq-schema-missing')).toBe(true)
226
+ })
223
227
  })
224
228
 
225
229
  describe('SEO audit mutation contract', () => {
@@ -100,6 +100,36 @@ describe('proposal service helpers', () => {
100
100
  })
101
101
  })
102
102
 
103
+ it('applySeoIssueFix sends includeItems and headingOverride only when given', async () => {
104
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
105
+ await expect(
106
+ svc.applySeoIssueFix('i1', 'fp', { includeItems: [0, 2], headingOverride: 'H' }),
107
+ ).resolves.toEqual({ issue: { id: 'i1' } })
108
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
109
+ method: 'POST',
110
+ body: JSON.stringify({ fingerprint: 'fp', includeItems: [0, 2], headingOverride: 'H' }),
111
+ })
112
+
113
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
114
+ await svc.applySeoIssueFix('i1', 'fp')
115
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
116
+ method: 'POST',
117
+ body: JSON.stringify({ fingerprint: 'fp' }),
118
+ })
119
+
120
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
121
+ await svc.applySeoIssueFix('i1', 'fp', {})
122
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
123
+ method: 'POST',
124
+ body: JSON.stringify({ fingerprint: 'fp' }),
125
+ })
126
+
127
+ cmsApi.mockResolvedValueOnce({ status: 409, error: 'Fix suggestion is stale.' })
128
+ await expect(svc.applySeoIssueFix('i1', 'fp', { includeItems: [0, 1] })).resolves.toEqual({
129
+ error: 'Fix suggestion is stale.',
130
+ })
131
+ })
132
+
103
133
  it('dismissSeoIssueFix POSTs to the encoded issue path', async () => {
104
134
  cmsApi.mockResolvedValueOnce({ status: 200, data: { dismissed: true } })
105
135
  await expect(svc.dismissSeoIssueFix('a/b')).resolves.toEqual({})
@@ -27,10 +27,51 @@ function proposal(over: Partial<SeoProposal> = {}): SeoProposal {
27
27
  severity: 'warning',
28
28
  issueId: 'i1',
29
29
  suggestionId: null,
30
+ fixStrategy: null,
31
+ section: null,
32
+ grounding: null,
33
+ signals: null,
30
34
  ...over,
31
35
  }
32
36
  }
33
37
 
38
+ /** An `add-section` FAQ proposal with three grounded questions and both signal kinds. */
39
+ function faqProposal(over: Partial<SeoProposal> = {}): SeoProposal {
40
+ return proposal({
41
+ id: 'issue:faq1',
42
+ issueId: 'faq1',
43
+ fingerprint: 'fp-faq',
44
+ title: 'Page lacks an FAQ section',
45
+ issueType: 'missing-faq',
46
+ severity: 'info',
47
+ entity: { type: 'page', id: 'd9', title: 'Solar installs', url: '/solar' },
48
+ changes: [{ field: 'sections', label: 'Sections', before: null, after: 'FAQ (3 questions)' }],
49
+ justification: 'Answer engines cite this page; an FAQ makes the answers extractable.',
50
+ fixStrategy: 'add-section',
51
+ section: {
52
+ sectionType: 'faq',
53
+ content: {
54
+ heading: 'Questions',
55
+ items: [
56
+ { question: 'How long does it take?', answer: 'Three to five days.' },
57
+ { question: 'Can I finance?', answer: 'Yes, zero down.' },
58
+ { question: 'Warranty?', answer: '25 years.' },
59
+ ],
60
+ },
61
+ },
62
+ grounding: [
63
+ { sourceQuote: 'take three to five days' },
64
+ { sourceQuote: 'zero down' },
65
+ { sourceQuote: '' },
66
+ ],
67
+ signals: {
68
+ geo: { referralVisits: 14, retrievalHits: 0, engines: ['chatgpt'] },
69
+ queries: [{ query: 'how long does it take', impressions: 120, clicks: 3 }],
70
+ },
71
+ ...over,
72
+ })
73
+ }
74
+
34
75
  function payload(over: Partial<SeoProposalsPayload> = {}): SeoProposalsPayload {
35
76
  return {
36
77
  proposals: [
@@ -54,6 +95,7 @@ function payload(over: Partial<SeoProposalsPayload> = {}): SeoProposalsPayload {
54
95
  suggestionId: 's1',
55
96
  severity: null,
56
97
  changes: [{ field: 'redirect', label: 'Redirect', before: '/old', after: '/new' }],
98
+ fixStrategy: 'redirect',
57
99
  }),
58
100
  ],
59
101
  counts: { issueFixes: 2, redirects: 1, stale: 0, eligibleForGeneration: 5 },
@@ -86,11 +128,23 @@ const bulkApplySeoProposals = vi.fn(
86
128
  )
87
129
  const dismissSeoIssueFix = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
88
130
  const dismissRedirectSuggestion = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
131
+ const applySeoIssueFix = vi.fn(
132
+ async (
133
+ _issueId: string,
134
+ _fingerprint: string,
135
+ _opts?: { includeItems?: number[]; headingOverride?: string },
136
+ ): Promise<{ issue?: unknown; error?: string }> => ({}),
137
+ )
89
138
 
90
139
  vi.mock('../../lib/seo-service.js', () => ({
91
140
  fetchSeoProposals: () => fetchSeoProposals(),
92
141
  generateSeoProposals: () => generateSeoProposals(),
93
142
  bulkApplySeoProposals: (items: unknown) => bulkApplySeoProposals(items),
143
+ applySeoIssueFix: (
144
+ issueId: string,
145
+ fingerprint: string,
146
+ opts?: { includeItems?: number[]; headingOverride?: string },
147
+ ) => applySeoIssueFix(issueId, fingerprint, opts),
94
148
  dismissSeoIssueFix: (id: string) => dismissSeoIssueFix(id),
95
149
  dismissRedirectSuggestion: (id: string) => dismissRedirectSuggestion(id),
96
150
  fetchAutopilotApplied: (params: unknown) => fetchAutopilotApplied(params),
@@ -125,6 +179,8 @@ beforeEach(() => {
125
179
  bulkApplySeoProposals.mockClear()
126
180
  dismissSeoIssueFix.mockClear()
127
181
  dismissRedirectSuggestion.mockClear()
182
+ applySeoIssueFix.mockClear()
183
+ applySeoIssueFix.mockImplementation(async () => ({}))
128
184
  fetchAutopilotApplied.mockClear()
129
185
  revertAutopilotChange.mockClear()
130
186
  vi.mocked(toast.success).mockClear()
@@ -522,6 +578,308 @@ describe('ProposalsTab', () => {
522
578
  expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2)
523
579
  })
524
580
 
581
+ describe('FAQ (add-section) proposals', () => {
582
+ function faqPayload(row: SeoProposal = faqProposal()): SeoProposalsPayload {
583
+ return payload({
584
+ proposals: [row],
585
+ counts: { issueFixes: 1, redirects: 0, stale: 0, eligibleForGeneration: 0 },
586
+ })
587
+ }
588
+
589
+ it('shows the FAQ badge and a question-count summary with the strongest signal', async () => {
590
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
591
+ render(<ProposalsTab planInfo={null} />)
592
+ await screen.findByText('FAQ section')
593
+ expect(screen.getByText(/^3 questions · Cited by chatgpt 14× in 28 days/)).toBeTruthy()
594
+ })
595
+
596
+ it('approves a FAQ proposal through applySeoIssueFix with includeItems and headingOverride', async () => {
597
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
598
+ render(<ProposalsTab planInfo={null} onNavigate={vi.fn()} />)
599
+ await screen.findByText('FAQ section')
600
+ fireEvent.click(
601
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
602
+ )
603
+ // The third grounding row is malformed (`''`): it must render as "no quote".
604
+ expect(screen.getByText(/take three to five days/)).toBeTruthy()
605
+ expect(screen.queryByText(/“”/)).toBeNull()
606
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
607
+ fireEvent.change(screen.getByLabelText('Section heading'), {
608
+ target: { value: 'Top questions' },
609
+ })
610
+ fireEvent.click(screen.getByRole('button', { name: /^Approve$/ }))
611
+ await waitFor(() =>
612
+ expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {
613
+ includeItems: [0, 1],
614
+ headingOverride: 'Top questions',
615
+ }),
616
+ )
617
+ expect(bulkApplySeoProposals).not.toHaveBeenCalled()
618
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('FAQ section added.'))
619
+ await waitFor(() => expect(fetchSeoProposals).toHaveBeenCalledTimes(2))
620
+ })
621
+
622
+ it('sends neither includeItems nor headingOverride when nothing was changed', async () => {
623
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
624
+ render(<ProposalsTab planInfo={null} />)
625
+ await screen.findByText('FAQ section')
626
+ fireEvent.click(screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }))
627
+ await waitFor(() => expect(applySeoIssueFix).toHaveBeenCalledTimes(1))
628
+ expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {})
629
+ expect(bulkApplySeoProposals).not.toHaveBeenCalled()
630
+ })
631
+
632
+ it('does not send a heading override that only differs by whitespace', async () => {
633
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
634
+ render(<ProposalsTab planInfo={null} />)
635
+ await screen.findByText('FAQ section')
636
+ fireEvent.click(
637
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
638
+ )
639
+ fireEvent.change(screen.getByLabelText('Section heading'), {
640
+ target: { value: ' Questions ' },
641
+ })
642
+ fireEvent.click(screen.getByRole('button', { name: /^Approve$/ }))
643
+ await waitFor(() => expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {}))
644
+ })
645
+
646
+ it('falls back to the drafted heading when the heading field is cleared', async () => {
647
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
648
+ render(<ProposalsTab planInfo={null} />)
649
+ await screen.findByText('FAQ section')
650
+ fireEvent.click(
651
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
652
+ )
653
+ fireEvent.change(screen.getByLabelText('Section heading'), { target: { value: '' } })
654
+ expect(screen.getByText('3 of 3 questions will be added.')).toBeTruthy()
655
+ const approve = screen.getByRole('button', { name: /^Approve$/ })
656
+ expect(approve).toHaveProperty('disabled', false)
657
+ fireEvent.click(approve)
658
+ await waitFor(() => expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {}))
659
+ })
660
+
661
+ it('disables approve with a hint once fewer than two questions are selected', async () => {
662
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
663
+ render(<ProposalsTab planInfo={null} />)
664
+ await screen.findByText('FAQ section')
665
+ fireEvent.click(
666
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
667
+ )
668
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
669
+ fireEvent.click(screen.getByRole('checkbox', { name: /Can I finance\?/ }))
670
+ // The visible hint (the sr-only copy on the row button is asserted separately).
671
+ expect(
672
+ screen.getByText(/Select at least 2 questions to approve \(1 selected\)/, {
673
+ selector: '[role="status"]',
674
+ }),
675
+ ).toBeTruthy()
676
+ const approve = screen.getByRole('button', { name: /^Approve$/ })
677
+ expect(approve).toHaveProperty('disabled', true)
678
+ expect(
679
+ screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }),
680
+ ).toHaveProperty('disabled', true)
681
+ fireEvent.click(approve)
682
+ expect(applySeoIssueFix).not.toHaveBeenCalled()
683
+ // Re-checking one restores the button.
684
+ fireEvent.click(screen.getByRole('checkbox', { name: /Can I finance\?/ }))
685
+ expect(screen.getByRole('button', { name: /^Approve$/ })).toHaveProperty('disabled', false)
686
+ })
687
+
688
+ it('exposes the blocker on the collapsed row approve button', async () => {
689
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
690
+ render(<ProposalsTab planInfo={null} />)
691
+ await screen.findByText('FAQ section')
692
+ fireEvent.click(
693
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
694
+ )
695
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
696
+ fireEvent.click(screen.getByRole('checkbox', { name: /Can I finance\?/ }))
697
+ fireEvent.click(
698
+ screen.getByRole('button', { name: 'Hide changes for Page lacks an FAQ section' }),
699
+ )
700
+ const approve = screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' })
701
+ expect(approve).toHaveProperty('disabled', true)
702
+ expect(approve.getAttribute('title')).toMatch(/Select at least 2 questions/)
703
+ const describedBy = approve.getAttribute('aria-describedby') ?? ''
704
+ expect(describedBy).not.toBe('')
705
+ expect(document.getElementById(describedBy)?.textContent).toMatch(
706
+ /Select at least 2 questions to approve \(1 selected\)/,
707
+ )
708
+ })
709
+
710
+ it('keeps per-row drafts isolated between two FAQ rows', async () => {
711
+ const rowB = faqProposal({
712
+ id: 'issue:faq2',
713
+ issueId: 'faq2',
714
+ fingerprint: 'fp-faq2',
715
+ title: 'Roofing page lacks an FAQ section',
716
+ entity: { type: 'page', id: 'd10', title: 'Roofing', url: '/roofing' },
717
+ section: {
718
+ sectionType: 'faq',
719
+ content: {
720
+ heading: 'Roofing questions',
721
+ items: [
722
+ { question: 'Is it insured?', answer: 'Yes.' },
723
+ { question: 'Do you travel?', answer: 'Statewide.' },
724
+ { question: 'Deposit?', answer: '10%.' },
725
+ ],
726
+ },
727
+ },
728
+ grounding: null,
729
+ })
730
+ fetchSeoProposals.mockImplementationOnce(async () =>
731
+ payload({ proposals: [faqProposal(), rowB] }),
732
+ )
733
+ render(<ProposalsTab planInfo={null} />)
734
+ await screen.findAllByText('FAQ section')
735
+ fireEvent.click(
736
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
737
+ )
738
+ fireEvent.click(
739
+ screen.getByRole('button', { name: 'Show changes for Roofing page lacks an FAQ section' }),
740
+ )
741
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
742
+ expect(screen.getByRole('checkbox', { name: /Warranty\?/ })).toHaveProperty('checked', false)
743
+ for (const name of [/Is it insured\?/, /Do you travel\?/, /Deposit\?/]) {
744
+ expect(screen.getByRole('checkbox', { name })).toHaveProperty('checked', true)
745
+ }
746
+ fireEvent.click(screen.getByRole('checkbox', { name: /Deposit\?/ }))
747
+ expect(screen.getByRole('checkbox', { name: /Deposit\?/ })).toHaveProperty('checked', false)
748
+ expect(screen.getByRole('checkbox', { name: /Is it insured\?/ })).toHaveProperty(
749
+ 'checked',
750
+ true,
751
+ )
752
+ expect(screen.getByRole('checkbox', { name: /Warranty\?/ })).toHaveProperty('checked', false)
753
+ expect(screen.getByRole('checkbox', { name: /How long does it take\?/ })).toHaveProperty(
754
+ 'checked',
755
+ true,
756
+ )
757
+ })
758
+
759
+ it('starts a fresh draft when a refetch returns new questions under the same id and fingerprint', async () => {
760
+ const regenerated = faqProposal({
761
+ section: {
762
+ sectionType: 'faq',
763
+ content: {
764
+ heading: 'Common questions',
765
+ items: [
766
+ { question: 'Is it insured?', answer: 'Yes.' },
767
+ { question: 'Do you travel?', answer: 'Statewide.' },
768
+ { question: 'Deposit?', answer: '10%.' },
769
+ ],
770
+ },
771
+ },
772
+ grounding: null,
773
+ })
774
+ fetchSeoProposals
775
+ .mockImplementationOnce(async () => payload({ proposals: [proposal(), faqProposal()] }))
776
+ .mockImplementationOnce(async () => payload({ proposals: [regenerated] }))
777
+ render(<ProposalsTab planInfo={null} />)
778
+ await screen.findByText('FAQ section')
779
+ fireEvent.click(
780
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
781
+ )
782
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
783
+ fireEvent.change(screen.getByLabelText('Section heading'), {
784
+ target: { value: 'Top questions' },
785
+ })
786
+ // Dismissing another row refetches the inbox; the FAQ row comes back regenerated.
787
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss Duplicate meta title' }))
788
+ await screen.findByRole('checkbox', { name: /Deposit\?/ })
789
+ for (const name of [/Is it insured\?/, /Do you travel\?/, /Deposit\?/]) {
790
+ expect(screen.getByRole('checkbox', { name })).toHaveProperty('checked', true)
791
+ }
792
+ expect(screen.getByLabelText('Section heading')).toHaveProperty('value', 'Common questions')
793
+ expect(screen.getByText('3 of 3 questions will be added.')).toBeTruthy()
794
+ })
795
+
796
+ it('degrades when the cached section is unreadable: badge + signal, approve disabled', async () => {
797
+ fetchSeoProposals.mockImplementationOnce(async () =>
798
+ faqPayload(faqProposal({ section: null, grounding: null })),
799
+ )
800
+ render(<ProposalsTab planInfo={null} />)
801
+ await screen.findByText('FAQ section')
802
+ expect(screen.getByText(/Cited by chatgpt 14× in 28 days/)).toBeTruthy()
803
+ const rowApprove = screen.getByRole('button', {
804
+ name: 'Approve Page lacks an FAQ section',
805
+ })
806
+ expect(rowApprove).toHaveProperty('disabled', true)
807
+ expect(rowApprove.getAttribute('title')).toMatch(/could not be read/i)
808
+ expect(
809
+ document.getElementById(rowApprove.getAttribute('aria-describedby') ?? '')?.textContent,
810
+ ).toMatch(/could not be read/i)
811
+ fireEvent.click(
812
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
813
+ )
814
+ // Once in the expanded body, once as the row button's sr-only description.
815
+ expect(screen.getAllByText(/could not be read/i)).toHaveLength(2)
816
+ expect(screen.queryByRole('checkbox', { name: /Warranty\?/ })).toBeNull()
817
+ expect(screen.queryByRole('button', { name: /^Approve$/ })).toBeNull()
818
+ })
819
+
820
+ it('surfaces a server error and re-enables the controls', async () => {
821
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
822
+ applySeoIssueFix.mockResolvedValueOnce({ error: 'Fix suggestion is stale.' })
823
+ render(<ProposalsTab planInfo={null} />)
824
+ await screen.findByText('FAQ section')
825
+ fireEvent.click(screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }))
826
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Fix suggestion is stale.'))
827
+ await waitFor(() =>
828
+ expect(
829
+ screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }),
830
+ ).toHaveProperty('disabled', false),
831
+ )
832
+ expect(fetchSeoProposals).toHaveBeenCalledTimes(1)
833
+ })
834
+
835
+ it('issues a single request when approve is clicked twice while in flight', async () => {
836
+ // Persistent: the row must survive the post-approve refetch so the button can be re-read.
837
+ fetchSeoProposals.mockImplementation(async () => faqPayload())
838
+ const inflight = deferred<{ issue?: unknown; error?: string }>()
839
+ applySeoIssueFix.mockImplementationOnce(() => inflight.promise)
840
+ render(<ProposalsTab planInfo={null} />)
841
+ await screen.findByText('FAQ section')
842
+ const approve = screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' })
843
+ fireEvent.click(approve)
844
+ fireEvent.click(approve)
845
+ await flushMicrotasks()
846
+ expect(applySeoIssueFix).toHaveBeenCalledTimes(1)
847
+ expect(approve).toHaveProperty('disabled', true)
848
+ expect(
849
+ screen.getByRole('button', { name: 'Dismiss Page lacks an FAQ section' }),
850
+ ).toHaveProperty('disabled', true)
851
+ inflight.resolve({})
852
+ await waitFor(() =>
853
+ expect(
854
+ screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }),
855
+ ).toHaveProperty('disabled', false),
856
+ )
857
+ expect(applySeoIssueFix).toHaveBeenCalledTimes(1)
858
+ })
859
+
860
+ it('bulk approve still sends the whole section through bulk-apply', async () => {
861
+ fetchSeoProposals.mockImplementationOnce(async () =>
862
+ payload({ proposals: [proposal(), faqProposal()] }),
863
+ )
864
+ render(<ProposalsTab planInfo={null} />)
865
+ await screen.findByText('FAQ section')
866
+ fireEvent.click(
867
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
868
+ )
869
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
870
+ fireEvent.click(screen.getByLabelText('Select all proposals'))
871
+ fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
872
+ fireEvent.click(await screen.findByRole('button', { name: 'Apply 2 proposals' }))
873
+ await waitFor(() =>
874
+ expect(bulkApplySeoProposals).toHaveBeenCalledWith([
875
+ { kind: 'issue-fix', id: 'i1', fingerprint: 'fp-1' },
876
+ { kind: 'issue-fix', id: 'faq1', fingerprint: 'fp-faq' },
877
+ ]),
878
+ )
879
+ expect(applySeoIssueFix).not.toHaveBeenCalled()
880
+ })
881
+ })
882
+
525
883
  it('hides Generate behind the plan callout when seo.inlineApply is off', async () => {
526
884
  render(<ProposalsTab planInfo={{ tier: 'starter', features: [], upgradeUrl: null }} />)
527
885
  await screen.findByText('Duplicate meta title')