@raketa-cloud/admin 1.8.17 → 1.8.19

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 (59) hide show
  1. package/dist/application.css +288 -11
  2. package/package.json +8 -6
  3. package/src/handlers/resources/ReportsAPIHandler.js +27 -0
  4. package/src/handlers/resources/ResourcesHandler.js +16 -0
  5. package/src/helpers/content_tasks_helper.js +1 -1
  6. package/src/helpers/reports/cwv.js +217 -0
  7. package/src/helpers/reports/googleAuth.js +149 -0
  8. package/src/helpers/reports/quick-insights.js +294 -0
  9. package/src/helpers/reports/reportsUtils.js +181 -0
  10. package/src/index.js +8 -0
  11. package/src/localCache.js +112 -0
  12. package/src/report/analytics.js +107 -0
  13. package/src/report/reports.js +390 -0
  14. package/src/routeAssets.js +74 -0
  15. package/src/routeResourceModule.js +24 -0
  16. package/src/routeStandard.js +7 -1
  17. package/src/styles/application.css +1 -1
  18. package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
  19. package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
  20. package/src/tests/handlers/categories/update_defaults.test.js +35 -0
  21. package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
  22. package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
  23. package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
  24. package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
  25. package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
  26. package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
  27. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
  28. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
  29. package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +211 -0
  30. package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
  31. package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
  32. package/src/tests/helpers/HTMLBuilder.test.js +51 -0
  33. package/src/tests/helpers/content_tasks_helper.test.js +1 -1
  34. package/src/tests/helpers/historyUtils.branches.test.js +36 -0
  35. package/src/tests/helpers/reports/cwv.test.js +239 -0
  36. package/src/tests/helpers/reports/quick-insights.test.js +593 -0
  37. package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
  38. package/src/tests/helpers/utils.test.js +37 -0
  39. package/src/tests/helpers/view_helpers.test.js +87 -0
  40. package/src/tests/index.test.js +74 -0
  41. package/src/tests/localCache.test.js +236 -0
  42. package/src/tests/report/analytics.test.js +230 -0
  43. package/src/tests/report/reports.test.js +831 -0
  44. package/src/tests/routeAssets.test.js +120 -0
  45. package/src/tests/routeResourceModule.test.js +99 -2
  46. package/src/tests/routeStandard.test.js +3 -3
  47. package/src/tests/support/fakeDom.js +164 -0
  48. package/src/views/content_tasks/index.ejs +2 -2
  49. package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
  50. package/src/views/resources/_shared/_quick_insights.ejs +64 -0
  51. package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
  52. package/src/views/resources/_shared/_title.ejs +16 -12
  53. package/src/views/resources/_shared/_toolbar.ejs +11 -21
  54. package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
  55. package/src/views/resources/_shared/_translations_strip.ejs +13 -25
  56. package/src/views/resources/_shared/_vital_meter.ejs +54 -0
  57. package/src/views/resources/index.ejs +1 -1
  58. package/src/views/resources/show.ejs +88 -98
  59. package/src/views/resources/translations/create_form.ejs +14 -5
