249 lines
7.0 KiB
Go
249 lines
7.0 KiB
Go
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:
|
|
}
|
|
}
|
|
}
|