From 75538245206a88a0ef61f22e92fce45f3f1c78e9 Mon Sep 17 00:00:00 2001 From: nkanaev Date: Fri, 15 May 2026 15:53:13 +0100 Subject: [PATCH] feedstate: implement + test --- src/storage/feedstate.go | 104 ++++++++++++++++++++++++---- src/storage/feedstate_test.go | 126 ++++++++++++++++++++++++++++++++++ src/storage/migration.go | 30 ++++++++ src/storage/storage.go | 4 ++ 4 files changed, 251 insertions(+), 13 deletions(-) diff --git a/src/storage/feedstate.go b/src/storage/feedstate.go index e887373..7a342c1 100644 --- a/src/storage/feedstate.go +++ b/src/storage/feedstate.go @@ -1,31 +1,109 @@ package storage -import "time" +import ( + "database/sql" + "time" +) type FeedState struct { - LastRefreshed time.Time - LastError string - + FeedID int64 + LastRefreshed time.Time + LastError *string HTTPLastModified string HTTPEtag string } func (s *Storage) ListFeedStates() ([]FeedState, error) { - // TODO: implement + rows, err := s.db.Query(` + select feed_id, last_refreshed, last_modified, etag, last_error + from feed_states + `) + if err != nil { + return nil, err + } + defer rows.Close() + + states := make([]FeedState, 0) + for rows.Next() { + var state FeedState + err := rows.Scan( + &state.FeedID, + &state.LastRefreshed, + &state.HTTPLastModified, + &state.HTTPEtag, + &state.LastError, + ) + if err != nil { + return nil, err + } + states = append(states, state) + } + return states, nil } -func (s *Storage) GetFeedState() (FeedState, error) { - // TODO: implement +func (s *Storage) GetFeedState(feedID int64) (*FeedState, error) { + var state FeedState + err := s.db.QueryRow(` + select feed_id, last_refreshed, last_modified, etag, last_error + from feed_states where feed_id = :id + `, sql.Named("id", feedID)).Scan( + &state.FeedID, + &state.LastRefreshed, + &state.HTTPLastModified, + &state.HTTPEtag, + &state.LastError, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &state, nil } type UpdateFeedStateParams struct { - LastRefreshed *time.Time - LastError *string - + LastRefreshed *time.Time + LastError *string HTTPLastModified *string - HTTPEtag *string + HTTPEtag *string } -func (s *Storage) UpdateFeedState(params UpdateFeedStateParams) (bool, error) { - // TODO: implement +func (s *Storage) UpdateFeedState(feedID int64, params UpdateFeedStateParams) (bool, error) { + lastError := params.LastError + if lastError != nil && *lastError == "" { + lastError = nil + } + + _, err := s.db.Exec(` + insert into feed_states ( + feed_id + , last_refreshed + , last_modified + , etag + , last_error + ) + values ( + :id + , coalesce(:refreshed, 0) + , coalesce(:last_modified, '') + , coalesce(:etag, '') + , coalesce(:last_error, '') + ) + on conflict (feed_id) do update set + last_refreshed = coalesce(:refreshed, last_refreshed), + last_modified = coalesce(:last_modified, last_modified), + etag = coalesce(:etag, etag), + last_error = coalesce(:last_error, last_error) + `, + sql.Named("id", feedID), + sql.Named("refreshed", params.LastRefreshed), + sql.Named("last_modified", params.HTTPLastModified), + sql.Named("etag", params.HTTPEtag), + sql.Named("last_error", params.LastError), + ) + if err != nil { + return false, err + } + return true, nil } diff --git a/src/storage/feedstate_test.go b/src/storage/feedstate_test.go index e69de29..841255a 100644 --- a/src/storage/feedstate_test.go +++ b/src/storage/feedstate_test.go @@ -0,0 +1,126 @@ +package storage + +import ( + "testing" + "time" +) + +func TestUpdateFeedState_Full(t *testing.T) { + s := testDB() + defer s.Close() + + f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"}) + + now := time.Now().UTC().Truncate(time.Second) + errMsg := "error" + lmod := "today" + etag := "v1" + + ok, err := s.UpdateFeedState(f.Id, UpdateFeedStateParams{ + LastRefreshed: &now, + LastError: &errMsg, + HTTPLastModified: &lmod, + HTTPEtag: &etag, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Error("expected true") + } + + state, err := s.GetFeedState(f.Id) + if err != nil { + t.Fatal(err) + } + if state == nil { + t.Fatal("expected state, got nil") + } + if !state.LastRefreshed.Equal(now) { + t.Errorf("expected %v, got %v", now, state.LastRefreshed) + } + if state.LastError == nil || *state.LastError != errMsg { + t.Errorf("expected %s, got %v", errMsg, state.LastError) + } + if state.HTTPLastModified != lmod { + t.Errorf("expected %s, got %s", lmod, state.HTTPLastModified) + } + if state.HTTPEtag != etag { + t.Errorf("expected %s, got %s", etag, state.HTTPEtag) + } +} + +func TestUpdateFeedState_Partial(t *testing.T) { + s := testDB() + defer s.Close() + + f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"}) + etag := "v1" + s.UpdateFeedState(f.Id, UpdateFeedStateParams{HTTPEtag: &etag}) + + newErr := "new error" + _, err := s.UpdateFeedState(f.Id, UpdateFeedStateParams{ + LastError: &newErr, + }) + if err != nil { + t.Fatal(err) + } + + state, err := s.GetFeedState(f.Id) + if err != nil { + t.Fatal(err) + } + if state.LastError == nil || *state.LastError != newErr { + t.Errorf("expected %s, got %v", newErr, state.LastError) + } + if state.HTTPEtag != etag { + t.Errorf("etag should be unchanged, got %s", state.HTTPEtag) + } +} + +func TestUpdateFeedState_ClearError(t *testing.T) { + s := testDB() + defer s.Close() + + f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"}) + errMsg := "error" + s.UpdateFeedState(f.Id, UpdateFeedStateParams{LastError: &errMsg}) + + empty := "" + _, err := s.UpdateFeedState(f.Id, UpdateFeedStateParams{ + LastError: &empty, + }) + if err != nil { + t.Fatal(err) + } + + state, err := s.GetFeedState(f.Id) + if err != nil { + t.Fatal(err) + } + if state.LastError == nil || *state.LastError != "" { + t.Errorf("expected empty string error, got %v", state.LastError) + } +} + +func TestListFeedStates(t *testing.T) { + s := testDB() + defer s.Close() + + f1 := s.CreateFeed(CreateFeedParams{Title: "F1", FeedLink: "L1"}) + f2 := s.CreateFeed(CreateFeedParams{Title: "F2", FeedLink: "L2"}) + + errMsg := "fail" + etag := "e" + s.UpdateFeedState(f1.Id, UpdateFeedStateParams{LastError: &errMsg}) + s.UpdateFeedState(f2.Id, UpdateFeedStateParams{HTTPEtag: &etag}) + + states, err := s.ListFeedStates() + if err != nil { + t.Fatal(err) + } + + if len(states) != 2 { + t.Errorf("expected 2 states, got %d", len(states)) + } +} diff --git a/src/storage/migration.go b/src/storage/migration.go index 2b785cb..82d231e 100644 --- a/src/storage/migration.go +++ b/src/storage/migration.go @@ -20,6 +20,7 @@ var migrations = []func(*sql.Tx) error{ m10_add_item_medialinks, m11_add_item_last_arrived, m12_remove_feed_sizes, + m13_consolidate_feed_states, } var maxVersion = int64(len(migrations)) @@ -345,3 +346,32 @@ func m12_remove_feed_sizes(tx *sql.Tx) error { _, err := tx.Exec(`drop table if exists feed_sizes`) return err } + +func m13_consolidate_feed_states(tx *sql.Tx) error { + sql := ` + create table feed_states ( + feed_id references feeds(id) on delete cascade unique, + last_refreshed datetime not null default 0, + last_modified string not null default '', + etag string not null default '', + last_error string not null default '' + ); + + insert into feed_states (feed_id, last_refreshed, last_modified, etag, last_error) + select + f.id, + coalesce(h.last_refreshed, 0), + coalesce(h.last_modified, ''), + coalesce(h.etag, ''), + coalesce(e.error, '') + from feeds f + left join http_states h on f.id = h.feed_id + left join feed_errors e on f.id = e.feed_id + where h.feed_id is not null or e.feed_id is not null; + + drop table http_states; + drop table feed_errors; + ` + _, err := tx.Exec(sql) + return err +} diff --git a/src/storage/storage.go b/src/storage/storage.go index b64e786..c933148 100644 --- a/src/storage/storage.go +++ b/src/storage/storage.go @@ -38,3 +38,7 @@ func New(path string) (*Storage, error) { } return &Storage{db: db}, nil } + +func (s *Storage) Close() error { + return s.db.Close() +}