@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,35 @@
1
+ import CategoriesHandler from "../../../handlers/categories";
2
+ import Categories from "../../../cloud/Categories";
3
+
4
+ function buildCtx(id, resource) {
5
+ return {
6
+ client: { categories: new Categories(() => ({}), "token") },
7
+ params: { id },
8
+ resourceParams: { resource },
9
+ locale: "en",
10
+ resourceConfig: {
11
+ categoryType: "cat",
12
+ categoryTypePlural: "cats",
13
+ },
14
+ };
15
+ }
16
+
17
+ describe("Categories update defaults", () => {
18
+ test("update sends empty settings when the form carries none", async () => {
19
+ const ctx = buildCtx("42", { name: "Movie", slug: "movie" });
20
+
21
+ const mockUpdate = jest
22
+ .spyOn(ctx.client.categories, "update")
23
+ .mockImplementation(() => ({ errors: null }));
24
+
25
+ const result = await CategoriesHandler.update(ctx);
26
+
27
+ expect(mockUpdate).toHaveBeenCalledWith(
28
+ "42",
29
+ { name: "Movie", slug: "movie", settings: {} },
30
+ "en",
31
+ "cat",
32
+ );
33
+ expect(result).toEqual({ redirect: "/en/cats" });
34
+ });
35
+ });
@@ -0,0 +1,29 @@
1
+ import ContentTasks from "../../../../cloud/ContentTasks";
2
+ import ContentTasksHandler from "../../../../handlers/content_tasks/ContentTasksHandler";
3
+
4
+ function buildCtx() {
5
+ return {
6
+ client: { contentTasks: new ContentTasks(() => ({}), "token") },
7
+ query: { "f:locale": "en" },
8
+ locale: "en",
9
+ };
10
+ }
11
+
12
+ describe("ContentTasksHandler editors", () => {
13
+ test("index falls back to an empty editor list", async () => {
14
+ const ctx = buildCtx();
15
+
16
+ const mockList = jest
17
+ .spyOn(ctx.client.contentTasks, "list")
18
+ .mockImplementation(() => ({ list: [], pagination: {} }));
19
+ const mockEditors = jest
20
+ .spyOn(ctx.client.contentTasks, "editors")
21
+ .mockImplementation(() => ({}));
22
+
23
+ const result = await ContentTasksHandler.index(ctx);
24
+
25
+ expect(mockList).toHaveBeenCalledWith(ctx.query);
26
+ expect(mockEditors).toHaveBeenCalled();
27
+ expect(result.assigns.editors).toEqual([]);
28
+ });
29
+ });
@@ -0,0 +1,137 @@
1
+ import PublishesHandler from "../../../../handlers/resource_revisions/PublishesHandler";
2
+ import ResourceRevisions from "../../../../cloud/ResourceRevisions";
3
+ import Resources from "../../../../cloud/Resources";
4
+
5
+ import Factory from "../../../support/factory";
6
+
7
+ function buildCtx(revision, resourceParams) {
8
+ return {
9
+ client: {
10
+ resourceRevisions: new ResourceRevisions(() => ({}), "token"),
11
+ resources: new Resources(() => ({}), "token"),
12
+ },
13
+ params: { parent_id: revision.id },
14
+ resourceParams,
15
+ locale: "en",
16
+ resourceConfig: {
17
+ resourceType: "article",
18
+ resourceTypePlural: "articles",
19
+ },
20
+ };
21
+ }
22
+
23
+ // "Publish later" schedules the merge instead of publishing straight away.
24
+ describe("Schedule a resource revision", () => {
25
+ test("create schedules the revision for the given date", async () => {
26
+ const revision = Factory.build("resource_revision");
27
+ const publishing = {
28
+ title: "Campaign launch",
29
+ description: "Goes live with the campaign",
30
+ visibility: "public",
31
+ type: "later",
32
+ mergeAt: "2026-09-01T10:30",
33
+ };
34
+
35
+ const ctx = buildCtx(revision, { publishing });
36
+
37
+ jest
38
+ .spyOn(ctx.client.resourceRevisions, "find")
39
+ .mockImplementation(() => revision);
40
+ const mockSchedule = jest
41
+ .spyOn(ctx.client.resourceRevisions, "schedule")
42
+ .mockImplementation(() => ({ errors: null }));
43
+ const mockPublish = jest.spyOn(ctx.client.resourceRevisions, "publish");
44
+
45
+ const result = await PublishesHandler.create(ctx);
46
+
47
+ expect(mockPublish).not.toHaveBeenCalled();
48
+ expect(mockSchedule).toHaveBeenCalledWith(
49
+ revision.id,
50
+ "en",
51
+ {
52
+ title: publishing.title,
53
+ description: publishing.description,
54
+ mergeAt: expect.stringContaining("2026-09-01"),
55
+ },
56
+ { visibility: "public" },
57
+ "article",
58
+ );
59
+ expect(result).toEqual({
60
+ redirect: `/en/articles/${revision.originalId}`,
61
+ });
62
+ });
63
+
64
+ test("create schedules a protected revision with its credentials", async () => {
65
+ const revision = Factory.build("resource_revision");
66
+ const publishing = {
67
+ visibility: "protected",
68
+ username: "admin",
69
+ password: "admin123!@#",
70
+ type: "later",
71
+ mergeAt: "2026-09-01T10:30",
72
+ };
73
+
74
+ const ctx = buildCtx(revision, { publishing });
75
+
76
+ jest
77
+ .spyOn(ctx.client.resourceRevisions, "find")
78
+ .mockImplementation(() => revision);
79
+ const mockSchedule = jest
80
+ .spyOn(ctx.client.resourceRevisions, "schedule")
81
+ .mockImplementation(() => ({ errors: null }));
82
+
83
+ await PublishesHandler.create(ctx);
84
+
85
+ const [, , scheduleParams, resourceParams] = mockSchedule.mock.calls[0];
86
+
87
+ // Title and description are optional on the form.
88
+ expect(scheduleParams.title).toEqual("");
89
+ expect(scheduleParams.description).toEqual("");
90
+ expect(JSON.parse(resourceParams.settings).credentials).toEqual({
91
+ user: "admin",
92
+ password: "admin123!@#",
93
+ });
94
+ });
95
+
96
+ test("create publishes now when the date is missing", async () => {
97
+ const revision = Factory.build("resource_revision");
98
+ const ctx = buildCtx(revision, {
99
+ publishing: { type: "later", visibility: "public" },
100
+ });
101
+
102
+ jest
103
+ .spyOn(ctx.client.resourceRevisions, "find")
104
+ .mockImplementation(() => revision);
105
+ const mockSchedule = jest.spyOn(ctx.client.resourceRevisions, "schedule");
106
+ const mockPublish = jest
107
+ .spyOn(ctx.client.resourceRevisions, "publish")
108
+ .mockImplementation(() => ({ errors: null }));
109
+
110
+ await PublishesHandler.create(ctx);
111
+
112
+ expect(mockSchedule).not.toHaveBeenCalled();
113
+ expect(mockPublish).toHaveBeenCalled();
114
+ });
115
+
116
+ test("create re-renders the form with the resource errors", async () => {
117
+ const revision = Factory.build("resource_revision");
118
+ const ctx = buildCtx(revision, {
119
+ publishing: { type: "later", mergeAt: "2026-09-01T10:30" },
120
+ });
121
+
122
+ jest
123
+ .spyOn(ctx.client.resourceRevisions, "find")
124
+ .mockImplementation(() => revision);
125
+ jest
126
+ .spyOn(ctx.client.resourceRevisions, "schedule")
127
+ .mockImplementation(() => ({
128
+ resourceErrors: [{ field: "mergeAt", message: "is invalid" }],
129
+ }));
130
+
131
+ const result = await PublishesHandler.create(ctx);
132
+
133
+ expect(result.assigns.errors).toBeNull();
134
+ expect(result.assigns.resourceErrors).toBeDefined();
135
+ expect(result.assigns.publishing.id).toEqual(revision.id);
136
+ });
137
+ });
@@ -0,0 +1,162 @@
1
+ import TagHandler from "../../../../handlers/resource_revisions/TagHandler";
2
+ import ResourceRevisions from "../../../../cloud/ResourceRevisions";
3
+ import Resources from "../../../../cloud/Resources";
4
+ import Tags from "../../../../cloud/Tags";
5
+
6
+ import Factory from "../../../support/factory";
7
+
8
+ // Translated revisions show the tags of their default locale original as a
9
+ // reference, so the handler resolves them through the original record.
10
+ function buildCtx(revision) {
11
+ return {
12
+ client: {
13
+ resourceRevisions: new ResourceRevisions(() => ({}), "token"),
14
+ resources: new Resources(() => ({}), "token"),
15
+ tags: new Tags(() => ({}), "token"),
16
+ },
17
+ params: { parent_id: revision.id },
18
+ locale: "de",
19
+ defaultLocale: "en",
20
+ locales: {
21
+ en: { name: "English", code: "en-gb" },
22
+ de: { name: "German", code: "de-de" },
23
+ },
24
+ resourceConfig: {
25
+ resourceType: "article",
26
+ resourceTypePlural: "articles",
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("Resource revision tags in a translated locale", () => {
32
+ const tags = [
33
+ Factory.build("tag", { id: "42", name: "News" }),
34
+ Factory.build("tag", { id: "69", name: "Sports" }),
35
+ ];
36
+
37
+ function mockClient(ctx, { original, defaultLocalePage }) {
38
+ jest
39
+ .spyOn(ctx.client.tags, "all")
40
+ .mockImplementation(() => ({ list: tags }));
41
+
42
+ return jest
43
+ .spyOn(ctx.client.resources, "find")
44
+ .mockImplementation((id) =>
45
+ id === original?.id ? original : defaultLocalePage,
46
+ );
47
+ }
48
+
49
+ test("edit_form exposes the default locale tags", async () => {
50
+ const revision = Factory.build("resource_revision", {
51
+ originalId: "7",
52
+ tags: [tags[1]],
53
+ });
54
+ const original = Factory.build("resource", {
55
+ id: "7",
56
+ translations: { en: { id: "3" } },
57
+ });
58
+ const defaultLocalePage = Factory.build("resource", {
59
+ id: "3",
60
+ tags: [tags[0]],
61
+ });
62
+
63
+ const ctx = buildCtx(revision);
64
+ jest
65
+ .spyOn(ctx.client.resourceRevisions, "find")
66
+ .mockImplementation(() => revision);
67
+ const mockFind = mockClient(ctx, { original, defaultLocalePage });
68
+
69
+ const result = await TagHandler.edit_form(ctx);
70
+
71
+ expect(mockFind).toHaveBeenNthCalledWith(1, "7", "de", "article");
72
+ expect(mockFind).toHaveBeenNthCalledWith(2, "3", "en", "article");
73
+ expect(result.assigns.defaultLocaleName).toEqual("English");
74
+ expect(result.assigns.defaultLocaleTags).toEqual([tags[0]]);
75
+ });
76
+
77
+ test("edit_form falls back to no tags when the original has no translations", async () => {
78
+ const revision = Factory.build("resource_revision", { originalId: "7" });
79
+ const original = Factory.build("resource", { id: "7", translations: {} });
80
+
81
+ const ctx = buildCtx(revision);
82
+ jest
83
+ .spyOn(ctx.client.resourceRevisions, "find")
84
+ .mockImplementation(() => revision);
85
+ const mockFind = mockClient(ctx, { original });
86
+
87
+ const result = await TagHandler.edit_form(ctx);
88
+
89
+ expect(mockFind).toHaveBeenCalledTimes(1);
90
+ expect(result.assigns.defaultLocaleName).toEqual("");
91
+ expect(result.assigns.defaultLocaleTags).toEqual([]);
92
+ });
93
+
94
+ test("edit_form falls back to no tags when the default locale is not translated", async () => {
95
+ const revision = Factory.build("resource_revision", { originalId: "7" });
96
+ const original = Factory.build("resource", {
97
+ id: "7",
98
+ translations: { fr: { id: "9" } },
99
+ });
100
+
101
+ const ctx = buildCtx(revision);
102
+ jest
103
+ .spyOn(ctx.client.resourceRevisions, "find")
104
+ .mockImplementation(() => revision);
105
+ mockClient(ctx, { original });
106
+
107
+ const result = await TagHandler.edit_form(ctx);
108
+
109
+ expect(result.assigns.defaultLocaleTags).toEqual([]);
110
+ });
111
+
112
+ test("edit_form copes with a default locale page that has no tags", async () => {
113
+ const revision = Factory.build("resource_revision", { originalId: "7" });
114
+ const original = Factory.build("resource", {
115
+ id: "7",
116
+ translations: { en: { id: "3" } },
117
+ });
118
+
119
+ const ctx = buildCtx(revision);
120
+ jest
121
+ .spyOn(ctx.client.resourceRevisions, "find")
122
+ .mockImplementation(() => revision);
123
+ mockClient(ctx, { original, defaultLocalePage: null });
124
+
125
+ const result = await TagHandler.edit_form(ctx);
126
+
127
+ expect(result.assigns.defaultLocaleName).toEqual("English");
128
+ expect(result.assigns.defaultLocaleTags).toEqual([]);
129
+ });
130
+
131
+ test("update re-renders with the default locale tags when the update fails", async () => {
132
+ const revision = Factory.build("resource_revision", {
133
+ originalId: "7",
134
+ tags: [],
135
+ });
136
+ const original = Factory.build("resource", {
137
+ id: "7",
138
+ translations: { en: { id: "3" } },
139
+ });
140
+ const defaultLocalePage = Factory.build("resource", {
141
+ id: "3",
142
+ tags: [tags[0]],
143
+ });
144
+
145
+ const ctx = buildCtx(revision);
146
+ ctx.resourceParams = { resource: { tags: ["42"] } };
147
+
148
+ jest
149
+ .spyOn(ctx.client.resourceRevisions, "find")
150
+ .mockImplementation(() => revision);
151
+ jest
152
+ .spyOn(ctx.client.resourceRevisions, "update")
153
+ .mockImplementation(() => ({ errors: [{ field: "tags" }] }));
154
+ mockClient(ctx, { original, defaultLocalePage });
155
+
156
+ const result = await TagHandler.update(ctx);
157
+
158
+ expect(result.assigns.defaultLocaleName).toEqual("English");
159
+ expect(result.assigns.defaultLocaleTags).toEqual([tags[0]]);
160
+ expect(result.assigns.errors.resource).toBeDefined();
161
+ });
162
+ });
@@ -0,0 +1,61 @@
1
+ import WidgetHandler from "../../../../handlers/resource_revisions/WidgetHandler";
2
+ import ResourceRevisions from "../../../../cloud/ResourceRevisions";
3
+ import Settings from "../../../../cloud/Settings";
4
+
5
+ import Factory from "../../../support/factory";
6
+
7
+ function buildCtx(revision, resourceParams = {}) {
8
+ return {
9
+ client: {
10
+ resourceRevisions: new ResourceRevisions(() => ({}), "token"),
11
+ settings: new Settings(() => ({}), "token"),
12
+ },
13
+ params: { parent_id: revision.id },
14
+ resourceParams,
15
+ locale: "en",
16
+ resourceConfig: {
17
+ resourceType: "article",
18
+ resourceTypePlural: "articles",
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("Resource revision WidgetHandler defaults", () => {
24
+ test("update sends an empty widget list when the request carries none", async () => {
25
+ const revision = Factory.build("resource_revision");
26
+ const ctx = buildCtx(revision);
27
+
28
+ jest
29
+ .spyOn(ctx.client.resourceRevisions, "find")
30
+ .mockImplementation(() => revision);
31
+ const mockUpdate = jest
32
+ .spyOn(ctx.client.resourceRevisions, "updateWidgets")
33
+ .mockImplementation(() => ({ revision, errors: null }));
34
+ jest
35
+ .spyOn(ctx.client.settings, "find")
36
+ .mockImplementation(() => Factory.build("settings"));
37
+
38
+ await WidgetHandler.update(ctx);
39
+
40
+ expect(mockUpdate).toHaveBeenCalledWith(revision.id, "en", [], "article");
41
+ });
42
+
43
+ test("update skips the website settings when the update returned errors", async () => {
44
+ const revision = Factory.build("resource_revision");
45
+ const ctx = buildCtx(revision, { widgets: [] });
46
+ const errors = [{ field: "widgets", message: "is invalid" }];
47
+
48
+ jest
49
+ .spyOn(ctx.client.resourceRevisions, "find")
50
+ .mockImplementation(() => revision);
51
+ jest
52
+ .spyOn(ctx.client.resourceRevisions, "updateWidgets")
53
+ .mockImplementation(() => ({ errors }));
54
+ const mockSettings = jest.spyOn(ctx.client.settings, "find");
55
+
56
+ const result = await WidgetHandler.update(ctx);
57
+
58
+ expect(mockSettings).not.toHaveBeenCalled();
59
+ expect(result).toEqual({ json: { errors } });
60
+ });
61
+ });
@@ -0,0 +1,55 @@
1
+ import DeletesHandler from "../../../../handlers/resources/DeletesHandler";
2
+ import Resources from "../../../../cloud/Resources";
3
+ import Factory from "../../../support/factory";
4
+ import { remoteConfig } from "../../../support/handlersHelper";
5
+
6
+ function buildCtx(resource, params) {
7
+ return {
8
+ client: { resources: new Resources(() => ({}), "token") },
9
+ params: { parent_id: resource.id },
10
+ resourceParams: { resource: params },
11
+ locale: "en",
12
+ remoteConfig: remoteConfig(),
13
+ resourceConfig: {
14
+ resourceType: "article",
15
+ resourceTypePlural: "articles",
16
+ },
17
+ };
18
+ }
19
+
20
+ describe("Resource delete without a redirect", () => {
21
+ test("create deletes the resource and leaves no redirect behind", async () => {
22
+ const resource = Factory.build("resource");
23
+ const ctx = buildCtx(resource, { redirect: "" });
24
+
25
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
26
+ const mockDelete = jest
27
+ .spyOn(ctx.client.resources, "delete")
28
+ .mockImplementation(() => ({ resource, errors: null }));
29
+
30
+ const result = await DeletesHandler.create(ctx);
31
+
32
+ expect(mockDelete).toHaveBeenCalledWith(resource.id, "en", "article", null);
33
+ expect(result).toEqual({ redirect: "/en/articles" });
34
+ });
35
+
36
+ test("create_form throws for a missing resource", async () => {
37
+ const ctx = buildCtx({ id: "42" }, {});
38
+
39
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => null);
40
+
41
+ await expect(DeletesHandler.create_form(ctx)).rejects.toThrow(
42
+ "Missing article #42",
43
+ );
44
+ });
45
+
46
+ test("create throws for a missing resource", async () => {
47
+ const ctx = buildCtx({ id: "42" }, { redirect: "" });
48
+
49
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => null);
50
+
51
+ await expect(DeletesHandler.create(ctx)).rejects.toThrow(
52
+ "Missing article #42",
53
+ );
54
+ });
55
+ });
@@ -0,0 +1,156 @@
1
+ import Resources from "../../../../cloud/Resources";
2
+ import localCache from "../../../../localCache";
3
+
4
+ jest.mock("../../../../helpers/reports/cwv", () => ({
5
+ ...jest.requireActual("../../../../helpers/reports/cwv"),
6
+ pageWebVitals: jest.fn(),
7
+ }));
8
+ jest.mock("../../../../helpers/reports/quick-insights", () => ({
9
+ pageInsights: jest.fn(),
10
+ }));
11
+
12
+ import { pageWebVitals } from "../../../../helpers/reports/cwv";
13
+ import { pageInsights } from "../../../../helpers/reports/quick-insights";
14
+ import ReportsAPIHandler from "../../../../handlers/resources/ReportsAPIHandler";
15
+
16
+ import Factory from "../../../support/factory";
17
+
18
+ const SERVICE_ACCOUNT_PATH = "/tmp/service-account.json";
19
+
20
+ describe("Reports API Handler", () => {
21
+ function buildCtx(resource, { ga, reports = true } = {}) {
22
+ return {
23
+ client: { resources: new Resources(() => ({}), "token") },
24
+ params: { id: resource.id },
25
+ locale: "en",
26
+ query: {},
27
+ globalConfig: { ga },
28
+ remoteConfig: {
29
+ resourcePath: () => "/some-page",
30
+ resourceUrl: () => "https://example.com/some-page",
31
+ },
32
+ resourceConfig: {
33
+ resourceType: "article",
34
+ resourceTypePlural: "articles",
35
+ reports,
36
+ },
37
+ };
38
+ }
39
+
40
+ const insights = {
41
+ error: null,
42
+ pagePath: "/some-page",
43
+ summary: { views: { value: 10, trend: 5 } },
44
+ dateRange: { startDate: "2026-07-05", endDate: "2026-08-03" },
45
+ series: { dates: ["2026-08-01"], labels: ["August 1"], data: [5] },
46
+ publishDates: [],
47
+ };
48
+
49
+ const vitals = {
50
+ scope: "url",
51
+ assessment: "good",
52
+ metrics: [{ key: "lcp", p75: 1200, rating: "good" }],
53
+ collectionPeriod: null,
54
+ };
55
+
56
+ beforeEach(() => {
57
+ localCache.clear();
58
+ jest.clearAllMocks();
59
+ });
60
+
61
+ test("returns both reports as JSON", async () => {
62
+ const resource = Factory.build("resource", { translations: {} });
63
+ const ctx = buildCtx(resource, {
64
+ ga: {
65
+ propertyId: "1234",
66
+ serviceAccountPath: SERVICE_ACCOUNT_PATH,
67
+ apiKey: "crux-key",
68
+ },
69
+ });
70
+
71
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
72
+
73
+ pageInsights.mockResolvedValue({
74
+ summary: insights.summary,
75
+ rows: [{ date: "2026-08-01", views: 5 }],
76
+ dateRange: insights.dateRange,
77
+ });
78
+ pageWebVitals.mockResolvedValue(vitals);
79
+
80
+ const result = await ReportsAPIHandler.show(ctx);
81
+
82
+ expect(result.json.insights.error).toBeNull();
83
+ expect(result.json.insights.summary).toEqual(insights.summary);
84
+ expect(result.json.insights.series).toEqual({
85
+ dates: ["2026-08-01"],
86
+ labels: ["August 1"],
87
+ data: [5],
88
+ });
89
+ expect(result.json.webVitals).toEqual({
90
+ pageUrl: "https://example.com/some-page",
91
+ ...vitals,
92
+ error: null,
93
+ });
94
+ });
95
+
96
+ // Caching belongs to pageInsights / pageWebVitals (3h per page); the handler
97
+ // assembles the payload fresh so publish dates aren't held back with it.
98
+ test("does not cache the assembled payload", async () => {
99
+ const resource = Factory.build("resource", { translations: {} });
100
+ const ctx = buildCtx(resource, {
101
+ ga: { propertyId: "1234", serviceAccountPath: SERVICE_ACCOUNT_PATH },
102
+ });
103
+
104
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
105
+ pageInsights.mockResolvedValue({
106
+ summary: insights.summary,
107
+ rows: [],
108
+ dateRange: insights.dateRange,
109
+ });
110
+
111
+ await ReportsAPIHandler.show(ctx);
112
+ await ReportsAPIHandler.show(ctx);
113
+
114
+ expect(pageInsights).toHaveBeenCalledTimes(2);
115
+ });
116
+
117
+ test("passes a failed side through as an error", async () => {
118
+ const resource = Factory.build("resource", { translations: {} });
119
+ const ctx = buildCtx(resource, {
120
+ ga: { propertyId: "1234", serviceAccountPath: SERVICE_ACCOUNT_PATH },
121
+ });
122
+
123
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
124
+ pageInsights.mockRejectedValue(new Error("GA is down"));
125
+
126
+ const result = await ReportsAPIHandler.show(ctx);
127
+
128
+ expect(result.json).toEqual({
129
+ insights: { error: "GA is down" },
130
+ webVitals: null,
131
+ });
132
+ });
133
+
134
+ test("returns nulls when no report source is configured", async () => {
135
+ const resource = Factory.build("resource", { translations: {} });
136
+ const ctx = buildCtx(resource);
137
+
138
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
139
+
140
+ const result = await ReportsAPIHandler.show(ctx);
141
+
142
+ expect(pageInsights).not.toHaveBeenCalled();
143
+ expect(pageWebVitals).not.toHaveBeenCalled();
144
+ expect(result.json).toEqual({ insights: null, webVitals: null });
145
+ });
146
+
147
+ test("throws for a missing resource", async () => {
148
+ const ctx = buildCtx({ id: "42" });
149
+
150
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => null);
151
+
152
+ await expect(ReportsAPIHandler.show(ctx)).rejects.toThrow(
153
+ "Missing article #42",
154
+ );
155
+ });
156
+ });
@@ -85,6 +85,14 @@ describe("Resources Handler", () => {
85
85
  expect(result).toEqual({
86
86
  assigns: {
87
87
  resource,
88
+ // Nothing is prefetched — the page fetches the JSON feed client-side —
89
+ // and with no credentials there's no feed to point at either.
90
+ reports: {
91
+ insights: false,
92
+ webVitals: false,
93
+ path: null,
94
+ vitalsMeta: [],
95
+ },
88
96
  revisions: [revision],
89
97
  changes: { list: [change] },
90
98
  _translations: [