@@ -0,0 +1,593 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ clearAuthCache,
8
+ serviceAccountConfigured,
9
+ } from "../../../helpers/reports/googleAuth";
10
+ import {
11
+ pageInsights,
12
+ runPageReport,
13
+ runSummary,
14
+ } from "../../../helpers/reports/quick-insights";
15
+ import localCache from "../../../localCache";
16
+
17
+ // One throwaway RSA key written to a temp file — the module reads the key from
18
+ // ga.serviceAccountPath, exactly like it does in the app.
19
+ const { privateKey } = generateKeyPairSync("rsa", {
20
+ modulusLength: 2048,
21
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
22
+ publicKeyEncoding: { type: "spki", format: "pem" },
23
+ });
24
+
25
+ const dir = mkdtempSync(join(tmpdir(), "quick-insights-"));
26
+ const keyPath = join(dir, "service-account.json");
27
+ const missingPath = join(dir, "nope.json");
28
+ const brokenPath = join(dir, "broken.json");
29
+
30
+ writeFileSync(
31
+ keyPath,
32
+ JSON.stringify({
33
+ type: "service_account",
34
+ client_email: "reports@project.iam.gserviceaccount.com",
35
+ private_key_id: "key-1",
36
+ private_key: privateKey,
37
+ }),
38
+ );
39
+ writeFileSync(brokenPath, JSON.stringify({ client_email: "a@b.c" }));
40
+
41
+ const GA = { propertyId: "1234", serviceAccountPath: keyPath };
42
+
43
+ const reportBody = {
44
+ rows: [
45
+ {
46
+ dimensionValues: [{ value: "20260701" }],
47
+ metricValues: [{ value: "4" }],
48
+ },
49
+ ],
50
+ };
51
+
52
+ // First call is the token exchange, the rest are runReport calls.
53
+ function mockGoogle({ tokenStatus = 200, reportStatus = 200 } = {}) {
54
+ global.fetch = jest.fn().mockImplementation(async (url) => {
55
+ const isToken = String(url).includes("oauth2.googleapis.com");
56
+ const status = isToken ? tokenStatus : reportStatus;
57
+ const body = isToken
58
+ ? { access_token: "at-1", expires_in: 3600 }
59
+ : reportBody;
60
+
61
+ return {
62
+ ok: status === 200,
63
+ status,
64
+ json: async () => body,
65
+ text: async () => JSON.stringify(body),
66
+ };
67
+ });
68
+ }
69
+
70
+ const decode = (segment) =>
71
+ JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
72
+
73
+ describe("quick-insights auth", () => {
74
+ beforeEach(() => {
75
+ clearAuthCache();
76
+ jest.clearAllMocks();
77
+ });
78
+
79
+ afterEach(() => {
80
+ delete global.fetch;
81
+ });
82
+
83
+ test("serviceAccountConfigured follows the key path", () => {
84
+ expect(serviceAccountConfigured(GA)).toBe(true);
85
+ expect(serviceAccountConfigured({})).toBe(false);
86
+ expect(serviceAccountConfigured()).toBe(false);
87
+ });
88
+
89
+ test("signs a JWT from the key file and calls the Data API with it", async () => {
90
+ mockGoogle();
91
+
92
+ const result = await runPageReport({
93
+ pagePath: "/pricing",
94
+ startDate: "2026-07-01",
95
+ endDate: "2026-07-01",
96
+ ga: GA,
97
+ });
98
+
99
+ const [tokenUrl, tokenOptions] = global.fetch.mock.calls[0];
100
+ expect(tokenUrl).toBe("https://oauth2.googleapis.com/token");
101
+
102
+ const params = Object.fromEntries(tokenOptions.body);
103
+ expect(params.grant_type).toBe(
104
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
105
+ );
106
+
107
+ const [header, claims] = params.assertion.split(".");
108
+ expect(decode(header)).toEqual({
109
+ alg: "RS256",
110
+ typ: "JWT",
111
+ kid: "key-1",
112
+ });
113
+ expect(decode(claims)).toMatchObject({
114
+ iss: "reports@project.iam.gserviceaccount.com",
115
+ scope: "https://www.googleapis.com/auth/analytics.readonly",
116
+ aud: "https://oauth2.googleapis.com/token",
117
+ });
118
+
119
+ const [reportUrl, reportOptions] = global.fetch.mock.calls[1];
120
+ expect(reportUrl).toBe(
121
+ "https://analyticsdata.googleapis.com/v1beta/properties/1234:runReport",
122
+ );
123
+ expect(reportOptions.headers.authorization).toBe("Bearer at-1");
124
+
125
+ // The chart plots views only, so that is the single metric requested.
126
+ expect(JSON.parse(reportOptions.body).metrics).toEqual([
127
+ { name: "screenPageViews" },
128
+ ]);
129
+ expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
130
+ });
131
+
132
+ test("reuses the access token across reports", async () => {
133
+ mockGoogle();
134
+
135
+ const args = {
136
+ pagePath: "/pricing",
137
+ startDate: "2026-07-01",
138
+ endDate: "2026-07-01",
139
+ ga: GA,
140
+ };
141
+ await runPageReport(args);
142
+ await runPageReport(args);
143
+
144
+ const tokenCalls = global.fetch.mock.calls.filter(([url]) =>
145
+ String(url).includes("oauth2.googleapis.com"),
146
+ );
147
+ expect(tokenCalls).toHaveLength(1);
148
+ expect(global.fetch).toHaveBeenCalledTimes(3);
149
+ });
150
+
151
+ // Reports fire their GA calls together, so on a cold cache each one used to
152
+ // mint its own token.
153
+ test("mints one token for reports started concurrently", async () => {
154
+ mockGoogle();
155
+
156
+ const args = {
157
+ pagePath: "/pricing",
158
+ startDate: "2026-07-01",
159
+ endDate: "2026-07-01",
160
+ ga: GA,
161
+ };
162
+ await Promise.all([runPageReport(args), runPageReport(args)]);
163
+
164
+ const tokenCalls = global.fetch.mock.calls.filter(([url]) =>
165
+ String(url).includes("oauth2.googleapis.com"),
166
+ );
167
+ expect(tokenCalls).toHaveLength(1);
168
+ });
169
+
170
+ test("retries the token exchange after a failed one", async () => {
171
+ const args = {
172
+ pagePath: "/pricing",
173
+ startDate: "2026-07-01",
174
+ endDate: "2026-07-01",
175
+ ga: GA,
176
+ };
177
+
178
+ mockGoogle({ tokenStatus: 400 });
179
+ await expect(runPageReport(args)).rejects.toThrow(
180
+ /Google token request failed/,
181
+ );
182
+
183
+ // The failed exchange must not be left behind for the next caller to await.
184
+ mockGoogle();
185
+ await expect(runPageReport(args)).resolves.toBeDefined();
186
+ });
187
+
188
+ test("surfaces token and Data API failures", async () => {
189
+ const args = {
190
+ pagePath: "/pricing",
191
+ startDate: "2026-07-01",
192
+ endDate: "2026-07-01",
193
+ ga: GA,
194
+ };
195
+
196
+ mockGoogle({ tokenStatus: 400 });
197
+ await expect(runPageReport(args)).rejects.toThrow(
198
+ /Google token request failed \(400\)/,
199
+ );
200
+
201
+ clearAuthCache();
202
+ mockGoogle({ reportStatus: 403 });
203
+ await expect(runPageReport(args)).rejects.toThrow(/GA Data API 403/);
204
+ });
205
+
206
+ test("reports unusable key paths", async () => {
207
+ // The property id is checked while building the request URL, so every case
208
+ // here keeps it set — otherwise that error would mask the key one.
209
+ mockGoogle();
210
+
211
+ const args = {
212
+ pagePath: "/pricing",
213
+ startDate: "2026-07-01",
214
+ endDate: "2026-07-01",
215
+ };
216
+
217
+ await expect(
218
+ runPageReport({ ...args, ga: { propertyId: "1234" } }),
219
+ ).rejects.toThrow(/Google service-account key is not set/);
220
+ await expect(
221
+ runPageReport({
222
+ ...args,
223
+ ga: { propertyId: "1234", serviceAccountPath: missingPath },
224
+ }),
225
+ ).rejects.toThrow(/Could not read the service-account key/);
226
+ await expect(
227
+ runPageReport({
228
+ ...args,
229
+ ga: { propertyId: "1234", serviceAccountPath: brokenPath },
230
+ }),
231
+ ).rejects.toThrow(/no client_email \/ private_key/);
232
+ });
233
+
234
+ test("requires a property id", async () => {
235
+ mockGoogle();
236
+
237
+ await expect(
238
+ runPageReport({
239
+ pagePath: "/pricing",
240
+ startDate: "2026-07-01",
241
+ endDate: "2026-07-01",
242
+ ga: { serviceAccountPath: keyPath },
243
+ }),
244
+ ).rejects.toThrow(/GA property id is not set/);
245
+ });
246
+ });
247
+
248
+ describe("runSummary", () => {
249
+ const metrics = (views, users, engagementSeconds, rate, sessions) => ({
250
+ metricValues: [views, users, engagementSeconds, rate, sessions].map(
251
+ (value) => ({ value: String(value) }),
252
+ ),
253
+ });
254
+
255
+ // GA tags each row with the name of the date range it belongs to when the
256
+ // report asks for more than one.
257
+ const summaryBody = (rows) => ({
258
+ dimensionHeaders: [{ name: "dateRange" }],
259
+ rows,
260
+ });
261
+
262
+ const mockSummary = (body) => {
263
+ global.fetch = jest.fn(async (url) => {
264
+ const isToken = String(url).includes("oauth2.googleapis.com");
265
+
266
+ return {
267
+ ok: true,
268
+ status: 200,
269
+ json: async () =>
270
+ isToken ? { access_token: "at-1", expires_in: 3600 } : body,
271
+ text: async () => "",
272
+ };
273
+ });
274
+ };
275
+
276
+ const reportCalls = () =>
277
+ global.fetch.mock.calls.filter(
278
+ ([url]) => !String(url).includes("oauth2.googleapis.com"),
279
+ );
280
+
281
+ const ARGS = {
282
+ pagePath: "/pricing",
283
+ startDate: "2026-07-08",
284
+ endDate: "2026-07-14",
285
+ ga: GA,
286
+ };
287
+
288
+ beforeEach(() => {
289
+ clearAuthCache();
290
+ jest.clearAllMocks();
291
+ });
292
+
293
+ afterEach(() => {
294
+ delete global.fetch;
295
+ });
296
+
297
+ test("asks for both periods in one request", async () => {
298
+ mockSummary(
299
+ summaryBody([
300
+ {
301
+ dimensionValues: [{ value: "current" }],
302
+ ...metrics(200, 100, 600, 0.6, 20),
303
+ },
304
+ {
305
+ dimensionValues: [{ value: "previous" }],
306
+ ...metrics(100, 50, 200, 0.3, 10),
307
+ },
308
+ ]),
309
+ );
310
+
311
+ const result = await runSummary(ARGS);
312
+
313
+ expect(reportCalls()).toHaveLength(1);
314
+
315
+ const body = JSON.parse(reportCalls()[0][1].body);
316
+ expect(body.dateRanges).toEqual([
317
+ { startDate: "2026-07-08", endDate: "2026-07-14", name: "current" },
318
+ { startDate: "2026-07-01", endDate: "2026-07-07", name: "previous" },
319
+ ]);
320
+ expect(body.dimensionFilter).toEqual({
321
+ filter: {
322
+ fieldName: "pagePath",
323
+ stringFilter: { matchType: "EXACT", value: "/pricing" },
324
+ },
325
+ });
326
+
327
+ expect(result.views).toEqual({ value: 200, previous: 100, trend: 100 });
328
+ expect(result.users).toEqual({ value: 100, previous: 50, trend: 100 });
329
+ // 600s over 20 sessions against 200s over 10.
330
+ expect(result.avgDuration.seconds).toEqual(30);
331
+ expect(result.avgDuration.trend).toBeCloseTo(50);
332
+ expect(result.engagement.rate).toBeCloseTo(0.6);
333
+ expect(result.engagement.trend).toBeCloseTo(100);
334
+ });
335
+
336
+ test("matches rows by range name, whatever order they arrive in", async () => {
337
+ mockSummary(
338
+ summaryBody([
339
+ {
340
+ dimensionValues: [{ value: "previous" }],
341
+ ...metrics(100, 50, 200, 0.3, 10),
342
+ },
343
+ {
344
+ dimensionValues: [{ value: "current" }],
345
+ ...metrics(50, 25, 100, 0.15, 5),
346
+ },
347
+ ]),
348
+ );
349
+
350
+ const result = await runSummary(ARGS);
351
+
352
+ expect(result.views).toEqual({ value: 50, previous: 100, trend: -50 });
353
+ });
354
+
355
+ // A period with no traffic comes back with no row at all.
356
+ test("reads zeros and drops the trend for a period GA left out", async () => {
357
+ mockSummary(
358
+ summaryBody([
359
+ {
360
+ dimensionValues: [{ value: "current" }],
361
+ ...metrics(10, 5, 60, 0.5, 2),
362
+ },
363
+ ]),
364
+ );
365
+
366
+ const result = await runSummary(ARGS);
367
+
368
+ // The zero baseline travels with the metric so the page can tell a brand
369
+ // new page from one that was never measured.
370
+ expect(result.views).toEqual({ value: 10, previous: 0, trend: null });
371
+ expect(result.engagement).toEqual({ rate: 0.5, previous: 0, trend: null });
372
+ });
373
+
374
+ test("answers with zeros when the report has no rows at all", async () => {
375
+ mockSummary(summaryBody([]));
376
+
377
+ const result = await runSummary(ARGS);
378
+
379
+ expect(result.views).toEqual({ value: 0, previous: 0, trend: null });
380
+ expect(result.avgDuration).toEqual({
381
+ seconds: 0,
382
+ previous: 0,
383
+ trend: null,
384
+ });
385
+ });
386
+ });
387
+
388
+ describe("pageInsights batching", () => {
389
+ // The window is the last 30 days from today, so the one day the series
390
+ // reports has to be today for the backfill to keep it.
391
+ const today = new Date().toISOString().slice(0, 10);
392
+ const todayCompact = today.replace(/-/g, "");
393
+
394
+ const BATCH = {
395
+ reports: [
396
+ {
397
+ dimensionHeaders: [{ name: "dateRange" }],
398
+ rows: [
399
+ {
400
+ dimensionValues: [{ value: "current" }],
401
+ metricValues: [
402
+ { value: "200" },
403
+ { value: "100" },
404
+ { value: "600" },
405
+ { value: "0.6" },
406
+ { value: "20" },
407
+ ],
408
+ },
409
+ {
410
+ dimensionValues: [{ value: "previous" }],
411
+ metricValues: [
412
+ { value: "100" },
413
+ { value: "50" },
414
+ { value: "200" },
415
+ { value: "0.3" },
416
+ { value: "10" },
417
+ ],
418
+ },
419
+ ],
420
+ },
421
+ {
422
+ rows: [
423
+ {
424
+ dimensionValues: [{ value: todayCompact }],
425
+ metricValues: [{ value: "7" }],
426
+ },
427
+ ],
428
+ },
429
+ ],
430
+ };
431
+
432
+ const dataCalls = () =>
433
+ global.fetch.mock.calls.filter(
434
+ ([url]) => !String(url).includes("oauth2.googleapis.com"),
435
+ );
436
+
437
+ beforeEach(() => {
438
+ clearAuthCache();
439
+ localCache.clear();
440
+ jest.clearAllMocks();
441
+
442
+ global.fetch = jest.fn(async (url) => ({
443
+ ok: true,
444
+ status: 200,
445
+ json: async () =>
446
+ String(url).includes("oauth2.googleapis.com")
447
+ ? { access_token: "at-1", expires_in: 3600 }
448
+ : BATCH,
449
+ text: async () => "",
450
+ }));
451
+ });
452
+
453
+ afterEach(() => {
454
+ localCache.clear();
455
+ delete global.fetch;
456
+ });
457
+
458
+ // Summary current + summary previous + the daily series: three periods over
459
+ // two report shapes, one round trip.
460
+ test("asks for the summary and the series in a single request", async () => {
461
+ await pageInsights("/pricing", GA, { cacheKey: "page:1" });
462
+
463
+ expect(dataCalls()).toHaveLength(1);
464
+
465
+ const [url, options] = dataCalls()[0];
466
+ expect(url).toBe(
467
+ "https://analyticsdata.googleapis.com/v1beta/properties/1234:batchRunReports",
468
+ );
469
+
470
+ const { requests } = JSON.parse(options.body);
471
+ expect(requests).toHaveLength(2);
472
+
473
+ // The summary carries both periods and no date breakdown.
474
+ expect(requests[0].dimensions).toBeUndefined();
475
+ expect(requests[0].dateRanges).toHaveLength(2);
476
+ expect(requests[0].dateRanges.map((r) => r.name)).toEqual([
477
+ "current",
478
+ "previous",
479
+ ]);
480
+
481
+ // The series is broken down by date, which is why it can't be the same
482
+ // report as the summary.
483
+ expect(requests[1].dimensions).toEqual([{ name: "date" }]);
484
+ expect(requests[1].dateRanges).toHaveLength(1);
485
+
486
+ // The property lives in the URL — repeating it in a request is an error.
487
+ requests.forEach((request) => expect(request.property).toBeUndefined());
488
+ });
489
+
490
+ test("reads the summary off the first report and the series off the second", async () => {
491
+ const result = await pageInsights("/pricing", GA, { cacheKey: "page:1" });
492
+
493
+ expect(result.summary.views).toEqual({
494
+ value: 200,
495
+ previous: 100,
496
+ trend: 100,
497
+ });
498
+ expect(result.rows).toContainEqual({ date: today, views: 7 });
499
+ // Days GA skipped are still plotted, so the line has no false gaps.
500
+ expect(result.rows).toHaveLength(30);
501
+ });
502
+
503
+ // Both instants land on the same UTC day but on different local days for a
504
+ // host that isn't on UTC — the window has to follow the host's calendar.
505
+ test.each(["2026-08-11T23:30:00Z", "2026-08-12T00:30:00Z"])(
506
+ "asks for the window in the server's own timezone (%s)",
507
+ async (instant) => {
508
+ jest.useFakeTimers().setSystemTime(new Date(instant));
509
+
510
+ try {
511
+ await pageInsights("/pricing", GA, { cacheKey: `page:${instant}` });
512
+
513
+ // en-CA renders a local date as YYYY-MM-DD without going through UTC.
514
+ const local = (date) => date.toLocaleDateString("en-CA");
515
+ const start = new Date();
516
+ start.setDate(start.getDate() - 29);
517
+
518
+ const { requests } = JSON.parse(dataCalls()[0][1].body);
519
+ expect(requests[1].dateRanges[0]).toEqual({
520
+ startDate: local(start),
521
+ endDate: local(new Date()),
522
+ });
523
+ } finally {
524
+ jest.useRealTimers();
525
+ }
526
+ },
527
+ );
528
+
529
+ test("survives a batch that came back short", async () => {
530
+ global.fetch = jest.fn(async (url) => ({
531
+ ok: true,
532
+ status: 200,
533
+ json: async () =>
534
+ String(url).includes("oauth2.googleapis.com")
535
+ ? { access_token: "at-1", expires_in: 3600 }
536
+ : {},
537
+ text: async () => "",
538
+ }));
539
+
540
+ const result = await pageInsights("/pricing", GA, { cacheKey: "page:2" });
541
+
542
+ expect(result.summary.views).toEqual({
543
+ value: 0,
544
+ previous: 0,
545
+ trend: null,
546
+ });
547
+ expect(result.rows.every((row) => row.views === 0)).toBe(true);
548
+ });
549
+ });
550
+
551
+ describe("pageInsights caching", () => {
552
+ const reportCalls = () =>
553
+ global.fetch.mock.calls.filter(
554
+ ([url]) => !String(url).includes("oauth2.googleapis.com"),
555
+ ).length;
556
+
557
+ beforeEach(() => {
558
+ clearAuthCache();
559
+ localCache.clear();
560
+ jest.clearAllMocks();
561
+ mockGoogle();
562
+ });
563
+
564
+ afterEach(() => {
565
+ localCache.clear();
566
+ delete global.fetch;
567
+ });
568
+
569
+ test("keys the entry on the resource, not on the page path", async () => {
570
+ await pageInsights("/old-slug", GA, { cacheKey: "page:42" });
571
+ const first = reportCalls();
572
+ expect(first).toBeGreaterThan(0);
573
+
574
+ // Same resource after a slug change — served from cache.
575
+ await pageInsights("/new-slug", GA, { cacheKey: "page:42" });
576
+ expect(reportCalls()).toEqual(first);
577
+
578
+ // A different resource on the same path gets its own entry.
579
+ await pageInsights("/old-slug", GA, { cacheKey: "page:43" });
580
+ expect(reportCalls()).toEqual(first * 2);
581
+ });
582
+
583
+ test("falls back to the page path without a cacheKey", async () => {
584
+ await pageInsights("/pricing", GA);
585
+ const first = reportCalls();
586
+
587
+ await pageInsights("/pricing", GA);
588
+ expect(reportCalls()).toEqual(first);
589
+
590
+ await pageInsights("/about", GA);
591
+ expect(reportCalls()).toEqual(first * 2);
592
+ });
593
+ });