textmachine/platform/internal/ingest/decoder_test.go

105 lines
3.3 KiB
Go

package ingest
import (
"context"
"errors"
"io"
"strings"
"testing"
)
const helloLine = `{"seq":1,"type":"hello","time":"2026-08-04T10:00:00Z","data":{"stream_version":"1.0","engine_run_id":"tr_1","book_id":"gzr"}}`
func TestHelloIsRequiredFirst(t *testing.T) {
d := NewDecoder(strings.NewReader(`{"seq":1,"type":"progress","data":{}}` + "\n"))
if _, err := d.Hello(); !errors.Is(err, ErrNoHandshake) {
t.Fatalf("want ErrNoHandshake, got %v", err)
}
if _, err := NewDecoder(strings.NewReader("")).Hello(); !errors.Is(err, ErrNoHandshake) {
t.Fatalf("empty stream: want ErrNoHandshake, got %v", err)
}
}
func TestMajorVersionRefused(t *testing.T) {
line := strings.Replace(helloLine, `"stream_version":"1.0"`, `"stream_version":"2.0"`, 1)
if _, err := NewDecoder(strings.NewReader(line)).Hello(); !errors.Is(err, ErrUnsupportedVersion) {
t.Fatalf("want ErrUnsupportedVersion, got %v", err)
}
}
func TestMinorVersionAndUnknownFieldsTolerated(t *testing.T) {
line := strings.Replace(helloLine, `"stream_version":"1.0"`, `"stream_version":"1.7","future":42`, 1)
d := NewDecoder(strings.NewReader(line + "\n" + `{"seq":2,"type":"weather","data":{"sky":"grey"}}`))
if _, err := d.Hello(); err != nil {
t.Fatalf("minor bump must be accepted: %v", err)
}
ev, err := d.Next()
if err != nil {
t.Fatalf("unknown event type must reach the sink: %v", err)
}
if ev.Type != "weather" {
t.Fatalf("type = %q", ev.Type)
}
}
func TestSequenceGapAndReplayAreReported(t *testing.T) {
for name, second := range map[string]string{
"gap": `{"seq":5,"type":"progress","data":{}}`,
"replay": `{"seq":1,"type":"progress","data":{}}`,
"reverse": `{"seq":0,"type":"progress","data":{}}`,
} {
t.Run(name, func(t *testing.T) {
d := NewDecoder(strings.NewReader(helloLine + "\n" + second))
if _, err := d.Hello(); err != nil {
t.Fatal(err)
}
if _, err := d.Next(); !errors.Is(err, ErrStreamGap) {
t.Fatalf("want ErrStreamGap, got %v", err)
}
})
}
}
func TestIngestFeedsSinkInOrder(t *testing.T) {
stream := helloLine + "\n" +
`{"seq":2,"type":"progress","data":{"draft":{"done":1,"total":10},"edit":{"done":0,"total":10}}}` + "\n" +
"\n" + // a blank line is not an event
`{"seq":3,"type":"ceiling","data":{"halted":true}}` + "\n"
s := &recordingSink{}
if err := Ingest(context.Background(), strings.NewReader(stream), s); err != nil {
t.Fatalf("ingest: %v", err)
}
if s.hello.EngineRunID != "tr_1" {
t.Fatalf("hello not bound: %+v", s.hello)
}
if got := len(s.applied); got != 2 {
t.Fatalf("applied %d events, want 2", got)
}
if s.applied[0].Type != TypeProgress || s.applied[1].Type != TypeCeiling {
t.Fatalf("order: %v", s.applied)
}
}
func TestIngestStopsAtMalformedLine(t *testing.T) {
stream := helloLine + "\n" + "{not json\n"
s := &recordingSink{}
err := Ingest(context.Background(), strings.NewReader(stream), s)
if err == nil || errors.Is(err, io.EOF) {
t.Fatalf("want a decode error, got %v", err)
}
if len(s.applied) != 0 {
t.Fatalf("nothing may be applied from a broken stream, got %d", len(s.applied))
}
}
type recordingSink struct {
hello Hello
applied []Envelope
}
func (s *recordingSink) Begin(_ context.Context, h Hello) error { s.hello = h; return nil }
func (s *recordingSink) Apply(_ context.Context, ev Envelope) error {
s.applied = append(s.applied, ev)
return nil
}