sync
This commit is contained in:
parent
bdd794a1e7
commit
bb522038f9
|
@ -44,14 +44,14 @@ var AddCmd = &cli.Command{
|
|||
},
|
||||
}
|
||||
|
||||
func NewAddCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command {
|
||||
func NewAddCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command {
|
||||
AddCmd.Action = func(cCtx *cli.Context) error {
|
||||
return Add(localRepo, eventRepo, cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for"))
|
||||
return Add(localRepo, eventRepo, syncRepo, cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for"))
|
||||
}
|
||||
return AddCmd
|
||||
}
|
||||
|
||||
func Add(localIDRepo storage.LocalID, eventRepo storage.Event, nameStr, onStr, atStr, frStr string) error {
|
||||
func Add(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, nameStr, onStr, atStr, frStr string) error {
|
||||
if nameStr == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrInvalidArg)
|
||||
}
|
||||
|
@ -103,5 +103,13 @@ func Add(localIDRepo storage.LocalID, eventRepo storage.Event, nameStr, onStr, a
|
|||
return fmt.Errorf("could not store local id: %v", err)
|
||||
}
|
||||
|
||||
it, err := e.Item()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not convert event to sync item: %v", err)
|
||||
}
|
||||
if err := syncRepo.Store(it); err != nil {
|
||||
return fmt.Errorf("could not store sync item: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -106,7 +106,8 @@ func TestAdd(t *testing.T) {
|
|||
t.Run(tc.name, func(t *testing.T) {
|
||||
eventRepo := memory.NewEvent()
|
||||
localRepo := memory.NewLocalID()
|
||||
actErr := command.Add(localRepo, eventRepo, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil
|
||||
syncRepo := memory.NewSync()
|
||||
actErr := command.Add(localRepo, eventRepo, syncRepo, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil
|
||||
if tc.expErr != actErr {
|
||||
t.Errorf("exp %v, got %v", tc.expErr, actErr)
|
||||
}
|
||||
|
@ -139,6 +140,14 @@ func TestAdd(t *testing.T) {
|
|||
if diff := cmp.Diff(tc.expEvent, actEvents[0]); diff != "" {
|
||||
t.Errorf("(exp +, got -)\n%s", diff)
|
||||
}
|
||||
|
||||
updated, err := syncRepo.FindAll()
|
||||
if err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
if len(updated) != 1 {
|
||||
t.Errorf("exp 1, got %v", len(updated))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,14 +20,14 @@ var DeleteCmd = &cli.Command{
|
|||
},
|
||||
}
|
||||
|
||||
func NewDeleteCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command {
|
||||
func NewDeleteCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command {
|
||||
DeleteCmd.Action = func(cCtx *cli.Context) error {
|
||||
return Delete(localRepo, eventRepo, cCtx.Int("localID"))
|
||||
return Delete(localRepo, eventRepo, syncRepo, cCtx.Int("localID"))
|
||||
}
|
||||
return DeleteCmd
|
||||
}
|
||||
|
||||
func Delete(localRepo storage.LocalID, eventRepo storage.Event, localID int) error {
|
||||
func Delete(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, localID int) error {
|
||||
var id string
|
||||
idMap, err := localRepo.FindAll()
|
||||
if err != nil {
|
||||
|
@ -46,5 +46,17 @@ func Delete(localRepo storage.LocalID, eventRepo storage.Event, localID int) err
|
|||
return fmt.Errorf("could not delete event: %v", err)
|
||||
}
|
||||
|
||||
e, err := eventRepo.Find(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get event: %v", err)
|
||||
}
|
||||
|
||||
it, err := e.Item()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not convert event to sync item: %v", err)
|
||||
}
|
||||
if err := syncRepo.Store(it); err != nil {
|
||||
return fmt.Errorf("could not store sync item: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ func TestDelete(t *testing.T) {
|
|||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
eventRepo := memory.NewEvent()
|
||||
syncRepo := memory.NewSync()
|
||||
if err := eventRepo.Store(e); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
|
@ -43,7 +44,7 @@ func TestDelete(t *testing.T) {
|
|||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
|
||||
actErr := command.Delete(localRepo, eventRepo, tc.localID) != nil
|
||||
actErr := command.Delete(localRepo, eventRepo, syncRepo, tc.localID) != nil
|
||||
if tc.expErr != actErr {
|
||||
t.Errorf("exp %v, got %v", tc.expErr, actErr)
|
||||
}
|
||||
|
@ -62,6 +63,13 @@ func TestDelete(t *testing.T) {
|
|||
if len(idMap) != 0 {
|
||||
t.Errorf("exp 0, got %v", len(idMap))
|
||||
}
|
||||
updated, err := syncRepo.FindAll()
|
||||
if err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
if len(updated) != 1 {
|
||||
t.Errorf("exp 1, got %v", len(updated))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
"go-mod.ewintr.nl/planner/plan/storage"
|
||||
"go-mod.ewintr.nl/planner/sync/client"
|
||||
)
|
||||
|
||||
var SyncCmd = &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "Synchronize with server",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "full",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Force full sync",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func NewSyncCmd(client client.Client, syncRepo storage.Sync, localIDRepo storage.LocalID, eventRepo storage.Event) *cli.Command {
|
||||
SyncCmd.Action = func(cCtx *cli.Context) error {
|
||||
return Sync(client, syncRepo, localIDRepo, eventRepo, cCtx.Bool("full"))
|
||||
}
|
||||
return SyncCmd
|
||||
}
|
||||
|
||||
func Sync(client client.Client, syncRepo storage.Sync, localIDRepo storage.LocalID, eventRepo storage.Event, full bool) error {
|
||||
// local new and updated
|
||||
sendItems, err := syncRepo.FindAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get updated items: %v", err)
|
||||
}
|
||||
if err := client.Update(sendItems); err != nil {
|
||||
return fmt.Errorf("could not send updated items: %v", err)
|
||||
}
|
||||
if err := syncRepo.DeleteAll(); err != nil {
|
||||
return fmt.Errorf("could not clear updated items: %v", err)
|
||||
}
|
||||
|
||||
// get new/updated items
|
||||
ts, err := syncRepo.LastUpdate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not find timestamp of last update: %v", err)
|
||||
}
|
||||
recItems, err := client.Updated([]item.Kind{item.KindEvent}, ts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not receive updates: %v", err)
|
||||
}
|
||||
|
||||
updated := make([]item.Item, 0)
|
||||
for _, ri := range recItems {
|
||||
if ri.Deleted {
|
||||
if err := localIDRepo.Delete(ri.ID); err != nil {
|
||||
return fmt.Errorf("could not delete local id: %v", err)
|
||||
}
|
||||
if err := eventRepo.Delete(ri.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
return fmt.Errorf("could not delete event: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
updated = append(updated, ri)
|
||||
}
|
||||
|
||||
lidMap, err := localIDRepo.FindAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get local ids: %v", err)
|
||||
}
|
||||
for _, u := range updated {
|
||||
var eBody item.EventBody
|
||||
if err := json.Unmarshal([]byte(u.Body), &eBody); err != nil {
|
||||
return fmt.Errorf("could not unmarshal event body: %v", err)
|
||||
}
|
||||
e := item.Event{
|
||||
ID: u.ID,
|
||||
EventBody: eBody,
|
||||
}
|
||||
if err := eventRepo.Store(e); err != nil {
|
||||
return fmt.Errorf("could not store event: %v", err)
|
||||
}
|
||||
lid, ok := lidMap[u.ID]
|
||||
if !ok {
|
||||
lid, err = localIDRepo.Next()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get next local id: %v", err)
|
||||
}
|
||||
|
||||
if err := localIDRepo.Store(u.ID, lid); err != nil {
|
||||
return fmt.Errorf("could not store local id: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
package command_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
"go-mod.ewintr.nl/planner/plan/command"
|
||||
"go-mod.ewintr.nl/planner/plan/storage/memory"
|
||||
"go-mod.ewintr.nl/planner/sync/client"
|
||||
)
|
||||
|
||||
func TestSyncSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
syncClient := client.NewMemory()
|
||||
syncRepo := memory.NewSync()
|
||||
localIDRepo := memory.NewLocalID()
|
||||
eventRepo := memory.NewEvent()
|
||||
|
||||
it := item.Item{
|
||||
ID: "a",
|
||||
Kind: item.KindEvent,
|
||||
Body: `{
|
||||
"title":"title",
|
||||
"start":"2024-10-18T08:00:00Z",
|
||||
"duration":"1h"
|
||||
}`,
|
||||
}
|
||||
if err := syncRepo.Store(it); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ks []item.Kind
|
||||
ts time.Time
|
||||
expItems []item.Item
|
||||
}{
|
||||
{
|
||||
name: "single",
|
||||
ks: []item.Kind{item.KindEvent},
|
||||
expItems: []item.Item{it},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
if err := command.Sync(syncClient, syncRepo, localIDRepo, eventRepo, false); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
actItems, actErr := syncClient.Updated(tc.ks, tc.ts)
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if diff := cmp.Diff(tc.expItems, actItems); diff != "" {
|
||||
t.Errorf("(exp +, got -)\n%s", diff)
|
||||
}
|
||||
|
||||
actLeft, actErr := syncRepo.FindAll()
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if len(actLeft) != 0 {
|
||||
t.Errorf("exp 0, got %v", actLeft)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncReceive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
oneHour, err := time.ParseDuration("1h")
|
||||
if err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
present []item.Event
|
||||
updated []item.Item
|
||||
expEvent []item.Event
|
||||
expLocalID map[string]int
|
||||
}{
|
||||
{
|
||||
name: "no new",
|
||||
expEvent: []item.Event{},
|
||||
expLocalID: map[string]int{},
|
||||
},
|
||||
{
|
||||
name: "new",
|
||||
updated: []item.Item{{
|
||||
ID: "a",
|
||||
Kind: item.KindEvent,
|
||||
Body: `{
|
||||
"title":"title",
|
||||
"start":"2024-10-23T08:00:00Z",
|
||||
"duration":"1h"
|
||||
}`,
|
||||
}},
|
||||
expEvent: []item.Event{{
|
||||
ID: "a",
|
||||
EventBody: item.EventBody{
|
||||
Title: "title",
|
||||
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
|
||||
Duration: oneHour,
|
||||
},
|
||||
}},
|
||||
expLocalID: map[string]int{
|
||||
"a": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update existing",
|
||||
present: []item.Event{{
|
||||
ID: "a",
|
||||
EventBody: item.EventBody{
|
||||
Title: "title",
|
||||
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
|
||||
Duration: oneHour,
|
||||
},
|
||||
}},
|
||||
updated: []item.Item{{
|
||||
ID: "a",
|
||||
Kind: item.KindEvent,
|
||||
Body: `{
|
||||
"title":"new title",
|
||||
"start":"2024-10-23T08:00:00Z",
|
||||
"duration":"1h"
|
||||
}`,
|
||||
}},
|
||||
expEvent: []item.Event{{
|
||||
ID: "a",
|
||||
EventBody: item.EventBody{
|
||||
Title: "new title",
|
||||
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
|
||||
Duration: oneHour,
|
||||
},
|
||||
}},
|
||||
expLocalID: map[string]int{
|
||||
"a": 1,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// setup
|
||||
syncClient := client.NewMemory()
|
||||
syncRepo := memory.NewSync()
|
||||
localIDRepo := memory.NewLocalID()
|
||||
eventRepo := memory.NewEvent()
|
||||
|
||||
for i, p := range tc.present {
|
||||
if err := eventRepo.Store(p); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
if err := localIDRepo.Store(p.ID, i+1); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
}
|
||||
if err := syncClient.Update(tc.updated); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
|
||||
// sync
|
||||
if err := command.Sync(syncClient, syncRepo, localIDRepo, eventRepo, false); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
|
||||
// check result
|
||||
actEvents, err := eventRepo.FindAll()
|
||||
if err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.expEvent, actEvents); diff != "" {
|
||||
t.Errorf("(exp +, got -)\n%s", diff)
|
||||
}
|
||||
actLocalIDs, err := localIDRepo.FindAll()
|
||||
if err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.expLocalID, actLocalIDs); diff != "" {
|
||||
t.Errorf("(exp +, got -)\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -41,14 +41,14 @@ var UpdateCmd = &cli.Command{
|
|||
},
|
||||
}
|
||||
|
||||
func NewUpdateCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command {
|
||||
func NewUpdateCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command {
|
||||
UpdateCmd.Action = func(cCtx *cli.Context) error {
|
||||
return Update(localRepo, eventRepo, cCtx.Int("localID"), cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for"))
|
||||
return Update(localRepo, eventRepo, syncRepo, cCtx.Int("localID"), cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for"))
|
||||
}
|
||||
return UpdateCmd
|
||||
}
|
||||
|
||||
func Update(localRepo storage.LocalID, eventRepo storage.Event, localID int, nameStr, onStr, atStr, frStr string) error {
|
||||
func Update(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, localID int, nameStr, onStr, atStr, frStr string) error {
|
||||
var id string
|
||||
idMap, err := localRepo.FindAll()
|
||||
if err != nil {
|
||||
|
@ -99,5 +99,13 @@ func Update(localRepo storage.LocalID, eventRepo storage.Event, localID int, nam
|
|||
return fmt.Errorf("could not store event: %v", err)
|
||||
}
|
||||
|
||||
it, err := e.Item()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not convert event to sync item: %v", err)
|
||||
}
|
||||
if err := syncRepo.Store(it); err != nil {
|
||||
return fmt.Errorf("could not store sync item: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -154,6 +154,7 @@ func TestUpdate(t *testing.T) {
|
|||
t.Run(tc.name, func(t *testing.T) {
|
||||
eventRepo := memory.NewEvent()
|
||||
localIDRepo := memory.NewLocalID()
|
||||
syncRepo := memory.NewSync()
|
||||
if err := eventRepo.Store(item.Event{
|
||||
ID: eid,
|
||||
EventBody: item.EventBody{
|
||||
|
@ -168,7 +169,7 @@ func TestUpdate(t *testing.T) {
|
|||
t.Errorf("exp nil, ,got %v", err)
|
||||
}
|
||||
|
||||
actErr := command.Update(localIDRepo, eventRepo, tc.localID, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil
|
||||
actErr := command.Update(localIDRepo, eventRepo, syncRepo, tc.localID, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil
|
||||
if tc.expErr != actErr {
|
||||
t.Errorf("exp %v, got %v", tc.expErr, actErr)
|
||||
}
|
||||
|
@ -183,7 +184,13 @@ func TestUpdate(t *testing.T) {
|
|||
if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" {
|
||||
t.Errorf("(exp +, got -)\n%s", diff)
|
||||
}
|
||||
|
||||
updated, err := syncRepo.FindAll()
|
||||
if err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
if len(updated) != 1 {
|
||||
t.Errorf("exp 1, got %v", len(updated))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
50
plan/main.go
50
plan/main.go
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/urfave/cli/v2"
|
||||
"go-mod.ewintr.nl/planner/plan/command"
|
||||
"go-mod.ewintr.nl/planner/plan/storage/sqlite"
|
||||
"go-mod.ewintr.nl/planner/sync/client"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
@ -23,20 +24,23 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
localIDRepo, eventRepo, err := sqlite.NewSqlites(conf.DBPath)
|
||||
localIDRepo, eventRepo, syncRepo, err := sqlite.NewSqlites(conf.DBPath)
|
||||
if err != nil {
|
||||
fmt.Printf("could not open db file: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
syncClient := client.New(conf.SyncURL, conf.ApiKey)
|
||||
|
||||
app := &cli.App{
|
||||
Name: "plan",
|
||||
Usage: "Plan your day with events",
|
||||
Commands: []*cli.Command{
|
||||
command.NewAddCmd(localIDRepo, eventRepo),
|
||||
command.NewAddCmd(localIDRepo, eventRepo, syncRepo),
|
||||
command.NewListCmd(localIDRepo, eventRepo),
|
||||
command.NewUpdateCmd(localIDRepo, eventRepo),
|
||||
command.NewDeleteCmd(localIDRepo, eventRepo),
|
||||
command.NewUpdateCmd(localIDRepo, eventRepo, syncRepo),
|
||||
command.NewDeleteCmd(localIDRepo, eventRepo, syncRepo),
|
||||
command.NewSyncCmd(syncClient, syncRepo, localIDRepo, eventRepo),
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -44,46 +48,12 @@ func main() {
|
|||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// all, err := repo.FindAll()
|
||||
// if err != nil {
|
||||
// fmt.Println(err)
|
||||
// os.Exit(1)
|
||||
// }
|
||||
|
||||
// fmt.Printf("all: %+v\n", all)
|
||||
|
||||
// c := client.NewClient("http://localhost:8092", "testKey")
|
||||
// items, err := c.Updated([]item.Kind{item.KindEvent}, time.Time{})
|
||||
// if err != nil {
|
||||
// fmt.Println(err)
|
||||
// os.Exit(1)
|
||||
// }
|
||||
|
||||
// fmt.Printf("%+v\n", items)
|
||||
|
||||
// i := item.Item{
|
||||
// ID: "id-1",
|
||||
// Kind: item.KindEvent,
|
||||
// Updated: time.Now(),
|
||||
// Body: "body",
|
||||
// }
|
||||
// if err := c.Update([]item.Item{i}); err != nil {
|
||||
// fmt.Println(err)
|
||||
// os.Exit(1)
|
||||
// }
|
||||
|
||||
// items, err = c.Updated([]item.Kind{item.KindEvent}, time.Time{})
|
||||
// if err != nil {
|
||||
// fmt.Println(err)
|
||||
// os.Exit(1)
|
||||
// }
|
||||
|
||||
// fmt.Printf("%+v\n", items)
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
DBPath string `yaml:"dbpath"`
|
||||
SyncURL string `yaml:"sync_url"`
|
||||
ApiKey string `yaml:"api_key"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (Configuration, error) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"go-mod.ewintr.nl/planner/plan/storage"
|
||||
|
@ -24,6 +25,30 @@ func (ml *LocalID) FindAll() (map[string]int, error) {
|
|||
return ml.ids, nil
|
||||
}
|
||||
|
||||
func (ml *LocalID) Find(id string) (int, error) {
|
||||
ml.mutex.RLock()
|
||||
defer ml.mutex.RUnlock()
|
||||
|
||||
lid, ok := ml.ids[id]
|
||||
if !ok {
|
||||
return 0, storage.ErrNotFound
|
||||
}
|
||||
|
||||
return lid, nil
|
||||
}
|
||||
|
||||
func (ml *LocalID) FindOrNext(id string) (int, error) {
|
||||
lid, err := ml.Find(id)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return ml.Next()
|
||||
case err != nil:
|
||||
return 0, err
|
||||
default:
|
||||
return lid, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ml *LocalID) Next() (int, error) {
|
||||
ml.mutex.RLock()
|
||||
defer ml.mutex.RUnlock()
|
||||
|
|
|
@ -37,6 +37,23 @@ func TestLocalID(t *testing.T) {
|
|||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
|
||||
t.Log("retrieve known")
|
||||
actLid, actErr := repo.FindOrNext("test")
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if actLid != 1 {
|
||||
t.Errorf("exp 1, git %v", actLid)
|
||||
}
|
||||
t.Log("retrieve unknown")
|
||||
actLid, actErr = repo.FindOrNext("new")
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if actLid != 2 {
|
||||
t.Errorf("exp 2, got %v", actLid)
|
||||
}
|
||||
|
||||
actIDs, actErr = repo.FindAll()
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
)
|
||||
|
||||
type Sync struct {
|
||||
items map[string]item.Item
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewSync() *Sync {
|
||||
return &Sync{
|
||||
items: make(map[string]item.Item),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Sync) FindAll() ([]item.Item, error) {
|
||||
r.mutex.RLock()
|
||||
defer r.mutex.RUnlock()
|
||||
|
||||
items := make([]item.Item, 0, len(r.items))
|
||||
for _, item := range r.items {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Sync) Store(e item.Item) error {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
|
||||
r.items[e.ID] = e
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Sync) DeleteAll() error {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
|
||||
r.items = make(map[string]item.Item)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Sync) LastUpdate() (time.Time, error) {
|
||||
r.mutex.RLock()
|
||||
defer r.mutex.RUnlock()
|
||||
|
||||
var last time.Time
|
||||
for _, i := range r.items {
|
||||
if i.Updated.After(last) {
|
||||
last = i.Updated
|
||||
}
|
||||
}
|
||||
|
||||
return last, nil
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package memory_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
"go-mod.ewintr.nl/planner/plan/storage/memory"
|
||||
)
|
||||
|
||||
func TestSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mem := memory.NewSync()
|
||||
|
||||
t.Log("store")
|
||||
now := time.Now()
|
||||
ts := now
|
||||
count := 3
|
||||
for i := 0; i < count; i++ {
|
||||
mem.Store(item.Item{
|
||||
ID: fmt.Sprintf("id-%d", i),
|
||||
Updated: ts,
|
||||
})
|
||||
ts = ts.Add(-1 * time.Minute)
|
||||
}
|
||||
|
||||
t.Log("find all")
|
||||
actItems, actErr := mem.FindAll()
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if len(actItems) != count {
|
||||
t.Errorf("exp %v, got %v", count, len(actItems))
|
||||
}
|
||||
|
||||
t.Log("last update")
|
||||
actLU, actErr := mem.LastUpdate()
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if !actLU.Equal(now) {
|
||||
t.Errorf("exp %v, got %v", now, actLU)
|
||||
}
|
||||
|
||||
t.Log("delete all")
|
||||
if err := mem.DeleteAll(); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
actItems, actErr = mem.FindAll()
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if len(actItems) != 0 {
|
||||
t.Errorf("exp 0, got %v", len(actItems))
|
||||
}
|
||||
|
||||
}
|
|
@ -33,6 +33,10 @@ FROM localids
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (l *LocalID) FindOrNext(id string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (l *LocalID) Next() (int, error) {
|
||||
idMap, err := l.FindAll()
|
||||
if err != nil {
|
||||
|
|
|
@ -18,6 +18,13 @@ var migrations = []string{
|
|||
`PRAGMA synchronous=NORMAL`,
|
||||
`PRAGMA cache_size=2000`,
|
||||
`CREATE TABLE localids ("id" TEXT UNIQUE, "local_id" INTEGER)`,
|
||||
`CREATE TABLE items (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
updated TIMESTAMP NOT NULL,
|
||||
deleted BOOLEAN NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -27,10 +34,10 @@ var (
|
|||
ErrSqliteFailure = errors.New("sqlite returned an error")
|
||||
)
|
||||
|
||||
func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, error) {
|
||||
func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, *SqliteSync, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err)
|
||||
return nil, nil, nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err)
|
||||
}
|
||||
|
||||
sl := &LocalID{
|
||||
|
@ -39,12 +46,15 @@ func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, error) {
|
|||
se := &SqliteEvent{
|
||||
db: db,
|
||||
}
|
||||
|
||||
if err := migrate(db, migrations); err != nil {
|
||||
return nil, nil, err
|
||||
ss := &SqliteSync{
|
||||
db: db,
|
||||
}
|
||||
|
||||
return sl, se, nil
|
||||
if err := migrate(db, migrations); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return sl, se, ss, nil
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB, wanted []string) error {
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
)
|
||||
|
||||
type SqliteSync struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSqliteSync(db *sql.DB) *SqliteSync {
|
||||
return &SqliteSync{db: db}
|
||||
}
|
||||
|
||||
func (s *SqliteSync) FindAll() ([]item.Item, error) {
|
||||
rows, err := s.db.Query("SELECT id, kind, updated, deleted, body FROM items")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to query items: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []item.Item
|
||||
for rows.Next() {
|
||||
var i item.Item
|
||||
var updatedStr string
|
||||
err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &i.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to scan item: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
i.Updated, err = time.Parse(time.RFC3339, updatedStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to parse updated time: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%w: error iterating over rows: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *SqliteSync) Store(i item.Item) error {
|
||||
// Ensure we have a valid time
|
||||
if i.Updated.IsZero() {
|
||||
i.Updated = time.Now()
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR REPLACE INTO items (id, kind, updated, deleted, body) VALUES (?, ?, ?, ?, ?)",
|
||||
i.ID,
|
||||
i.Kind,
|
||||
i.Updated.UTC().Format(time.RFC3339),
|
||||
i.Deleted,
|
||||
sql.NullString{String: i.Body, Valid: i.Body != ""}, // This allows empty string but not NULL
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to store item: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqliteSync) DeleteAll() error {
|
||||
_, err := s.db.Exec("DELETE FROM items")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to delete all items: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqliteSync) LastUpdate() (time.Time, error) {
|
||||
var updatedStr sql.NullString
|
||||
err := s.db.QueryRow("SELECT MAX(updated) FROM items").Scan(&updatedStr)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("%w: failed to get last update: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
|
||||
if !updatedStr.Valid {
|
||||
return time.Time{}, nil // Return zero time if NULL or no rows
|
||||
}
|
||||
|
||||
lastUpdate, err := time.Parse(time.RFC3339, updatedStr.String)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("%w: failed to parse last update time: %v", ErrSqliteFailure, err)
|
||||
}
|
||||
return lastUpdate, nil
|
||||
}
|
|
@ -3,6 +3,7 @@ package storage
|
|||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
)
|
||||
|
@ -13,11 +14,19 @@ var (
|
|||
|
||||
type LocalID interface {
|
||||
FindAll() (map[string]int, error)
|
||||
FindOrNext(id string) (int, error)
|
||||
Next() (int, error)
|
||||
Store(id string, localID int) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type Sync interface {
|
||||
FindAll() ([]item.Item, error)
|
||||
Store(i item.Item) error
|
||||
DeleteAll() error
|
||||
LastUpdate() (time.Time, error)
|
||||
}
|
||||
|
||||
type Event interface {
|
||||
Store(event item.Event) error
|
||||
Find(id string) (item.Event, error)
|
||||
|
|
|
@ -1,89 +1,12 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
c *http.Client
|
||||
}
|
||||
|
||||
func NewClient(url, apiKey string) *Client {
|
||||
return &Client{
|
||||
baseURL: url,
|
||||
apiKey: apiKey,
|
||||
c: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Update(items []item.Item) error {
|
||||
body, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not marhal body: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/sync", c.baseURL), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
|
||||
|
||||
res, err := c.c.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not make request: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("server returned status %d", res.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Updated(ks []item.Kind, ts time.Time) ([]item.Item, error) {
|
||||
ksStr := make([]string, 0, len(ks))
|
||||
for _, k := range ks {
|
||||
ksStr = append(ksStr, string(k))
|
||||
}
|
||||
u := fmt.Sprintf("%s/sync?ks=%s", c.baseURL, strings.Join(ksStr, ","))
|
||||
if !ts.IsZero() {
|
||||
u = fmt.Sprintf("%s&ts=", url.QueryEscape(ts.Format(time.RFC3339)))
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
|
||||
|
||||
res, err := c.c.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get response: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("server returned status %d", res.StatusCode)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read response body: %v", err)
|
||||
}
|
||||
|
||||
var items []item.Item
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return nil, fmt.Errorf("could not unmarshal response body: %v", err)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
type Client interface {
|
||||
Update(items []item.Item) error
|
||||
Updated(ks []item.Kind, ts time.Time) ([]item.Item, error)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
c *http.Client
|
||||
}
|
||||
|
||||
func New(url, apiKey string) *HTTP {
|
||||
return &HTTP{
|
||||
baseURL: url,
|
||||
apiKey: apiKey,
|
||||
c: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTP) Update(items []item.Item) error {
|
||||
body, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not marhal body: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/sync", c.baseURL), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
|
||||
|
||||
res, err := c.c.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not make request: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("server returned status %d", res.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTP) Updated(ks []item.Kind, ts time.Time) ([]item.Item, error) {
|
||||
ksStr := make([]string, 0, len(ks))
|
||||
for _, k := range ks {
|
||||
ksStr = append(ksStr, string(k))
|
||||
}
|
||||
u := fmt.Sprintf("%s/sync?ks=%s", c.baseURL, strings.Join(ksStr, ","))
|
||||
if !ts.IsZero() {
|
||||
u = fmt.Sprintf("%s&ts=", url.QueryEscape(ts.Format(time.RFC3339)))
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
|
||||
|
||||
res, err := c.c.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get response: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("server returned status %d", res.StatusCode)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read response body: %v", err)
|
||||
}
|
||||
|
||||
var items []item.Item
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return nil, fmt.Errorf("could not unmarshal response body: %v", err)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
)
|
||||
|
||||
type Memory struct {
|
||||
items map[string]item.Item
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
items: make(map[string]item.Item, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Memory) Update(items []item.Item) error {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
for _, i := range items {
|
||||
m.items[i.ID] = i
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) Updated(kw []item.Kind, ts time.Time) ([]item.Item, error) {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
|
||||
res := make([]item.Item, 0)
|
||||
for _, i := range m.items {
|
||||
if slices.Contains(kw, i.Kind) && (i.Updated.After(ts) || i.Updated.Equal(ts)) {
|
||||
res = append(res, i)
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package client_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go-mod.ewintr.nl/planner/item"
|
||||
"go-mod.ewintr.nl/planner/sync/client"
|
||||
)
|
||||
|
||||
func TestMemory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mem := client.NewMemory()
|
||||
|
||||
now := time.Now()
|
||||
items := []item.Item{
|
||||
{ID: "a", Kind: item.KindTask, Updated: now.Add(-15 * time.Minute)},
|
||||
{ID: "b", Kind: item.KindEvent, Updated: now.Add(-10 * time.Minute)},
|
||||
{ID: "c", Kind: item.KindTask, Updated: now.Add(-5 * time.Minute)},
|
||||
}
|
||||
if err := mem.Update(items); err != nil {
|
||||
t.Errorf("exp nil, got %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ks []item.Kind
|
||||
ts time.Time
|
||||
expItems []item.Item
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
ks: make([]item.Kind, 0),
|
||||
expItems: make([]item.Item, 0),
|
||||
},
|
||||
{
|
||||
name: "kind",
|
||||
ks: []item.Kind{item.KindEvent},
|
||||
expItems: []item.Item{items[1]},
|
||||
},
|
||||
{
|
||||
name: "timestamp",
|
||||
ks: []item.Kind{item.KindTask, item.KindEvent},
|
||||
ts: now.Add(-10 * time.Minute),
|
||||
expItems: items[1:],
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actItems, actErr := mem.Updated(tc.ks, tc.ts)
|
||||
if actErr != nil {
|
||||
t.Errorf("exp nil, got %v", actErr)
|
||||
}
|
||||
if diff := cmp.Diff(tc.expItems, actItems); diff != "" {
|
||||
t.Errorf("(exp +, got -)\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue