@prenta/admin 1.15.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.
- package/CHANGELOG.md +16 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +3 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
- package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +16 -4
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
- package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
- package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
- package/dist/components/seo/FaqProposalPreview.js +62 -0
- package/dist/components/seo/FaqProposalPreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +7 -2
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/seo-service.d.ts +155 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +42 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.d.ts +5 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +12 -7
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
- package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
- package/dist/views/seo/AiVisibilityTab.js +165 -0
- package/dist/views/seo/AiVisibilityTab.js.map +1 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +160 -7
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts +3 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +4 -4
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.js +6 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
- package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
- package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
- package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
- package/src/__tests__/lib/seo-service-errors.test.ts +4 -0
- package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
- package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
- package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
- package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +28 -7
- package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
- package/src/components/seo/FaqProposalPreview.tsx +134 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
- package/src/lib/seo-service.ts +162 -1
- package/src/views/SEO.tsx +15 -6
- package/src/views/seo/AiVisibilityTab.tsx +616 -0
- package/src/views/seo/ProposalsTab.tsx +279 -13
- package/src/views/seo/TechnicalTab.tsx +27 -8
- package/src/views/settings/SeoAutopilotCard.tsx +6 -0
- package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
type Settings = { enabled: boolean; retentionDays: number }
|
|
6
|
+
type UpdateResult = { ok: boolean; error?: string }
|
|
7
|
+
|
|
8
|
+
let current: Settings = { enabled: true, retentionDays: 90 }
|
|
9
|
+
/** When set, the next PUT waits on this so a test can observe the in-flight state. */
|
|
10
|
+
let putGate: Promise<void> | null = null
|
|
11
|
+
|
|
12
|
+
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
13
|
+
let resolve!: () => void
|
|
14
|
+
const promise = new Promise<void>((r) => {
|
|
15
|
+
resolve = r
|
|
16
|
+
})
|
|
17
|
+
return { promise, resolve }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function defaultUpdate(patch: Partial<Settings>): Promise<UpdateResult> {
|
|
21
|
+
if (putGate) await putGate
|
|
22
|
+
current = { ...current, ...patch }
|
|
23
|
+
return { ok: true }
|
|
24
|
+
}
|
|
25
|
+
const fetchGeoSettings = vi.fn(async (): Promise<Settings | null> => ({ ...current }))
|
|
26
|
+
const updateGeoSettings = vi.fn(defaultUpdate)
|
|
27
|
+
vi.mock('../../lib/seo-service.js', () => ({
|
|
28
|
+
SEO_GEO_RETENTION: { min: 7, max: 365 },
|
|
29
|
+
fetchGeoSettings: () => fetchGeoSettings(),
|
|
30
|
+
updateGeoSettings: (p: Partial<Settings>) => updateGeoSettings(p),
|
|
31
|
+
}))
|
|
32
|
+
const toastSuccess = vi.fn()
|
|
33
|
+
const toastError = vi.fn()
|
|
34
|
+
vi.mock('sonner', () => ({
|
|
35
|
+
toast: {
|
|
36
|
+
success: (...a: unknown[]) => toastSuccess(...a),
|
|
37
|
+
error: (...a: unknown[]) => toastError(...a),
|
|
38
|
+
warning: vi.fn(),
|
|
39
|
+
info: vi.fn(),
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const { SeoGeoSettingsCard } = await import('../../views/settings/SeoGeoSettingsCard.js')
|
|
44
|
+
|
|
45
|
+
const SWITCH = /record ai crawler and referral activity/i
|
|
46
|
+
const RETENTION = /retention \(days\)/i
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
current = { enabled: true, retentionDays: 90 }
|
|
50
|
+
putGate = null
|
|
51
|
+
fetchGeoSettings.mockClear()
|
|
52
|
+
fetchGeoSettings.mockImplementation(async () => ({ ...current }))
|
|
53
|
+
updateGeoSettings.mockClear()
|
|
54
|
+
updateGeoSettings.mockImplementation(defaultUpdate)
|
|
55
|
+
toastSuccess.mockClear()
|
|
56
|
+
toastError.mockClear()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('SeoGeoSettingsCard', () => {
|
|
60
|
+
it('renders the toggle, retention, and both middleware snippets', async () => {
|
|
61
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
62
|
+
const toggle = await screen.findByRole('switch', { name: SWITCH })
|
|
63
|
+
expect(toggle.getAttribute('aria-checked')).toBe('true')
|
|
64
|
+
expect((screen.getByLabelText(RETENTION) as HTMLInputElement).value).toBe('90')
|
|
65
|
+
expect(screen.getByText(/observeGeoTraffic\(request/)).toBeTruthy()
|
|
66
|
+
expect(screen.getByText(/createAstroRedirectMiddleware/)).toBeTruthy()
|
|
67
|
+
// Scrollable snippets are focusable and named so keyboard users can scroll them.
|
|
68
|
+
for (const name of ['Next.js middleware snippet', 'Astro middleware snippet']) {
|
|
69
|
+
const pre = screen.getByLabelText(name)
|
|
70
|
+
expect(pre.tagName).toBe('PRE')
|
|
71
|
+
expect(pre.getAttribute('tabindex')).toBe('0')
|
|
72
|
+
}
|
|
73
|
+
// The kill-switch consequences are spelled out in the card copy.
|
|
74
|
+
const card = screen.getByRole('region', { name: 'AI visibility' })
|
|
75
|
+
expect(card.textContent ?? '').toMatch(/drops incoming beacons/i)
|
|
76
|
+
expect(card.textContent ?? '').toMatch(/AI visibility tab/i)
|
|
77
|
+
// This card takes no `onNavigate`, so it must not link anywhere.
|
|
78
|
+
expect(card.querySelector('a')).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('toggling PUTs { enabled: false }, reloads, and reflects the server state', async () => {
|
|
82
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
83
|
+
fireEvent.click(await screen.findByRole('switch', { name: SWITCH }))
|
|
84
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ enabled: false }))
|
|
85
|
+
await waitFor(() => expect(fetchGeoSettings).toHaveBeenCalledTimes(2))
|
|
86
|
+
await waitFor(() =>
|
|
87
|
+
expect(screen.getByRole('switch', { name: SWITCH }).getAttribute('aria-checked')).toBe(
|
|
88
|
+
'false',
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
expect(toastSuccess).toHaveBeenCalledWith('AI visibility measurement disabled')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('marks the card busy while a save is in flight and keeps the toggle focused', async () => {
|
|
95
|
+
const gate = deferred()
|
|
96
|
+
putGate = gate.promise
|
|
97
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
98
|
+
const toggle = await screen.findByRole('switch', { name: SWITCH })
|
|
99
|
+
const card = screen.getByRole('region', { name: 'AI visibility' })
|
|
100
|
+
toggle.focus()
|
|
101
|
+
fireEvent.click(toggle)
|
|
102
|
+
await waitFor(() => expect(card.getAttribute('aria-busy')).toBe('true'))
|
|
103
|
+
expect((toggle as HTMLButtonElement).disabled).toBe(false)
|
|
104
|
+
expect(document.activeElement).toBe(toggle)
|
|
105
|
+
gate.resolve()
|
|
106
|
+
await waitFor(() => expect(card.getAttribute('aria-busy')).toBeNull())
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('surfaces a failed toggle PUT as a toast and keeps the previous state', async () => {
|
|
110
|
+
updateGeoSettings.mockResolvedValueOnce({ ok: false, error: 'enabled must be a boolean' })
|
|
111
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
112
|
+
fireEvent.click(await screen.findByRole('switch', { name: SWITCH }))
|
|
113
|
+
await waitFor(() => expect(toastError).toHaveBeenCalledWith('enabled must be a boolean'))
|
|
114
|
+
expect(screen.getByRole('switch', { name: SWITCH }).getAttribute('aria-checked')).toBe('true')
|
|
115
|
+
expect(toastSuccess).not.toHaveBeenCalled()
|
|
116
|
+
expect(fetchGeoSettings).toHaveBeenCalledTimes(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('clamps retention to 7–365 and commits on blur', async () => {
|
|
120
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
121
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
122
|
+
fireEvent.change(input, { target: { value: '1000' } })
|
|
123
|
+
fireEvent.blur(input)
|
|
124
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 365 }))
|
|
125
|
+
await waitFor(() => expect(input.value).toBe('365'))
|
|
126
|
+
expect(toastSuccess).toHaveBeenCalledWith('Retention set to 365 days')
|
|
127
|
+
|
|
128
|
+
fireEvent.change(input, { target: { value: '2' } })
|
|
129
|
+
fireEvent.blur(input)
|
|
130
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenLastCalledWith({ retentionDays: 7 }))
|
|
131
|
+
await waitFor(() => expect(input.value).toBe('7'))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('commits retention on Enter', async () => {
|
|
135
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
136
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
137
|
+
// Enter commits by blurring the (focused) input, so the blur path is the
|
|
138
|
+
// single commit point and a following real blur cannot double-PUT.
|
|
139
|
+
input.focus()
|
|
140
|
+
fireEvent.change(input, { target: { value: '30' } })
|
|
141
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
142
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 30 }))
|
|
143
|
+
expect(updateGeoSettings).toHaveBeenCalledTimes(1)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does not PUT when retention is blurred unchanged', async () => {
|
|
147
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
148
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
149
|
+
fireEvent.blur(input)
|
|
150
|
+
fireEvent.change(input, { target: { value: '90' } })
|
|
151
|
+
fireEvent.blur(input)
|
|
152
|
+
expect(updateGeoSettings).not.toHaveBeenCalled()
|
|
153
|
+
expect(input.value).toBe('90')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('shows an inline error for a non-numeric retention instead of a toast or a PUT', async () => {
|
|
157
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
158
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
159
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
160
|
+
fireEvent.blur(input)
|
|
161
|
+
const error = await screen.findByText(/whole number of days/i)
|
|
162
|
+
expect(input.getAttribute('aria-invalid')).toBe('true')
|
|
163
|
+
expect(input.getAttribute('aria-describedby') ?? '').toContain(error.id)
|
|
164
|
+
expect(updateGeoSettings).not.toHaveBeenCalled()
|
|
165
|
+
expect(toastError).not.toHaveBeenCalled()
|
|
166
|
+
// Typing again clears the error; a valid commit then lands.
|
|
167
|
+
fireEvent.change(input, { target: { value: '14' } })
|
|
168
|
+
expect(input.getAttribute('aria-invalid')).toBeNull()
|
|
169
|
+
fireEvent.blur(input)
|
|
170
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 14 }))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('rejects a fractional retention with the inline error and no PUT', async () => {
|
|
174
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
175
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
176
|
+
fireEvent.change(input, { target: { value: '7.5' } })
|
|
177
|
+
fireEvent.blur(input)
|
|
178
|
+
await screen.findByText(/whole number of days/i)
|
|
179
|
+
expect(input.getAttribute('aria-invalid')).toBe('true')
|
|
180
|
+
expect(input.value).toBe('7.5')
|
|
181
|
+
expect(updateGeoSettings).not.toHaveBeenCalled()
|
|
182
|
+
expect(toastError).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('a failed second queued commit reverts to the first commit’s confirmed value', async () => {
|
|
186
|
+
// A: 90→30 is held in flight; B: →60 is blurred behind it and fails only
|
|
187
|
+
// after A's result has rendered. The revert must land on 30 (what the
|
|
188
|
+
// server holds), not the 90 that B's commit closed over.
|
|
189
|
+
const gateA = deferred()
|
|
190
|
+
const gateB = deferred()
|
|
191
|
+
updateGeoSettings.mockImplementation(async (patch) => {
|
|
192
|
+
if (updateGeoSettings.mock.calls.length === 1) {
|
|
193
|
+
await gateA.promise
|
|
194
|
+
current = { ...current, ...patch }
|
|
195
|
+
return { ok: true }
|
|
196
|
+
}
|
|
197
|
+
await gateB.promise
|
|
198
|
+
return { ok: false, error: 'nope' }
|
|
199
|
+
})
|
|
200
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
201
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
202
|
+
fireEvent.change(input, { target: { value: '30' } })
|
|
203
|
+
fireEvent.blur(input)
|
|
204
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 30 }))
|
|
205
|
+
fireEvent.change(input, { target: { value: '60' } })
|
|
206
|
+
fireEvent.blur(input)
|
|
207
|
+
expect(updateGeoSettings).toHaveBeenCalledTimes(1)
|
|
208
|
+
|
|
209
|
+
gateA.resolve()
|
|
210
|
+
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Retention set to 30 days'))
|
|
211
|
+
await waitFor(() => expect(input.value).toBe('30'))
|
|
212
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 60 }))
|
|
213
|
+
|
|
214
|
+
gateB.resolve()
|
|
215
|
+
await waitFor(() => expect(toastError).toHaveBeenCalledWith('nope'))
|
|
216
|
+
// Let the failed commit's revert (a microtask after the toast) render
|
|
217
|
+
// before asserting; a stale-closure revert would now show "90".
|
|
218
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
219
|
+
expect(input.value).toBe('30')
|
|
220
|
+
expect(toastSuccess).toHaveBeenCalledTimes(1)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('reverts retention to the last saved value when its PUT fails', async () => {
|
|
224
|
+
updateGeoSettings.mockResolvedValueOnce({ ok: false, error: 'nope' })
|
|
225
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
226
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
227
|
+
fireEvent.change(input, { target: { value: '1000' } })
|
|
228
|
+
fireEvent.blur(input)
|
|
229
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 365 }))
|
|
230
|
+
await waitFor(() => expect(toastError).toHaveBeenCalledWith('nope'))
|
|
231
|
+
await waitFor(() => expect(input.value).toBe('90'))
|
|
232
|
+
expect(toastSuccess).not.toHaveBeenCalled()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('disables controls without edit rights', async () => {
|
|
236
|
+
render(<SeoGeoSettingsCard canEdit={false} />)
|
|
237
|
+
const toggle = (await screen.findByRole('switch', { name: SWITCH })) as HTMLButtonElement
|
|
238
|
+
expect(toggle.disabled).toBe(true)
|
|
239
|
+
expect((screen.getByLabelText(RETENTION) as HTMLInputElement).disabled).toBe(true)
|
|
240
|
+
// Read-only users still see the setup snippets.
|
|
241
|
+
expect(screen.getByText(/observeGeoTraffic\(request/)).toBeTruthy()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('shows an error state with Retry when settings fail to load', async () => {
|
|
245
|
+
fetchGeoSettings.mockResolvedValueOnce(null)
|
|
246
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
247
|
+
const retry = await screen.findByRole('button', { name: /retry/i })
|
|
248
|
+
expect(screen.getByRole('alert').textContent ?? '').toMatch(/AI visibility settings/i)
|
|
249
|
+
fireEvent.click(retry)
|
|
250
|
+
await screen.findByRole('switch', { name: SWITCH })
|
|
251
|
+
expect(fetchGeoSettings).toHaveBeenCalledTimes(2)
|
|
252
|
+
})
|
|
253
|
+
})
|