iop/packages/go/streamgate/terminal_test.go
toki c90bb755a9 feat: streamline plan/code-review/finalize router, add stream gate SDDs, sync dev-test inventory, update roadmap milestones
- Refactor plan, code-review, finalize-task-routing, refine-local-plans, router skills
- Add agent-workflow-loop-orchestration skill and plan agent configs
- Update roadmap: knowledge-tool-optimization milestones, stream-evidence-gate-core SDD
- Add stream-evidence-gate-core task, archive, and Go streamgate package
- Update dev-test inventory (edge/node smoke), agent-contract, edge-local-dev-guide
- Deprecate USER_REVIEW for output-validation-filters SDD
2026-07-24 15:11:00 +09:00

657 lines
20 KiB
Go

package streamgate
import (
"context"
"testing"
"time"
)
// TestFailureCauseChainCapsAtFour verifies that FailureCauseChain enforces the
// MaxFailureCauses (4) cap on construction and Append, that accessors return
// defensive copies, that distinct codes prove the latest-four retention order,
// and that invalid stage/code/consumer/filter/rule tokens are rejected.
func TestFailureCauseChainCapsAtFour(t *testing.T) {
// --- Empty chain ---
empty, err := NewFailureCauseChain(nil)
if err != nil {
t.Fatalf("NewFailureCauseChain(nil): %v", err)
}
if empty.Len() != 0 {
t.Errorf("empty chain Len() = %d, want 0", empty.Len())
}
if empty.All() != nil {
t.Errorf("empty chain All() = %v, want nil", empty.All())
}
// --- Chain with 4 causes: all retained, distinct codes ---
codes4 := []string{"code0", "code1", "code2", "code3"}
causes4 := make([]FailureCause, MaxFailureCauses)
for i, code := range codes4 {
c, err := NewFailureCause("stage", code, "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
causes4[i] = c
}
chain4, err := NewFailureCauseChain(causes4)
if err != nil {
t.Fatalf("NewFailureCauseChain(4): %v", err)
}
if chain4.Len() != 4 {
t.Errorf("4-cause chain Len() = %d, want 4", chain4.Len())
}
// Verify order and distinct codes.
for i := 0; i < 4; i++ {
c, err := chain4.At(i)
if err != nil {
t.Fatalf("At(%d): %v", i, err)
}
if c.Code() != codes4[i] {
t.Errorf("At(%d) Code() = %q, want %q", i, c.Code(), codes4[i])
}
}
// --- Chain with 5 causes: oldest dropped, latest 4 retained in order ---
codes5 := []string{"code0", "code1", "code2", "code3", "code4"}
causes5 := make([]FailureCause, MaxFailureCauses+1)
for i, code := range codes5 {
c, err := NewFailureCause("stage", code, "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
causes5[i] = c
}
chain5, err := NewFailureCauseChain(causes5)
if err != nil {
t.Fatalf("NewFailureCauseChain(5): %v", err)
}
if chain5.Len() != 4 {
t.Errorf("5-cause chain Len() = %d, want 4", chain5.Len())
}
// Latest 4 are code1..code4 in order.
for i := 0; i < 4; i++ {
c, err := chain5.At(i)
if err != nil {
t.Fatalf("At(%d): %v", i, err)
}
want := codes5[i+1]
if c.Code() != want {
t.Errorf("At(%d) Code() = %q, want %q (retention order wrong)", i, c.Code(), want)
}
}
// --- Constructor input mutation does not affect stored chain ---
t.Run("constructor_input_mutation_isolation", func(t *testing.T) {
input := make([]FailureCause, MaxFailureCauses+1)
for i, code := range codes5 {
c, err := NewFailureCause("stage", code, "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
input[i] = c
}
chain, err := NewFailureCauseChain(input)
if err != nil {
t.Fatalf("NewFailureCauseChain: %v", err)
}
// Replace the element with a different cause.
mutated, err := NewFailureCause("stage", "mutated", "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
input[0] = mutated
input[1] = mutated
input = append(input, mutated)
// Stored chain should still be code1..code4.
if chain.Len() != 4 {
t.Errorf("chain Len() = %d, want 4 (input mutation leaked)", chain.Len())
}
for i := 0; i < 4; i++ {
c, err := chain.At(i)
if err != nil {
t.Fatalf("At(%d): %v", i, err)
}
want := codes5[i+1]
if c.Code() != want {
t.Errorf("At(%d) Code() = %q, want %q (input mutation leaked)", i, c.Code(), want)
}
}
})
// --- Append to 4-cause chain: oldest dropped, latest 4 retained in order ---
appendCause, err := NewFailureCause("stage", "code4", "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
appended, err := chain4.Append(appendCause)
if err != nil {
t.Fatalf("Append: %v", err)
}
if appended.Len() != 4 {
t.Errorf("appended chain Len() = %d, want 4", appended.Len())
}
// Latest 4 are code1..code4 in order.
for i := 0; i < 4; i++ {
c, err := appended.At(i)
if err != nil {
t.Fatalf("At(%d): %v", i, err)
}
want := codes5[i+1]
if c.Code() != want {
t.Errorf("At(%d) Code() = %q, want %q (Append retention order wrong)", i, c.Code(), want)
}
}
// --- Append returned chain mutation does not affect original ---
t.Run("append_returned_chain_mutation_isolation", func(t *testing.T) {
appendCause2, err := NewFailureCause("stage", "code4", "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
appended2, err := chain4.Append(appendCause2)
if err != nil {
t.Fatalf("Append: %v", err)
}
// Replace retained elements in the returned chain directly.
mutated, err := NewFailureCause("stage", "mutated", "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
appended2.causes[0] = mutated
appended2.causes = append(appended2.causes, mutated)
// Original chain4 should be unchanged.
if chain4.Len() != 4 {
t.Errorf("chain4 Len() = %d, want 4 (Append mutation leaked)", chain4.Len())
}
for i := 0; i < 4; i++ {
c, err := chain4.At(i)
if err != nil {
t.Fatalf("At(%d): %v", i, err)
}
if c.Code() != codes4[i] {
t.Errorf("At(%d) Code() = %q, want %q (Append mutation leaked)", i, c.Code(), codes4[i])
}
}
})
// --- Append to 3-cause chain: 4 retained ---
chain3, err := NewFailureCauseChain(causes4[:3])
if err != nil {
t.Fatalf("NewFailureCauseChain(3): %v", err)
}
appended3, err := chain3.Append(appendCause)
if err != nil {
t.Fatalf("Append: %v", err)
}
if appended3.Len() != 4 {
t.Errorf("appended 3-cause chain Len() = %d, want 4", appended3.Len())
}
// --- At() returns defensive copy ---
orig, err := chain4.At(0)
if err != nil {
t.Fatalf("At(0): %v", err)
}
if orig.Stage() != "stage" {
t.Errorf("At(0) Stage() = %q, want %q", orig.Stage(), "stage")
}
// --- At() out of range returns error ---
if _, err := chain4.At(-1); err == nil {
t.Error("At(-1) should return error")
}
if _, err := chain4.At(4); err == nil {
t.Error("At(4) should return error")
}
// --- All() returns defensive copy ---
all := chain4.All()
if len(all) != 4 {
t.Errorf("All() length = %d, want 4", len(all))
}
all = append(all, orig)
if chain4.Len() != 4 {
t.Errorf("chain4 Len() after All() mutation = %d, want 4", chain4.Len())
}
// --- All() element replacement does not affect stored ---
all2 := chain4.All()
mutated, err := NewFailureCause("stage", "mutated", "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
all2[0] = mutated
c, err := chain4.At(0)
if err != nil {
t.Fatalf("At(0): %v", err)
}
if c.Code() != codes4[0] {
t.Errorf("chain4 At(0) Code() = %q, want %q (All mutation leaked)", c.Code(), codes4[0])
}
// --- Copy() returns defensive copy ---
cp := chain4.Copy()
if cp.Len() != 4 {
t.Errorf("Copy() Len() = %d, want 4", cp.Len())
}
cpCauses := cp.All()
cpCauses = append(cpCauses, orig)
if chain4.Len() != 4 {
t.Errorf("chain4 Len() after Copy() mutation = %d, want 4", chain4.Len())
}
// --- Copy() element replacement does not affect original ---
cp2 := chain4.Copy()
cp2.causes[0] = mutated
c, err = chain4.At(0)
if err != nil {
t.Fatalf("At(0): %v", err)
}
if c.Code() != codes4[0] {
t.Errorf("chain4 At(0) Code() = %q, want %q (Copy mutation leaked)", c.Code(), codes4[0])
}
// --- Invalid cause in chain is rejected by constructor ---
invalidCause := FailureCause{
stage: StableToken{value: "INVALID"},
code: mustToken("code"),
}
if _, err := NewFailureCauseChain([]FailureCause{invalidCause}); err == nil {
t.Error("NewFailureCauseChain with invalid cause should return error")
}
// --- Append rejects invalid cause ---
if _, err := chain4.Append(invalidCause); err == nil {
t.Error("Append with invalid cause should return error")
}
// --- Invalid token table ---
t.Run("invalid_stage_empty", func(t *testing.T) {
_, err := NewFailureCause("", "code", "", "", "")
if err == nil {
t.Fatal("empty stage should be rejected")
}
})
t.Run("invalid_stage_uppercase", func(t *testing.T) {
_, err := NewFailureCause("STAGE", "code", "", "", "")
if err == nil {
t.Fatal("uppercase stage should be rejected")
}
})
t.Run("invalid_code_empty", func(t *testing.T) {
_, err := NewFailureCause("stage", "", "", "", "")
if err == nil {
t.Fatal("empty code should be rejected")
}
})
t.Run("invalid_code_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "CODE", "", "", "")
if err == nil {
t.Fatal("uppercase code should be rejected")
}
})
t.Run("invalid_consumer_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "code", "CONSUMER", "", "")
if err == nil {
t.Fatal("uppercase consumer should be rejected")
}
})
t.Run("invalid_filter_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "code", "", "FILTER", "")
if err == nil {
t.Fatal("uppercase filter should be rejected")
}
})
t.Run("invalid_rule_id_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "code", "", "", "RULE")
if err == nil {
t.Fatal("uppercase rule id should be rejected")
}
})
}
// TestTerminalResultValidation verifies that TerminalResult enforces the
// success/error mutual exclusion, required channel/timestamp, required
// external descriptor for error results, cause cap, and that accessors return
// defensive copies.
func TestTerminalResultValidation(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
desc, err := NewExternalDescriptor("error.type", "code", "message", "")
if err != nil {
t.Fatalf("NewExternalDescriptor: %v", err)
}
// --- Success terminal: valid ---
success, err := NewSuccessTerminalResult("ch", ts)
if err != nil {
t.Fatalf("NewSuccessTerminalResult: %v", err)
}
if !success.Success() {
t.Error("success terminal should be success")
}
if success.Error() {
t.Error("success terminal should not be error")
}
if success.ExternalDesc() != nil {
t.Error("success terminal should not have external descriptor")
}
if success.FailureCauses().Len() != 0 {
t.Errorf("success terminal FailureCauses().Len() = %d, want 0", success.FailureCauses().Len())
}
if err := success.Validate(); err != nil {
t.Errorf("success terminal Validate() = %v, want nil", err)
}
// --- Error terminal with descriptor: valid ---
errorResult, err := NewErrorTerminalResult("ch", desc, FailureCauseChain{}, ts)
if err != nil {
t.Fatalf("NewErrorTerminalResult: %v", err)
}
if errorResult.Success() {
t.Error("error terminal should not be success")
}
if !errorResult.Error() {
t.Error("error terminal should be error")
}
if errorResult.ExternalDesc() == nil {
t.Error("error terminal should have external descriptor")
}
if err := errorResult.Validate(); err != nil {
t.Errorf("error terminal Validate() = %v, want nil", err)
}
// --- Error terminal with descriptor + 4 causes: valid ---
codes4 := []string{"code0", "code1", "code2", "code3"}
causes4 := make([]FailureCause, MaxFailureCauses)
for i, code := range codes4 {
c, err := NewFailureCause("stage", code, "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
causes4[i] = c
}
chain4, err := NewFailureCauseChain(causes4)
if err != nil {
t.Fatalf("NewFailureCauseChain: %v", err)
}
errorWithCauses, err := NewErrorTerminalResult("ch", desc, chain4, ts)
if err != nil {
t.Fatalf("NewErrorTerminalResult with causes: %v", err)
}
if err := errorWithCauses.Validate(); err != nil {
t.Errorf("error terminal with 4 causes Validate() = %v, want nil", err)
}
// --- Error terminal without descriptor: rejected ---
if _, err := NewErrorTerminalResult("ch", ExternalDescriptor{}, FailureCauseChain{}, ts); err == nil {
t.Fatal("error terminal without descriptor should return error")
}
// --- Error terminal with nil descriptor: rejected ---
badErr := TerminalResult{
channel: "ch",
err: true,
occurredAt: ts,
}
if err := badErr.Validate(); err == nil {
t.Fatal("error terminal with nil descriptor should fail Validate")
}
// --- Success terminal with descriptor: rejected ---
badSuccess := TerminalResult{
channel: "ch",
success: true,
externalDesc: &desc,
occurredAt: ts,
}
if err := badSuccess.Validate(); err == nil {
t.Fatal("success terminal with descriptor should fail Validate")
}
// --- Success terminal with causes: rejected ---
badSuccessCauses := TerminalResult{
channel: "ch",
success: true,
failureCauses: chain4,
occurredAt: ts,
}
if err := badSuccessCauses.Validate(); err == nil {
t.Fatal("success terminal with causes should fail Validate")
}
// --- Neither success nor error: rejected ---
neither := TerminalResult{
channel: "ch",
occurredAt: ts,
}
if err := neither.Validate(); err == nil {
t.Fatal("terminal with neither success nor error should fail Validate")
}
// --- Both success and error: rejected ---
both := TerminalResult{
channel: "ch",
success: true,
err: true,
externalDesc: &desc,
occurredAt: ts,
}
if err := both.Validate(); err == nil {
t.Fatal("terminal with both success and error should fail Validate")
}
// --- Empty channel: rejected ---
if _, err := NewSuccessTerminalResult("", ts); err == nil {
t.Fatal("success terminal with empty channel should return error")
}
if _, err := NewErrorTerminalResult("", desc, FailureCauseChain{}, ts); err == nil {
t.Fatal("error terminal with empty channel should return error")
}
// --- Zero timestamp: rejected ---
if _, err := NewSuccessTerminalResult("ch", time.Time{}); err == nil {
t.Fatal("success terminal with zero timestamp should return error")
}
if _, err := NewErrorTerminalResult("ch", desc, FailureCauseChain{}, time.Time{}); err == nil {
t.Fatal("error terminal with zero timestamp should return error")
}
// --- Error with 5 causes: rejected ---
causes5 := make([]FailureCause, MaxFailureCauses+1)
for i := range causes5 {
c, err := NewFailureCause("stage", "code", "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
causes5[i] = c
}
chain5Raw := FailureCauseChain{causes: causes5}
badTr := TerminalResult{
channel: "ch",
err: true,
externalDesc: &desc,
failureCauses: chain5Raw,
occurredAt: ts,
}
if err := badTr.Validate(); err == nil {
t.Fatal("error terminal with 5 causes should fail Validate")
}
// --- ExternalDesc accessor returns defensive copy ---
ed := errorResult.ExternalDesc()
if ed == nil {
t.Fatal("ExternalDesc() returned nil")
}
// Mutate the returned descriptor.
*ed = ExternalDescriptor{}
// The stored descriptor should be unchanged.
ed2 := errorResult.ExternalDesc()
if ed2 == nil {
t.Fatal("ExternalDesc() returned nil after mutation")
}
if ed2.Type() != "error.type" {
t.Errorf("ExternalDesc() Type() = %q, want %q (mutation leaked)", ed2.Type(), "error.type")
}
// --- FailureCauses accessor returns defensive copy ---
fc := errorWithCauses.FailureCauses()
if fc.Len() != 4 {
t.Errorf("FailureCauses().Len() = %d, want 4", fc.Len())
}
fcCauses := fc.All()
fcCauses = append(fcCauses, fcCauses[0])
if errorWithCauses.FailureCauses().Len() != 4 {
t.Error("FailureCauses() mutated (accessor mutation leaked)")
}
// --- FailureCauses accessor element mutation does not affect stored ---
fc2 := errorWithCauses.FailureCauses()
mutated, err := NewFailureCause("stage", "mutated", "", "", "")
if err != nil {
t.Fatalf("NewFailureCause: %v", err)
}
fc2.causes[0] = mutated
fc3 := errorWithCauses.FailureCauses()
if fc3.Len() != 4 {
t.Errorf("FailureCauses().Len() after mutation = %d, want 4", fc3.Len())
}
c, err := fc3.At(0)
if err != nil {
t.Fatalf("At(0): %v", err)
}
if c.Code() != codes4[0] {
t.Errorf("FailureCauses().At(0) Code() = %q, want %q (accessor mutation leaked)", c.Code(), codes4[0])
}
// --- FailureCause invalid token table ---
t.Run("invalid_stage_empty", func(t *testing.T) {
_, err := NewFailureCause("", "code", "", "", "")
if err == nil {
t.Fatal("empty stage should be rejected")
}
})
t.Run("invalid_stage_uppercase", func(t *testing.T) {
_, err := NewFailureCause("STAGE", "code", "", "", "")
if err == nil {
t.Fatal("uppercase stage should be rejected")
}
})
t.Run("invalid_code_empty", func(t *testing.T) {
_, err := NewFailureCause("stage", "", "", "", "")
if err == nil {
t.Fatal("empty code should be rejected")
}
})
t.Run("invalid_code_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "CODE", "", "", "")
if err == nil {
t.Fatal("uppercase code should be rejected")
}
})
t.Run("invalid_consumer_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "code", "CONSUMER", "", "")
if err == nil {
t.Fatal("uppercase consumer should be rejected")
}
})
t.Run("invalid_filter_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "code", "", "FILTER", "")
if err == nil {
t.Fatal("uppercase filter should be rejected")
}
})
t.Run("invalid_rule_id_uppercase", func(t *testing.T) {
_, err := NewFailureCause("stage", "code", "", "", "RULE")
if err == nil {
t.Fatal("uppercase rule id should be rejected")
}
})
}
// fakeReleaseSink is a test-only implementation of ReleaseSink that records
// all committed payloads. It is used solely for the compile-time interface
// assertion; single-terminal behavior is verified in the host fixture.
type fakeReleaseSink struct {
responseStarts []ResponseStart
releases []ReleaseEvent
terminals []TerminalResult
}
// Compile-time assertion that fakeReleaseSink implements ReleaseSink.
var _ ReleaseSink = (*fakeReleaseSink)(nil)
func (f *fakeReleaseSink) CommitResponseStart(ctx context.Context, rs ResponseStart) (CommitState, error) {
f.responseStarts = append(f.responseStarts, rs)
return CommitStateStreamOpen, nil
}
func (f *fakeReleaseSink) Release(ctx context.Context, ev ReleaseEvent) (CommitState, error) {
f.releases = append(f.releases, ev)
return CommitStateStreamOpen, nil
}
func (f *fakeReleaseSink) CommitTerminal(ctx context.Context, tr TerminalResult) (CommitState, error) {
f.terminals = append(f.terminals, tr)
return CommitStateTerminalCommitted, nil
}
// TestReleaseSinkContractCompiles verifies that the ReleaseSink interface
// contract is satisfied by a test-only fake sink via a compile-time assertion,
// and that all three methods can be invoked through the interface.
func TestReleaseSinkContractCompiles(t *testing.T) {
ctx := context.Background()
sink := &fakeReleaseSink{}
// Verify the interface is usable through the interface type.
var iface ReleaseSink = sink
// CommitResponseStart
rs, err := NewResponseStart("ch", 200, map[string]string{"x-custom": "val"}, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("NewResponseStart: %v", err)
}
state, err := iface.CommitResponseStart(ctx, rs)
if err != nil {
t.Fatalf("CommitResponseStart: %v", err)
}
if state != CommitStateStreamOpen {
t.Errorf("CommitResponseStart state = %q, want %q", state, CommitStateStreamOpen)
}
if len(sink.responseStarts) != 1 {
t.Errorf("responseStarts length = %d, want 1", len(sink.responseStarts))
}
// Release
releaseEv, err := NewReleaseTextDeltaEvent("ch", "hello", time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("NewReleaseTextDeltaEvent: %v", err)
}
state, err = iface.Release(ctx, releaseEv)
if err != nil {
t.Fatalf("Release: %v", err)
}
if state != CommitStateStreamOpen {
t.Errorf("Release state = %q, want %q", state, CommitStateStreamOpen)
}
if len(sink.releases) != 1 {
t.Errorf("releases length = %d, want 1", len(sink.releases))
}
// CommitTerminal
terminal, err := NewSuccessTerminalResult("ch", time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("NewSuccessTerminalResult: %v", err)
}
state, err = iface.CommitTerminal(ctx, terminal)
if err != nil {
t.Fatalf("CommitTerminal: %v", err)
}
if state != CommitStateTerminalCommitted {
t.Errorf("CommitTerminal state = %q, want %q", state, CommitStateTerminalCommitted)
}
if len(sink.terminals) != 1 {
t.Errorf("terminals length = %d, want 1", len(sink.terminals))
}
}