package hub import ( "context" "errors" "testing" "time" ) // recordingReporter records each pushed report and serves a scripted per-call // (envelope, error) sequence — the S2 heartbeat log-pull harness. type recordingReporter struct { reports []*HostReport script []struct { env *ControlEnvelope err error } } func (r *recordingReporter) Report(_ context.Context, rep *HostReport) (*ControlEnvelope, error) { // Copy the LogTail pointer state at push time (the loop reuses collector reports). cp := *rep r.reports = append(r.reports, &cp) i := len(r.reports) - 1 if i < len(r.script) { return r.script[i].env, r.script[i].err } return &ControlEnvelope{}, nil } func tailLoop(rep *recordingReporter) *Loop { var cn int32 l := NewLoop(&fakeCollector{report: &HostReport{}, n: &cn}, rep, time.Hour, quietLogger()) l.SetLogTailSource(func(maxBytes int) []string { return []string{"line-a", "line-b"} }) return l } // S2 (agent half): an envelope's log_tail_requested arms the pull; the NEXT report // carries log_tail; the one after (request cleared hub-side) carries nothing — // consume-once. Companion red-proof: drop the `l.logTailPending = false` drain → // report 3 also carries a tail → the last assertion fails. func TestLoop_LogTailRequestedShipsOnNextReportOnce(t *testing.T) { rep := &recordingReporter{script: []struct { env *ControlEnvelope err error }{ {env: &ControlEnvelope{LogTailRequested: true}}, {env: &ControlEnvelope{}}, // the tail arrived — hub cleared the request {env: &ControlEnvelope{}}, }} l := tailLoop(rep) ctx := context.Background() l.cycle(ctx, time.Hour) l.cycle(ctx, time.Hour) l.cycle(ctx, time.Hour) if len(rep.reports) != 3 { t.Fatalf("reports = %d, want 3", len(rep.reports)) } if rep.reports[0].LogTail != nil { t.Errorf("report 1 must not carry a tail (the request only arrived in its envelope)") } got := rep.reports[1].LogTail if got == nil || len(got.Lines) != 2 || got.Lines[0] != "line-a" || got.CollectedAt == "" { t.Fatalf("report 2 log_tail = %+v, want the 2 ring lines + collected_at", got) } if rep.reports[2].LogTail != nil { t.Errorf("report 3 carries a tail again — consume-once broken: %+v", rep.reports[2].LogTail) } } // S2 companion (fail-safe retry): the push CARRYING the tail fails → the local pending // is spent, but the hub's request is still pending, so the next envelope re-arms it and // the following report fulfills. Asserts the retry ships the tail exactly once more. func TestLoop_FailedTailPushIsReArmedByNextEnvelope(t *testing.T) { rep := &recordingReporter{script: []struct { env *ControlEnvelope err error }{ {env: &ControlEnvelope{LogTailRequested: true}}, // arm {err: errors.New("hub 5xx")}, // the carrying push FAILS {env: &ControlEnvelope{LogTailRequested: true}}, // hub still pending → re-arm {env: &ControlEnvelope{}}, // fulfilled }} l := tailLoop(rep) ctx := context.Background() for i := 0; i < 4; i++ { l.cycle(ctx, time.Hour) } if len(rep.reports) != 4 { t.Fatalf("reports = %d, want 4", len(rep.reports)) } if rep.reports[1].LogTail == nil { t.Errorf("report 2 (the failed push) should have carried the tail") } if rep.reports[2].LogTail != nil { t.Errorf("report 3 must not carry a tail (pending was spent; envelope re-arms only after it)") } if rep.reports[3].LogTail == nil { t.Errorf("report 4 must fulfill the re-armed request — retry lost") } } // No source wired → the request is ignored (clean no-op, no panic). func TestLoop_LogTailRequestIgnoredWithoutSource(t *testing.T) { rep := &recordingReporter{script: []struct { env *ControlEnvelope err error }{ {env: &ControlEnvelope{LogTailRequested: true}}, {env: &ControlEnvelope{}}, }} var cn int32 l := NewLoop(&fakeCollector{report: &HostReport{}, n: &cn}, rep, time.Hour, quietLogger()) ctx := context.Background() l.cycle(ctx, time.Hour) l.cycle(ctx, time.Hour) if rep.reports[1].LogTail != nil { t.Errorf("tail shipped with no source wired: %+v", rep.reports[1].LogTail) } }