@terreno/announcements 57.4.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/LICENSE +21 -0
- package/README.md +50 -0
- package/biome.jsonc +20 -0
- package/bunfig.toml +2 -0
- package/dist/announcementsApp.d.ts +9 -0
- package/dist/announcementsApp.js +820 -0
- package/dist/help.d.ts +30 -0
- package/dist/help.js +72 -0
- package/dist/helpRoutes.d.ts +8 -0
- package/dist/helpRoutes.js +233 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +34 -0
- package/dist/models/announcement.d.ts +3 -0
- package/dist/models/announcement.js +214 -0
- package/dist/models/announcementAcknowledgement.d.ts +2 -0
- package/dist/models/announcementAcknowledgement.js +41 -0
- package/dist/models/announcementClickEvent.d.ts +3 -0
- package/dist/models/announcementClickEvent.js +56 -0
- package/dist/models/announcementImpression.d.ts +3 -0
- package/dist/models/announcementImpression.js +50 -0
- package/dist/overview.d.ts +14 -0
- package/dist/overview.js +310 -0
- package/dist/pending.d.ts +64 -0
- package/dist/pending.js +242 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.js +2 -0
- package/package.json +57 -0
- package/src/announcementsApp.ts +722 -0
- package/src/help.ts +98 -0
- package/src/helpRoutes.ts +160 -0
- package/src/index.ts +45 -0
- package/src/models/announcement.ts +189 -0
- package/src/models/announcementAcknowledgement.ts +50 -0
- package/src/models/announcementClickEvent.ts +68 -0
- package/src/models/announcementImpression.ts +62 -0
- package/src/overview.ts +228 -0
- package/src/pending.ts +286 -0
- package/src/tests/announcementsApp.test.ts +844 -0
- package/src/tests/bunSetup.ts +9 -0
- package/src/tests/help.test.ts +29 -0
- package/src/tests/helpRoutes.test.ts +291 -0
- package/src/tests/index.test.ts +57 -0
- package/src/tests/overview.test.ts +351 -0
- package/src/tests/pending.test.ts +262 -0
- package/src/types.ts +157 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
addAuthRoutes,
|
|
4
|
+
apiErrorMiddleware,
|
|
5
|
+
apiUnauthorizedMiddleware,
|
|
6
|
+
setupAuth,
|
|
7
|
+
type UserModel as UserModelType,
|
|
8
|
+
} from "@terreno/api";
|
|
9
|
+
import {authAsUser, getBaseServer, setupDb, UserModel} from "@terreno/api/testing";
|
|
10
|
+
import {assert} from "chai";
|
|
11
|
+
import type express from "express";
|
|
12
|
+
import {DateTime} from "luxon";
|
|
13
|
+
import supertest from "supertest";
|
|
14
|
+
import type TestAgent from "supertest/lib/agent";
|
|
15
|
+
import {AnnouncementsApp} from "../announcementsApp";
|
|
16
|
+
import {Announcement} from "../models/announcement";
|
|
17
|
+
import {AnnouncementAcknowledgement} from "../models/announcementAcknowledgement";
|
|
18
|
+
import {AnnouncementClickEvent} from "../models/announcementClickEvent";
|
|
19
|
+
import {AnnouncementImpression} from "../models/announcementImpression";
|
|
20
|
+
|
|
21
|
+
const buildApp = (options?: {
|
|
22
|
+
basePath?: string;
|
|
23
|
+
defaultAcknowledgementPolicy?: "required" | "dismiss-only";
|
|
24
|
+
isStaff?: (user: unknown) => boolean;
|
|
25
|
+
matchAudience?: (user: unknown, announcement: unknown) => boolean;
|
|
26
|
+
}): express.Application => {
|
|
27
|
+
const app = getBaseServer();
|
|
28
|
+
setupAuth(app, UserModel as unknown as UserModelType);
|
|
29
|
+
addAuthRoutes(app, UserModel as unknown as UserModelType);
|
|
30
|
+
|
|
31
|
+
const plugin = new AnnouncementsApp(options);
|
|
32
|
+
plugin.register(app);
|
|
33
|
+
|
|
34
|
+
app.use(apiUnauthorizedMiddleware);
|
|
35
|
+
app.use(apiErrorMiddleware);
|
|
36
|
+
return app;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("AnnouncementsApp", () => {
|
|
40
|
+
let app: express.Application;
|
|
41
|
+
let adminAgent: TestAgent;
|
|
42
|
+
let userAgent: TestAgent;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
await setupDb();
|
|
46
|
+
await Announcement.deleteMany({});
|
|
47
|
+
await AnnouncementAcknowledgement.deleteMany({});
|
|
48
|
+
await AnnouncementImpression.deleteMany({});
|
|
49
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
50
|
+
app = buildApp();
|
|
51
|
+
adminAgent = await authAsUser(app, "admin");
|
|
52
|
+
userAgent = await authAsUser(app, "notAdmin");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await Announcement.deleteMany({});
|
|
57
|
+
await AnnouncementAcknowledgement.deleteMany({});
|
|
58
|
+
await AnnouncementImpression.deleteMany({});
|
|
59
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("contributes announcement admin models and grouped overview screen", () => {
|
|
63
|
+
const contribution = new AnnouncementsApp().adminContribution();
|
|
64
|
+
expect(contribution.models?.length).toBe(4);
|
|
65
|
+
expect(contribution.models?.[0]?.routePath).toBe("/announcements");
|
|
66
|
+
expect(contribution.models?.[0]?.admin.displayName).toBe("All announcements");
|
|
67
|
+
expect(contribution.models?.every((entry) => entry.admin.group === "Announcements")).toBe(true);
|
|
68
|
+
for (const eventModel of contribution.models?.slice(1) ?? []) {
|
|
69
|
+
assert.deepEqual(eventModel.admin.adminPermissions?.create, []);
|
|
70
|
+
assert.deepEqual(eventModel.admin.adminPermissions?.delete, []);
|
|
71
|
+
assert.deepEqual(eventModel.admin.adminPermissions?.update, []);
|
|
72
|
+
}
|
|
73
|
+
expect(contribution.customScreens).toEqual([
|
|
74
|
+
{
|
|
75
|
+
displayName: "Overview",
|
|
76
|
+
group: "Announcements",
|
|
77
|
+
icon: "bullhorn",
|
|
78
|
+
name: "announcements",
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("publishes draft announcements and returns them as pending", async () => {
|
|
84
|
+
const createRes = await adminAgent
|
|
85
|
+
.post("/announcements")
|
|
86
|
+
.send({
|
|
87
|
+
acknowledgementPolicy: "required",
|
|
88
|
+
body: "## Welcome\n\nCheck out the new feature.",
|
|
89
|
+
title: "New feature",
|
|
90
|
+
})
|
|
91
|
+
.expect(201);
|
|
92
|
+
|
|
93
|
+
const announcementId = createRes.body.data._id as string;
|
|
94
|
+
await adminAgent.post(`/announcements/${announcementId}/publish`).expect(200);
|
|
95
|
+
|
|
96
|
+
const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
|
|
97
|
+
expect(pendingRes.body.data.current?.id).toBe(announcementId);
|
|
98
|
+
expect(pendingRes.body.data.current?.requiresAcknowledgement).toBe(true);
|
|
99
|
+
expect(pendingRes.body.data.remainingCount).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("resolves requiresAcknowledgement on GET pending for each policy case", async () => {
|
|
103
|
+
const requiredApp = buildApp();
|
|
104
|
+
const requiredUser = await authAsUser(requiredApp, "notAdmin");
|
|
105
|
+
|
|
106
|
+
const requiredAnnouncement = await Announcement.create({
|
|
107
|
+
acknowledgementPolicy: "required",
|
|
108
|
+
body: "Required body",
|
|
109
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
110
|
+
status: "published",
|
|
111
|
+
title: "Required ack",
|
|
112
|
+
version: 1,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const requiredRes = await requiredUser.get("/announcements/pending?platform=web").expect(200);
|
|
116
|
+
expect(requiredRes.body.data.current?.id).toBe(requiredAnnouncement._id.toString());
|
|
117
|
+
expect(requiredRes.body.data.current?.requiresAcknowledgement).toBe(true);
|
|
118
|
+
|
|
119
|
+
await Announcement.deleteMany({});
|
|
120
|
+
|
|
121
|
+
const dismissAnnouncement = await Announcement.create({
|
|
122
|
+
acknowledgementPolicy: "dismiss-only",
|
|
123
|
+
body: "Dismiss body",
|
|
124
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
125
|
+
status: "published",
|
|
126
|
+
title: "Dismiss only",
|
|
127
|
+
version: 1,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const dismissRes = await requiredUser.get("/announcements/pending?platform=web").expect(200);
|
|
131
|
+
expect(dismissRes.body.data.current?.id).toBe(dismissAnnouncement._id.toString());
|
|
132
|
+
expect(dismissRes.body.data.current?.requiresAcknowledgement).toBe(false);
|
|
133
|
+
|
|
134
|
+
await Announcement.deleteMany({});
|
|
135
|
+
|
|
136
|
+
const defaultApp = buildApp({defaultAcknowledgementPolicy: "required"});
|
|
137
|
+
const defaultUser = await authAsUser(defaultApp, "notAdmin");
|
|
138
|
+
const omittedAnnouncement = await Announcement.create({
|
|
139
|
+
body: "Default policy body",
|
|
140
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
141
|
+
status: "published",
|
|
142
|
+
title: "Omitted policy",
|
|
143
|
+
version: 1,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const defaultRes = await defaultUser.get("/announcements/pending?platform=web").expect(200);
|
|
147
|
+
expect(defaultRes.body.data.current?.id).toBe(omittedAnnouncement._id.toString());
|
|
148
|
+
expect(defaultRes.body.data.current?.requiresAcknowledgement).toBe(true);
|
|
149
|
+
|
|
150
|
+
await Announcement.deleteMany({});
|
|
151
|
+
|
|
152
|
+
const legacyAnnouncement = await Announcement.collection.insertOne({
|
|
153
|
+
body: "Legacy body",
|
|
154
|
+
created: DateTime.utc().toJSDate(),
|
|
155
|
+
platforms: ["web"],
|
|
156
|
+
priority: 0,
|
|
157
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
158
|
+
requiresAcknowledgement: true,
|
|
159
|
+
status: "published",
|
|
160
|
+
title: "Legacy boolean",
|
|
161
|
+
updated: DateTime.utc().toJSDate(),
|
|
162
|
+
version: 1,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const legacyRes = await requiredUser.get("/announcements/pending?platform=web").expect(200);
|
|
166
|
+
expect(legacyRes.body.data.current?.id).toBe(legacyAnnouncement.insertedId.toString());
|
|
167
|
+
expect(legacyRes.body.data.current?.requiresAcknowledgement).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("clears pending after acknowledgement", async () => {
|
|
171
|
+
const announcement = await Announcement.create({
|
|
172
|
+
acknowledgementPolicy: "required",
|
|
173
|
+
body: "Body",
|
|
174
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
175
|
+
status: "published",
|
|
176
|
+
title: "Shipped",
|
|
177
|
+
version: 1,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await userAgent.get("/announcements/pending?platform=web").expect(200);
|
|
181
|
+
await userAgent.post(`/announcements/${announcement._id.toString()}/acknowledge`).expect(200);
|
|
182
|
+
|
|
183
|
+
const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
|
|
184
|
+
expect(pendingRes.body.data.current).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("bumps version when published title/body changes", async () => {
|
|
188
|
+
const announcement = await Announcement.create({
|
|
189
|
+
acknowledgementPolicy: "required",
|
|
190
|
+
body: "Body v1",
|
|
191
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
192
|
+
status: "published",
|
|
193
|
+
title: "Title v1",
|
|
194
|
+
version: 1,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await userAgent.post(`/announcements/${announcement._id.toString()}/acknowledge`).expect(200);
|
|
198
|
+
|
|
199
|
+
announcement.body = "Body v2";
|
|
200
|
+
await announcement.save();
|
|
201
|
+
|
|
202
|
+
const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
|
|
203
|
+
expect(pendingRes.body.data.current?.version).toBe(2);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("rejects non-admins from admin CRUD", async () => {
|
|
207
|
+
await userAgent.get("/announcements").expect(405);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("rejects invalid publish and archive transitions", async () => {
|
|
211
|
+
const published = await Announcement.create({
|
|
212
|
+
body: "Body",
|
|
213
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
214
|
+
status: "published",
|
|
215
|
+
title: "Already published",
|
|
216
|
+
version: 1,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await adminAgent.post(`/announcements/${published._id.toString()}/publish`).expect(400);
|
|
220
|
+
|
|
221
|
+
const draft = await Announcement.create({
|
|
222
|
+
body: "Draft body",
|
|
223
|
+
status: "draft",
|
|
224
|
+
title: "Draft",
|
|
225
|
+
version: 1,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await adminAgent.post(`/announcements/${draft._id.toString()}/archive`).expect(400);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("records impressions and keeps duplicate acknowledgements idempotent", async () => {
|
|
232
|
+
const announcement = await Announcement.create({
|
|
233
|
+
acknowledgementPolicy: "required",
|
|
234
|
+
body: "Body",
|
|
235
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
236
|
+
status: "published",
|
|
237
|
+
title: "Track views",
|
|
238
|
+
version: 1,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const announcementId = announcement._id.toString();
|
|
242
|
+
await userAgent
|
|
243
|
+
.post(`/announcements/${announcementId}/impression`)
|
|
244
|
+
.send({platform: "web"})
|
|
245
|
+
.expect(200);
|
|
246
|
+
|
|
247
|
+
const impressions = await AnnouncementImpression.find({announcementId: announcement._id});
|
|
248
|
+
expect(impressions.length).toBe(1);
|
|
249
|
+
expect(impressions[0]?.platform).toBe("web");
|
|
250
|
+
|
|
251
|
+
await userAgent.post(`/announcements/${announcementId}/acknowledge`).expect(200);
|
|
252
|
+
await userAgent.post(`/announcements/${announcementId}/acknowledge`).expect(200);
|
|
253
|
+
|
|
254
|
+
const acknowledgements = await AnnouncementAcknowledgement.find({
|
|
255
|
+
announcementId: announcement._id,
|
|
256
|
+
});
|
|
257
|
+
expect(acknowledgements.length).toBe(1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("POST acknowledge and impression return 404 when the announcement is not visible", async () => {
|
|
261
|
+
await Announcement.deleteMany({});
|
|
262
|
+
await AnnouncementAcknowledgement.deleteMany({});
|
|
263
|
+
await AnnouncementImpression.deleteMany({});
|
|
264
|
+
|
|
265
|
+
const visibilityApp = buildApp({
|
|
266
|
+
isStaff: (user) => (user as {admin?: boolean}).admin === true,
|
|
267
|
+
matchAudience: (_user, announcement) => {
|
|
268
|
+
const audience = announcement as {audience?: {include?: boolean}};
|
|
269
|
+
return audience.audience?.include !== false;
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
const staffAgent = await authAsUser(visibilityApp, "admin");
|
|
273
|
+
const patientAgent = await authAsUser(visibilityApp, "notAdmin");
|
|
274
|
+
|
|
275
|
+
const staffOnly = await Announcement.create({
|
|
276
|
+
audienceType: "staff",
|
|
277
|
+
body: "Staff",
|
|
278
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
279
|
+
status: "published",
|
|
280
|
+
title: "Staff only",
|
|
281
|
+
version: 1,
|
|
282
|
+
});
|
|
283
|
+
await patientAgent.post(`/announcements/${staffOnly._id.toString()}/acknowledge`).expect(404);
|
|
284
|
+
await patientAgent
|
|
285
|
+
.post(`/announcements/${staffOnly._id.toString()}/impression`)
|
|
286
|
+
.send({platform: "web"})
|
|
287
|
+
.expect(404);
|
|
288
|
+
|
|
289
|
+
const excluded = await Announcement.create({
|
|
290
|
+
audience: {include: false},
|
|
291
|
+
audienceType: "all",
|
|
292
|
+
body: "Hidden",
|
|
293
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
294
|
+
status: "published",
|
|
295
|
+
title: "Custom excluded",
|
|
296
|
+
version: 1,
|
|
297
|
+
});
|
|
298
|
+
await staffAgent
|
|
299
|
+
.post(`/announcements/${excluded._id.toString()}/acknowledge?platform=web`)
|
|
300
|
+
.expect(404);
|
|
301
|
+
await staffAgent
|
|
302
|
+
.post(`/announcements/${excluded._id.toString()}/impression?platform=web`)
|
|
303
|
+
.send({platform: "web"})
|
|
304
|
+
.expect(404);
|
|
305
|
+
|
|
306
|
+
const expired = await Announcement.create({
|
|
307
|
+
body: "Expired",
|
|
308
|
+
expiresAt: DateTime.utc().minus({days: 1}).toJSDate(),
|
|
309
|
+
publishedAt: DateTime.utc().minus({days: 2}).toJSDate(),
|
|
310
|
+
status: "published",
|
|
311
|
+
title: "Expired",
|
|
312
|
+
version: 1,
|
|
313
|
+
});
|
|
314
|
+
await patientAgent.post(`/announcements/${expired._id.toString()}/acknowledge`).expect(404);
|
|
315
|
+
await patientAgent
|
|
316
|
+
.post(`/announcements/${expired._id.toString()}/impression`)
|
|
317
|
+
.send({platform: "web"})
|
|
318
|
+
.expect(404);
|
|
319
|
+
|
|
320
|
+
const gated = await Announcement.create({
|
|
321
|
+
body: "Gated",
|
|
322
|
+
minBuildNumber: 10,
|
|
323
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
324
|
+
status: "published",
|
|
325
|
+
title: "Min build",
|
|
326
|
+
version: 1,
|
|
327
|
+
});
|
|
328
|
+
await patientAgent
|
|
329
|
+
.post(`/announcements/${gated._id.toString()}/acknowledge?platform=web&version=9`)
|
|
330
|
+
.expect(404);
|
|
331
|
+
await patientAgent
|
|
332
|
+
.post(`/announcements/${gated._id.toString()}/impression?platform=web&version=9`)
|
|
333
|
+
.send({platform: "web"})
|
|
334
|
+
.expect(404);
|
|
335
|
+
|
|
336
|
+
assert.lengthOf(await AnnouncementAcknowledgement.find({}), 0);
|
|
337
|
+
assert.lengthOf(await AnnouncementImpression.find({}), 0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("filters pending announcements with matchAudience", async () => {
|
|
341
|
+
await Announcement.deleteMany({});
|
|
342
|
+
await AnnouncementImpression.deleteMany({});
|
|
343
|
+
await AnnouncementAcknowledgement.deleteMany({});
|
|
344
|
+
|
|
345
|
+
const targetedApp = buildApp({
|
|
346
|
+
matchAudience: (_user, announcement) => {
|
|
347
|
+
const audience = announcement as {audience?: {include?: boolean}};
|
|
348
|
+
return audience.audience?.include !== false;
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
const targetedUserAgent = await authAsUser(targetedApp, "notAdmin");
|
|
352
|
+
|
|
353
|
+
await Announcement.create({
|
|
354
|
+
acknowledgementPolicy: "required",
|
|
355
|
+
audience: {include: true},
|
|
356
|
+
body: "Visible",
|
|
357
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
358
|
+
status: "published",
|
|
359
|
+
title: "Included",
|
|
360
|
+
version: 1,
|
|
361
|
+
});
|
|
362
|
+
await Announcement.create({
|
|
363
|
+
acknowledgementPolicy: "required",
|
|
364
|
+
audience: {include: false},
|
|
365
|
+
body: "Hidden",
|
|
366
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
367
|
+
status: "published",
|
|
368
|
+
title: "Excluded",
|
|
369
|
+
version: 1,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const pendingRes = await targetedUserAgent
|
|
373
|
+
.get("/announcements/pending?platform=web")
|
|
374
|
+
.expect(200);
|
|
375
|
+
|
|
376
|
+
expect(pendingRes.body.data.current?.title).toBe("Included");
|
|
377
|
+
expect(pendingRes.body.data.remainingCount).toBe(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("returns a paginated announcement feed", async () => {
|
|
381
|
+
await Announcement.create({
|
|
382
|
+
body: "Feed body",
|
|
383
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
384
|
+
status: "published",
|
|
385
|
+
title: "Feed item",
|
|
386
|
+
version: 1,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const feedRes = await userAgent
|
|
390
|
+
.get("/announcements/feed?page=1&limit=10&platform=web")
|
|
391
|
+
.expect(200);
|
|
392
|
+
expect(feedRes.body.data.length).toBeGreaterThanOrEqual(1);
|
|
393
|
+
expect(feedRes.body.total).toBeGreaterThanOrEqual(1);
|
|
394
|
+
expect(feedRes.body.page).toBe(1);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("GET pending omits feed displayMode and GET feed includes feed-only items", async () => {
|
|
398
|
+
await Announcement.deleteMany({});
|
|
399
|
+
|
|
400
|
+
const modal = await Announcement.create({
|
|
401
|
+
acknowledgementPolicy: "required",
|
|
402
|
+
body: "Modal body",
|
|
403
|
+
displayMode: "modal",
|
|
404
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
405
|
+
status: "published",
|
|
406
|
+
title: "Modal item",
|
|
407
|
+
version: 1,
|
|
408
|
+
});
|
|
409
|
+
await Announcement.create({
|
|
410
|
+
acknowledgementPolicy: "required",
|
|
411
|
+
body: "Feed body",
|
|
412
|
+
displayMode: "feed",
|
|
413
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
414
|
+
status: "published",
|
|
415
|
+
title: "Feed only",
|
|
416
|
+
version: 1,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
|
|
420
|
+
assert.strictEqual(pendingRes.body.data.current?.id, modal._id.toString());
|
|
421
|
+
assert.strictEqual(pendingRes.body.data.current?.displayMode, "modal");
|
|
422
|
+
|
|
423
|
+
const feedRes = await userAgent
|
|
424
|
+
.get("/announcements/feed?page=1&limit=10&platform=web")
|
|
425
|
+
.expect(200);
|
|
426
|
+
const feedTitles = feedRes.body.data.map((item: {title: string}) => item.title);
|
|
427
|
+
assert.include(feedTitles, "Modal item");
|
|
428
|
+
assert.include(feedTitles, "Feed only");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("GET pending and feed honor minBuildNumber when version query is present", async () => {
|
|
432
|
+
await Announcement.deleteMany({});
|
|
433
|
+
|
|
434
|
+
const visible = await Announcement.create({
|
|
435
|
+
acknowledgementPolicy: "required",
|
|
436
|
+
body: "Visible body",
|
|
437
|
+
minBuildNumber: 10,
|
|
438
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
439
|
+
status: "published",
|
|
440
|
+
title: "Visible at 10",
|
|
441
|
+
version: 1,
|
|
442
|
+
});
|
|
443
|
+
await Announcement.create({
|
|
444
|
+
acknowledgementPolicy: "required",
|
|
445
|
+
body: "Hidden body",
|
|
446
|
+
minBuildNumber: 20,
|
|
447
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
448
|
+
status: "published",
|
|
449
|
+
title: "Hidden below 20",
|
|
450
|
+
version: 1,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const pendingHidden = await userAgent
|
|
454
|
+
.get("/announcements/pending?platform=web&version=9")
|
|
455
|
+
.expect(200);
|
|
456
|
+
assert.isNull(pendingHidden.body.data.current);
|
|
457
|
+
|
|
458
|
+
const pendingVisible = await userAgent
|
|
459
|
+
.get("/announcements/pending?platform=web&version=10")
|
|
460
|
+
.expect(200);
|
|
461
|
+
assert.strictEqual(pendingVisible.body.data.current?.id, visible._id.toString());
|
|
462
|
+
|
|
463
|
+
const feedHidden = await userAgent
|
|
464
|
+
.get("/announcements/feed?platform=web&version=9")
|
|
465
|
+
.expect(200);
|
|
466
|
+
assert.lengthOf(feedHidden.body.data, 0);
|
|
467
|
+
|
|
468
|
+
const feedOmitted = await userAgent.get("/announcements/feed?platform=web").expect(200);
|
|
469
|
+
assert.isAtLeast(feedOmitted.body.data.length, 2);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("filters pending by audienceType composed with matchAudience", async () => {
|
|
473
|
+
await Announcement.deleteMany({});
|
|
474
|
+
|
|
475
|
+
const audienceApp = buildApp({
|
|
476
|
+
isStaff: (user) => (user as {admin?: boolean}).admin === true,
|
|
477
|
+
matchAudience: (_user, announcement) => {
|
|
478
|
+
const audience = announcement as {audience?: {include?: boolean}};
|
|
479
|
+
return audience.audience?.include !== false;
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
const staffAgent = await authAsUser(audienceApp, "admin");
|
|
483
|
+
const patientAgent = await authAsUser(audienceApp, "notAdmin");
|
|
484
|
+
|
|
485
|
+
await Announcement.create({
|
|
486
|
+
acknowledgementPolicy: "required",
|
|
487
|
+
audience: {include: true},
|
|
488
|
+
audienceType: "staff",
|
|
489
|
+
body: "Staff only",
|
|
490
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
491
|
+
status: "published",
|
|
492
|
+
title: "Staff modal",
|
|
493
|
+
version: 1,
|
|
494
|
+
});
|
|
495
|
+
await Announcement.create({
|
|
496
|
+
acknowledgementPolicy: "required",
|
|
497
|
+
audience: {include: true},
|
|
498
|
+
audienceType: "patient",
|
|
499
|
+
body: "Patient only",
|
|
500
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
501
|
+
status: "published",
|
|
502
|
+
title: "Patient modal",
|
|
503
|
+
version: 1,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const staffPending = await staffAgent.get("/announcements/pending?platform=web").expect(200);
|
|
507
|
+
assert.strictEqual(staffPending.body.data.current?.title, "Staff modal");
|
|
508
|
+
|
|
509
|
+
const patientPending = await patientAgent
|
|
510
|
+
.get("/announcements/pending?platform=web")
|
|
511
|
+
.expect(200);
|
|
512
|
+
assert.strictEqual(patientPending.body.data.current?.title, "Patient modal");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("POST click records primaryAction with version and optional platform", async () => {
|
|
516
|
+
const announcement = await Announcement.create({
|
|
517
|
+
body: "Body",
|
|
518
|
+
platforms: ["web"],
|
|
519
|
+
primaryAction: {label: "Learn more", url: "https://example.com"},
|
|
520
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
521
|
+
status: "published",
|
|
522
|
+
title: "Clickable",
|
|
523
|
+
version: 3,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const announcementId = announcement._id.toString();
|
|
527
|
+
await userAgent
|
|
528
|
+
.post(`/announcements/${announcementId}/click`)
|
|
529
|
+
.send({action: "primaryAction", platform: "web"})
|
|
530
|
+
.expect(200);
|
|
531
|
+
|
|
532
|
+
const clicks = await AnnouncementClickEvent.find({announcementId: announcement._id});
|
|
533
|
+
assert.lengthOf(clicks, 1);
|
|
534
|
+
assert.strictEqual(clicks[0]?.action, "primaryAction");
|
|
535
|
+
assert.strictEqual(clicks[0]?.version, 3);
|
|
536
|
+
assert.strictEqual(clicks[0]?.platform, "web");
|
|
537
|
+
assert.isOk(clicks[0]?.clickedAt);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("POST click inserts a new row on each repeat click", async () => {
|
|
541
|
+
const announcement = await Announcement.create({
|
|
542
|
+
body: "Body",
|
|
543
|
+
primaryAction: {label: "Go", url: "https://example.com/go"},
|
|
544
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
545
|
+
status: "published",
|
|
546
|
+
title: "Repeat clicks",
|
|
547
|
+
version: 1,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const announcementId = announcement._id.toString();
|
|
551
|
+
await userAgent
|
|
552
|
+
.post(`/announcements/${announcementId}/click`)
|
|
553
|
+
.send({action: "primaryAction"})
|
|
554
|
+
.expect(200);
|
|
555
|
+
await userAgent
|
|
556
|
+
.post(`/announcements/${announcementId}/click`)
|
|
557
|
+
.send({action: "primaryAction"})
|
|
558
|
+
.expect(200);
|
|
559
|
+
|
|
560
|
+
const clicks = await AnnouncementClickEvent.find({announcementId: announcement._id});
|
|
561
|
+
assert.lengthOf(clicks, 2);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("POST click returns 400 when primaryAction is missing or action is unknown", async () => {
|
|
565
|
+
await Announcement.deleteMany({});
|
|
566
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
567
|
+
|
|
568
|
+
const withoutPrimary = await Announcement.create({
|
|
569
|
+
body: "Body",
|
|
570
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
571
|
+
status: "published",
|
|
572
|
+
title: "No CTA",
|
|
573
|
+
version: 1,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await userAgent
|
|
577
|
+
.post(`/announcements/${withoutPrimary._id.toString()}/click`)
|
|
578
|
+
.send({action: "primaryAction"})
|
|
579
|
+
.expect(400);
|
|
580
|
+
|
|
581
|
+
const withPrimary = await Announcement.create({
|
|
582
|
+
body: "Body",
|
|
583
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
584
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
585
|
+
status: "published",
|
|
586
|
+
title: "Has CTA",
|
|
587
|
+
version: 1,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await userAgent
|
|
591
|
+
.post(`/announcements/${withPrimary._id.toString()}/click`)
|
|
592
|
+
.send({action: "secondaryAction"})
|
|
593
|
+
.expect(400);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("POST click returns 404 for non-visible announcements before validating action or CTA", async () => {
|
|
597
|
+
await Announcement.deleteMany({});
|
|
598
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
599
|
+
|
|
600
|
+
const visibilityApp = buildApp({
|
|
601
|
+
isStaff: (user) => (user as {admin?: boolean}).admin === true,
|
|
602
|
+
});
|
|
603
|
+
const patientAgent = await authAsUser(visibilityApp, "notAdmin");
|
|
604
|
+
|
|
605
|
+
const staffOnly = await Announcement.create({
|
|
606
|
+
audienceType: "staff",
|
|
607
|
+
body: "Staff",
|
|
608
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
609
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
610
|
+
status: "published",
|
|
611
|
+
title: "Staff only",
|
|
612
|
+
version: 1,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const invalidActionRes = await patientAgent
|
|
616
|
+
.post(`/announcements/${staffOnly._id.toString()}/click?platform=web`)
|
|
617
|
+
.send({action: "secondaryAction"});
|
|
618
|
+
assert.strictEqual(invalidActionRes.status, 404);
|
|
619
|
+
|
|
620
|
+
const staffOnlyNoCta = await Announcement.create({
|
|
621
|
+
audienceType: "staff",
|
|
622
|
+
body: "Staff no CTA",
|
|
623
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
624
|
+
status: "published",
|
|
625
|
+
title: "Staff no CTA",
|
|
626
|
+
version: 1,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const missingCtaRes = await patientAgent
|
|
630
|
+
.post(`/announcements/${staffOnlyNoCta._id.toString()}/click?platform=web`)
|
|
631
|
+
.send({action: "primaryAction"});
|
|
632
|
+
assert.strictEqual(missingCtaRes.status, 404);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("POST click returns 400 when platform is supplied but invalid", async () => {
|
|
636
|
+
await Announcement.deleteMany({});
|
|
637
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
638
|
+
|
|
639
|
+
const announcement = await Announcement.create({
|
|
640
|
+
body: "Body",
|
|
641
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
642
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
643
|
+
status: "published",
|
|
644
|
+
title: "Visible",
|
|
645
|
+
version: 1,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
await userAgent
|
|
649
|
+
.post(`/announcements/${announcement._id.toString()}/click`)
|
|
650
|
+
.send({action: "primaryAction", platform: "desktop"})
|
|
651
|
+
.expect(400);
|
|
652
|
+
|
|
653
|
+
const clicks = await AnnouncementClickEvent.find({announcementId: announcement._id});
|
|
654
|
+
assert.lengthOf(clicks, 0);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("POST click requires authentication", async () => {
|
|
658
|
+
const announcement = await Announcement.create({
|
|
659
|
+
body: "Body",
|
|
660
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
661
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
662
|
+
status: "published",
|
|
663
|
+
title: "Auth required",
|
|
664
|
+
version: 1,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
await supertest(app)
|
|
668
|
+
.post(`/announcements/${announcement._id.toString()}/click`)
|
|
669
|
+
.send({action: "primaryAction"})
|
|
670
|
+
.expect(401);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("POST click returns 404 when announcement is not visible to the user", async () => {
|
|
674
|
+
await Announcement.deleteMany({});
|
|
675
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
676
|
+
|
|
677
|
+
const visibilityApp = buildApp({
|
|
678
|
+
isStaff: (user) => (user as {admin?: boolean}).admin === true,
|
|
679
|
+
matchAudience: (_user, announcement) => {
|
|
680
|
+
const audience = announcement as {audience?: {include?: boolean}};
|
|
681
|
+
return audience.audience?.include !== false;
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
const staffAgent = await authAsUser(visibilityApp, "admin");
|
|
685
|
+
const patientAgent = await authAsUser(visibilityApp, "notAdmin");
|
|
686
|
+
|
|
687
|
+
const draft = await Announcement.create({
|
|
688
|
+
body: "Draft",
|
|
689
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
690
|
+
status: "draft",
|
|
691
|
+
title: "Draft",
|
|
692
|
+
version: 1,
|
|
693
|
+
});
|
|
694
|
+
await patientAgent
|
|
695
|
+
.post(`/announcements/${draft._id.toString()}/click`)
|
|
696
|
+
.send({action: "primaryAction"})
|
|
697
|
+
.expect(404);
|
|
698
|
+
|
|
699
|
+
const expired = await Announcement.create({
|
|
700
|
+
body: "Expired",
|
|
701
|
+
expiresAt: DateTime.utc().minus({days: 1}).toJSDate(),
|
|
702
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
703
|
+
publishedAt: DateTime.utc().minus({days: 2}).toJSDate(),
|
|
704
|
+
status: "published",
|
|
705
|
+
title: "Expired",
|
|
706
|
+
version: 1,
|
|
707
|
+
});
|
|
708
|
+
await patientAgent
|
|
709
|
+
.post(`/announcements/${expired._id.toString()}/click`)
|
|
710
|
+
.send({action: "primaryAction"})
|
|
711
|
+
.expect(404);
|
|
712
|
+
|
|
713
|
+
const scheduled = await Announcement.create({
|
|
714
|
+
body: "Future",
|
|
715
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
716
|
+
publishAt: DateTime.utc().plus({days: 1}).toJSDate(),
|
|
717
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
718
|
+
status: "published",
|
|
719
|
+
title: "Scheduled",
|
|
720
|
+
version: 1,
|
|
721
|
+
});
|
|
722
|
+
await patientAgent
|
|
723
|
+
.post(`/announcements/${scheduled._id.toString()}/click`)
|
|
724
|
+
.send({action: "primaryAction"})
|
|
725
|
+
.expect(404);
|
|
726
|
+
|
|
727
|
+
const iosOnly = await Announcement.create({
|
|
728
|
+
body: "iOS",
|
|
729
|
+
platforms: ["ios"],
|
|
730
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
731
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
732
|
+
status: "published",
|
|
733
|
+
title: "iOS only",
|
|
734
|
+
version: 1,
|
|
735
|
+
});
|
|
736
|
+
await patientAgent
|
|
737
|
+
.post(`/announcements/${iosOnly._id.toString()}/click?platform=web`)
|
|
738
|
+
.send({action: "primaryAction"})
|
|
739
|
+
.expect(404);
|
|
740
|
+
|
|
741
|
+
const gated = await Announcement.create({
|
|
742
|
+
body: "Gated",
|
|
743
|
+
minBuildNumber: 10,
|
|
744
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
745
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
746
|
+
status: "published",
|
|
747
|
+
title: "Min build",
|
|
748
|
+
version: 1,
|
|
749
|
+
});
|
|
750
|
+
await patientAgent
|
|
751
|
+
.post(`/announcements/${gated._id.toString()}/click?platform=web&version=9`)
|
|
752
|
+
.send({action: "primaryAction"})
|
|
753
|
+
.expect(404);
|
|
754
|
+
|
|
755
|
+
const staffOnly = await Announcement.create({
|
|
756
|
+
audience: {include: true},
|
|
757
|
+
audienceType: "staff",
|
|
758
|
+
body: "Staff",
|
|
759
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
760
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
761
|
+
status: "published",
|
|
762
|
+
title: "Staff only",
|
|
763
|
+
version: 1,
|
|
764
|
+
});
|
|
765
|
+
await patientAgent
|
|
766
|
+
.post(`/announcements/${staffOnly._id.toString()}/click?platform=web`)
|
|
767
|
+
.send({action: "primaryAction"})
|
|
768
|
+
.expect(404);
|
|
769
|
+
|
|
770
|
+
const excluded = await Announcement.create({
|
|
771
|
+
audience: {include: false},
|
|
772
|
+
audienceType: "all",
|
|
773
|
+
body: "Hidden",
|
|
774
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
775
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
776
|
+
status: "published",
|
|
777
|
+
title: "Custom excluded",
|
|
778
|
+
version: 1,
|
|
779
|
+
});
|
|
780
|
+
await staffAgent
|
|
781
|
+
.post(`/announcements/${excluded._id.toString()}/click?platform=web`)
|
|
782
|
+
.send({action: "primaryAction"})
|
|
783
|
+
.expect(404);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("GET /config returns defaultAcknowledgementPolicy for admins", async () => {
|
|
787
|
+
const defaultApp = buildApp();
|
|
788
|
+
const defaultAdmin = await authAsUser(defaultApp, "admin");
|
|
789
|
+
const defaultRes = await defaultAdmin.get("/announcements/config").expect(200);
|
|
790
|
+
assert.strictEqual(defaultRes.body.data.defaultAcknowledgementPolicy, "dismiss-only");
|
|
791
|
+
|
|
792
|
+
const requiredApp = buildApp({defaultAcknowledgementPolicy: "required"});
|
|
793
|
+
const requiredAdmin = await authAsUser(requiredApp, "admin");
|
|
794
|
+
const requiredRes = await requiredAdmin.get("/announcements/config").expect(200);
|
|
795
|
+
assert.strictEqual(requiredRes.body.data.defaultAcknowledgementPolicy, "required");
|
|
796
|
+
|
|
797
|
+
const dismissApp = buildApp({defaultAcknowledgementPolicy: "dismiss-only"});
|
|
798
|
+
const dismissAdmin = await authAsUser(dismissApp, "admin");
|
|
799
|
+
const dismissRes = await dismissAdmin.get("/announcements/config").expect(200);
|
|
800
|
+
assert.strictEqual(dismissRes.body.data.defaultAcknowledgementPolicy, "dismiss-only");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("GET /config returns 403 for non-admins", async () => {
|
|
804
|
+
const res = await userAgent.get("/announcements/config").expect(403);
|
|
805
|
+
assert.strictEqual(res.body.title, "Admin access required");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("GET /config requires authentication", async () => {
|
|
809
|
+
await supertest(app).get("/announcements/config").expect(401);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("admin can list announcement click events and non-admins cannot", async () => {
|
|
813
|
+
await Announcement.deleteMany({});
|
|
814
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
815
|
+
|
|
816
|
+
const announcement = await Announcement.create({
|
|
817
|
+
body: "Body",
|
|
818
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
819
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
820
|
+
status: "published",
|
|
821
|
+
title: "Tracked click",
|
|
822
|
+
version: 2,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
await userAgent
|
|
826
|
+
.post(`/announcements/${announcement._id.toString()}/click`)
|
|
827
|
+
.send({action: "primaryAction", platform: "web"})
|
|
828
|
+
.expect(200);
|
|
829
|
+
|
|
830
|
+
const listRes = await adminAgent.get("/announcement-click-events").expect(200);
|
|
831
|
+
assert.isAtLeast(listRes.body.data.length, 1);
|
|
832
|
+
const listed = listRes.body.data.find((row: {announcementId: string | {_id: string}}) => {
|
|
833
|
+
const announcementRef = row.announcementId;
|
|
834
|
+
const id =
|
|
835
|
+
typeof announcementRef === "string" ? announcementRef : announcementRef?._id?.toString();
|
|
836
|
+
return id === announcement._id.toString();
|
|
837
|
+
});
|
|
838
|
+
assert.isOk(listed);
|
|
839
|
+
assert.strictEqual(listed.action, "primaryAction");
|
|
840
|
+
assert.strictEqual(listed.version, 2);
|
|
841
|
+
|
|
842
|
+
await userAgent.get("/announcement-click-events").expect(405);
|
|
843
|
+
});
|
|
844
|
+
});
|