feedstate: implement + test

This commit is contained in:
nkanaev
2026-05-15 15:53:13 +01:00
parent 54e197ad85
commit 7553824520
4 changed files with 251 additions and 13 deletions

View File

@@ -1,31 +1,109 @@
package storage
import "time"
import (
"database/sql"
"time"
)
type FeedState struct {
FeedID int64
LastRefreshed time.Time
LastError string
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
HTTPLastModified *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
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -38,3 +38,7 @@ func New(path string) (*Storage, error) {
}
return &Storage{db: db}, nil
}
func (s *Storage) Close() error {
return s.db.Close()
}