Openstatus
www.openstatus.dev
1import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2import { and, db, eq, isNotNull, isNull } from "@openstatus/db";
3import { page, pageSubscriber, workspace } from "@openstatus/db/src/schema";
4
5/**
6 * End-to-end integration tests for the full unsubscribe flow.
7 * These tests simulate the complete user journey:
8 * subscribe -> verify -> receive email -> unsubscribe
9 */
10
11let testPageId: number;
12let testWorkspaceId: number;
13const testSlug = "e2e-unsubscribe-test-page";
14const testEmail = "e2e-test-user@example.com";
15let subscriberToken: string;
16
17beforeAll(async () => {
18 // Clean up any existing test data
19 await db.delete(pageSubscriber).where(eq(pageSubscriber.email, testEmail));
20 await db.delete(page).where(eq(page.slug, testSlug));
21
22 // Get an existing workspace (use workspace id 1 from seed data)
23 const existingWorkspace = await db.query.workspace.findFirst({
24 where: eq(workspace.id, 1),
25 });
26
27 if (!existingWorkspace) {
28 throw new Error(
29 "Test workspace not found. Please ensure seed data exists.",
30 );
31 }
32
33 testWorkspaceId = existingWorkspace.id;
34
35 // Create a test page
36 const testPage = await db
37 .insert(page)
38 .values({
39 workspaceId: testWorkspaceId,
40 title: "E2E Test Status Page",
41 description: "A test page for E2E unsubscribe flow tests",
42 slug: testSlug,
43 customDomain: "",
44 })
45 .returning()
46 .get();
47
48 testPageId = testPage.id;
49});
50
51afterAll(async () => {
52 // Clean up test data
53 await db.delete(pageSubscriber).where(eq(pageSubscriber.email, testEmail));
54 await db.delete(page).where(eq(page.slug, testSlug));
55});
56
57describe("Full unsubscribe flow: subscribe -> verify -> unsubscribe", () => {
58 test("Step 1: User subscribes to status page", async () => {
59 // Simulate subscription by inserting a subscriber
60 const subscriber = await db
61 .insert(pageSubscriber)
62 .values({
63 pageId: testPageId,
64 email: testEmail,
65 token: crypto.randomUUID(),
66 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
67 })
68 .returning()
69 .get();
70
71 expect(subscriber.id).toBeDefined();
72 expect(subscriber.email).toBe(testEmail);
73 expect(subscriber.token).toBeDefined();
74 expect(subscriber.acceptedAt).toBeNull();
75 expect(subscriber.unsubscribedAt).toBeNull();
76
77 if (!subscriber.token) {
78 throw new Error("Subscriber token is undefined");
79 }
80
81 subscriberToken = subscriber.token;
82 });
83
84 test("Step 2: User verifies their email subscription", async () => {
85 // Verify the subscription
86 await db
87 .update(pageSubscriber)
88 .set({ acceptedAt: new Date() })
89 .where(eq(pageSubscriber.token, subscriberToken));
90
91 // Verify the subscription is now active
92 const subscriber = await db.query.pageSubscriber.findFirst({
93 where: eq(pageSubscriber.token, subscriberToken),
94 });
95
96 expect(subscriber?.acceptedAt).not.toBeNull();
97 expect(subscriber?.unsubscribedAt).toBeNull();
98 });
99
100 test("Step 3: Verified subscriber is included in email recipient list", async () => {
101 // This query mirrors the exact query used in statusReports/post.ts
102 const subscribers = await db
103 .select()
104 .from(pageSubscriber)
105 .where(
106 and(
107 eq(pageSubscriber.pageId, testPageId),
108 isNotNull(pageSubscriber.acceptedAt),
109 isNull(pageSubscriber.unsubscribedAt),
110 ),
111 )
112 .all();
113
114 expect(subscribers.length).toBe(1);
115 expect(subscribers[0].email).toBe(testEmail);
116 expect(subscribers[0].token).toBe(subscriberToken);
117 });
118
119 test("Step 4: User clicks unsubscribe and sets unsubscribedAt", async () => {
120 // Simulate the unsubscribe action
121 await db
122 .update(pageSubscriber)
123 .set({ unsubscribedAt: new Date() })
124 .where(eq(pageSubscriber.token, subscriberToken));
125
126 // Verify the unsubscription
127 const subscriber = await db.query.pageSubscriber.findFirst({
128 where: eq(pageSubscriber.token, subscriberToken),
129 });
130
131 expect(subscriber?.unsubscribedAt).not.toBeNull();
132 expect(subscriber?.unsubscribedAt).toBeInstanceOf(Date);
133 });
134
135 test("Step 5: Unsubscribed user is excluded from email recipient list", async () => {
136 // This query mirrors the exact query used in statusReports/post.ts
137 const subscribers = await db
138 .select()
139 .from(pageSubscriber)
140 .where(
141 and(
142 eq(pageSubscriber.pageId, testPageId),
143 isNotNull(pageSubscriber.acceptedAt),
144 isNull(pageSubscriber.unsubscribedAt),
145 ),
146 )
147 .all();
148
149 expect(subscribers.length).toBe(0);
150 });
151});
152
153describe("Confirmation page displays correct information", () => {
154 let confirmPageToken: string;
155
156 beforeAll(async () => {
157 // Create a fresh subscriber for confirmation page tests
158 await db
159 .delete(pageSubscriber)
160 .where(eq(pageSubscriber.email, "confirm-page-test@example.com"));
161
162 const subscriber = await db
163 .insert(pageSubscriber)
164 .values({
165 pageId: testPageId,
166 email: "confirm-page-test@example.com",
167 token: crypto.randomUUID(),
168 acceptedAt: new Date(), // Already verified
169 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
170 })
171 .returning()
172 .get();
173
174 if (!subscriber.token) {
175 throw new Error("Subscriber token is undefined");
176 }
177
178 confirmPageToken = subscriber.token;
179 });
180
181 afterAll(async () => {
182 await db
183 .delete(pageSubscriber)
184 .where(eq(pageSubscriber.email, "confirm-page-test@example.com"));
185 });
186
187 test("Confirmation page displays correct page name", async () => {
188 const subscriber = await db.query.pageSubscriber.findFirst({
189 where: eq(pageSubscriber.token, confirmPageToken),
190 with: {
191 page: true,
192 },
193 });
194
195 expect(subscriber?.page.title).toBe("E2E Test Status Page");
196 });
197
198 test("Confirmation page displays masked email (first char + *** + @domain)", async () => {
199 const subscriber = await db.query.pageSubscriber.findFirst({
200 where: eq(pageSubscriber.token, confirmPageToken),
201 });
202
203 if (!subscriber) {
204 throw new Error("Subscriber not found");
205 }
206
207 const email = subscriber.email;
208 expect(email).toBe("confirm-page-test@example.com");
209
210 // Apply the same masking logic as in the API
211 const [localPart, domain] = email.split("@");
212 const maskedEmail =
213 localPart.length > 0 ? `${localPart[0]}***@${domain}` : `***@${domain}`;
214
215 expect(maskedEmail).toBe("c***@example.com");
216 });
217
218 test("Email masking works for single character local part", async () => {
219 const email = "a@example.com";
220 const [localPart, domain] = email.split("@");
221 const maskedEmail =
222 localPart.length > 0 ? `${localPart[0]}***@${domain}` : `***@${domain}`;
223
224 expect(maskedEmail).toBe("a***@example.com");
225 });
226
227 test("Email masking works for long local part", async () => {
228 const email = "verylongemailaddress@example.com";
229 const [localPart, domain] = email.split("@");
230 const maskedEmail =
231 localPart.length > 0 ? `${localPart[0]}***@${domain}` : `***@${domain}`;
232
233 expect(maskedEmail).toBe("v***@example.com");
234 });
235});
236
237describe("Clicking confirm sets unsubscribedAt timestamp", () => {
238 let unsubscribeToken: string;
239
240 beforeAll(async () => {
241 // Create a fresh subscriber
242 await db
243 .delete(pageSubscriber)
244 .where(eq(pageSubscriber.email, "unsubscribe-click-test@example.com"));
245
246 const subscriber = await db
247 .insert(pageSubscriber)
248 .values({
249 pageId: testPageId,
250 email: "unsubscribe-click-test@example.com",
251 token: crypto.randomUUID(),
252 acceptedAt: new Date(), // Already verified
253 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
254 })
255 .returning()
256 .get();
257
258 if (!subscriber.token) {
259 throw new Error("Subscriber token is undefined");
260 }
261
262 unsubscribeToken = subscriber.token;
263 });
264
265 afterAll(async () => {
266 await db
267 .delete(pageSubscriber)
268 .where(eq(pageSubscriber.email, "unsubscribe-click-test@example.com"));
269 });
270
271 test("Before clicking confirm, unsubscribedAt is null", async () => {
272 const subscriber = await db.query.pageSubscriber.findFirst({
273 where: eq(pageSubscriber.token, unsubscribeToken),
274 });
275
276 expect(subscriber?.unsubscribedAt).toBeNull();
277 });
278
279 test("After clicking confirm, unsubscribedAt is set to current timestamp", async () => {
280 const beforeUnsubscribe = new Date();
281
282 // Simulate clicking "Confirm Unsubscribe"
283 await db
284 .update(pageSubscriber)
285 .set({ unsubscribedAt: new Date() })
286 .where(eq(pageSubscriber.token, unsubscribeToken));
287
288 const afterUnsubscribe = new Date();
289
290 const subscriber = await db.query.pageSubscriber.findFirst({
291 where: eq(pageSubscriber.token, unsubscribeToken),
292 });
293
294 if (!subscriber) {
295 throw new Error("Subscriber not found");
296 }
297
298 expect(subscriber.unsubscribedAt).not.toBeNull();
299 expect(subscriber.unsubscribedAt).toBeInstanceOf(Date);
300
301 // Verify the timestamp is within the expected range
302 if (!subscriber.unsubscribedAt) {
303 throw new Error("Subscriber unsubscribedAt is undefined");
304 }
305
306 // SQLite stores timestamps in seconds, so we compare at second precision
307 const unsubscribedTime = Math.floor(
308 subscriber.unsubscribedAt.getTime() / 1000,
309 );
310 const beforeTime = Math.floor(beforeUnsubscribe.getTime() / 1000);
311 const afterTime = Math.floor(afterUnsubscribe.getTime() / 1000);
312
313 expect(unsubscribedTime).toBeGreaterThanOrEqual(beforeTime);
314 expect(unsubscribedTime).toBeLessThanOrEqual(afterTime);
315 });
316
317 test("Subscriber state transitions correctly through the flow", async () => {
318 // Verify the subscriber has completed the full lifecycle
319 const subscriber = await db.query.pageSubscriber.findFirst({
320 where: eq(pageSubscriber.token, unsubscribeToken),
321 });
322
323 // Has been verified (acceptedAt is set)
324 expect(subscriber?.acceptedAt).not.toBeNull();
325
326 // Has been unsubscribed (unsubscribedAt is set)
327 expect(subscriber?.unsubscribedAt).not.toBeNull();
328
329 // Token is still present (for audit purposes)
330 expect(subscriber?.token).toBe(unsubscribeToken);
331 });
332});
333
334describe("Unsubscribed user does not receive new emails", () => {
335 let unsubscribedToken: string;
336 let pendingToken: string;
337
338 beforeAll(async () => {
339 // Clean up and create multiple subscribers with different states
340 await db
341 .delete(pageSubscriber)
342 .where(eq(pageSubscriber.email, "active-user@example.com"));
343 await db
344 .delete(pageSubscriber)
345 .where(eq(pageSubscriber.email, "unsubscribed-user@example.com"));
346 await db
347 .delete(pageSubscriber)
348 .where(eq(pageSubscriber.email, "pending-user@example.com"));
349
350 // Active subscriber
351 const active = await db
352 .insert(pageSubscriber)
353 .values({
354 pageId: testPageId,
355 email: "active-user@example.com",
356 token: crypto.randomUUID(),
357 acceptedAt: new Date(),
358 unsubscribedAt: null,
359 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
360 })
361 .returning()
362 .get();
363
364 if (!active.token) {
365 throw new Error("Active subscriber token is undefined");
366 }
367
368 // Unsubscribed subscriber
369 const unsubscribed = await db
370 .insert(pageSubscriber)
371 .values({
372 pageId: testPageId,
373 email: "unsubscribed-user@example.com",
374 token: crypto.randomUUID(),
375 acceptedAt: new Date(),
376 unsubscribedAt: new Date(),
377 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
378 })
379 .returning()
380 .get();
381
382 if (!unsubscribed.token) {
383 throw new Error("Unsubscribed subscriber token is undefined");
384 }
385
386 unsubscribedToken = unsubscribed.token;
387
388 // Pending (unverified) subscriber
389 const pending = await db
390 .insert(pageSubscriber)
391 .values({
392 pageId: testPageId,
393 email: "pending-user@example.com",
394 token: crypto.randomUUID(),
395 acceptedAt: null,
396 unsubscribedAt: null,
397 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
398 })
399 .returning()
400 .get();
401
402 if (!pending.token) {
403 throw new Error("Pending subscriber token is undefined");
404 }
405
406 pendingToken = pending.token;
407 });
408
409 afterAll(async () => {
410 await db
411 .delete(pageSubscriber)
412 .where(eq(pageSubscriber.email, "active-user@example.com"));
413 await db
414 .delete(pageSubscriber)
415 .where(eq(pageSubscriber.email, "unsubscribed-user@example.com"));
416 await db
417 .delete(pageSubscriber)
418 .where(eq(pageSubscriber.email, "pending-user@example.com"));
419 });
420
421 test("Email query returns only active subscribers with valid tokens", async () => {
422 // This mirrors the exact query pattern used in email-sending routes
423 const emailRecipients = await db
424 .select({
425 email: pageSubscriber.email,
426 token: pageSubscriber.token,
427 })
428 .from(pageSubscriber)
429 .where(
430 and(
431 eq(pageSubscriber.pageId, testPageId),
432 isNotNull(pageSubscriber.acceptedAt),
433 isNull(pageSubscriber.unsubscribedAt),
434 ),
435 )
436 .all();
437
438 // Should only include active subscriber
439 expect(emailRecipients.length).toBeGreaterThanOrEqual(1);
440
441 const emails = emailRecipients.map((r) => r.email);
442 expect(emails).toContain("active-user@example.com");
443 expect(emails).not.toContain("unsubscribed-user@example.com");
444 expect(emails).not.toContain("pending-user@example.com");
445 });
446
447 test("Unsubscribed users are filtered out even with acceptedAt set", async () => {
448 // Verify the unsubscribed user has acceptedAt set
449 const unsubscribedUser = await db.query.pageSubscriber.findFirst({
450 where: eq(pageSubscriber.token, unsubscribedToken),
451 });
452
453 expect(unsubscribedUser?.acceptedAt).not.toBeNull();
454 expect(unsubscribedUser?.unsubscribedAt).not.toBeNull();
455
456 // Query with proper filters
457 const subscribers = await db
458 .select()
459 .from(pageSubscriber)
460 .where(
461 and(
462 eq(pageSubscriber.pageId, testPageId),
463 isNotNull(pageSubscriber.acceptedAt),
464 isNull(pageSubscriber.unsubscribedAt),
465 ),
466 )
467 .all();
468
469 const foundUnsubscribed = subscribers.find(
470 (s) => s.email === "unsubscribed-user@example.com",
471 );
472 expect(foundUnsubscribed).toBeUndefined();
473 });
474
475 test("Pending users are filtered out (not verified)", async () => {
476 // Verify the pending user has no acceptedAt
477 const pendingUser = await db.query.pageSubscriber.findFirst({
478 where: eq(pageSubscriber.token, pendingToken),
479 });
480
481 expect(pendingUser?.acceptedAt).toBeNull();
482
483 // Query with proper filters
484 const subscribers = await db
485 .select()
486 .from(pageSubscriber)
487 .where(
488 and(
489 eq(pageSubscriber.pageId, testPageId),
490 isNotNull(pageSubscriber.acceptedAt),
491 isNull(pageSubscriber.unsubscribedAt),
492 ),
493 )
494 .all();
495
496 const foundPending = subscribers.find(
497 (s) => s.email === "pending-user@example.com",
498 );
499 expect(foundPending).toBeUndefined();
500 });
501
502 test("Email recipients list includes token for unsubscribe URL generation", async () => {
503 const emailRecipients = await db
504 .select({
505 email: pageSubscriber.email,
506 token: pageSubscriber.token,
507 })
508 .from(pageSubscriber)
509 .where(
510 and(
511 eq(pageSubscriber.pageId, testPageId),
512 isNotNull(pageSubscriber.acceptedAt),
513 isNull(pageSubscriber.unsubscribedAt),
514 ),
515 )
516 .all();
517
518 // Filter for valid tokens (as done in email sending routes)
519 const validRecipients = emailRecipients.filter(
520 (r): r is { email: string; token: string } => r.token !== null,
521 );
522
523 expect(validRecipients.length).toBeGreaterThanOrEqual(1);
524
525 // Each valid recipient should have a UUID token
526 for (const recipient of validRecipients) {
527 expect(recipient.token).toMatch(
528 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
529 );
530 }
531 });
532});
533
534describe("Re-subscription after unsubscribe flow", () => {
535 let resubscribeToken: string;
536
537 beforeAll(async () => {
538 // Clean up
539 await db
540 .delete(pageSubscriber)
541 .where(eq(pageSubscriber.email, "resubscribe-test@example.com"));
542
543 // Create an initially subscribed and verified user
544 const subscriber = await db
545 .insert(pageSubscriber)
546 .values({
547 pageId: testPageId,
548 email: "resubscribe-test@example.com",
549 token: crypto.randomUUID(),
550 acceptedAt: new Date(),
551 unsubscribedAt: null,
552 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
553 })
554 .returning()
555 .get();
556
557 if (!subscriber.token) {
558 throw new Error("Subscriber token is undefined");
559 }
560
561 resubscribeToken = subscriber.token;
562 });
563
564 afterAll(async () => {
565 await db
566 .delete(pageSubscriber)
567 .where(eq(pageSubscriber.email, "resubscribe-test@example.com"));
568 });
569
570 test("User can complete full subscribe -> unsubscribe -> resubscribe cycle", async () => {
571 // Step 1: Verify initial subscription state
572 let subscriber = await db.query.pageSubscriber.findFirst({
573 where: eq(pageSubscriber.email, "resubscribe-test@example.com"),
574 });
575
576 if (!subscriber) {
577 throw new Error("Subscriber ID is undefined");
578 }
579
580 expect(subscriber?.acceptedAt).not.toBeNull();
581 expect(subscriber?.unsubscribedAt).toBeNull();
582
583 // Step 2: User unsubscribes
584 await db
585 .update(pageSubscriber)
586 .set({ unsubscribedAt: new Date() })
587 .where(eq(pageSubscriber.id, subscriber.id));
588
589 subscriber = await db.query.pageSubscriber.findFirst({
590 where: eq(pageSubscriber.id, subscriber.id),
591 });
592
593 expect(subscriber?.unsubscribedAt).not.toBeNull();
594
595 // Step 3: User is excluded from emails
596 const subscribersAfterUnsub = await db
597 .select()
598 .from(pageSubscriber)
599 .where(
600 and(
601 eq(pageSubscriber.pageId, testPageId),
602 eq(pageSubscriber.email, "resubscribe-test@example.com"),
603 isNotNull(pageSubscriber.acceptedAt),
604 isNull(pageSubscriber.unsubscribedAt),
605 ),
606 )
607 .all();
608
609 expect(subscribersAfterUnsub.length).toBe(0);
610
611 if (!subscriber) {
612 throw new Error("Subscriber is undefined");
613 }
614
615 // Step 4: User re-subscribes (simulating the re-subscription flow)
616 const newToken = crypto.randomUUID();
617 await db
618 .update(pageSubscriber)
619 .set({
620 unsubscribedAt: null,
621 acceptedAt: null, // Requires re-verification
622 token: newToken,
623 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
624 })
625 .where(eq(pageSubscriber.id, subscriber.id));
626
627 // Step 5: User is still excluded (not yet verified)
628 const subscribersPendingVerify = await db
629 .select()
630 .from(pageSubscriber)
631 .where(
632 and(
633 eq(pageSubscriber.pageId, testPageId),
634 eq(pageSubscriber.email, "resubscribe-test@example.com"),
635 isNotNull(pageSubscriber.acceptedAt),
636 isNull(pageSubscriber.unsubscribedAt),
637 ),
638 )
639 .all();
640
641 expect(subscribersPendingVerify.length).toBe(0);
642
643 // Step 6: User verifies their email again
644 await db
645 .update(pageSubscriber)
646 .set({ acceptedAt: new Date() })
647 .where(eq(pageSubscriber.token, newToken));
648
649 // Step 7: User is now included in email list again
650 const subscribersAfterReverify = await db
651 .select()
652 .from(pageSubscriber)
653 .where(
654 and(
655 eq(pageSubscriber.pageId, testPageId),
656 eq(pageSubscriber.email, "resubscribe-test@example.com"),
657 isNotNull(pageSubscriber.acceptedAt),
658 isNull(pageSubscriber.unsubscribedAt),
659 ),
660 )
661 .all();
662
663 expect(subscribersAfterReverify.length).toBe(1);
664 expect(subscribersAfterReverify[0].token).toBe(newToken);
665 expect(subscribersAfterReverify[0].token).not.toBe(resubscribeToken);
666 });
667});
668
669describe("Invalid token handling", () => {
670 test("Non-existent token returns no subscriber", async () => {
671 const fakeToken = crypto.randomUUID();
672
673 const subscriber = await db.query.pageSubscriber.findFirst({
674 where: eq(pageSubscriber.token, fakeToken),
675 });
676
677 expect(subscriber).toBeUndefined();
678 });
679
680 test("Invalid UUID format is handled gracefully", async () => {
681 const invalidToken = "not-a-valid-uuid";
682
683 // The database query will still work, just return no results
684 const subscriber = await db.query.pageSubscriber.findFirst({
685 where: eq(pageSubscriber.token, invalidToken),
686 });
687
688 expect(subscriber).toBeUndefined();
689 });
690
691 test("Already unsubscribed token returns subscriber with unsubscribedAt set", async () => {
692 // Create an unsubscribed subscriber
693 await db
694 .delete(pageSubscriber)
695 .where(eq(pageSubscriber.email, "already-unsub@example.com"));
696
697 const subscriber = await db
698 .insert(pageSubscriber)
699 .values({
700 pageId: testPageId,
701 email: "already-unsub@example.com",
702 token: crypto.randomUUID(),
703 acceptedAt: new Date(),
704 unsubscribedAt: new Date(),
705 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
706 })
707 .returning()
708 .get();
709
710 if (!subscriber.token) {
711 throw new Error("Subscriber token is undefined");
712 }
713
714 // Query the subscriber
715 const found = await db.query.pageSubscriber.findFirst({
716 where: eq(pageSubscriber.token, subscriber.token),
717 });
718
719 expect(found).toBeDefined();
720 expect(found?.unsubscribedAt).not.toBeNull();
721
722 // Clean up
723 await db
724 .delete(pageSubscriber)
725 .where(eq(pageSubscriber.email, "already-unsub@example.com"));
726 });
727});
728
729describe("statusPage.get endpoint validation", () => {
730 test("Returns all required output fields with correct types", async () => {
731 // Use the edgeRouter to call the statusPage.get endpoint
732 const { edgeRouter } = await import("../edge");
733 const { createInnerTRPCContext } = await import("../trpc");
734
735 const ctx = createInnerTRPCContext({
736 req: undefined,
737 // @ts-expect-error - auth not required for public procedure
738 auth: undefined,
739 });
740
741 const caller = edgeRouter.createCaller(ctx);
742 const result = await caller.statusPage.get({ slug: testSlug });
743
744 // Validate that result is not null
745 expect(result).toBeDefined();
746 expect(result).not.toBeNull();
747
748 if (!result) {
749 throw new Error("Result should not be null");
750 }
751
752 // Validate core page fields with specific types
753 expect(typeof result.slug).toBe("string");
754 expect(typeof result.title).toBe("string");
755 expect(typeof result.description).toBe("string");
756 expect(result.createdAt).toBeInstanceOf(Date);
757 expect(result.updatedAt).toBeInstanceOf(Date);
758
759 // Validate slug matches what we requested
760 expect(result.slug).toBe(testSlug);
761
762 // Validate all array fields exist and are arrays
763 expect(Array.isArray(result.monitors)).toBe(true);
764 expect(Array.isArray(result.monitorGroups)).toBe(true);
765 expect(Array.isArray(result.pageComponents)).toBe(true);
766 expect(Array.isArray(result.pageComponentGroups)).toBe(true);
767 expect(Array.isArray(result.trackers)).toBe(true);
768 expect(Array.isArray(result.lastEvents)).toBe(true);
769 expect(Array.isArray(result.openEvents)).toBe(true);
770 expect(Array.isArray(result.statusReports)).toBe(true);
771 expect(Array.isArray(result.incidents)).toBe(true);
772 expect(Array.isArray(result.maintenances)).toBe(true);
773
774 // Validate status field is one of the allowed values
775 expect(["success", "degraded", "error", "info"]).toContain(result.status);
776
777 // Validate workspacePlan field
778 expect(result.workspacePlan).toBeDefined();
779 expect(typeof result.workspacePlan).toBe("string");
780
781 // Validate whiteLabel field
782 expect(typeof result.whiteLabel).toBe("boolean");
783 });
784
785 test("Returns null for non-existent slug", async () => {
786 const { edgeRouter } = await import("../edge");
787 const { createInnerTRPCContext } = await import("../trpc");
788
789 const ctx = createInnerTRPCContext({
790 req: undefined,
791 // @ts-expect-error - auth not required for public procedure
792 auth: undefined,
793 });
794
795 const caller = edgeRouter.createCaller(ctx);
796 const result = await caller.statusPage.get({
797 slug: "non-existent-slug-12345",
798 });
799
800 expect(result).toBeNull();
801 });
802
803 test("Tracker objects have correct discriminated union types", async () => {
804 const { edgeRouter } = await import("../edge");
805 const { createInnerTRPCContext } = await import("../trpc");
806
807 const ctx = createInnerTRPCContext({
808 req: undefined,
809 // @ts-expect-error - auth not required for public procedure
810 auth: undefined,
811 });
812
813 const caller = edgeRouter.createCaller(ctx);
814 const result = await caller.statusPage.get({ slug: testSlug });
815
816 if (!result) {
817 // If no result, skip this test as there are no trackers to validate
818 return;
819 }
820
821 // Validate each tracker has the correct structure
822 for (const tracker of result.trackers) {
823 expect(tracker).toHaveProperty("type");
824 expect(tracker).toHaveProperty("order");
825
826 if (tracker.type === "component") {
827 expect(tracker).toHaveProperty("component");
828 expect(tracker.component).toHaveProperty("id");
829 expect(tracker.component).toHaveProperty("name");
830 expect(tracker.component).toHaveProperty("status");
831 expect(tracker.component).toHaveProperty("type");
832 expect(["monitor", "static"]).toContain(tracker.component.type);
833 expect(["success", "degraded", "error", "info"]).toContain(
834 tracker.component.status,
835 );
836
837 // Monitor-type components should have monitor relation
838 if (tracker.component.type === "monitor") {
839 expect(tracker.component).toHaveProperty("monitor");
840 expect(tracker.component.monitor).toBeDefined();
841 }
842 } else if (tracker.type === "group") {
843 expect(tracker).toHaveProperty("groupId");
844 expect(tracker).toHaveProperty("groupName");
845 expect(tracker).toHaveProperty("components");
846 expect(tracker).toHaveProperty("status");
847 expect(Array.isArray(tracker.components)).toBe(true);
848 expect(["success", "degraded", "error", "info"]).toContain(
849 tracker.status,
850 );
851 }
852 }
853 });
854
855 test("Event objects have required fields", async () => {
856 const { edgeRouter } = await import("../edge");
857 const { createInnerTRPCContext } = await import("../trpc");
858
859 const ctx = createInnerTRPCContext({
860 req: undefined,
861 // @ts-expect-error - auth not required for public procedure
862 auth: undefined,
863 });
864
865 const caller = edgeRouter.createCaller(ctx);
866 const result = await caller.statusPage.get({ slug: testSlug });
867
868 if (!result) {
869 return;
870 }
871
872 // Validate lastEvents structure
873 for (const event of result.lastEvents) {
874 expect(event).toMatchObject({
875 id: expect.any(Number),
876 name: expect.any(String),
877 from: expect.any(Date),
878 status: expect.any(String),
879 type: expect.any(String),
880 });
881 expect(["maintenance", "incident", "report"]).toContain(event.type);
882 expect(["success", "degraded", "error", "info"]).toContain(event.status);
883 }
884
885 // Validate openEvents structure
886 for (const event of result.openEvents) {
887 expect(event).toMatchObject({
888 id: expect.any(Number),
889 name: expect.any(String),
890 from: expect.any(Date),
891 status: expect.any(String),
892 type: expect.any(String),
893 });
894 expect(["maintenance", "incident", "report"]).toContain(event.type);
895 expect(["success", "degraded", "error", "info"]).toContain(event.status);
896 }
897 });
898
899 test("Monitor objects contain status field", async () => {
900 const { edgeRouter } = await import("../edge");
901 const { createInnerTRPCContext } = await import("../trpc");
902
903 const ctx = createInnerTRPCContext({
904 req: undefined,
905 // @ts-expect-error - auth not required for public procedure
906 auth: undefined,
907 });
908
909 const caller = edgeRouter.createCaller(ctx);
910 const result = await caller.statusPage.get({ slug: testSlug });
911
912 if (!result || result.monitors.length === 0) {
913 return;
914 }
915
916 // Validate each monitor has status field
917 for (const monitor of result.monitors) {
918 expect(monitor).toHaveProperty("status");
919 expect(["success", "degraded", "error", "info"]).toContain(
920 monitor.status,
921 );
922 expect(monitor).toHaveProperty("id");
923 expect(monitor).toHaveProperty("name");
924 }
925 });
926});