@terreno/announcements 57.7.0 → 57.9.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/dist/announcementsApp.js +28 -16
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/models/announcement.js +42 -1
- package/dist/models/announcementClickEvent.d.ts +1 -2
- package/dist/models/announcementClickEvent.js +1 -3
- package/dist/releaseImport.d.ts +91 -0
- package/dist/releaseImport.js +204 -0
- package/dist/requestBodies.d.ts +32 -0
- package/dist/requestBodies.js +42 -0
- package/dist/types.d.ts +11 -0
- package/package.json +3 -3
- package/src/announcementsApp.ts +39 -18
- package/src/index.ts +11 -0
- package/src/models/announcement.ts +46 -1
- package/src/models/announcementClickEvent.ts +1 -8
- package/src/releaseImport.ts +258 -0
- package/src/requestBodies.ts +50 -0
- package/src/tests/announcementsApp.test.ts +281 -0
- package/src/types.ts +12 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
APIError,
|
|
3
4
|
addAuthRoutes,
|
|
4
5
|
apiErrorMiddleware,
|
|
5
6
|
apiUnauthorizedMiddleware,
|
|
@@ -17,12 +18,14 @@ import {Announcement} from "../models/announcement";
|
|
|
17
18
|
import {AnnouncementAcknowledgement} from "../models/announcementAcknowledgement";
|
|
18
19
|
import {AnnouncementClickEvent} from "../models/announcementClickEvent";
|
|
19
20
|
import {AnnouncementImpression} from "../models/announcementImpression";
|
|
21
|
+
import {importAnnouncementRelease} from "../releaseImport";
|
|
20
22
|
|
|
21
23
|
const buildApp = (options?: {
|
|
22
24
|
basePath?: string;
|
|
23
25
|
defaultAcknowledgementPolicy?: "required" | "dismiss-only";
|
|
24
26
|
isStaff?: (user: unknown) => boolean;
|
|
25
27
|
matchAudience?: (user: unknown, announcement: unknown) => boolean;
|
|
28
|
+
uploadToken?: string;
|
|
26
29
|
}): express.Application => {
|
|
27
30
|
const app = getBaseServer();
|
|
28
31
|
setupAuth(app, UserModel as unknown as UserModelType);
|
|
@@ -99,6 +102,238 @@ describe("AnnouncementsApp", () => {
|
|
|
99
102
|
expect(pendingRes.body.data.remainingCount).toBe(0);
|
|
100
103
|
});
|
|
101
104
|
|
|
105
|
+
it("imports multiple release announcements as idempotent drafts with a bearer token", async () => {
|
|
106
|
+
const uploadApp = buildApp({uploadToken: "release-upload-secret"});
|
|
107
|
+
const request = supertest(uploadApp);
|
|
108
|
+
const pack = {
|
|
109
|
+
announcements: [
|
|
110
|
+
{
|
|
111
|
+
acknowledgementPolicy: "required",
|
|
112
|
+
body: "Staff release details",
|
|
113
|
+
displayMode: "modal",
|
|
114
|
+
slug: "staff-release",
|
|
115
|
+
title: "Staff: version 1.14.0",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
body: "Patient release details",
|
|
119
|
+
slug: "patient-release",
|
|
120
|
+
title: "New in version 1.14.0",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
defaults: {
|
|
124
|
+
audienceType: "patient",
|
|
125
|
+
platforms: ["ios", "android"],
|
|
126
|
+
},
|
|
127
|
+
release: {
|
|
128
|
+
buildNumber: 1842,
|
|
129
|
+
channel: "production",
|
|
130
|
+
product: "example",
|
|
131
|
+
version: "1.14.0",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const firstResponse = await request
|
|
136
|
+
.post("/announcements/import-release")
|
|
137
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
138
|
+
.send(pack)
|
|
139
|
+
.expect(200);
|
|
140
|
+
assert.equal(firstResponse.body.data.created, 2);
|
|
141
|
+
assert.equal(firstResponse.body.data.published, 0);
|
|
142
|
+
|
|
143
|
+
const imported = await Announcement.find({}).sort({releaseSlug: 1});
|
|
144
|
+
assert.lengthOf(imported, 2);
|
|
145
|
+
assert.equal(imported[0]?.status, "draft");
|
|
146
|
+
assert.equal(imported[0]?.minBuildNumber, 1842);
|
|
147
|
+
assert.deepEqual(imported[0]?.platforms, ["ios", "android"]);
|
|
148
|
+
assert.equal(imported[0]?.release?.version, "1.14.0");
|
|
149
|
+
|
|
150
|
+
const secondResponse = await request
|
|
151
|
+
.post("/announcements/import-release")
|
|
152
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
153
|
+
.send(pack)
|
|
154
|
+
.expect(200);
|
|
155
|
+
assert.equal(secondResponse.body.data.created, 0);
|
|
156
|
+
assert.equal(secondResponse.body.data.unchanged, 2);
|
|
157
|
+
assert.equal(secondResponse.body.data.updated, 0);
|
|
158
|
+
assert.equal(await Announcement.countDocuments({}), 2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("publishes a release pack only when publish is explicitly true", async () => {
|
|
162
|
+
const uploadApp = buildApp({uploadToken: "release-upload-secret"});
|
|
163
|
+
const response = await supertest(uploadApp)
|
|
164
|
+
.post("/announcements/import-release")
|
|
165
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
166
|
+
.send({
|
|
167
|
+
announcements: [
|
|
168
|
+
{
|
|
169
|
+
body: "Public release details",
|
|
170
|
+
displayMode: "feed",
|
|
171
|
+
slug: "changelog",
|
|
172
|
+
title: "Version 1.14.0",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
publish: true,
|
|
176
|
+
release: {
|
|
177
|
+
product: "example",
|
|
178
|
+
version: "1.14.0",
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
.expect(200);
|
|
182
|
+
|
|
183
|
+
assert.equal(response.body.data.published, 1);
|
|
184
|
+
const announcement = await Announcement.findExactlyOne({releaseSlug: "changelog"});
|
|
185
|
+
assert.equal(announcement.status, "published");
|
|
186
|
+
assert.exists(announcement.publishedAt);
|
|
187
|
+
assert.equal(announcement.release?.channel, "production");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("restores a soft-deleted announcement when its release pack is re-imported", async () => {
|
|
191
|
+
const uploadApp = buildApp({uploadToken: "release-upload-secret"});
|
|
192
|
+
const pack = {
|
|
193
|
+
announcements: [{body: "Updated details", slug: "changelog", title: "Version 1.14.0"}],
|
|
194
|
+
release: {product: "example", version: "1.14.0"},
|
|
195
|
+
};
|
|
196
|
+
await supertest(uploadApp)
|
|
197
|
+
.post("/announcements/import-release")
|
|
198
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
199
|
+
.send(pack)
|
|
200
|
+
.expect(200);
|
|
201
|
+
const original = await Announcement.findExactlyOne({releaseSlug: "changelog"});
|
|
202
|
+
original.deleted = true;
|
|
203
|
+
await original.save();
|
|
204
|
+
|
|
205
|
+
const response = await supertest(uploadApp)
|
|
206
|
+
.post("/announcements/import-release")
|
|
207
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
208
|
+
.send(pack)
|
|
209
|
+
.expect(200);
|
|
210
|
+
|
|
211
|
+
assert.equal(response.body.data.updated, 1);
|
|
212
|
+
const restored = await Announcement.findExactlyOne({releaseSlug: "changelog"});
|
|
213
|
+
assert.equal(restored.deleted, false);
|
|
214
|
+
assert.equal(await Announcement.countDocuments({}), 1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("bumps the content version when a restored published import changes its body", async () => {
|
|
218
|
+
const uploadApp = buildApp({uploadToken: "release-upload-secret"});
|
|
219
|
+
const buildPack = (body: string): Record<string, unknown> => ({
|
|
220
|
+
announcements: [{body, slug: "changelog", title: "Version 1.14.0"}],
|
|
221
|
+
publish: true,
|
|
222
|
+
release: {product: "example", version: "1.14.0"},
|
|
223
|
+
});
|
|
224
|
+
await supertest(uploadApp)
|
|
225
|
+
.post("/announcements/import-release")
|
|
226
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
227
|
+
.send(buildPack("Original published details"))
|
|
228
|
+
.expect(200);
|
|
229
|
+
const original = await Announcement.findExactlyOne({releaseSlug: "changelog"});
|
|
230
|
+
original.deleted = true;
|
|
231
|
+
await original.save();
|
|
232
|
+
|
|
233
|
+
await supertest(uploadApp)
|
|
234
|
+
.post("/announcements/import-release")
|
|
235
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
236
|
+
.send(buildPack("Revised published details"))
|
|
237
|
+
.expect(200);
|
|
238
|
+
|
|
239
|
+
const restored = await Announcement.findExactlyOne({releaseSlug: "changelog"});
|
|
240
|
+
assert.equal(restored.version, 2);
|
|
241
|
+
assert.equal(restored.body, "Revised published details");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("rejects release imports with an invalid upload token", async () => {
|
|
245
|
+
const uploadApp = buildApp({uploadToken: "release-upload-secret"});
|
|
246
|
+
await supertest(uploadApp)
|
|
247
|
+
.post("/announcements/import-release")
|
|
248
|
+
.set("Authorization", "Bearer wrong-secret")
|
|
249
|
+
.send({
|
|
250
|
+
announcements: [{body: "Details", slug: "changelog", title: "Version 1.14.0"}],
|
|
251
|
+
release: {product: "example", version: "1.14.0"},
|
|
252
|
+
})
|
|
253
|
+
.expect(401);
|
|
254
|
+
assert.equal(await Announcement.countDocuments({}), 0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("rejects duplicate slugs within one release pack", async () => {
|
|
258
|
+
const uploadApp = buildApp({uploadToken: "release-upload-secret"});
|
|
259
|
+
const response = await supertest(uploadApp)
|
|
260
|
+
.post("/announcements/import-release")
|
|
261
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
262
|
+
.send({
|
|
263
|
+
announcements: [
|
|
264
|
+
{body: "First", slug: "changelog", title: "First"},
|
|
265
|
+
{body: "Second", slug: "changelog", title: "Second"},
|
|
266
|
+
],
|
|
267
|
+
release: {product: "example", version: "1.14.0"},
|
|
268
|
+
})
|
|
269
|
+
.expect(400);
|
|
270
|
+
|
|
271
|
+
assert.equal(response.body.title, "Validation failed");
|
|
272
|
+
assert.equal(
|
|
273
|
+
response.body.meta.fields.announcements,
|
|
274
|
+
"Release announcement slugs must be unique"
|
|
275
|
+
);
|
|
276
|
+
assert.equal(await Announcement.countDocuments({}), 0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("rejects release packs with unknown fields or malformed items", async () => {
|
|
280
|
+
const uploadApp = buildApp({uploadToken: "release-upload-secret"});
|
|
281
|
+
const invalidBodies = [
|
|
282
|
+
{
|
|
283
|
+
announcements: [{body: "Body", slug: "changelog", title: "Title"}],
|
|
284
|
+
release: {product: "example", version: "1.14.0"},
|
|
285
|
+
unexpected: true,
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
announcements: [{body: "Body", slug: "Not A Slug", title: "Title"}],
|
|
289
|
+
release: {product: "example", version: "1.14.0"},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
announcements: [{body: "", slug: "changelog", title: "Title"}],
|
|
293
|
+
release: {product: "example", version: "1.14.0"},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
announcements: [{body: "Body", displayMode: "popup", slug: "changelog", title: "Title"}],
|
|
297
|
+
release: {product: "example", version: "1.14.0"},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
announcements: [{body: "Body", slug: "changelog", title: "Title"}],
|
|
301
|
+
release: {version: "1.14.0"},
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
for (const invalidBody of invalidBodies) {
|
|
306
|
+
const response = await supertest(uploadApp)
|
|
307
|
+
.post("/announcements/import-release")
|
|
308
|
+
.set("Authorization", "Bearer release-upload-secret")
|
|
309
|
+
.send(invalidBody)
|
|
310
|
+
.expect(400);
|
|
311
|
+
assert.equal(response.body.title, "Validation failed");
|
|
312
|
+
}
|
|
313
|
+
assert.equal(await Announcement.countDocuments({}), 0);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("validates release packs passed directly to importAnnouncementRelease", async () => {
|
|
317
|
+
let caught: unknown;
|
|
318
|
+
try {
|
|
319
|
+
await importAnnouncementRelease({
|
|
320
|
+
input: {
|
|
321
|
+
announcements: [
|
|
322
|
+
{body: "First", slug: "changelog", title: "First"},
|
|
323
|
+
{body: "Second", slug: "changelog", title: "Second"},
|
|
324
|
+
],
|
|
325
|
+
release: {product: "example", version: "1.14.0"},
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
} catch (error) {
|
|
329
|
+
caught = error;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
assert.instanceOf(caught, APIError);
|
|
333
|
+
assert.equal((caught as APIError).status, 400);
|
|
334
|
+
assert.equal(await Announcement.countDocuments({}), 0);
|
|
335
|
+
});
|
|
336
|
+
|
|
102
337
|
it("resolves requiresAcknowledgement on GET pending for each policy case", async () => {
|
|
103
338
|
const requiredApp = buildApp();
|
|
104
339
|
const requiredUser = await authAsUser(requiredApp, "notAdmin");
|
|
@@ -593,6 +828,52 @@ describe("AnnouncementsApp", () => {
|
|
|
593
828
|
.expect(400);
|
|
594
829
|
});
|
|
595
830
|
|
|
831
|
+
it("POST click and impression reject bodies that fail the Zod schema", async () => {
|
|
832
|
+
await Announcement.deleteMany({});
|
|
833
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
834
|
+
await AnnouncementImpression.deleteMany({});
|
|
835
|
+
|
|
836
|
+
const announcement = await Announcement.create({
|
|
837
|
+
body: "Body",
|
|
838
|
+
primaryAction: {label: "Go", url: "https://example.com"},
|
|
839
|
+
publishedAt: DateTime.utc().toJSDate(),
|
|
840
|
+
status: "published",
|
|
841
|
+
title: "Strict bodies",
|
|
842
|
+
version: 1,
|
|
843
|
+
});
|
|
844
|
+
const announcementId = announcement._id.toString();
|
|
845
|
+
|
|
846
|
+
const missingAction = await userAgent
|
|
847
|
+
.post(`/announcements/${announcementId}/click`)
|
|
848
|
+
.send({platform: "web"})
|
|
849
|
+
.expect(400);
|
|
850
|
+
assert.equal(missingAction.body.title, "Validation failed");
|
|
851
|
+
assert.isString(missingAction.body.meta.fields.action);
|
|
852
|
+
|
|
853
|
+
await userAgent
|
|
854
|
+
.post(`/announcements/${announcementId}/click`)
|
|
855
|
+
.send({action: "primaryAction", extra: true})
|
|
856
|
+
.expect(400);
|
|
857
|
+
|
|
858
|
+
const badImpressionPlatform = await userAgent
|
|
859
|
+
.post(`/announcements/${announcementId}/impression`)
|
|
860
|
+
.send({platform: "desktop"})
|
|
861
|
+
.expect(400);
|
|
862
|
+
assert.isString(badImpressionPlatform.body.meta.fields.platform);
|
|
863
|
+
|
|
864
|
+
await userAgent
|
|
865
|
+
.post(`/announcements/${announcementId}/impression`)
|
|
866
|
+
.send({unexpected: "value"})
|
|
867
|
+
.expect(400);
|
|
868
|
+
|
|
869
|
+
await userAgent.post(`/announcements/${announcementId}/impression?platform=ios`).expect(200);
|
|
870
|
+
|
|
871
|
+
assert.lengthOf(await AnnouncementClickEvent.find({announcementId: announcement._id}), 0);
|
|
872
|
+
const impressions = await AnnouncementImpression.find({announcementId: announcement._id});
|
|
873
|
+
assert.lengthOf(impressions, 1);
|
|
874
|
+
assert.strictEqual(impressions[0]?.platform, "ios");
|
|
875
|
+
});
|
|
876
|
+
|
|
596
877
|
it("POST click returns 404 for non-visible announcements before validating action or CTA", async () => {
|
|
597
878
|
await Announcement.deleteMany({});
|
|
598
879
|
await AnnouncementClickEvent.deleteMany({});
|
package/src/types.ts
CHANGED
|
@@ -12,8 +12,16 @@ export interface AnnouncementPrimaryAction {
|
|
|
12
12
|
url: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface AnnouncementRelease {
|
|
16
|
+
buildNumber?: number;
|
|
17
|
+
channel: string;
|
|
18
|
+
product: string;
|
|
19
|
+
version: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export interface AnnouncementDocument {
|
|
16
23
|
_id: Types.ObjectId;
|
|
24
|
+
deleted: boolean;
|
|
17
25
|
title: string;
|
|
18
26
|
body: string;
|
|
19
27
|
status: AnnouncementStatus;
|
|
@@ -28,6 +36,8 @@ export interface AnnouncementDocument {
|
|
|
28
36
|
expiresAt?: Date;
|
|
29
37
|
platforms: AnnouncementPlatform[];
|
|
30
38
|
primaryAction?: AnnouncementPrimaryAction;
|
|
39
|
+
release?: AnnouncementRelease;
|
|
40
|
+
releaseSlug?: string;
|
|
31
41
|
publishedAt?: Date;
|
|
32
42
|
archivedAt?: Date;
|
|
33
43
|
created: Date;
|
|
@@ -108,6 +118,8 @@ export interface AnnouncementsOptions {
|
|
|
108
118
|
/** When omitted, staff targeting uses `user.admin === true`. */
|
|
109
119
|
isStaff?: (user: unknown) => boolean;
|
|
110
120
|
matchAudience?: MatchAudienceFunction;
|
|
121
|
+
/** Dedicated bearer token accepted by POST /announcements/import-release. */
|
|
122
|
+
uploadToken?: string;
|
|
111
123
|
permissions?: Partial<{
|
|
112
124
|
create: PermissionMethod<AnnouncementDocument>[];
|
|
113
125
|
delete: PermissionMethod<AnnouncementDocument>[];
|