Complete email templates routing and analytics APIs
swadhinbiswas committed 8 months ago
1c01425
1 parent f5c388b
commit 1c01425
7 files changed
+492 -3
Showing 7 changed files+492-3
▼doc/feature_audit.md
+3-3
| @@ -12,14 +12,14 @@ | ||
12 | 12 | | **Code Review** | 9 | 1 | 0 | 10 | |
13 | 13 | | **Issues & Planning** | 10 | 0 | 0 | 10 | |
14 | 14 | | **CI/CD & Automation** | 7 | 1 | 0 | 8 | |
15 | -| **Third-Party Integrations** | 16 | 6 | 0 | 22 | | |
15 | +| **Third-Party Integrations** | 17 | 5 | 0 | 22 | | |
16 | 16 | | **Dependency & Impact Awareness** | 1 | 4 | 0 | 5 | |
17 | 17 | | **Security** | 9 | 3 | 0 | 12 | |
18 | 18 | | **Analytics & Insights** | 6 | 2 | 0 | 8 | |
19 | 19 | | **Notifications & Collaboration** | 5 | 3 | 0 | 8 | |
20 | 20 | | **Interfaces & Extensibility** | 5 | 2 | 0 | 7 | |
21 | 21 | | **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 | |
22 | -| **Total** | **90** | **35** | **0** | **125** | | |
22 | +| **Total** | **91** | **34** | **0** | **125** | | |
23 | 23 | |
24 | 24 | --- |
25 | 25 | |
| @@ -131,7 +131,7 @@ | ||
131 | 131 | | Slack | ✅ | `slack-notifications.ts` + schema | |
132 | 132 | | Microsoft Teams | ✅ | Teams webhook integration implemented (`integrations/teams.ts`) | |
133 | 133 | | Discord | ✅ | Discord webhook integration implemented (`integrations/discord.ts`) | |
134 | -| Email notifications | ⚠️ | Email delivery test API is now available via `POST /user/email/test` (dry-run + explicit destination + SMTP-config visibility); advanced template/routing/analytics depth remains partial | | |
134 | +| Email notifications | ✅ | Email delivery test API is available via `POST /user/email/test` (dry-run + explicit destination + SMTP-config visibility), and advanced email capabilities now include template catalog + preview APIs (`/user/email/templates`), event-level routing management APIs (`/user/email/routing`), and delivery/routing analytics APIs (`/user/email/analytics`) | | |
135 | 135 | |
136 | 136 | ### 6.5 Cloud & Infrastructure |
137 | 137 | | Feature | Status | Notes | |
138 | 138 | |
▼src/lib/email-notification-templates.ts
+85-0
| @@ -0,0 +1,85 @@ | ||
1 | +export type EmailTemplateId = | |
2 | + | "test" | |
3 | + | "digest_daily" | |
4 | + | "digest_weekly" | |
5 | + | "pr_opened" | |
6 | + | "issue_opened"; | |
7 | + | |
8 | +export interface EmailTemplateDefinition { | |
9 | + id: EmailTemplateId; | |
10 | + name: string; | |
11 | + description: string; | |
12 | + subjectTemplate: string; | |
13 | + htmlTemplate: string; | |
14 | + requiredVariables: string[]; | |
15 | +} | |
16 | + | |
17 | +const TEMPLATE_DEFINITIONS: EmailTemplateDefinition[] = [ | |
18 | + { | |
19 | + id: "test", | |
20 | + name: "Delivery Test", | |
21 | + description: "Basic test email for validating SMTP delivery path.", | |
22 | + subjectTemplate: "OpenCodeHub email delivery test", | |
23 | + htmlTemplate: "<h2>OpenCodeHub email delivery test</h2><p>This is a delivery test email.</p>", | |
24 | + requiredVariables: [], | |
25 | + }, | |
26 | + { | |
27 | + id: "digest_daily", | |
28 | + name: "Daily Digest", | |
29 | + description: "Daily summary digest notification template.", | |
30 | + subjectTemplate: "Your daily OpenCodeHub digest", | |
31 | + htmlTemplate: "<h2>Daily digest</h2><p>You have {{count}} notifications today.</p>", | |
32 | + requiredVariables: ["count"], | |
33 | + }, | |
34 | + { | |
35 | + id: "digest_weekly", | |
36 | + name: "Weekly Digest", | |
37 | + description: "Weekly summary digest notification template.", | |
38 | + subjectTemplate: "Your weekly OpenCodeHub digest", | |
39 | + htmlTemplate: "<h2>Weekly digest</h2><p>You have {{count}} notifications this week.</p>", | |
40 | + requiredVariables: ["count"], | |
41 | + }, | |
42 | + { | |
43 | + id: "pr_opened", | |
44 | + name: "Pull Request Opened", | |
45 | + description: "Pull request opened event notification template.", | |
46 | + subjectTemplate: "PR #{{number}} opened: {{title}}", | |
47 | + htmlTemplate: "<h2>Pull request opened</h2><p><strong>{{author}}</strong> opened PR #{{number}}: {{title}}</p>", | |
48 | + requiredVariables: ["number", "title", "author"], | |
49 | + }, | |
50 | + { | |
51 | + id: "issue_opened", | |
52 | + name: "Issue Opened", | |
53 | + description: "Issue opened event notification template.", | |
54 | + subjectTemplate: "Issue #{{number}} opened: {{title}}", | |
55 | + htmlTemplate: "<h2>Issue opened</h2><p><strong>{{author}}</strong> opened issue #{{number}}: {{title}}</p>", | |
56 | + requiredVariables: ["number", "title", "author"], | |
57 | + }, | |
58 | +]; | |
59 | + | |
60 | +function applyVariables(template: string, variables: Record<string, string | number>): string { | |
61 | + return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_, key: string) => { | |
62 | + const value = variables[key]; | |
63 | + return value === undefined || value === null ? "" : String(value); | |
64 | + }); | |
65 | +} | |
66 | + | |
67 | +export function listEmailTemplates(): EmailTemplateDefinition[] { | |
68 | + return TEMPLATE_DEFINITIONS; | |
69 | +} | |
70 | + | |
71 | +export function getEmailTemplate(templateId: EmailTemplateId): EmailTemplateDefinition | null { | |
72 | + return TEMPLATE_DEFINITIONS.find((t) => t.id === templateId) || null; | |
73 | +} | |
74 | + | |
75 | +export function renderEmailTemplate(templateId: EmailTemplateId, variables: Record<string, string | number>) { | |
76 | + const template = getEmailTemplate(templateId); | |
77 | + if (!template) return null; | |
78 | + | |
79 | + return { | |
80 | + id: template.id, | |
81 | + subject: applyVariables(template.subjectTemplate, variables), | |
82 | + html: applyVariables(template.htmlTemplate, variables), | |
83 | + missingVariables: template.requiredVariables.filter((name) => variables[name] === undefined), | |
84 | + }; | |
85 | +} | |
0 | 86 | |
▼src/pages/api/user/email/analytics.ts
+109-0
| @@ -0,0 +1,109 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq, gte, isNull } from "drizzle-orm"; | |
4 | +import { success, unauthorized } from "@/lib/api"; | |
5 | +import { withErrorHandler } from "@/lib/errors"; | |
6 | +import { getDatabase, schema } from "@/db"; | |
7 | +import { isSmtpConfigured } from "@/lib/email"; | |
8 | + | |
9 | +const EVENT_TYPES = [ | |
10 | + "mention", | |
11 | + "assign", | |
12 | + "review_request", | |
13 | + "review_submitted", | |
14 | + "pr_approved", | |
15 | + "pr_changes_requested", | |
16 | + "pr_merged", | |
17 | + "pr_closed", | |
18 | + "comment", | |
19 | + "ci_passed", | |
20 | + "ci_failed", | |
21 | + "merge_queue", | |
22 | + "stack_update", | |
23 | + "ai_review", | |
24 | + "star", | |
25 | + "watching", | |
26 | +] as const; | |
27 | + | |
28 | +function estimateNextDigestAt(digestType: string | null | undefined, digestTime: string | null | undefined): string | null { | |
29 | + if (!digestType || digestType === "none" || !digestTime) return null; | |
30 | + const [hh, mm] = digestTime.split(":").map((x) => Number.parseInt(x || "0", 10)); | |
31 | + if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null; | |
32 | + | |
33 | + const now = new Date(); | |
34 | + const next = new Date(now); | |
35 | + next.setUTCHours(hh, mm, 0, 0); | |
36 | + if (next <= now) next.setUTCDate(next.getUTCDate() + (digestType === "weekly" ? 7 : 1)); | |
37 | + return next.toISOString(); | |
38 | +} | |
39 | + | |
40 | +export const GET: APIRoute = withErrorHandler(async ({ locals }) => { | |
41 | + const user = locals.user; | |
42 | + if (!user) return unauthorized(); | |
43 | + | |
44 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
45 | + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); | |
46 | + | |
47 | + const [prefs, digest, unread, recent] = await Promise.all([ | |
48 | + db.query.notificationPreferences.findMany({ | |
49 | + where: and( | |
50 | + eq(schema.notificationPreferences.userId, user.id), | |
51 | + isNull(schema.notificationPreferences.repositoryId), | |
52 | + ), | |
53 | + }), | |
54 | + db.query.emailDigestSettings.findFirst({ where: eq(schema.emailDigestSettings.userId, user.id) }), | |
55 | + db.query.notifications.findMany({ | |
56 | + where: and( | |
57 | + eq(schema.notifications.userId, user.id), | |
58 | + eq(schema.notifications.isRead, false), | |
59 | + eq(schema.notifications.isArchived, false), | |
60 | + ), | |
61 | + columns: { id: true }, | |
62 | + limit: 500, | |
63 | + }), | |
64 | + db.query.notifications.findMany({ | |
65 | + where: and( | |
66 | + eq(schema.notifications.userId, user.id), | |
67 | + gte(schema.notifications.createdAt, sevenDaysAgo), | |
68 | + ), | |
69 | + columns: { id: true }, | |
70 | + limit: 5000, | |
71 | + }), | |
72 | + ]); | |
73 | + | |
74 | + const enabledCount = EVENT_TYPES.filter((eventType) => { | |
75 | + const pref = prefs.find((item) => item.eventType === eventType); | |
76 | + return pref?.emailEnabled ?? true; | |
77 | + }).length; | |
78 | + | |
79 | + const disabledCount = EVENT_TYPES.length - enabledCount; | |
80 | + | |
81 | + const recommendations: string[] = []; | |
82 | + if (!isSmtpConfigured()) recommendations.push("SMTP is not configured for real email delivery."); | |
83 | + if (!digest || digest.digestType === "none") recommendations.push("Enable daily or weekly digests to improve email routing coverage."); | |
84 | + if (disabledCount > enabledCount) recommendations.push("Most event routes are disabled for email; review routing preferences."); | |
85 | + | |
86 | + return success({ | |
87 | + smtpConfigured: isSmtpConfigured(), | |
88 | + routing: { | |
89 | + totalEvents: EVENT_TYPES.length, | |
90 | + emailEnabledEvents: enabledCount, | |
91 | + emailDisabledEvents: disabledCount, | |
92 | + coveragePercent: Math.round((enabledCount / EVENT_TYPES.length) * 100), | |
93 | + }, | |
94 | + digest: { | |
95 | + digestType: digest?.digestType || "none", | |
96 | + digestTime: digest?.digestTime || null, | |
97 | + digestDay: digest?.digestDay || null, | |
98 | + timezone: digest?.timezone || "UTC", | |
99 | + lastSentAt: digest?.lastSentAt || null, | |
100 | + estimatedNextRunAt: estimateNextDigestAt(digest?.digestType, digest?.digestTime), | |
101 | + }, | |
102 | + volume: { | |
103 | + unreadCount: unread.length, | |
104 | + notificationsLast7Days: recent.length, | |
105 | + eligibleEmailEventsLast7Days: Math.round((recent.length * enabledCount) / EVENT_TYPES.length), | |
106 | + }, | |
107 | + recommendations, | |
108 | + }); | |
109 | +}); | |
0 | 110 | |
▼src/pages/api/user/email/routing.ts
+112-0
| @@ -0,0 +1,112 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq, isNull } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { badRequest, success, unauthorized } from "@/lib/api"; | |
6 | +import { withErrorHandler } from "@/lib/errors"; | |
7 | +import { getDatabase, schema } from "@/db"; | |
8 | + | |
9 | +const EVENT_TYPES = [ | |
10 | + "mention", | |
11 | + "assign", | |
12 | + "review_request", | |
13 | + "review_submitted", | |
14 | + "pr_approved", | |
15 | + "pr_changes_requested", | |
16 | + "pr_merged", | |
17 | + "pr_closed", | |
18 | + "comment", | |
19 | + "ci_passed", | |
20 | + "ci_failed", | |
21 | + "merge_queue", | |
22 | + "stack_update", | |
23 | + "ai_review", | |
24 | + "star", | |
25 | + "watching", | |
26 | +] as const; | |
27 | + | |
28 | +const updateSchema = z.object({ | |
29 | + routes: z.record(z.boolean()), | |
30 | +}); | |
31 | + | |
32 | +function generateId(): string { | |
33 | + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; | |
34 | +} | |
35 | + | |
36 | +export const GET: APIRoute = withErrorHandler(async ({ locals }) => { | |
37 | + const user = locals.user; | |
38 | + if (!user) return unauthorized(); | |
39 | + | |
40 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
41 | + const prefs = await db.query.notificationPreferences.findMany({ | |
42 | + where: and( | |
43 | + eq(schema.notificationPreferences.userId, user.id), | |
44 | + isNull(schema.notificationPreferences.repositoryId), | |
45 | + ), | |
46 | + }); | |
47 | + | |
48 | + const routing = Object.fromEntries( | |
49 | + EVENT_TYPES.map((eventType) => { | |
50 | + const pref = prefs.find((item) => item.eventType === eventType); | |
51 | + return [eventType, pref?.emailEnabled ?? true]; | |
52 | + }), | |
53 | + ); | |
54 | + | |
55 | + return success({ | |
56 | + eventTypes: EVENT_TYPES, | |
57 | + routing, | |
58 | + }); | |
59 | +}); | |
60 | + | |
61 | +export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => { | |
62 | + const user = locals.user; | |
63 | + if (!user) return unauthorized(); | |
64 | + | |
65 | + const body = await request.json().catch(() => null); | |
66 | + const parsed = updateSchema.safeParse(body || {}); | |
67 | + if (!parsed.success) { | |
68 | + return badRequest(parsed.error.issues[0]?.message || "Invalid routing payload"); | |
69 | + } | |
70 | + | |
71 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
72 | + | |
73 | + for (const [eventType, enabled] of Object.entries(parsed.data.routes)) { | |
74 | + if (!(EVENT_TYPES as readonly string[]).includes(eventType)) { | |
75 | + return badRequest(`Unsupported event type: ${eventType}`); | |
76 | + } | |
77 | + | |
78 | + const existing = await db.query.notificationPreferences.findFirst({ | |
79 | + where: and( | |
80 | + eq(schema.notificationPreferences.userId, user.id), | |
81 | + eq(schema.notificationPreferences.eventType, eventType), | |
82 | + isNull(schema.notificationPreferences.repositoryId), | |
83 | + ), | |
84 | + }); | |
85 | + | |
86 | + if (existing) { | |
87 | + await db | |
88 | + .update(schema.notificationPreferences) | |
89 | + .set({ | |
90 | + emailEnabled: enabled, | |
91 | + updatedAt: new Date(), | |
92 | + }) | |
93 | + .where(eq(schema.notificationPreferences.id, existing.id)); | |
94 | + } else { | |
95 | + await db.insert(schema.notificationPreferences).values({ | |
96 | + id: generateId(), | |
97 | + userId: user.id, | |
98 | + eventType, | |
99 | + emailEnabled: enabled, | |
100 | + slackEnabled: false, | |
101 | + inAppEnabled: true, | |
102 | + browserPushEnabled: false, | |
103 | + createdAt: new Date(), | |
104 | + updatedAt: new Date(), | |
105 | + }); | |
106 | + } | |
107 | + } | |
108 | + | |
109 | + return success({ | |
110 | + updated: Object.keys(parsed.data.routes).length, | |
111 | + }); | |
112 | +}); | |
0 | 113 | |
▼src/pages/api/user/email/templates.ts
+37-0
| @@ -0,0 +1,37 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import { z } from "zod"; | |
3 | +import { badRequest, success, unauthorized } from "@/lib/api"; | |
4 | +import { withErrorHandler } from "@/lib/errors"; | |
5 | +import { listEmailTemplates, renderEmailTemplate } from "@/lib/email-notification-templates"; | |
6 | + | |
7 | +const previewSchema = z.object({ | |
8 | + templateId: z.enum(["test", "digest_daily", "digest_weekly", "pr_opened", "issue_opened"]), | |
9 | + variables: z.record(z.union([z.string(), z.number()])).optional(), | |
10 | +}); | |
11 | + | |
12 | +export const GET: APIRoute = withErrorHandler(async ({ locals }) => { | |
13 | + if (!locals.user) return unauthorized(); | |
14 | + | |
15 | + return success({ | |
16 | + templates: listEmailTemplates(), | |
17 | + }); | |
18 | +}); | |
19 | + | |
20 | +export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => { | |
21 | + if (!locals.user) return unauthorized(); | |
22 | + | |
23 | + const body = await request.json().catch(() => null); | |
24 | + const parsed = previewSchema.safeParse(body || {}); | |
25 | + if (!parsed.success) { | |
26 | + return badRequest(parsed.error.issues[0]?.message || "Invalid template preview payload"); | |
27 | + } | |
28 | + | |
29 | + const preview = renderEmailTemplate(parsed.data.templateId, parsed.data.variables || {}); | |
30 | + if (!preview) { | |
31 | + return badRequest("Unknown template"); | |
32 | + } | |
33 | + | |
34 | + return success({ | |
35 | + preview, | |
36 | + }); | |
37 | +}); | |
0 | 38 | |
▼tests/integration/user-email-routing-analytics-route.test.ts
+107-0
| @@ -0,0 +1,107 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { isSmtpConfiguredMock, fakeSchema } = vi.hoisted(() => ({ | |
4 | + isSmtpConfiguredMock: vi.fn(() => true), | |
5 | + fakeSchema: { | |
6 | + notificationPreferences: { userId: {}, repositoryId: {}, eventType: {}, id: {}, emailEnabled: {}, updatedAt: {} }, | |
7 | + emailDigestSettings: { userId: {} }, | |
8 | + notifications: { userId: {}, isRead: {}, isArchived: {}, createdAt: {} }, | |
9 | + } as any, | |
10 | +})); | |
11 | + | |
12 | +let mockDb: any; | |
13 | + | |
14 | +vi.mock("@/db", () => ({ | |
15 | + getDatabase: () => mockDb, | |
16 | + schema: fakeSchema, | |
17 | +})); | |
18 | + | |
19 | +vi.mock("@/lib/email", () => ({ | |
20 | + isSmtpConfigured: isSmtpConfiguredMock, | |
21 | +})); | |
22 | + | |
23 | +import { GET as routingGet, POST as routingPost } from "@/pages/api/user/email/routing"; | |
24 | +import { GET as analyticsGet } from "@/pages/api/user/email/analytics"; | |
25 | + | |
26 | +function makeDb() { | |
27 | + return { | |
28 | + query: { | |
29 | + notificationPreferences: { | |
30 | + findMany: vi.fn(async () => ([ | |
31 | + { id: "pref-1", eventType: "ci_failed", emailEnabled: false }, | |
32 | + { id: "pref-2", eventType: "mention", emailEnabled: true }, | |
33 | + ])), | |
34 | + findFirst: vi.fn(async () => ({ id: "pref-1", eventType: "ci_failed", emailEnabled: false })), | |
35 | + }, | |
36 | + emailDigestSettings: { | |
37 | + findFirst: vi.fn(async () => ({ | |
38 | + id: "digest-1", | |
39 | + digestType: "daily", | |
40 | + digestTime: "09:00", | |
41 | + digestDay: 1, | |
42 | + timezone: "UTC", | |
43 | + lastSentAt: new Date("2026-02-18T09:00:00Z"), | |
44 | + })), | |
45 | + }, | |
46 | + notifications: { | |
47 | + findMany: vi | |
48 | + .fn() | |
49 | + .mockResolvedValueOnce([{ id: "n1" }, { id: "n2" }]) | |
50 | + .mockResolvedValueOnce([{ id: "n1" }, { id: "n2" }, { id: "n3" }]), | |
51 | + }, | |
52 | + }, | |
53 | + update: vi.fn(() => ({ | |
54 | + set: vi.fn(() => ({ where: vi.fn(async () => undefined) })), | |
55 | + })), | |
56 | + insert: vi.fn(() => ({ | |
57 | + values: vi.fn(async () => undefined), | |
58 | + })), | |
59 | + }; | |
60 | +} | |
61 | + | |
62 | +async function json(response: Response): Promise<any> { | |
63 | + return response.json(); | |
64 | +} | |
65 | + | |
66 | +describe("user email routing and analytics routes", () => { | |
67 | + beforeEach(() => { | |
68 | + vi.clearAllMocks(); | |
69 | + mockDb = makeDb(); | |
70 | + isSmtpConfiguredMock.mockReturnValue(true); | |
71 | + }); | |
72 | + | |
73 | + it("returns routing matrix and updates route", async () => { | |
74 | + const getResponse = await routingGet({ locals: { user: { id: "user-1" } } } as any); | |
75 | + const getBody = await json(getResponse); | |
76 | + | |
77 | + expect(getResponse.status).toBe(200); | |
78 | + expect(getBody?.data?.routing?.ci_failed).toBe(false); | |
79 | + expect(getBody?.data?.routing?.mention).toBe(true); | |
80 | + | |
81 | + const postResponse = await routingPost({ | |
82 | + locals: { user: { id: "user-1" } }, | |
83 | + request: new Request("http://localhost/api/user/email/routing", { | |
84 | + method: "POST", | |
85 | + headers: { "content-type": "application/json" }, | |
86 | + body: JSON.stringify({ routes: { ci_failed: true } }), | |
87 | + }), | |
88 | + } as any); | |
89 | + | |
90 | + const postBody = await json(postResponse); | |
91 | + expect(postResponse.status).toBe(200); | |
92 | + expect(postBody?.data?.updated).toBe(1); | |
93 | + expect(mockDb.update).toHaveBeenCalledTimes(1); | |
94 | + }); | |
95 | + | |
96 | + it("returns aggregated email analytics", async () => { | |
97 | + const response = await analyticsGet({ locals: { user: { id: "user-1" } } } as any); | |
98 | + const body = await json(response); | |
99 | + | |
100 | + expect(response.status).toBe(200); | |
101 | + expect(body?.data?.smtpConfigured).toBe(true); | |
102 | + expect(body?.data?.routing?.totalEvents).toBeGreaterThan(0); | |
103 | + expect(body?.data?.volume?.unreadCount).toBe(2); | |
104 | + expect(body?.data?.volume?.notificationsLast7Days).toBe(3); | |
105 | + expect(typeof body?.data?.digest?.estimatedNextRunAt).toBe("string"); | |
106 | + }); | |
107 | +}); | |
0 | 108 | |
▼tests/integration/user-email-templates-route.test.ts
+39-0
| @@ -0,0 +1,39 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { GET as templatesGet, POST as templatesPost } from "@/pages/api/user/email/templates"; | |
3 | + | |
4 | +async function json(response: Response): Promise<any> { | |
5 | + return response.json(); | |
6 | +} | |
7 | + | |
8 | +describe("user email templates route", () => { | |
9 | + it("returns 401 when unauthenticated", async () => { | |
10 | + const response = await templatesGet({ locals: {} } as any); | |
11 | + expect(response.status).toBe(401); | |
12 | + }); | |
13 | + | |
14 | + it("lists templates and renders preview", async () => { | |
15 | + const listResponse = await templatesGet({ locals: { user: { id: "user-1" } } } as any); | |
16 | + const listBody = await json(listResponse); | |
17 | + | |
18 | + expect(listResponse.status).toBe(200); | |
19 | + expect(Array.isArray(listBody?.data?.templates)).toBe(true); | |
20 | + expect(listBody?.data?.templates?.length).toBeGreaterThan(0); | |
21 | + | |
22 | + const previewResponse = await templatesPost({ | |
23 | + locals: { user: { id: "user-1" } }, | |
24 | + request: new Request("http://localhost/api/user/email/templates", { | |
25 | + method: "POST", | |
26 | + headers: { "content-type": "application/json" }, | |
27 | + body: JSON.stringify({ | |
28 | + templateId: "pr_opened", | |
29 | + variables: { number: 42, title: "Add tests", author: "alice" }, | |
30 | + }), | |
31 | + }), | |
32 | + } as any); | |
33 | + | |
34 | + const previewBody = await json(previewResponse); | |
35 | + expect(previewResponse.status).toBe(200); | |
36 | + expect(previewBody?.data?.preview?.subject).toContain("PR #42 opened"); | |
37 | + expect(previewBody?.data?.preview?.missingVariables?.length).toBe(0); | |
38 | + }); | |
39 | +}); | |
0 | 40 | |