@prenta/admin 1.14.0 → 1.16.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 (119) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/__tests__/lib/api.test.d.ts +2 -0
  3. package/dist/__tests__/lib/api.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/api.test.js +58 -0
  5. package/dist/__tests__/lib/api.test.js.map +1 -0
  6. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
  9. package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
  10. package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
  13. package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
  14. package/dist/__tests__/lib/seo-service-proposals.test.js +28 -0
  15. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  16. package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
  17. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  18. package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
  19. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  20. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
  21. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
  22. package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
  23. package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
  24. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
  25. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
  26. package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
  27. package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
  28. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
  29. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
  30. package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
  31. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
  32. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
  33. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
  34. package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
  35. package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
  36. package/dist/__tests__/views/seo-proposals-tab.render.test.js +139 -0
  37. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  38. package/dist/__tests__/views/seo-settings.render.test.js +53 -4
  39. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  40. package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
  41. package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
  42. package/dist/lib/api.d.ts +13 -2
  43. package/dist/lib/api.d.ts.map +1 -1
  44. package/dist/lib/api.js +1 -2
  45. package/dist/lib/api.js.map +1 -1
  46. package/dist/lib/seo-service.d.ts +300 -2
  47. package/dist/lib/seo-service.d.ts.map +1 -1
  48. package/dist/lib/seo-service.js +172 -5
  49. package/dist/lib/seo-service.js.map +1 -1
  50. package/dist/prenta-admin.css +1 -1
  51. package/dist/views/SEO.d.ts +5 -1
  52. package/dist/views/SEO.d.ts.map +1 -1
  53. package/dist/views/SEO.js +12 -7
  54. package/dist/views/SEO.js.map +1 -1
  55. package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
  56. package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
  57. package/dist/views/seo/AiVisibilityTab.js +165 -0
  58. package/dist/views/seo/AiVisibilityTab.js.map +1 -0
  59. package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
  60. package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
  61. package/dist/views/seo/AutopilotAppliedSection.js +295 -0
  62. package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
  63. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  64. package/dist/views/seo/ProposalsTab.js +53 -17
  65. package/dist/views/seo/ProposalsTab.js.map +1 -1
  66. package/dist/views/seo/RedirectsTab.d.ts +3 -1
  67. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  68. package/dist/views/seo/RedirectsTab.js +14 -19
  69. package/dist/views/seo/RedirectsTab.js.map +1 -1
  70. package/dist/views/seo/TechnicalTab.d.ts +3 -1
  71. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  72. package/dist/views/seo/TechnicalTab.js +4 -4
  73. package/dist/views/seo/TechnicalTab.js.map +1 -1
  74. package/dist/views/seo/relative-age.d.ts +7 -0
  75. package/dist/views/seo/relative-age.d.ts.map +1 -0
  76. package/dist/views/seo/relative-age.js +20 -0
  77. package/dist/views/seo/relative-age.js.map +1 -0
  78. package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
  79. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
  80. package/dist/views/settings/SeoAutopilotCard.js +375 -0
  81. package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
  82. package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
  83. package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
  84. package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
  85. package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
  86. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  87. package/dist/views/settings/SeoSettingsTab.js +3 -1
  88. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  89. package/dist/views/settings/components.d.ts +3 -1
  90. package/dist/views/settings/components.d.ts.map +1 -1
  91. package/dist/views/settings/components.js +2 -2
  92. package/dist/views/settings/components.js.map +1 -1
  93. package/package.json +3 -3
  94. package/src/__tests__/lib/api.test.ts +76 -0
  95. package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
  96. package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
  97. package/src/__tests__/lib/seo-service-proposals.test.ts +29 -0
  98. package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
  99. package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
  100. package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
  101. package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
  102. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
  103. package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
  104. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +161 -0
  105. package/src/__tests__/views/seo-settings.render.test.tsx +68 -7
  106. package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
  107. package/src/lib/api.ts +19 -3
  108. package/src/lib/seo-service.ts +439 -6
  109. package/src/views/SEO.tsx +16 -7
  110. package/src/views/seo/AiVisibilityTab.tsx +616 -0
  111. package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
  112. package/src/views/seo/ProposalsTab.tsx +92 -12
  113. package/src/views/seo/RedirectsTab.tsx +39 -60
  114. package/src/views/seo/TechnicalTab.tsx +27 -8
  115. package/src/views/seo/relative-age.ts +15 -0
  116. package/src/views/settings/SeoAutopilotCard.tsx +826 -0
  117. package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
  118. package/src/views/settings/SeoSettingsTab.tsx +4 -0
  119. package/src/views/settings/components.tsx +10 -1
