From fe70b331532cfee945794a6159aea205829b966e Mon Sep 17 00:00:00 2001 From: des Date: Mon, 4 May 2026 12:15:25 +0200 Subject: [PATCH] Add update_expiration tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP tool that updates or resets an entry's expiration without touching the underlying S3 object — distinguishing reset (preserve data) from delete (remove data). - Accepts a UUID or word reference + optional ttl_hours. - For active entries: only adjusts expires_at. - For expired entries: reactivates with collision resolution against the base word, mirroring restore_entry semantics. - ttl_hours follows store_entry conventions (nil = default, 0 = permanent). - File entries are preserved: handler does not call DeleteFileObject, so resetting expiration on a file entry never clears its S3 object. Tests cover extending active TTL, clearing expiry with ttl=0, reactivating expired, collision on reactivation, by-word lookup, not-found, invalid TTL, empty reference, MCP-level wiring, and the S3-preservation contract. --- internal/config/config.go | 29 ++-- internal/mcp/handlers.go | 42 ++++++ internal/mcp/handlers_test.go | 89 +++++++++++- internal/mcp/server.go | 13 ++ internal/service/entry_service.go | 96 ++++++++++++ internal/service/entry_service_test.go | 194 +++++++++++++++++++++++++ internal/service/file_service_test.go | 45 ++++++ 7 files changed, 497 insertions(+), 11 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 9d2a924..dcda476 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,16 +48,17 @@ type ProxyConfig struct { } type ToolsConfig struct { - StoreEntry ToolDesc `yaml:"store_entry"` - GetEntry ToolDesc `yaml:"get_entry"` - GetEntryByWord ToolDesc `yaml:"get_entry_by_word"` - SearchEntries ToolDesc `yaml:"search_entries"` - RestoreEntry ToolDesc `yaml:"restore_entry"` - ListEntries ToolDesc `yaml:"list_entries"` - DeleteEntry ToolDesc `yaml:"delete_entry"` - SearchWords ToolDesc `yaml:"search_words"` - UploadFile ToolDesc `yaml:"upload_file"` - DownloadFile ToolDesc `yaml:"download_file"` + StoreEntry ToolDesc `yaml:"store_entry"` + GetEntry ToolDesc `yaml:"get_entry"` + GetEntryByWord ToolDesc `yaml:"get_entry_by_word"` + SearchEntries ToolDesc `yaml:"search_entries"` + RestoreEntry ToolDesc `yaml:"restore_entry"` + UpdateExpiration ToolDesc `yaml:"update_expiration"` + ListEntries ToolDesc `yaml:"list_entries"` + DeleteEntry ToolDesc `yaml:"delete_entry"` + SearchWords ToolDesc `yaml:"search_words"` + UploadFile ToolDesc `yaml:"upload_file"` + DownloadFile ToolDesc `yaml:"download_file"` } type ToolDesc struct { @@ -103,6 +104,13 @@ var defaultToolsConfig = ToolsConfig{ "new_ttl_hours": "Optional. Set a new TTL in hours from now. 0 = never expires. Omit to use server default.", }, }, + UpdateExpiration: ToolDesc{ + Description: "Update or reset the expiration of an entry referenced by UUID or keyword. Works on both active and expired entries: if expired, the entry is reactivated (collision-resolved against its original word, like restore_entry). Use this to extend a TTL, make an entry permanent, or revive an expired one. The underlying file (for file entries) is preserved — resetting expiration never deletes data. IMPORTANT: tell the user the RETURNED word, which may differ from the original if a collision was resolved.", + Properties: map[string]string{ + "id": "The UUID or keyword of the entry to update.", + "ttl_hours": "Optional. New TTL in hours from now. 0 = never expires. Omit to use the server default.", + }, + }, ListEntries: ToolDesc{ Description: "List stored entries with optional filtering by status and pagination. Returns compact summaries (no payload) — use get_entry or get_entry_by_word to retrieve full content.", Properties: map[string]string{ @@ -511,6 +519,7 @@ func applyToolDefaults(cfg *Config) { mergeToolDesc(&cfg.Tools.GetEntryByWord, d.GetEntryByWord) mergeToolDesc(&cfg.Tools.SearchEntries, d.SearchEntries) mergeToolDesc(&cfg.Tools.RestoreEntry, d.RestoreEntry) + mergeToolDesc(&cfg.Tools.UpdateExpiration, d.UpdateExpiration) mergeToolDesc(&cfg.Tools.ListEntries, d.ListEntries) mergeToolDesc(&cfg.Tools.DeleteEntry, d.DeleteEntry) mergeToolDesc(&cfg.Tools.SearchWords, d.SearchWords) diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 68f69f7..9311f53 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -192,6 +192,48 @@ func (h *Handlers) RestoreEntry(ctx context.Context, req mcp.CallToolRequest) (* return marshalResult(resp) } +func (h *Handlers) UpdateExpiration(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := req.GetString("id", "") + + var ttlHours *int + if args := req.GetArguments(); args != nil { + if _, ok := args["ttl_hours"]; ok { + t := req.GetInt("ttl_hours", 0) + ttlHours = &t + } + } + + h.logger.Debug("update_expiration called", "id", id, "ttl_hours", ttlHours) + + result, err := h.svc.UpdateExpiration(ctx, id, ttlHours) + if err != nil { + h.logger.Warn("update_expiration failed", "id", id, "error", err) + return mcp.NewToolResultError(err.Error()), nil + } + + h.logger.Info("update_expiration success", + "id", result.Entry.ID, "word", result.Entry.Word, + "reactivated", result.Reactivated, "collision", result.CollisionResolved) + + resp := map[string]interface{}{ + "id": result.Entry.ID.String(), + "word": result.Entry.Word, + "status": result.Entry.Status, + "reactivated": result.Reactivated, + "collision_resolved": result.CollisionResolved, + } + if result.Entry.ExpiresAt != nil { + resp["expires_at"] = result.Entry.ExpiresAt + } + if result.Entry.CreatedBy != nil { + resp["created_by"] = *result.Entry.CreatedBy + } + if result.CollisionResolved { + resp["original_word"] = result.OriginalWord + } + return marshalResult(resp) +} + func (h *Handlers) ListEntries(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { status := req.GetString("status", "") limit := req.GetInt("limit", 20) diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go index 6e96450..524176a 100644 --- a/internal/mcp/handlers_test.go +++ b/internal/mcp/handlers_test.go @@ -6,14 +6,22 @@ import ( "log/slog" "os" "testing" + "time" "github.com/mark3labs/mcp-go/mcp" + "github.com/watchword/watchword/internal/domain" "github.com/watchword/watchword/internal/repository" "github.com/watchword/watchword/internal/service" ) func newTestHandlers(t *testing.T) *Handlers { + t.Helper() + h, _ := newTestHandlersWithRepo(t) + return h +} + +func newTestHandlersWithRepo(t *testing.T) (*Handlers, repository.Repository) { t.Helper() repo, err := repository.NewSQLiteRepo(":memory:") if err != nil { @@ -25,7 +33,7 @@ func newTestHandlers(t *testing.T) *Handlers { } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) svc := service.NewEntryService(repo, 168, logger) - return &Handlers{svc: svc, logger: logger} + return &Handlers{svc: svc, logger: logger}, repo } func callTool(t *testing.T, h *Handlers, name string, args map[string]interface{}) map[string]interface{} { @@ -53,6 +61,8 @@ func callTool(t *testing.T, h *Handlers, name string, args map[string]interface{ result, err = h.RestoreEntry(ctx, req) case "delete_entry": result, err = h.DeleteEntry(ctx, req) + case "update_expiration": + result, err = h.UpdateExpiration(ctx, req) default: t.Fatalf("unknown tool: %s", name) } @@ -293,6 +303,83 @@ func TestMCP_StoreEntry_ZeroTTL(t *testing.T) { } } +func TestMCP_UpdateExpiration_Active(t *testing.T) { + h := newTestHandlers(t) + + stored := callTool(t, h, "store_entry", map[string]interface{}{ + "word": "extend", "payload": "p", + }) + + out := callTool(t, h, "update_expiration", map[string]interface{}{ + "id": stored["id"], + "ttl_hours": float64(48), + }) + if out["status"] != "active" { + t.Errorf("expected status=active, got %v", out["status"]) + } + if out["reactivated"] != false { + t.Errorf("expected reactivated=false, got %v", out["reactivated"]) + } + if out["expires_at"] == nil { + t.Error("expected expires_at to be set") + } +} + +func TestMCP_UpdateExpiration_ZeroTTLClearsExpiry(t *testing.T) { + h := newTestHandlers(t) + + stored := callTool(t, h, "store_entry", map[string]interface{}{ + "word": "perm", "payload": "p", + }) + + out := callTool(t, h, "update_expiration", map[string]interface{}{ + "id": stored["id"], + "ttl_hours": float64(0), + }) + if _, hasExp := out["expires_at"]; hasExp { + t.Errorf("expected no expires_at for ttl=0, got %v", out["expires_at"]) + } + if out["status"] != "active" { + t.Errorf("expected status=active, got %v", out["status"]) + } +} + +func TestMCP_UpdateExpiration_ReactivatesExpired(t *testing.T) { + h, repo := newTestHandlersWithRepo(t) + ctx := context.Background() + + past := time.Now().UTC().Add(-1 * time.Hour) + if _, err := repo.Store(ctx, &domain.Entry{Word: "fossil", Payload: "old", ExpiresAt: &past}); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := repo.MarkExpiredBatch(ctx, 100); err != nil { + t.Fatalf("MarkExpiredBatch: %v", err) + } + + out := callTool(t, h, "update_expiration", map[string]interface{}{ + "id": "fossil", + }) + if out["status"] != "active" { + t.Errorf("expected status=active, got %v", out["status"]) + } + if out["reactivated"] != true { + t.Errorf("expected reactivated=true, got %v", out["reactivated"]) + } + if out["word"] != "fossil" { + t.Errorf("expected word=fossil, got %v", out["word"]) + } +} + +func TestMCP_UpdateExpiration_NotFound(t *testing.T) { + h := newTestHandlers(t) + out := callTool(t, h, "update_expiration", map[string]interface{}{ + "id": "no-such-word", + }) + if _, ok := out["_error"]; !ok { + t.Error("expected error for unknown reference") + } +} + func TestMCP_MultipleCollisions(t *testing.T) { h := newTestHandlers(t) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e92ca2e..bea459e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -27,6 +27,9 @@ var propTypes = map[string]map[string]string{ "restore_entry": { "id": "string", "new_ttl_hours": "integer", }, + "update_expiration": { + "id": "string", "ttl_hours": "integer", + }, "list_entries": { "status": "string", "limit": "integer", "offset": "integer", "sort_by": "string", "sort_order": "string", @@ -114,6 +117,16 @@ func NewServer(svc *service.EntryService, fileSvc *service.FileService, tools co }, }, h.RestoreEntry) + s.AddTool(mcp.Tool{ + Name: "update_expiration", + Description: tools.UpdateExpiration.Description, + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: buildProps("update_expiration", tools.UpdateExpiration.Properties), + Required: []string{"id"}, + }, + }, h.UpdateExpiration) + s.AddTool(mcp.Tool{ Name: "list_entries", Description: tools.ListEntries.Description, diff --git a/internal/service/entry_service.go b/internal/service/entry_service.go index 7a478cf..6c96647 100644 --- a/internal/service/entry_service.go +++ b/internal/service/entry_service.go @@ -46,6 +46,13 @@ type RestoreResult struct { OriginalWord string `json:"original_word,omitempty"` } +type UpdateExpirationResult struct { + Entry *domain.Entry `json:"entry"` + Reactivated bool `json:"reactivated"` + CollisionResolved bool `json:"collision_resolved"` + OriginalWord string `json:"original_word,omitempty"` +} + type EntryService struct { repo repository.Repository defaultTTL int // hours @@ -225,6 +232,95 @@ func (s *EntryService) RestoreEntry(ctx context.Context, idStr string, newTTLHou return result, nil } +// UpdateExpiration adjusts the expires_at of an entry referenced by UUID or +// word. When the entry is currently expired, it is reactivated (with collision +// resolution against the base word, mirroring restore_entry); when it is +// active, only expires_at is updated. ttlHours follows the same semantics as +// store_entry: nil → server default, 0 → never expires, positive → relative +// to now. The underlying S3 object (for file entries) is intentionally never +// touched — resetting an expiration must not destroy data. +func (s *EntryService) UpdateExpiration(ctx context.Context, idOrWord string, ttlHours *int) (*UpdateExpirationResult, error) { + if strings.TrimSpace(idOrWord) == "" { + return nil, domain.ErrInvalidWord + } + + ttl := s.defaultTTL + if ttlHours != nil { + if *ttlHours < 0 || *ttlHours > domain.MaxTTLHours { + return nil, domain.ErrInvalidTTL + } + ttl = *ttlHours + } + + var expiresAt *time.Time + if ttl > 0 { + t := time.Now().UTC().Add(time.Duration(ttl) * time.Hour) + expiresAt = &t + } + + var result *UpdateExpirationResult + err := s.repo.WithTx(ctx, func(txRepo repository.Repository) error { + entry, err := lookupEntry(ctx, txRepo, idOrWord) + if err != nil { + return err + } + + newStatus := entry.Status + newWord := entry.Word + var collision bool + var originalWord string + reactivated := false + + if entry.Status == domain.StatusExpired { + resolved, c, rErr := resolveWord(ctx, txRepo, entry.Word) + if rErr != nil { + return rErr + } + newWord = resolved + collision = c + if c { + originalWord = entry.Word + } + newStatus = domain.StatusActive + reactivated = true + } + + if err := txRepo.UpdateStatus(ctx, entry.ID, string(newStatus), newWord, expiresAt); err != nil { + return err + } + + entry.Status = newStatus + entry.Word = newWord + entry.ExpiresAt = expiresAt + entry.UpdatedAt = time.Now().UTC() + + result = &UpdateExpirationResult{ + Entry: entry, + Reactivated: reactivated, + CollisionResolved: collision, + OriginalWord: originalWord, + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// lookupEntry resolves a UUID-or-word reference within the active transaction. +// Word lookups include expired entries so update_expiration can target them. +func lookupEntry(ctx context.Context, repo repository.Repository, idOrWord string) (*domain.Entry, error) { + if id, err := uuid.Parse(idOrWord); err == nil { + return repo.GetByID(ctx, id) + } + word := strings.TrimSpace(idOrWord) + if word == "" { + return nil, domain.ErrInvalidWord + } + return repo.GetByWord(ctx, word, true) +} + // ResolveEntry resolves a UUID or word to an entry without modifying it. func (s *EntryService) ResolveEntry(ctx context.Context, idOrWord string) (*domain.Entry, error) { id, err := uuid.Parse(idOrWord) diff --git a/internal/service/entry_service_test.go b/internal/service/entry_service_test.go index c0da31c..249c0e0 100644 --- a/internal/service/entry_service_test.go +++ b/internal/service/entry_service_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "testing" + "time" "github.com/watchword/watchword/internal/auth" "github.com/watchword/watchword/internal/domain" @@ -223,6 +224,199 @@ func TestRestoreEntry(t *testing.T) { } } +// newTestServiceWithRepo gives tests access to the underlying repo so they +// can stage state (e.g. forcibly-expired entries) that the service API alone +// can't produce. +func newTestServiceWithRepo(t *testing.T) (*EntryService, repository.Repository) { + t.Helper() + repo, err := repository.NewSQLiteRepo(":memory:") + if err != nil { + t.Fatalf("NewSQLiteRepo: %v", err) + } + t.Cleanup(func() { repo.Close() }) + if err := repo.Migrate(context.Background()); err != nil { + t.Fatalf("Migrate: %v", err) + } + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + return NewEntryService(repo, 168, logger), repo +} + +func TestUpdateExpiration_ExtendsActive(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + stored, err := svc.StoreEntry(ctx, "alpha", "payload", nil) + if err != nil { + t.Fatalf("StoreEntry: %v", err) + } + originalExpiry := *stored.Entry.ExpiresAt + + ttl := 24 + res, err := svc.UpdateExpiration(ctx, stored.Entry.ID.String(), &ttl) + if err != nil { + t.Fatalf("UpdateExpiration: %v", err) + } + if res.Reactivated { + t.Error("expected Reactivated=false for already-active entry") + } + if res.CollisionResolved { + t.Error("expected no collision for active entry") + } + if res.Entry.Status != domain.StatusActive { + t.Errorf("expected status=active, got %s", res.Entry.Status) + } + if res.Entry.ExpiresAt == nil { + t.Fatal("expected expires_at to be set") + } + if !res.Entry.ExpiresAt.Before(originalExpiry) { + t.Errorf("expected new expiry (24h) before original (default 168h); got %v vs %v", + res.Entry.ExpiresAt, originalExpiry) + } +} + +func TestUpdateExpiration_ZeroTTLClearsExpiry(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + stored, _ := svc.StoreEntry(ctx, "perm", "p", nil) + if stored.Entry.ExpiresAt == nil { + t.Fatal("precondition: stored entry should have default expiry") + } + + zero := 0 + res, err := svc.UpdateExpiration(ctx, stored.Entry.ID.String(), &zero) + if err != nil { + t.Fatalf("UpdateExpiration: %v", err) + } + if res.Entry.ExpiresAt != nil { + t.Errorf("expected nil expires_at for ttl=0, got %v", res.Entry.ExpiresAt) + } +} + +func TestUpdateExpiration_ReactivatesExpired(t *testing.T) { + svc, repo := newTestServiceWithRepo(t) + ctx := context.Background() + + // Stage an entry expired in the past then mark expired via the worker path. + past := time.Now().UTC().Add(-1 * time.Hour) + if _, err := repo.Store(ctx, &domain.Entry{Word: "ghost", Payload: "x", ExpiresAt: &past}); err != nil { + t.Fatalf("seed Store: %v", err) + } + if _, err := repo.MarkExpiredBatch(ctx, 100); err != nil { + t.Fatalf("MarkExpiredBatch: %v", err) + } + + res, err := svc.UpdateExpiration(ctx, "ghost", nil) + if err != nil { + t.Fatalf("UpdateExpiration: %v", err) + } + if !res.Reactivated { + t.Error("expected Reactivated=true") + } + if res.Entry.Status != domain.StatusActive { + t.Errorf("expected status=active, got %s", res.Entry.Status) + } + if res.Entry.Word != "ghost" { + t.Errorf("expected word=ghost (no collision), got %q", res.Entry.Word) + } + if res.Entry.ExpiresAt == nil { + t.Error("expected default TTL to be applied") + } +} + +func TestUpdateExpiration_ReactivateWithCollision(t *testing.T) { + svc, repo := newTestServiceWithRepo(t) + ctx := context.Background() + + // Expired entry on the base word. + past := time.Now().UTC().Add(-1 * time.Hour) + expired, err := repo.Store(ctx, &domain.Entry{Word: "echo", Payload: "old", ExpiresAt: &past}) + if err != nil { + t.Fatalf("seed Store: %v", err) + } + if _, err := repo.MarkExpiredBatch(ctx, 100); err != nil { + t.Fatalf("MarkExpiredBatch: %v", err) + } + // Active entry now squats on the same word — by-word lookup would return + // this one, so target the expired entry explicitly via its UUID. + if _, err := svc.StoreEntry(ctx, "echo", "new", nil); err != nil { + t.Fatalf("StoreEntry squat: %v", err) + } + + res, err := svc.UpdateExpiration(ctx, expired.ID.String(), nil) + if err != nil { + t.Fatalf("UpdateExpiration: %v", err) + } + if !res.Reactivated || !res.CollisionResolved { + t.Errorf("expected Reactivated && CollisionResolved, got reactivated=%v collision=%v", + res.Reactivated, res.CollisionResolved) + } + if res.Entry.Word != "echo2" { + t.Errorf("expected resolved word echo2, got %q", res.Entry.Word) + } + if res.OriginalWord != "echo" { + t.Errorf("expected OriginalWord=echo, got %q", res.OriginalWord) + } +} + +func TestUpdateExpiration_ByWord(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + if _, err := svc.StoreEntry(ctx, "kappa", "payload", nil); err != nil { + t.Fatalf("StoreEntry: %v", err) + } + + zero := 0 + res, err := svc.UpdateExpiration(ctx, "kappa", &zero) + if err != nil { + t.Fatalf("UpdateExpiration by word: %v", err) + } + if res.Entry.Word != "kappa" { + t.Errorf("expected word=kappa, got %q", res.Entry.Word) + } + if res.Entry.ExpiresAt != nil { + t.Errorf("expected expires_at cleared, got %v", res.Entry.ExpiresAt) + } +} + +func TestUpdateExpiration_NotFound(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + if _, err := svc.UpdateExpiration(ctx, "nonexistent", nil); err != domain.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } + + if _, err := svc.UpdateExpiration(ctx, "00000000-0000-0000-0000-000000000000", nil); err != domain.ErrNotFound { + t.Errorf("expected ErrNotFound for unknown UUID, got %v", err) + } +} + +func TestUpdateExpiration_InvalidTTL(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + stored, _ := svc.StoreEntry(ctx, "thing", "payload", nil) + + tooBig := domain.MaxTTLHours + 1 + if _, err := svc.UpdateExpiration(ctx, stored.Entry.ID.String(), &tooBig); err != domain.ErrInvalidTTL { + t.Errorf("expected ErrInvalidTTL for ttl > max, got %v", err) + } + + negative := -1 + if _, err := svc.UpdateExpiration(ctx, stored.Entry.ID.String(), &negative); err != domain.ErrInvalidTTL { + t.Errorf("expected ErrInvalidTTL for negative ttl, got %v", err) + } +} + +func TestUpdateExpiration_EmptyRef(t *testing.T) { + svc := newTestService(t) + if _, err := svc.UpdateExpiration(context.Background(), " ", nil); err != domain.ErrInvalidWord { + t.Errorf("expected ErrInvalidWord for blank reference, got %v", err) + } +} + func TestSearchEntries(t *testing.T) { svc := newTestService(t) ctx := context.Background() diff --git a/internal/service/file_service_test.go b/internal/service/file_service_test.go index 35a2068..1a42909 100644 --- a/internal/service/file_service_test.go +++ b/internal/service/file_service_test.go @@ -8,6 +8,9 @@ import ( "os" "sync" "testing" + "time" + + "github.com/google/uuid" "github.com/watchword/watchword/internal/domain" "github.com/watchword/watchword/internal/repository" @@ -173,3 +176,45 @@ func TestUploadFile_KeyPrefix(t *testing.T) { }) } } + +// Resetting the expiration of a file entry — whether to extend its life or to +// reactivate it after expiry — must never delete the underlying S3 object. +// This is the contract relied on by the update_expiration tool. +func TestUpdateExpiration_FileEntry_PreservesS3(t *testing.T) { + fileSvc, repo, pres := newTestFileService(t) + ctx := context.Background() + + uploaded, err := fileSvc.UploadFile(ctx, "doc", "report.pdf", "application/pdf", nil) + if err != nil { + t.Fatalf("UploadFile: %v", err) + } + + // Force-expire the entry through the repo, mirroring what the worker does. + past := time.Now().UTC().Add(-1 * time.Hour) + if err := repo.UpdateStatus(ctx, mustParseUUID(t, uploaded.ID), string(domain.StatusActive), uploaded.Word, &past); err != nil { + t.Fatalf("seed expiry: %v", err) + } + if _, err := repo.MarkExpiredBatch(ctx, 100); err != nil { + t.Fatalf("MarkExpiredBatch: %v", err) + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + entrySvc := NewEntryService(repo, 168, logger) + + if _, err := entrySvc.UpdateExpiration(ctx, "doc", nil); err != nil { + t.Fatalf("UpdateExpiration: %v", err) + } + + if len(pres.deletes) != 0 { + t.Errorf("update_expiration must not delete S3 objects; observed deletes=%v", pres.deletes) + } +} + +func mustParseUUID(t *testing.T, s string) uuid.UUID { + t.Helper() + id, err := uuid.Parse(s) + if err != nil { + t.Fatalf("uuid.Parse(%q): %v", s, err) + } + return id +}