@terreno/announcements 57.7.0 → 57.8.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.
@@ -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>[];