@@ -0,0 +1,118 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import type { SeoGeoReport } from '../../lib/seo-service.js'
3
+
4
+ /**
5
+ * Wire-level tests for the AI visibility (GEO measurement) service wrapper.
6
+ * The render test replaces `seo-service` wholesale, so the URL and the
7
+ * envelope handling are pinned here against a mocked `cmsApi`.
8
+ */
9
+ const cmsApi = vi.fn()
10
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (...args: unknown[]) => cmsApi(...args) }))
11
+
12
+ const svc = await import('../../lib/seo-service.js')
13
+
14
+ const REPORT: SeoGeoReport = {
15
+ enabled: true,
16
+ window: { from: '2026-09-04', to: '2026-09-10', days: 7 },
17
+ firstSeenAt: null,
18
+ crawl: { total: { hits: 0, prevHits: null }, series: [], bots: [], topPaths: [], otherHits: 0 },
19
+ referrals: { total: { visits: 0, prevVisits: null }, engines: [], topPaths: [], otherVisits: 0 },
20
+ }
21
+
22
+ beforeEach(() => {
23
+ // Block body on purpose: `mockReset()` returns the mock, and vitest would
24
+ // run a returned function as after-test cleanup.
25
+ cmsApi.mockReset()
26
+ })
27
+
28
+ describe('fetchSeoGeo', () => {
29
+ it('GETs /seo/geo?days=N and unwraps the body', async () => {
30
+ cmsApi.mockResolvedValueOnce({ status: 200, data: REPORT })
31
+ await expect(svc.fetchSeoGeo(7)).resolves.toEqual(REPORT)
32
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/geo?days=7')
33
+
34
+ cmsApi.mockResolvedValueOnce({
35
+ status: 200,
36
+ data: { ...REPORT, window: { ...REPORT.window, days: 28 } },
37
+ })
38
+ const r = await svc.fetchSeoGeo(28)
39
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/geo?days=28')
40
+ expect(r.window.days).toBe(28)
41
+ })
42
+
43
+ it('throws on error envelopes', async () => {
44
+ cmsApi.mockResolvedValueOnce({ error: 'nope', status: 500 })
45
+ await expect(svc.fetchSeoGeo(28)).rejects.toThrow('nope')
46
+ })
47
+
48
+ it('rejects a malformed body instead of handing it to the UI', async () => {
49
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { enabled: 'yes' } })
50
+ await expect(svc.fetchSeoGeo(90)).rejects.toThrow('Unexpected AI visibility response')
51
+ cmsApi.mockResolvedValueOnce({ status: 200 })
52
+ await expect(svc.fetchSeoGeo(90)).rejects.toThrow('Unexpected AI visibility response')
53
+ })
54
+
55
+ it('rejects a body from an older server that lacks crawl.total', async () => {
56
+ const crawlWithoutTotal: Partial<SeoGeoReport['crawl']> = { ...REPORT.crawl }
57
+ delete crawlWithoutTotal.total
58
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { ...REPORT, crawl: crawlWithoutTotal } })
59
+ await expect(svc.fetchSeoGeo(28)).rejects.toThrow('Unexpected AI visibility response')
60
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { ...REPORT, crawl: undefined } })
61
+ await expect(svc.fetchSeoGeo(28)).rejects.toThrow('Unexpected AI visibility response')
62
+ })
63
+ })
64
+
65
+ // Mirrors `GET|PUT /seo/geo-settings` in `cms-core/src/api/routes/seo-geo.ts`:
66
+ // GET answers `{ data: { enabled, retentionDays } }`; PUT takes a sparse
67
+ // `{ enabled?, retentionDays? }` and answers `{ data: { ok: true } }` or a
68
+ // 400 `{ error }` envelope.
69
+ describe('fetchGeoSettings', () => {
70
+ it('GETs /seo/geo-settings and unwraps the body', async () => {
71
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { enabled: false, retentionDays: 30 } })
72
+ await expect(svc.fetchGeoSettings()).resolves.toEqual({ enabled: false, retentionDays: 30 })
73
+ expect(cmsApi).toHaveBeenCalledTimes(1)
74
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/geo-settings')
75
+ })
76
+
77
+ it('returns null on an error envelope (404 / GEO tables not migrated)', async () => {
78
+ cmsApi.mockResolvedValueOnce({ error: 'Not found', status: 404 })
79
+ await expect(svc.fetchGeoSettings()).resolves.toBeNull()
80
+ cmsApi.mockResolvedValueOnce({ error: 'Request failed (500)', status: 500 })
81
+ await expect(svc.fetchGeoSettings()).resolves.toBeNull()
82
+ })
83
+
84
+ it('returns null on a malformed body instead of handing it to the card', async () => {
85
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { enabled: 'yes', retentionDays: 90 } })
86
+ await expect(svc.fetchGeoSettings()).resolves.toBeNull()
87
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { enabled: true, retentionDays: '90' } })
88
+ await expect(svc.fetchGeoSettings()).resolves.toBeNull()
89
+ cmsApi.mockResolvedValueOnce({ status: 200 })
90
+ await expect(svc.fetchGeoSettings()).resolves.toBeNull()
91
+ })
92
+ })
93
+
94
+ describe('updateGeoSettings', () => {
95
+ it('PUTs the sparse patch as JSON and reports ok', async () => {
96
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { ok: true } })
97
+ await expect(svc.updateGeoSettings({ enabled: false })).resolves.toEqual({ ok: true })
98
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/geo-settings', {
99
+ method: 'PUT',
100
+ body: JSON.stringify({ enabled: false }),
101
+ })
102
+
103
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { ok: true } })
104
+ await svc.updateGeoSettings({ retentionDays: 30 })
105
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/geo-settings', {
106
+ method: 'PUT',
107
+ body: JSON.stringify({ retentionDays: 30 }),
108
+ })
109
+ })
110
+
111
+ it('surfaces the validation error verbatim', async () => {
112
+ cmsApi.mockResolvedValueOnce({ error: 'retentionDays must be an integer', status: 400 })
113
+ await expect(svc.updateGeoSettings({ retentionDays: 1.5 })).resolves.toEqual({
114
+ ok: false,
115
+ error: 'retentionDays must be an integer',
116
+ })
117
+ })
118
+ })
@@ -27,6 +27,35 @@ describe('proposal service helpers', () => {
27
27
  expect(cmsApi).toHaveBeenLastCalledWith('/seo/proposals')
28
28
  })
