diff --git a/internal/file_store.go b/internal/file_store.go index 23bef9d..0f16cd9 100644 --- a/internal/file_store.go +++ b/internal/file_store.go @@ -50,6 +50,9 @@ func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error) } return nil, fmt.Errorf("reading state file: %w", err) } + if len(data) == 0 { + return make(map[string]*FillerQueue), nil + } var s State if err := json.Unmarshal(data, &s); err != nil { @@ -74,7 +77,7 @@ func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error) for symbol, persisted := range s.Queues { q := new(FillerQueue) for _, pf := range persisted { - rec, err := fs.serializer.UnmarshalRecord(ctx, pf.ReaderData) + rec, err := fs.serializer.UnmarshalRecord(ctx, pf.RecordData) if err != nil { return nil, fmt.Errorf( "unmarshalling record for symbol %q: %w", symbol, err, @@ -98,7 +101,7 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er } for symbol, q := range queue { - if q == nil { + if q == nil || q.Len() == 0 { continue } var persisted []persistedFiller @@ -109,7 +112,7 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er return fmt.Errorf("marshalling record for symbol %q: %w", symbol, err) } persisted = append(persisted, persistedFiller{ - ReaderData: data, + RecordData: data, Quantity: f.Quantity(), Price: f.Price(), Filled: f.Filled(), diff --git a/internal/file_store_test.go b/internal/file_store_test.go index bdf4e3f..b3c12c6 100644 --- a/internal/file_store_test.go +++ b/internal/file_store_test.go @@ -19,7 +19,7 @@ import ( func TestFileStore_RoundTrip(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) - store, _ := newStore(t, "fake", ser) + store, path := newStore(t, "fake", ser) original := map[string]*internal.FillerQueue{ "AAA": newQueue( @@ -50,6 +50,73 @@ func TestFileStore_RoundTrip(t *testing.T) { } assertQueueEqual(t, symbol, gotQ, wantQ) } + + // Regression: the on-disk JSON key for the per-record blob must be + // "record_data" (renamed from "reader_data") so the field name stays + // honest about what it carries. + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read state file: %v", err) + } + if strings.Contains(string(data), `"reader_data"`) { + t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"") + } + if !strings.Contains(string(data), `"record_data"`) { + t.Errorf("saved state file does not contain expected key \"record_data\"") + } +} + +func TestFileStore_SaveSkipsEmptyQueue(t *testing.T) { + ctrl := gomock.NewController(t) + ser := roundTripSerializer(ctrl) + store, path := newStore(t, "fake", ser) + + // A non-nil FillerQueue whose underlying list is nil — i.e. a symbol + // that was registered but never received a Push. Save must not panic + // when iterating and must not emit any entry for that symbol. + queues := map[string]*internal.FillerQueue{ + "EMPTY": new(internal.FillerQueue), + "REAL": newQueue(newFiller(ctrl, "REAL", 5, 10, 0)), + } + + if err := store.Save(t.Context(), queues); err != nil { + t.Fatalf("Save returned unexpected error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read state file: %v", err) + } + body := string(data) + if strings.Contains(body, `"EMPTY"`) { + t.Errorf("saved state file contains entry for empty queue \"EMPTY\"; want it skipped") + } + if !strings.Contains(body, `"REAL"`) { + t.Errorf("saved state file missing expected entry for \"REAL\"") + } +} + +func TestFileStore_LoadEmptyFileReturnsEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + ser := roundTripSerializer(ctrl) + store, path := newStore(t, "fake", ser) + + // Pre-create the state file as a zero-byte file. Load must treat this + // the same as a missing file rather than failing JSON unmarshal. + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatalf("write empty state file: %v", err) + } + + queues, err := store.Load(t.Context()) + if err != nil { + t.Fatalf("Load returned unexpected error for empty file: %v", err) + } + if queues == nil { + t.Fatalf("Load returned nil map; want empty map") + } + if len(queues) != 0 { + t.Fatalf("Load returned %d entries; want 0", len(queues)) + } } func TestFileStore_LoadMissingFileReturnsEmpty(t *testing.T) { diff --git a/internal/state.go b/internal/state.go index c30413c..ad70c5f 100644 --- a/internal/state.go +++ b/internal/state.go @@ -21,7 +21,7 @@ type State struct { // Record-specific data is kept as opaque bytes so the internal package does // not need to know about any broker package's concrete Record type. type persistedFiller struct { - ReaderData []byte `json:"reader_data"` + RecordData []byte `json:"record_data"` Quantity decimal.Decimal `json:"quantity"` Price decimal.Decimal `json:"price"` Filled decimal.Decimal `json:"filled"`