package eventbus import ( "context" "encoding/json" "errors" "io" "os" "path/filepath" "testing" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) func TestDurableSinkSpoolsUnifiedWhenRawWasSpooled(t *testing.T) { dir := t.TempDir() delegate := &scriptedSink{rawErrors: []error{errSpoolTest}} sink := NewDurableSink(delegate, DurableConfig{Directory: dir}) env := durableTestEnvelope() if err := sink.PublishRaw(context.Background(), env); err != nil { t.Fatalf("PublishRaw() error = %v", err) } if err := sink.PublishUnified(context.Background(), env); err != nil { t.Fatalf("PublishUnified() error = %v", err) } if delegate.rawCalls != 1 { t.Fatalf("raw calls = %d, want 1", delegate.rawCalls) } if delegate.unifiedCalls != 0 { t.Fatalf("unified should not be delegated while raw is spooled, calls = %d", delegate.unifiedCalls) } files := spoolFiles(t, dir) if len(files) != 2 { t.Fatalf("spool files = %d, want 2: %#v", len(files), files) } assertSpoolKind(t, files[0], "raw") assertSpoolKind(t, files[1], "unified") } func TestDurableSinkReplayPublishesInFileOrderAndDeletesFiles(t *testing.T) { dir := t.TempDir() delegate := &scriptedSink{rawErrors: []error{errSpoolTest}} sink := NewDurableSink(delegate, DurableConfig{Directory: dir}) env := durableTestEnvelope() if err := sink.PublishRaw(context.Background(), env); err != nil { t.Fatalf("PublishRaw() error = %v", err) } if err := sink.PublishUnified(context.Background(), env); err != nil { t.Fatalf("PublishUnified() error = %v", err) } delegate.rawErrors = nil if err := sink.ReplayOnce(context.Background()); err != nil { t.Fatalf("ReplayOnce() error = %v", err) } got := delegate.calls want := []string{"raw", "raw", "unified"} if len(got) != len(want) { t.Fatalf("calls = %#v, want %#v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("calls[%d] = %q, want %q; all=%#v", i, got[i], want[i], got) } } if files := spoolFiles(t, dir); len(files) != 0 { t.Fatalf("spool files after replay = %#v, want none", files) } } func TestDurableSinkReplayPublishesRawBeforeUnifiedEvenWhenFilesAreOutOfOrder(t *testing.T) { dir := t.TempDir() env := durableTestEnvelope() writeDurableRecord(t, filepath.Join(dir, "0001-unified.json"), durableRecord{Kind: "unified", Envelope: env}) writeDurableRecord(t, filepath.Join(dir, "0002-raw.json"), durableRecord{Kind: "raw", Envelope: env}) delegate := &scriptedSink{} sink := NewDurableSink(delegate, DurableConfig{Directory: dir}) if err := sink.ReplayOnce(context.Background()); err != nil { t.Fatalf("ReplayOnce() error = %v", err) } got := delegate.calls want := []string{"raw", "unified"} if len(got) != len(want) { t.Fatalf("calls = %#v, want %#v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("calls[%d] = %q, want %q; all=%#v", i, got[i], want, got) } } } func TestDurableSinkReplayLoopTickUsesUncancelledContext(t *testing.T) { dir := t.TempDir() env := durableTestEnvelope() writeDurableRecord(t, filepath.Join(dir, "0001-raw.json"), durableRecord{Kind: "raw", Envelope: env}) delegate := &contextCheckingReplaySink{} sink := NewDurableSink(delegate, DurableConfig{Directory: dir}) parent, cancel := context.WithCancel(context.Background()) cancel() if err := sink.replayOnceFromLoop(parent); err != nil { t.Fatalf("replayOnceFromLoop() error = %v", err) } if delegate.rawCtxErr != nil { t.Fatalf("delegate saw cancelled context: %v", delegate.rawCtxErr) } if delegate.rawCalls != 1 { t.Fatalf("raw calls = %d, want 1", delegate.rawCalls) } if files := spoolFiles(t, dir); len(files) != 0 { t.Fatalf("spool files after replay = %#v, want none", files) } } func TestDurableSinkReplayLoopTickRespectsBatchSize(t *testing.T) { dir := t.TempDir() env := durableTestEnvelope() for index := 0; index < 5; index++ { writeDurableRecord(t, filepath.Join(dir, "000"+string(rune('1'+index))+"-raw.json"), durableRecord{Kind: "raw", Envelope: env}) } delegate := &contextCheckingReplaySink{} sink := NewDurableSink(delegate, DurableConfig{Directory: dir, ReplayBatchSize: 2}) if err := sink.replayOnceFromLoop(context.Background()); err != nil { t.Fatalf("replayOnceFromLoop() error = %v", err) } if delegate.rawCalls != 2 { t.Fatalf("raw calls = %d, want 2", delegate.rawCalls) } if files := spoolFiles(t, dir); len(files) != 3 { t.Fatalf("spool files after replay = %d, want 3: %#v", len(files), files) } } func TestDurableSinkReplayUsesBatchPublisherWhenAvailable(t *testing.T) { dir := t.TempDir() env := durableTestEnvelope() writeDurableRecord(t, filepath.Join(dir, "0001-raw.json"), durableRecord{Kind: "raw", Envelope: env}) writeDurableRecord(t, filepath.Join(dir, "0002-unified.json"), durableRecord{Kind: "unified", Envelope: env}) delegate := &batchRecordingSink{} sink := NewDurableSink(delegate, DurableConfig{Directory: dir}) if err := sink.ReplayOnce(context.Background()); err != nil { t.Fatalf("ReplayOnce() error = %v", err) } if delegate.batchCalls != 1 { t.Fatalf("batch calls = %d, want 1", delegate.batchCalls) } if delegate.rawCalls != 0 || delegate.unifiedCalls != 0 { t.Fatalf("individual publish should not be used, raw=%d unified=%d", delegate.rawCalls, delegate.unifiedCalls) } if got, want := len(delegate.records), 2; got != want { t.Fatalf("batch record count = %d, want %d", got, want) } if delegate.records[0].Kind != "raw" || delegate.records[1].Kind != "unified" { t.Fatalf("batch order = %#v", delegate.records) } if files := spoolFiles(t, dir); len(files) != 0 { t.Fatalf("spool files after replay = %#v, want none", files) } } func TestDurableFilesFromReaderStopsAfterLimitedJSONBatch(t *testing.T) { reader := &fakeNameReader{ batches: [][]string{ {"0001-raw.json", "notes.txt", "0002-unified.json"}, {"0003-raw.json"}, }, } files, err := durableFilesFromReader("/spool", 2, reader) if err != nil { t.Fatalf("durableFilesFromReader() error = %v", err) } if reader.calls != 1 { t.Fatalf("Readdirnames calls = %d, want 1", reader.calls) } want := []string{filepath.Join("/spool", "0001-raw.json"), filepath.Join("/spool", "0002-unified.json")} if len(files) != len(want) { t.Fatalf("files = %#v, want %#v", files, want) } for i := range want { if files[i] != want[i] { t.Fatalf("files[%d] = %q, want %q; all=%#v", i, files[i], want[i], files) } } } func durableTestEnvelope() envelope.FrameEnvelope { return envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", Phone: "013079963379", VIN: "LKLG7C4E3NA774736", Sequence: 183, EventTimeMS: 1782903940000, ReceivedAtMS: 1782917751903, RawHex: "0200002201307996337900B7", } } type fakeNameReader struct { batches [][]string calls int } func (r *fakeNameReader) Readdirnames(int) ([]string, error) { r.calls++ if len(r.batches) == 0 { return nil, io.EOF } batch := r.batches[0] r.batches = r.batches[1:] if len(r.batches) == 0 { return batch, io.EOF } return batch, nil } func writeDurableRecord(t *testing.T, path string, record durableRecord) { t.Helper() payload, err := json.Marshal(record) if err != nil { t.Fatalf("marshal durable record: %v", err) } if err := os.WriteFile(path, payload, 0o640); err != nil { t.Fatalf("write durable record: %v", err) } } func spoolFiles(t *testing.T, dir string) []string { t.Helper() matches, err := filepath.Glob(filepath.Join(dir, "*.json")) if err != nil { t.Fatalf("glob spool files: %v", err) } return matches } func assertSpoolKind(t *testing.T, path string, kind string) { t.Helper() payload, err := os.ReadFile(path) if err != nil { t.Fatalf("read spool file: %v", err) } if !containsString(string(payload), `"kind":"`+kind+`"`) { t.Fatalf("spool file %s payload = %s, want kind %q", path, payload, kind) } } func containsString(value string, needle string) bool { return len(needle) == 0 || (len(value) >= len(needle) && indexString(value, needle) >= 0) } func indexString(value string, needle string) int { for i := 0; i+len(needle) <= len(value); i++ { if value[i:i+len(needle)] == needle { return i } } return -1 } var errSpoolTest = errors.New("delegate unavailable") type scriptedSink struct { rawErrors []error unifiedErrors []error rawCalls int unifiedCalls int calls []string } func (s *scriptedSink) PublishRaw(context.Context, envelope.FrameEnvelope) error { s.rawCalls++ s.calls = append(s.calls, "raw") if len(s.rawErrors) > 0 { err := s.rawErrors[0] s.rawErrors = s.rawErrors[1:] return err } return nil } func (s *scriptedSink) PublishUnified(context.Context, envelope.FrameEnvelope) error { s.unifiedCalls++ s.calls = append(s.calls, "unified") if len(s.unifiedErrors) > 0 { err := s.unifiedErrors[0] s.unifiedErrors = s.unifiedErrors[1:] return err } return nil } func (s *scriptedSink) PublishFields(context.Context, envelope.FrameEnvelope) error { s.calls = append(s.calls, "fields") return nil } func (s *scriptedSink) Close() error { return nil } type contextCheckingReplaySink struct { rawCtxErr error unifiedCtxErr error rawCalls int unifiedCalls int } func (s *contextCheckingReplaySink) PublishRaw(ctx context.Context, _ envelope.FrameEnvelope) error { s.rawCtxErr = ctx.Err() s.rawCalls++ return s.rawCtxErr } func (s *contextCheckingReplaySink) PublishUnified(ctx context.Context, _ envelope.FrameEnvelope) error { s.unifiedCtxErr = ctx.Err() s.unifiedCalls++ return s.unifiedCtxErr } func (s *contextCheckingReplaySink) PublishFields(ctx context.Context, _ envelope.FrameEnvelope) error { return ctx.Err() } func (s *contextCheckingReplaySink) Close() error { return nil } type batchRecordingSink struct { batchCalls int records []durableRecord rawCalls int unifiedCalls int } func (s *batchRecordingSink) PublishRecords(_ context.Context, records []durableRecord) error { s.batchCalls++ s.records = append(s.records, records...) return nil } func (s *batchRecordingSink) PublishRaw(context.Context, envelope.FrameEnvelope) error { s.rawCalls++ return nil } func (s *batchRecordingSink) PublishUnified(context.Context, envelope.FrameEnvelope) error { s.unifiedCalls++ return nil } func (s *batchRecordingSink) PublishFields(context.Context, envelope.FrameEnvelope) error { return nil } func (s *batchRecordingSink) Close() error { return nil }