trails/apps/journal/app/lib/email.server.test.ts
Ullrich Schäfer 49aadd04a9
Add observability: health endpoints, structured logging, metrics, Grafana stack
Health endpoints:
- /api/health (Journal) and /health (Planner) with DB connectivity check
- Docker healthchecks updated to use app health endpoints

Structured logging:
- Pino with JSON output in production, pretty-print in dev
- Request logging middleware in Planner (method, path, status, duration)
- Replaced console.log/error with structured logger in email and auth flows

Prometheus metrics:
- prom-client with default Node.js metrics + custom histograms/gauges
- /metrics endpoints on both apps
- http_request_duration, planner_active_sessions, brouter_request_duration

Monitoring stack:
- Prometheus, Loki, Grafana containers in docker-compose
- Grafana provisioned with datasources, dashboards, and alert rules
- Caddy access logging (JSON to stdout for Loki)
- grafana.trails.cool with basic auth via Caddy

Dashboards and alerting:
- Overview: request rate, error rate, latency p50/p95/p99
- Planner: active sessions, connected clients, BRouter latency
- Infrastructure: memory, CPU, event loop lag
- Alerts: disk >80%, app down 2min, error rate >5%

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:59:44 +01:00

68 lines
2.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock nodemailer before importing
vi.mock("nodemailer", () => ({
createTransport: vi.fn().mockReturnValue({
sendMail: vi.fn().mockResolvedValue({ messageId: "test-id" }),
}),
}));
// Mock logger
vi.mock("./logger.server", () => ({
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
describe("email.server", () => {
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
delete process.env.SMTP_URL;
});
it("uses logger in dev mode instead of sending email", async () => {
process.env.NODE_ENV = "development";
const { sendEmail } = await import("./email.server");
const { logger } = await import("./logger.server");
await sendEmail("test@example.com", "Test Subject", "<p>Hello</p>", "Hello");
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ to: "test@example.com" }),
expect.any(String),
);
});
it("does not call SMTP in dev mode", async () => {
process.env.NODE_ENV = "development";
const nodemailer = await import("nodemailer");
const { sendEmail } = await import("./email.server");
await sendEmail("test@example.com", "Test", "<p>Hi</p>", "Hi");
expect(nodemailer.createTransport).not.toHaveBeenCalled();
});
it("magicLinkTemplate includes link and expiry note", async () => {
const { magicLinkTemplate } = await import("./email.server");
const { html, text } = magicLinkTemplate("https://trails.cool/auth/verify?token=abc");
expect(html).toContain("https://trails.cool/auth/verify?token=abc");
expect(html).toContain("15 minutes");
expect(text).toContain("https://trails.cool/auth/verify?token=abc");
expect(text).toContain("15 minutes");
});
it("welcomeTemplate includes username", async () => {
const { welcomeTemplate } = await import("./email.server");
const { html, text } = welcomeTemplate("Alice");
expect(html).toContain("Alice");
expect(text).toContain("Alice");
expect(html).toContain("trails.cool");
});
});