29
29
 
30
+ it('fetchSeoProposals fills in autopilot when an older core omits it', async () => {
31
+ cmsApi.mockResolvedValueOnce({
32
+ status: 200,
33
+ data: {
34
+ proposals: [],
35
+ counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
36
+ governance: { issueFixBulk: true, redirectBulk: false },
37
+ },
38
+ })
39
+ await expect(svc.fetchSeoProposals()).resolves.toEqual({
40
+ proposals: [],
41
+ counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
42
+ governance: { issueFixBulk: true, redirectBulk: false },
43
+ autopilot: { enabled: false, pausedReason: null, lastRunAt: null, appliedLast7d: 0 },
44
+ })
45
+ // A server that does send it wins over the fallback.
46
+ const autopilot = { enabled: true, pausedReason: null, lastRunAt: null, appliedLast7d: 3 }
47
+ cmsApi.mockResolvedValueOnce({
48
+ status: 200,
49
+ data: {
50
+ proposals: [],
51
+ counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
52
+ governance: { issueFixBulk: true, redirectBulk: false },
53
+ autopilot,
54
+ },
55
+ })
56
+ await expect(svc.fetchSeoProposals()).resolves.toMatchObject({ autopilot })
57
+ })
58
+
30
59
  it('generateSeoProposals POSTs confirm: true and returns the result or error', async () => {
31
60
  cmsApi.mockResolvedValueOnce({
32
61
  status: 200,
@@ -21,6 +21,11 @@ describe('seoTabForPath', () => {
21
21
  expect(seoTabForPath('/seo/proposals')).toBe('proposals')
22
22
  })
23
23
 
24
+ it('routes the AI visibility deep link (Technical → "View trends") to its tab', () => {
25
+ expect(SEO_DEEP_PATHS).toContain('/seo/ai-visibility')
26
+ expect(seoTabForPath('/seo/ai-visibility')).toBe('ai-visibility')
27
+ })
28
+
24
29
  it('returns null for non-SEO paths so they fall through', () => {
25
30
  expect(seoTabForPath('/dashboard')).toBeNull()
26
31
  expect(seoTabForPath('/media')).toBeNull()
@@ -36,9 +36,8 @@ const fetchRedirectRules = vi.fn(async () => RULES)
36
36
  const createRedirectRule = vi.fn(async (_p: unknown) => ({}))
37
37
  const updateRedirectRule = vi.fn(async (_id: string, _p: unknown) => ({}))
38
38
  const deleteRedirectRule = vi.fn(async (_id: string) => ({}))
39
- const generate404RecoverySuggestions = vi.fn(
40
- async (): Promise<{ error?: string; autoApplied?: number; governanceBlocked?: string }> => ({}),
41
- )
39
+ const generate404RecoverySuggestions = vi.fn(async (): Promise<{ error?: string }> => ({}))
40
+ const saveRedirectRecoverySettings = vi.fn(async (_p: unknown): Promise<{ error?: string }> => ({}))
42
41
 
43
42
  vi.mock('../../lib/seo-service.js', () => ({
44
43
  fetchRedirectOverview: vi.fn(async () => ({
@@ -63,9 +62,9 @@ vi.mock('../../lib/seo-service.js', () => ({
63
62
  fetchRedirectRecoverySettings: vi.fn(async () => ({
64
63
  aiRerank: false,
65
64
  autoApply: false,
66
- autoApplyMinConfidence: 0.85,
65
+ autoApplyMinConfidence: 85,
67
66
  })),
68
- saveRedirectRecoverySettings: vi.fn(async () => ({})),
67
+ saveRedirectRecoverySettings: (p: unknown) => saveRedirectRecoverySettings(p),
69
68
  }))
70
69
 
71
70
  vi.mock('sonner', () => ({
@@ -79,6 +78,8 @@ beforeEach(() => {
79
78
  createRedirectRule.mockClear()
80
79
  updateRedirectRule.mockClear()
81
80
  deleteRedirectRule.mockClear()
81
+ generate404RecoverySuggestions.mockClear()
82
+ saveRedirectRecoverySettings.mockClear()
82
83
  })
83
84
 
84
85
  describe('Redirects inline editor pane', () => {
@@ -168,23 +169,42 @@ describe('Redirects inline editor pane', () => {
168
169
  })
169
170
  })
170
171
 
171
- describe('AI 404 recovery governance feedback', () => {
172
- it('explains why auto-apply was skipped when governance blocked it', async () => {
173
- generate404RecoverySuggestions.mockResolvedValueOnce({
174
- autoApplied: 0,
175
- governanceBlocked:
176
- 'The "404 Recovery suggestions" feature requires human approval before AI results are applied.',
177
- })
172
+ describe('404 recovery settings', () => {
173
+ it('points auto-apply at SEO Autopilot instead of exposing its own controls', async () => {
174
+ const onNavigate = vi.fn()
175
+ render(<RedirectsTab onNavigate={onNavigate} />)
176
+ await screen.findByRole('button', { name: '/old-path' })
177
+
178
+ // Task 8 moved the auto-apply policy into the autopilot; the card keeps
179
+ // AI re-ranking only and links to the Autopilot settings card.
180
+ expect(screen.getByLabelText('AI re-ranking')).toBeTruthy()
181
+ expect(screen.queryByLabelText(/Auto-apply high-confidence redirects/)).toBeNull()
182
+ expect(screen.queryByLabelText(/Auto-apply minimum confidence/)).toBeNull()
183
+ fireEvent.click(screen.getByRole('button', { name: 'SEO Autopilot' }))
184
+ expect(onNavigate).toHaveBeenCalledWith('/settings?tab=seo')
185
+ expect(screen.queryByRole('link')).toBeNull()
186
+ })
187
+
188
+ it('saves only the AI re-ranking flag', async () => {
189
+ render(<RedirectsTab />)
190
+ await screen.findByRole('button', { name: '/old-path' })
191
+ fireEvent.click(screen.getByLabelText('AI re-ranking'))
192
+ fireEvent.click(screen.getByRole('button', { name: 'Save settings' }))
193
+ await waitFor(() =>
194
+ expect(saveRedirectRecoverySettings).toHaveBeenCalledWith({ aiRerank: true }),
195
+ )
196
+ })
197
+
198
+ it('reports generation success without the retired auto-apply / governance messages', async () => {
199
+ generate404RecoverySuggestions.mockResolvedValueOnce({})
178
200
  const { toast } = await import('sonner')
179
201
 
180
202
  render(<RedirectsTab />)
181
203
  fireEvent.click(await screen.findByRole('button', { name: /Generate suggestions/ }))
182
204
 
183
- // Generation succeeded — suggestions exist — but the operator is told the
184
- // governance reason instead of silently seeing nothing auto-applied.
185
205
  await waitFor(() =>
186
- expect(toast.info).toHaveBeenCalledWith(expect.stringContaining('requires human approval')),
206
+ expect(toast.success).toHaveBeenCalledWith('Generated 404 recovery suggestions.'),
187
207
  )
188
- expect(toast.success).toHaveBeenCalledWith('Generated 404 recovery suggestions.')
208
+ expect(toast.info).not.toHaveBeenCalled()
189
209
  })
190
210
  })
@@ -0,0 +1,279 @@
1
+ // @vitest-environment happy-dom
2
+ import type { ReactNode } from 'react'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
5
+ import type { SeoGeoReport } from '../../lib/seo-service.js'
6
+
7
+ function report(over: Partial<SeoGeoReport> = {}): SeoGeoReport {
8
+ return {
9
+ enabled: true,
10
+ window: { from: '2026-08-14', to: '2026-09-10', days: 28 },
11
+ firstSeenAt: '2026-08-01',
12
+ crawl: {
13
+ // Whole-window total from the server — includes the capped "other"
14
+ // bucket, so it is deliberately larger than the sum of `bots[].hits`.
15
+ total: { hits: 21, prevHits: 12 },
16
+ series: [
17
+ { date: '2026-09-09', hits: { GPTBot: 3, Googlebot: 5 } },
18
+ { date: '2026-09-10', hits: { GPTBot: 1 } },
19
+ ],
20
+ bots: [
21
+ {
22
+ bot: 'Googlebot',
23
+ category: 'search',
24
+ hits: 5,
25
+ prevHits: 4,
26
+ lastSeenAt: '2026-09-09',
27
+ policy: 'allowed',
28
+ },
29
+ {
30
+ bot: 'GPTBot',
31
+ category: 'training',
32
+ hits: 4,
33
+ prevHits: 8,
34
+ lastSeenAt: '2026-09-10',
35
+ policy: 'blocked_still_crawling',
36
+ },
37
+ {
38
+ bot: 'ClaudeBot',
39
+ category: 'training',
40
+ hits: 0,
41
+ prevHits: null,
42
+ lastSeenAt: null,
43
+ policy: 'blocked',
44
+ },
45
+ ],
46
+ topPaths: [
47
+ {
48
+ path: '/pricing',
49
+ hits: 6,
50
+ bots: ['GPTBot', 'Googlebot'],
51
+ entityId: 'doc-1',
52
+ entityTitle: 'Pricing',
53
+ collection: 'pages',
54
+ },
55
+ {
56
+ path: '/blog/launch',
57
+ hits: 4,
58
+ bots: ['GPTBot'],
59
+ entityId: 'doc-2',
60
+ entityTitle: 'Launch post',
61
+ collection: 'blog',
62
+ },
63
+ { path: '/legacy', hits: 3, bots: ['Googlebot'] },
64
+ ],
65
+ otherHits: 12,
66
+ },
67
+ referrals: {
68
+ total: { visits: 9, prevVisits: 3 },
69
+ engines: [
70
+ { engine: 'ChatGPT', visits: 7, prevVisits: 3 },
71
+ { engine: 'Perplexity', visits: 2, prevVisits: null },
72
+ ],
73
+ topPaths: [
74
+ {
75
+ path: '/pricing',
76
+ visits: 8,
77
+ engines: ['ChatGPT', 'Perplexity'],
78
+ entityId: 'doc-1',
79
+ entityTitle: 'Pricing',
80
+ collection: 'pages',
81
+ },
82
+ { path: '/blog/x', visits: 1, engines: ['ChatGPT'] },
83
+ ],
84
+ otherVisits: 4,
85
+ },
86
+ ...over,
87
+ }
88
+ }
89
+
90
+ const EMPTY_WINDOW: Pick<SeoGeoReport, 'crawl' | 'referrals'> = {
91
+ crawl: { total: { hits: 0, prevHits: null }, series: [], bots: [], topPaths: [], otherHits: 0 },
92
+ referrals: { total: { visits: 0, prevVisits: null }, engines: [], topPaths: [], otherVisits: 0 },
93
+ }
94
+
95
+ const fetchSeoGeo = vi.fn(async (_days: number): Promise<SeoGeoReport> => report())
96
+ vi.mock('../../lib/seo-service.js', () => ({ fetchSeoGeo: (d: number) => fetchSeoGeo(d) }))
97
+ // recharts needs layout; stub the chart so the table assertions drive the test.
98
+ vi.mock('recharts', () => ({
99
+ ResponsiveContainer: ({ children }: { children: ReactNode }) => (
100
+ <div data-testid="chart">{children}</div>
101
+ ),
102
+ BarChart: ({ children }: { children: ReactNode }) => <div>{children}</div>,
103
+ Bar: () => null,
104
+ XAxis: () => null,
105
+ YAxis: () => null,
106
+ Tooltip: () => null,
107
+ Legend: () => null,
108
+ CartesianGrid: () => null,
109
+ }))
110
+
111
+ const { AiVisibilityTab } = await import('../../views/seo/AiVisibilityTab.js')
112
+
113
+ beforeEach(() => {
114
+ fetchSeoGeo.mockReset()
115
+ fetchSeoGeo.mockImplementation(async () => report())
116
+ })
117
+
118
+ describe('AiVisibilityTab', () => {
119
+ it('renders bots with policy text, deltas, and top paths with editor links gated on entityId', async () => {
120
+ const onNavigate = vi.fn()
121
+ render(<AiVisibilityTab onNavigate={onNavigate} />)
122
+ await screen.findByRole('table', { name: /crawlers/i })
123
+ expect(fetchSeoGeo).toHaveBeenCalledWith(28)
124
+
125
+ const crawlers = screen.getByRole('table', { name: /crawlers/i })
126
+ expect(within(crawlers).getByText('Blocked — still crawling')).toBeTruthy()
127
+ expect(within(crawlers).getByText('Allowed')).toBeTruthy()
128
+ expect(within(crawlers).getByText('Blocked')).toBeTruthy()
129
+ expect(within(crawlers).getAllByText('AI training', { selector: 'td' })).toHaveLength(2)
130
+ expect(within(crawlers).getByText('Search', { selector: 'td' })).toBeTruthy()
131
+ // null prevHits (ClaudeBot) and null lastSeenAt both render as an em dash.
132
+ expect(within(crawlers).getAllByText('—').length).toBeGreaterThanOrEqual(2)
133
+ // +25% (5 vs 4) and -50% (4 vs 8).
134
+ expect(within(crawlers).getByText('+25%')).toBeTruthy()
135
+ expect(within(crawlers).getByText('-50%')).toBeTruthy()
136
+
137
+ // Headline crawl number comes from `crawl.total`, not from summing bots.
138
+ const crawlSummary = screen.getByRole('group', { name: /crawl summary/i })
139
+ expect(within(crawlSummary).getByText('21')).toBeTruthy()
140
+ expect(within(crawlSummary).getByText('+75%')).toBeTruthy()
141
+
142
+ expect(screen.getByTestId('chart')).toBeTruthy()
143
+
144
+ const cited = screen.getByRole('table', { name: /top cited pages/i })
145
+ fireEvent.click(within(cited).getByRole('button', { name: /open pricing in editor/i }))
146
+ expect(onNavigate).toHaveBeenCalledWith('/pages/doc-1/edit')
147
+ expect(within(cited).queryByRole('button', { name: /open \/blog\/x/i })).toBeNull()
148
+ expect(within(cited).getByText('/blog/x')).toBeTruthy()
149
+
150
+ // Post-type collections route through the post editor, not the pages one.
151
+ const crawled = screen.getByRole('table', { name: /top crawled pages/i })
152
+ fireEvent.click(within(crawled).getByRole('button', { name: /open launch post in editor/i }))
153
+ expect(onNavigate).toHaveBeenLastCalledWith('/posts/blog/doc-2/edit')
154
+ expect(within(crawled).queryByRole('button', { name: /open \/legacy/i })).toBeNull()
155
+
156
+ expect(screen.getByText(/12 hits on paths beyond the daily cap/i)).toBeTruthy()
157
+ expect(screen.getByText(/4 visits on paths beyond the daily cap/i)).toBeTruthy()
158
+ expect(screen.getByText(/self-reported by your middleware/i)).toBeTruthy()
159
+ })
160
+
161
+ it('renders paths as plain text when there is no onNavigate, even with an entityId', async () => {
162
+ render(<AiVisibilityTab />)
163
+ const cited = await screen.findByRole('table', { name: /top cited pages/i })
164
+ expect(within(cited).queryByRole('button')).toBeNull()
165
+ expect(within(cited).getByText('/pricing')).toBeTruthy()
166
+ })
167
+
168
+ it('switching the window refetches', async () => {
169
+ render(<AiVisibilityTab />)
170
+ await screen.findByRole('table', { name: /crawlers/i })
171
+ const group = screen.getByRole('group', { name: /^window$/i })
172
+ const seven = within(group).getByRole('button', { name: /7 days/i })
173
+ expect(seven.getAttribute('aria-pressed')).toBe('false')
174
+ expect(
175
+ within(group)
176
+ .getByRole('button', { name: /28 days/i })
177
+ .getAttribute('aria-pressed'),
178
+ ).toBe('true')
179
+ fireEvent.click(seven)
180
+ await waitFor(() => expect(fetchSeoGeo).toHaveBeenLastCalledWith(7))
181
+ expect(seven.getAttribute('aria-pressed')).toBe('true')
182
+ })
183
+
184
+ it('keeps the clicked window button focused and enabled while the refetch is in flight', async () => {
185
+ render(<AiVisibilityTab />)
186
+ await screen.findByRole('table', { name: /crawlers/i })
187
+ // Hold the second request open so we can inspect the loading state.
188
+ let release: (() => void) | undefined
189
+ fetchSeoGeo.mockImplementationOnce(
190
+ () =>
191
+ new Promise<SeoGeoReport>((resolve) => {
192
+ release = () => resolve(report())
193
+ }),
194
+ )
195
+ const seven = screen.getByRole('button', { name: /7 days/i })
196
+ seven.focus()
197
+ fireEvent.click(seven)
198
+ await waitFor(() => expect(fetchSeoGeo).toHaveBeenLastCalledWith(7))
199
+ expect((seven as HTMLButtonElement).disabled).toBe(false)
200
+ expect(document.activeElement).toBe(seven)
201
+ release?.()
202
+ await screen.findByRole('table', { name: /crawlers/i })
203
+ })
204
+
205
+ it('exposes the null-baseline dash to assistive tech, not only via title', async () => {
206
+ render(<AiVisibilityTab />)
207
+ const crawlers = await screen.findByRole('table', { name: /crawlers/i })
208
+ expect(within(crawlers).getAllByText('No full prior window yet').length).toBeGreaterThan(0)
209
+ expect(screen.getByText(/first seen/i)).toBeTruthy()
210
+ expect(screen.queryByText(/collecting since/i)).toBeNull()
211
+ })
212
+
213
+ it('shows the disabled state with a settings button (disabledReason: settings)', async () => {
214
+ fetchSeoGeo.mockResolvedValue(
215
+ report({ enabled: false, disabledReason: 'settings', firstSeenAt: null, ...EMPTY_WINDOW }),
216
+ )
217
+ const onNavigate = vi.fn()
218
+ render(<AiVisibilityTab onNavigate={onNavigate} />)
219
+ await screen.findByText(/measurement is off/i)
220
+ // The header (window switch + Refresh) stays available in every state.
221
+ expect(screen.getByRole('button', { name: /refresh/i })).toBeTruthy()
222
+ fireEvent.click(screen.getByRole('button', { name: /open seo settings/i }))
223
+ expect(onNavigate).toHaveBeenCalledWith('/settings?tab=seo')
224
+ })
225
+
226
+ it('falls back to the settings copy when enabled:false carries no disabledReason (older core)', async () => {
227
+ fetchSeoGeo.mockResolvedValue(report({ enabled: false, firstSeenAt: null, ...EMPTY_WINDOW }))
228
+ render(<AiVisibilityTab onNavigate={vi.fn()} />)
229
+ await screen.findByText(/measurement is off/i)
230
+ expect(screen.getByRole('button', { name: /open seo settings/i })).toBeTruthy()
231
+ })
232
+
233
+ it('shows the not-migrated state without a settings action (disabledReason: unavailable)', async () => {
234
+ fetchSeoGeo.mockResolvedValue(
235
+ report({ enabled: false, disabledReason: 'unavailable', firstSeenAt: null, ...EMPTY_WINDOW }),
236
+ )
237
+ render(<AiVisibilityTab onNavigate={vi.fn()} />)
238
+ await screen.findByText(/geo tables not migrated/i)
239
+ expect(screen.getByText(/prisma migrate deploy/i)).toBeTruthy()
240
+ expect(screen.getByText(/0018_geo_daily/i)).toBeTruthy()
241
+ expect(screen.queryByText(/measurement is off/i)).toBeNull()
242
+ expect(screen.queryByRole('button', { name: /open seo settings/i })).toBeNull()
243
+ expect(screen.getByRole('button', { name: /refresh/i })).toBeTruthy()
244
+ })
245
+
246
+ it('shows the not-reporting state with a middleware-snippet action when nothing has ever arrived', async () => {
247
+ fetchSeoGeo.mockResolvedValue(report({ firstSeenAt: null, ...EMPTY_WINDOW }))
248
+ const onNavigate = vi.fn()
249
+ render(<AiVisibilityTab onNavigate={onNavigate} />)
250
+ await screen.findByText(/site middleware is not reporting yet/i)
251
+ expect(screen.queryByRole('table')).toBeNull()
252
+ fireEvent.click(screen.getByRole('button', { name: /show middleware snippet/i }))
253
+ expect(onNavigate).toHaveBeenCalledWith('/settings?tab=seo')
254
+ })
255
+
256
+ it('omits the middleware-snippet action when there is no navigator', async () => {
257
+ fetchSeoGeo.mockResolvedValue(report({ firstSeenAt: null, ...EMPTY_WINDOW }))
258
+ render(<AiVisibilityTab />)
259
+ await screen.findByText(/site middleware is not reporting yet/i)
260
+ expect(screen.queryByRole('button', { name: /show middleware snippet/i })).toBeNull()
261
+ })
262
+
263
+ it('shows an empty state when reporting but the window is empty', async () => {
264
+ fetchSeoGeo.mockResolvedValue(report(EMPTY_WINDOW))
265
+ render(<AiVisibilityTab />)
266
+ await screen.findByText(/no ai crawler or referral activity in this window/i)
267
+ expect(screen.queryByRole('table')).toBeNull()
268
+ })
269
+
270
+ it('shows an error state with retry', async () => {
271
+ fetchSeoGeo.mockRejectedValueOnce(new Error('boom'))
272
+ render(<AiVisibilityTab />)
273
+ const alert = await screen.findByRole('alert')
274
+ expect(within(alert).getByText('boom')).toBeTruthy()
275
+ fireEvent.click(screen.getByRole('button', { name: /retry/i }))
276
+ await screen.findByRole('table', { name: /crawlers/i })
277
+ expect(fetchSeoGeo).toHaveBeenCalledTimes(2)
278
+ })
279
+ })