Add queued email notifications for collaboration events

This commit is contained in:
2026-07-22 08:51:42 -04:00
parent b6d2ded141
commit 09f51d1ef4
20 changed files with 1507 additions and 102 deletions

View File

@@ -0,0 +1,248 @@
package email
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
cfg := config.DatabaseConfig{Path: t.TempDir() + "/test.sqlite"}
handle, err := database.Open(context.Background(), cfg)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
db := handle.SQL()
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("migrate: %v", err)
}
// Seed a user + notification so the queue's notification_id FK is
// satisfiable and MarkNotificationEmailed can update a real row.
_, err = db.Exec(`INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) VALUES ('user:alice@example.com', 'alice@example.com', 'Alice', 'admin', ?, ?)`,
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed user: %v", err)
}
_, err = db.Exec(`INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, created_at) VALUES (?, 'user:alice@example.com', 'file_changed', 'document', 'doc:test', 'test', ?)`,
"notif:test-1", time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed notification: %v", err)
}
return db
}
func TestQueue_EnqueueAndPendingCount(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Subject", "Body")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
if id == "" {
t.Fatal("expected non-empty id")
}
count, err := q.PendingCount(context.Background())
if err != nil {
t.Fatalf("PendingCount: %v", err)
}
if count != 1 {
t.Errorf("pending = %d, want 1", count)
}
}
func TestQueue_EnqueueValidation(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
// Empty notification_id is allowed (the column is nullable).
if _, err := q.Enqueue(context.Background(), "", "alice@example.com", "s", "b"); err != nil {
t.Fatalf("empty notification_id should be allowed: %v", err)
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "", "s", "b"); err == nil {
t.Fatal("expected error for empty to")
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "", "b"); err == nil {
t.Fatal("expected error for empty subject")
}
}
type recordingSender struct {
sends []recordedSend
err error
}
type recordedSend struct {
to []string
subject string
body string
}
func (r *recordingSender) Send(ctx context.Context, to []string, subject, body string) error {
if r.err != nil {
return r.err
}
r.sends = append(r.sends, recordedSend{to: to, subject: subject, body: body})
return nil
}
func TestQueue_DeliverSuccess(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Hello", "World"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
n, _ := q.PendingCount(ctx)
return n == 0
}); err != nil {
t.Fatalf("email never sent: %v", err)
}
if len(sender.sends) != 1 {
t.Fatalf("sends = %d, want 1", len(sender.sends))
}
if sender.sends[0].subject != "Hello" {
t.Errorf("subject = %q", sender.sends[0].subject)
}
if sender.sends[0].body != "World" {
t.Errorf("body = %q", sender.sends[0].body)
}
// The linked notification should now have emailed_at stamped.
var emailedAt string
if err := db.QueryRow(`SELECT COALESCE(emailed_at, '') FROM notifications WHERE id = ?`, "notif:test-1").Scan(&emailedAt); err != nil {
t.Fatalf("query emailed_at: %v", err)
}
if emailedAt == "" {
t.Error("expected notifications.emailed_at to be set after send")
}
}
func TestQueue_RetryThenSucceed(t *testing.T) {
db := newTestDB(t)
// Fail twice, then succeed.
sender := &flakySender{failTimes: 2}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(50 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Retry", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
return sender.successCount >= 1
}); err != nil {
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err)
}
// Verify the row is marked sent.
var status string
if err := db.QueryRow(`SELECT status FROM email_queue WHERE to_address = ?`, "alice@example.com").Scan(&status); err != nil {
t.Fatalf("query status: %v", err)
}
if status != "sent" {
t.Errorf("status = %q, want sent", status)
}
}
func TestQueue_PermanentFailure(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{err: errors.New("permanent Postmark rejection")}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
failed, _ := q.ListFailed(ctx, 10)
return len(failed) == 1
}); err != nil {
failed, _ := q.ListFailed(ctx, 10)
t.Fatalf("expected 1 failed row, got %d: %v", len(failed), err)
}
failed, _ := q.ListFailed(ctx, 10)
if !strings.Contains(failed[0].LastError, "permanent") {
t.Errorf("last_error = %q", failed[0].LastError)
}
}
func TestQueue_NilSenderLeavesPending(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Pending", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
go q.Run(ctx)
<-ctx.Done()
count, _ := q.PendingCount(context.Background())
if count != 1 {
t.Errorf("expected row to stay pending with nil sender, got %d", count)
}
}
// flakySender fails the first N attempts then succeeds.
type flakySender struct {
failTimes int
attempts int
successCount int
}
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
f.attempts++
if f.attempts <= f.failTimes {
return errors.New("transient failure")
}
f.successCount++
return nil
}
func waitFor(ctx context.Context, cond func() bool) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if cond() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}