Files
JMR-devandClaude Opus 4.8 288f7e7fea #15 Mark reports processed after publishing
Wire the publisher's onPublished hook to lifecycle.MarkPublished so each
confirmed-201 publish immediately transitions that report pending->published,
completing cross-run de-duplication.

- internal/publish: add the narrow Marker seam (write half of lifecycle.Manager)
  and WithMarkPublished(m) option. It sets onPublished to call m.MarkPublished on
  each confirmed create; on a mark failure it logs loudly (naming the report and
  the duplicate-next-run risk) and surfaces the error so the run is recorded
  failed. Mirrors the existing PendingGetter read-half seam, so publish stays
  host-testable and free of the Wasm-only storage backends.
- worker/scheduled_wasm.go: buildPublish now passes WithMarkPublished(manager);
  the one Manager instance is both pending getter and marker. worker/main.go
  untouched.

Partial-failure guarantee falls out of #14's seam: the hook runs only on a 201
and per-report failures are isolated, so successes leave the pending set and a
failed report stays pending and is retried next run without duplicating the
already-published ones.

Tests (host, real lifecycle.Manager over MemoryStore + mock issue creator):
all-succeed run marks all published and a second run creates no new issues;
partial failure retries only the failed report next run without duplicating the
rest; MarkPublished is idempotent on an already-published report; and the
mark-failure edge case is surfaced, logged, and (honestly) re-publishes once.

Closes #15

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:44:54 -05:00

278 lines
11 KiB
Go

