RetainWithAudit appends the 'retain' audit row inside the same transaction and before the cap, so the cap counts it. After a run, actions_log holds at most EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS rows, including the audit row. Adds a test.
172 lines
5.1 KiB
Go
172 lines
5.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// seedBodyMessage stores one message with a body and a chosen received_at.
|
|
func seedBodyMessage(t *testing.T, s *Store, ctx context.Context, gmailID, body string, receivedAt time.Time) int64 {
|
|
t.Helper()
|
|
id, err := s.UpsertMessage(ctx, &Message{
|
|
GmailID: gmailID,
|
|
FromAddr: "sender@example.test",
|
|
ToAddr: "hello@where-woof.com",
|
|
Subject: "Subject " + gmailID,
|
|
BodyText: body,
|
|
SourceAddress: "hello@where-woof.com",
|
|
DateSent: receivedAt,
|
|
ReceivedAt: receivedAt,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed body message: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// bodyOf reads body_text directly, distinguishing NULL from empty.
|
|
func bodyOf(t *testing.T, s *Store, ctx context.Context, id int64) (string, bool) {
|
|
t.Helper()
|
|
var body *string
|
|
if err := s.DB().QueryRowContext(ctx,
|
|
`SELECT body_text FROM messages WHERE id = ?`, id).Scan(&body); err != nil {
|
|
t.Fatalf("read body of message %d: %v", id, err)
|
|
}
|
|
if body == nil {
|
|
return "", true
|
|
}
|
|
return *body, false
|
|
}
|
|
|
|
func TestRetainClearsOldBodiesKeepsMetadata(t *testing.T) {
|
|
s, ctx := testStore(t)
|
|
|
|
old := seedBodyMessage(t, s, ctx, "old-1", "an old body", time.Now().UTC().Add(-45*24*time.Hour))
|
|
recent := seedBodyMessage(t, s, ctx, "recent-1", "a recent body", time.Now().UTC().Add(-2*24*time.Hour))
|
|
|
|
res, err := s.Retain(ctx, RetentionPolicy{BodyMaxAge: 30 * 24 * time.Hour})
|
|
if err != nil {
|
|
t.Fatalf("Retain() error = %v", err)
|
|
}
|
|
if res.BodiesCleared != 1 {
|
|
t.Errorf("BodiesCleared = %d, want 1", res.BodiesCleared)
|
|
}
|
|
|
|
if body, null := bodyOf(t, s, ctx, old); !null || body != "" {
|
|
t.Errorf("old body = %q (null=%v), want NULL", body, null)
|
|
}
|
|
if body, null := bodyOf(t, s, ctx, recent); null || body != "a recent body" {
|
|
t.Errorf("recent body = %q (null=%v), want unchanged", body, null)
|
|
}
|
|
|
|
// Metadata must survive the body clear.
|
|
m, err := s.MessageByID(ctx, old)
|
|
if err != nil {
|
|
t.Fatalf("MessageByID() error = %v", err)
|
|
}
|
|
if m.Subject != "Subject old-1" || m.SourceAddress != "hello@where-woof.com" {
|
|
t.Errorf("metadata lost after retention: %+v", m)
|
|
}
|
|
if m.BodyText != "" {
|
|
t.Errorf("MessageByID().BodyText = %q, want empty", m.BodyText)
|
|
}
|
|
|
|
// A second pass is a no-op: the row is already NULL, not an empty string.
|
|
res2, err := s.Retain(ctx, RetentionPolicy{BodyMaxAge: 30 * 24 * time.Hour})
|
|
if err != nil {
|
|
t.Fatalf("second Retain() error = %v", err)
|
|
}
|
|
if res2.BodiesCleared != 0 {
|
|
t.Errorf("second BodiesCleared = %d, want 0", res2.BodiesCleared)
|
|
}
|
|
}
|
|
|
|
func TestRetainCapsActionsLog(t *testing.T) {
|
|
s, ctx := testStore(t)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if err := s.LogAction(ctx, "fetch", "scheduler", "pass"); err != nil {
|
|
t.Fatalf("LogAction() %d error = %v", i, err)
|
|
}
|
|
}
|
|
|
|
res, err := s.Retain(ctx, RetentionPolicy{ActionsLogMaxRows: 2})
|
|
if err != nil {
|
|
t.Fatalf("Retain() error = %v", err)
|
|
}
|
|
if res.ActionsDeleted != 3 {
|
|
t.Errorf("ActionsDeleted = %d, want 3", res.ActionsDeleted)
|
|
}
|
|
|
|
entries, err := s.RecentActions(ctx, 100)
|
|
if err != nil {
|
|
t.Fatalf("RecentActions() error = %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("actions_log rows = %d, want 2", len(entries))
|
|
}
|
|
// RecentActions is newest-first; the two kept rows are the newest inserts.
|
|
if entries[0].ID <= entries[1].ID {
|
|
t.Errorf("kept rows not the newest: %d, %d", entries[0].ID, entries[1].ID)
|
|
}
|
|
|
|
// A smaller table than the cap is untouched.
|
|
res2, err := s.Retain(ctx, RetentionPolicy{ActionsLogMaxRows: 50})
|
|
if err != nil {
|
|
t.Fatalf("second Retain() error = %v", err)
|
|
}
|
|
if res2.ActionsDeleted != 0 {
|
|
t.Errorf("second ActionsDeleted = %d, want 0", res2.ActionsDeleted)
|
|
}
|
|
}
|
|
|
|
func TestRetainWithAuditCountsItsOwnRow(t *testing.T) {
|
|
s, ctx := testStore(t)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if err := s.LogAction(ctx, "fetch", "scheduler", "pass"); err != nil {
|
|
t.Fatalf("LogAction() %d error = %v", i, err)
|
|
}
|
|
}
|
|
|
|
res, err := s.RetainWithAudit(ctx, RetentionPolicy{ActionsLogMaxRows: 3}, "scheduler", "bodies_cleared=0")
|
|
if err != nil {
|
|
t.Fatalf("RetainWithAudit() error = %v", err)
|
|
}
|
|
// 5 existing + 1 audit = 6; keep 3, so 3 are deleted.
|
|
if res.ActionsDeleted != 3 {
|
|
t.Errorf("ActionsDeleted = %d, want 3", res.ActionsDeleted)
|
|
}
|
|
entries, err := s.RecentActions(ctx, 100)
|
|
if err != nil {
|
|
t.Fatalf("RecentActions() error = %v", err)
|
|
}
|
|
if len(entries) != 3 {
|
|
t.Fatalf("actions_log rows = %d, want 3 (cap counts the audit row)", len(entries))
|
|
}
|
|
if entries[0].Action != "retain" {
|
|
t.Errorf("newest row action = %q, want retain", entries[0].Action)
|
|
}
|
|
}
|
|
|
|
func TestRetainDisabledPolicyIsNoOp(t *testing.T) {
|
|
s, ctx := testStore(t)
|
|
|
|
old := seedBodyMessage(t, s, ctx, "old-1", "an old body", time.Now().UTC().Add(-400*24*time.Hour))
|
|
if err := s.LogAction(ctx, "fetch", "scheduler", "pass"); err != nil {
|
|
t.Fatalf("LogAction() error = %v", err)
|
|
}
|
|
|
|
res, err := s.Retain(ctx, RetentionPolicy{})
|
|
if err != nil {
|
|
t.Fatalf("Retain() error = %v", err)
|
|
}
|
|
if res.BodiesCleared != 0 || res.ActionsDeleted != 0 {
|
|
t.Errorf("disabled policy changed data: %+v", res)
|
|
}
|
|
if body, null := bodyOf(t, s, ctx, old); null || body != "an old body" {
|
|
t.Errorf("old body = %q (null=%v), want unchanged", body, null)
|
|
}
|
|
}
|