package publish
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
// The lifecycle Manager must satisfy the write-half seam #15 wires onPublished to.
var _ Marker = (*lifecycle.Manager)(nil)
// putPending seeds one pending report (a sealed frame) at reports/pending/<id>,
// exactly as the ingest Sink would, so ListPending discovers it. The frame
// decrypts to sampleReport(id), whose free text embeds the id so a mock creator
// can be scripted to fail a specific report by inspecting the issue body.
func putPending(t *testing.T, store *storage.MemoryStore, kr *crypto.Keyring, id string) {
t.Helper()
frame := seal(t, kr, reportJSON(t, sampleReport(id)))
if err := store.Put(context.Background(), storage.ReportKey(storage.StatusPending, id), frame); err != nil {
t.Fatalf("seed pending %s: %v", id, err)
}
}
func publishedCount(t *testing.T, store *storage.MemoryStore) int {
t.Helper()
keys, err := store.List(context.Background(), storage.StatusPrefix(storage.StatusPublished))
if err != nil {
t.Fatalf("list published: %v", err)
}
return len(keys)
}
// TestMarkPublishedTransitionsAndDedupsAcrossRuns is the ticket's core acceptance:
// with WithMarkPublished wired to a real lifecycle.Manager, a full run where every
// report publishes leaves all of them `published` (ListPending empty), and a
// second run over the (now empty) pending set creates NO new issues.
func TestMarkPublishedTransitionsAndDedupsAcrossRuns(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
ids := []string{"20260101T000000-aaaa", "20260102T000000-bbbb", "20260103T000000-cccc"}
for _, id := range ids {
putPending(t, store, kr, id)
}
manager := lifecycle.New(store)
mc := &mockCreator{}
// The same Manager is both the pending getter and the mark-published marker,
// mirroring the Worker's buildPublish wiring.
pub := newPublisher(mc, kr, manager, WithMarkPublished(manager))
// --- Run 1: list pending, publish; every report succeeds. ---
run1, err := manager.ListPending(ctx)
if err != nil {
t.Fatalf("run1 ListPending: %v", err)
}
if !slices.Equal(run1, ids) {
t.Fatalf("run1 pending = %v, want %v", run1, ids)
}
if err := pub.Publish(ctx, run1); err != nil {
t.Fatalf("run1 Publish: %v", err)
}
if len(mc.created) != len(ids) {
t.Fatalf("run1 created %d issues, want %d", len(mc.created), len(ids))
}
// Acceptance: after the run every report shows `published` and none is pending.
if pending, _ := manager.ListPending(ctx); len(pending) != 0 {
t.Errorf("after run1 ListPending = %v, want empty (all marked published)", pending)
}
if got := publishedCount(t, store); got != len(ids) {
t.Errorf("published objects = %d, want %d", got, len(ids))
}
// --- Run 2: list pending (now empty) and publish again -> no duplication. ---
run2, _ := manager.ListPending(ctx)
if len(run2) != 0 {
t.Fatalf("run2 pending = %v, want empty", run2)
}
if err := pub.Publish(ctx, run2); err != nil {
t.Fatalf("run2 Publish: %v", err)
}
if len(mc.created) != len(ids) {
t.Errorf("run2 created new issues (total now %d), want no duplication (still %d)", len(mc.created), len(ids))
}
}
// TestMarkPublishedPartialFailureRetriesOnlyFailedNextRun is the ticket's
// partial-failure acceptance: report N's create fails while the others succeed, so
// 1..N-1 are `published` and N stays `pending`; the next run retries only N
// (creating exactly one issue for it) and does not duplicate 1..N-1.
func TestMarkPublishedPartialFailureRetriesOnlyFailedNextRun(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
// Oldest-first order after ListPending sorts ascending: aaaa, bbbb, cccc.
ids := []string{"20260101T000000-aaaa", "20260102T000000-bbbb", "20260103T000000-cccc"}
for _, id := range ids {
putPending(t, store, kr, id)
}
failID := ids[len(ids)-1] // report N (the last/oldest-first-processed report)
manager := lifecycle.New(store)
// Create fails the FIRST time it sees report N (identified by its id in the body,
// which sampleReport embeds), and succeeds otherwise. Numbers are cosmetic.
num := 0
failSeen := 0
mc := &mockCreator{createFn: func(_, body string, _ []string) (CreatedIssue, error) {
if strings.Contains(body, failID) && failSeen == 0 {
failSeen++
return CreatedIssue{}, errors.New("github: create issue: gave up after 5 attempt(s)")
}
num++
return CreatedIssue{Number: num, HTMLURL: fmt.Sprintf("https://github.com/o/r/issues/%d", num)}, nil
}}
pub := newPublisher(mc, kr, manager, WithMarkPublished(manager))
// --- Run 1: N fails, the rest publish. ---
run1, _ := manager.ListPending(ctx)
err := pub.Publish(ctx, run1)
if err == nil {
t.Fatal("run1 Publish: expected a surfaced error for the failed report")
}
if !strings.Contains(err.Error(), failID) {
t.Errorf("run1 error should name the failed report %s: %v", failID, err)
}
// N-1 issues created; N was not.
if len(mc.created) != len(ids)-1 {
t.Errorf("run1 created %d issues, want %d (report N isolated)", len(mc.created), len(ids)-1)
}
// N-1 are published; N remains the only pending report.
if got := publishedCount(t, store); got != len(ids)-1 {
t.Errorf("after run1 published objects = %d, want %d", got, len(ids)-1)
}
if pending, _ := manager.ListPending(ctx); !slices.Equal(pending, []string{failID}) {
t.Errorf("after run1 ListPending = %v, want exactly [%s] (only the failed report stays pending)", pending, failID)
}
// --- Run 2: retries only N; creates exactly one issue and does not touch 1..N-1. ---
createdBeforeRun2 := len(mc.created)
run2, _ := manager.ListPending(ctx)
if !slices.Equal(run2, []string{failID}) {
t.Fatalf("run2 pending = %v, want only the failed report [%s]", run2, failID)
}
if err := pub.Publish(ctx, run2); err != nil {
t.Fatalf("run2 Publish (retry of %s): %v", failID, err)
}
if newIssues := len(mc.created) - createdBeforeRun2; newIssues != 1 {
t.Errorf("run2 created %d new issues, want exactly 1 (only report N retried)", newIssues)
}
// The one new issue was for N.
last := mc.created[len(mc.created)-1]
if !strings.Contains(last.body, failID) {
t.Errorf("run2's new issue was not for the failed report %s: %q", failID, last.title)
}
// Everything published, nothing pending, and no duplicates: exactly N published
// objects and exactly N issues created across the two runs.
if pending, _ := manager.ListPending(ctx); len(pending) != 0 {
t.Errorf("after run2 ListPending = %v, want empty", pending)
}
if got := publishedCount(t, store); got != len(ids) {
t.Errorf("after run2 published objects = %d, want %d", got, len(ids))
}
if len(mc.created) != len(ids) {
t.Errorf("total issues created across both runs = %d, want %d (no duplication of 1..N-1)", len(mc.created), len(ids))
}
}
// TestMarkPublishedIdempotentAfterPublish covers the idempotency acceptance:
// MarkPublished on an already-published report (here, one the publish hook already
// marked) is a no-op success and does not create a second published object.
func TestMarkPublishedIdempotentAfterPublish(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
const id = "20260101T000000-aaaa"
putPending(t, store, kr, id)
manager := lifecycle.New(store)
mc := &mockCreator{}
pub := newPublisher(mc, kr, manager, WithMarkPublished(manager))
ids, _ := manager.ListPending(ctx)
if err := pub.Publish(ctx, ids); err != nil {
t.Fatalf("Publish: %v", err)
}
// The publish hook already transitioned it to published. A redundant, explicit
// MarkPublished must succeed (idempotent) and leave exactly one published object.
if err := manager.MarkPublished(ctx, id); err != nil {
t.Errorf("redundant MarkPublished = %v, want nil (idempotent no-op)", err)
}
if pending, _ := manager.ListPending(ctx); len(pending) != 0 {
t.Errorf("ListPending = %v, want empty", pending)
}
if got := publishedCount(t, store); got != 1 {
t.Errorf("published objects = %d, want exactly 1 (no duplicate from the redundant mark)", got)
}
}
// flakyMarker wraps a real Manager and fails MarkPublished for selected ids, to
// exercise the honest mark-failure edge case (issue created, mark failed).
type flakyMarker struct {
m *lifecycle.Manager
fail map[string]bool
}
func (f *flakyMarker) MarkPublished(ctx context.Context, id string) error {
if f.fail[id] {
return errors.New("r2: put published object failed")
}
return f.m.MarkPublished(ctx, id)
}
// TestMarkPublishedFailureSurfacedLoggedAndRepublishes documents the honest edge
// case: if the issue is created (201) but MarkPublished then fails, the failure is
// (a) surfaced so the run is recorded failed, (b) logged loudly with the duplicate
// risk, and (c) leaves the report pending — so once the mark backend recovers, the
// next run re-publishes it as a DUPLICATE. This is the residual risk WithMarkPublished
// notes; MarkPublished's idempotency bounds it to at most one duplicate.
func TestMarkPublishedFailureSurfacedLoggedAndRepublishes(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := storage.NewMemoryStore()
const id = "20260101T000000-aaaa"
putPending(t, store, kr, id)
manager := lifecycle.New(store)
marker := &flakyMarker{m: manager, fail: map[string]bool{id: true}}
var logs []string
mc := &mockCreator{}
pub := newPublisher(mc, kr, manager,
WithMarkPublished(marker),
WithLogger(func(format string, args ...any) { logs = append(logs, fmt.Sprintf(format, args...)) }),
)
// --- Run 1: create succeeds, the mark fails. ---
run1, _ := manager.ListPending(ctx)
if err := pub.Publish(ctx, run1); err == nil {
t.Fatal("expected the mark-published failure to be surfaced")
}
if len(mc.created) != 1 {
t.Errorf("created %d issues, want 1 (create succeeded before the mark failed)", len(mc.created))
}
// The report stays pending (the mark failed) -> it will be retried next run.
if pending, _ := manager.ListPending(ctx); !slices.Contains(pending, id) {
t.Errorf("report should remain pending after a mark failure; pending=%v", pending)
}
// A loud, specific log names the report and the duplicate risk.
joined := strings.Join(logs, "\n")
if !strings.Contains(joined, id) || !strings.Contains(strings.ToUpper(joined), "DUPLICATE") {
t.Errorf("expected a loud mark-failure log naming the report and the DUPLICATE risk; got:\n%s", joined)
}
// --- Run 2: the mark backend recovers; the still-pending report re-publishes,
// producing the DUPLICATE issue the note warns about (idempotency bounds it to
// one). This asserts the honest behaviour rather than an idealized one. ---
marker.fail[id] = false
run2, _ := manager.ListPending(ctx)
if err := pub.Publish(ctx, run2); err != nil {
t.Fatalf("run2 Publish: %v", err)
}
if len(mc.created) != 2 {
t.Errorf("created %d issues total, want 2 (the documented duplicate on retry)", len(mc.created))
}
if pending, _ := manager.ListPending(ctx); len(pending) != 0 {
t.Errorf("after recovery ListPending = %v, want empty (mark converged)", pending)
}
if got := publishedCount(t, store); got != 1 {
t.Errorf("published objects = %d, want 1", got)
}
}