From fa1da50fc5f8f9ef9df23a35a86e497ed9a9bbff Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Sun, 2 Dec 2018 11:04:16 +1000 Subject: [PATCH] Implement state-based sync (#144) * Migrate uuids of books that already exist in server * Remove actions * Add dirty flag for notes and books * Drop actions table * Implement sync * Add debug * Update uuid after posting resources to the server * Fix dev script --- client/client.go | 420 +++ cmd/add/add.go | 36 +- cmd/edit/edit.go | 11 +- cmd/remove/remove.go | 15 +- cmd/root/root.go | 4 +- cmd/sync/sync.go | 1029 +++++- cmd/sync/sync_test.go | 3001 +++++++++++++++++ cmd/version/version.go | 1 + cmd/view/view.go | 1 + core/action.go | 98 - core/action_test.go | 56 - core/core.go | 46 + core/core_test.go | 77 + core/models.go | 150 + core/models_test.go | 795 +++++ core/reducer.go | 257 -- core/reducer_test.go | 304 -- core/upgrade.go | 4 +- infra/main.go | 94 +- main_test.go | 272 +- ...pre-dnote.json => legacy-2-pre-dnote.json} | 0 ...pre-dnote.json => legacy-3-pre-dnote.json} | 0 ...dnoterc.yaml => legacy-4-pre-dnoterc.yaml} | 0 ...ctions.json => legacy-5-post-actions.json} | 0 ...actions.json => legacy-5-pre-actions.json} | 0 ...st-dnote.json => legacy-6-post-dnote.json} | 0 ...pre-dnote.json => legacy-6-pre-dnote.json} | 0 ...ctions.json => legacy-7-post-actions.json} | 0 ...actions.json => legacy-7-pre-actions.json} | 0 .../{8-actions.json => legacy-8-actions.json} | 0 .../{8-dnote.json => legacy-8-dnote.json} | 0 .../{8-dnoterc.yaml => legacy-8-dnoterc.yaml} | 0 .../{8-schema.yaml => legacy-8-schema.yaml} | 0 ...mestamps.yaml => legacy-8-timestamps.yaml} | 0 migrate/fixtures/local-1-pre-schema.sql | 35 + migrate/fixtures/local-5-pre-schema.sql | 35 + migrate/fixtures/local-7-pre-schema.sql | 25 + migrate/fixtures/remote-1-pre-schema.sql | 35 + migrate/legacy_test.go | 46 +- migrate/migrate.go | 58 +- migrate/migrate_test.go | 732 +++- migrate/migrations.go | 224 ++ scripts/dev.sh | 9 +- scripts/test.sh | 4 + testutils/fixtures/dnote1.json | 13 - testutils/fixtures/dnote2.json | 19 - testutils/fixtures/dnote3.json | 30 - testutils/fixtures/dnote4.json | 17 - testutils/fixtures/schema.sql | 12 +- testutils/main.go | 27 +- testutils/setup.go | 10 +- utils/utils.go | 25 + 52 files changed, 6598 insertions(+), 1429 deletions(-) create mode 100644 client/client.go create mode 100644 cmd/sync/sync_test.go delete mode 100644 core/action.go delete mode 100644 core/action_test.go create mode 100644 core/core_test.go create mode 100644 core/models.go create mode 100644 core/models_test.go delete mode 100644 core/reducer.go delete mode 100644 core/reducer_test.go rename migrate/fixtures/{2-pre-dnote.json => legacy-2-pre-dnote.json} (100%) rename migrate/fixtures/{3-pre-dnote.json => legacy-3-pre-dnote.json} (100%) rename migrate/fixtures/{4-pre-dnoterc.yaml => legacy-4-pre-dnoterc.yaml} (100%) rename migrate/fixtures/{5-post-actions.json => legacy-5-post-actions.json} (100%) rename migrate/fixtures/{5-pre-actions.json => legacy-5-pre-actions.json} (100%) rename migrate/fixtures/{6-post-dnote.json => legacy-6-post-dnote.json} (100%) rename migrate/fixtures/{6-pre-dnote.json => legacy-6-pre-dnote.json} (100%) rename migrate/fixtures/{7-post-actions.json => legacy-7-post-actions.json} (100%) rename migrate/fixtures/{7-pre-actions.json => legacy-7-pre-actions.json} (100%) rename migrate/fixtures/{8-actions.json => legacy-8-actions.json} (100%) rename migrate/fixtures/{8-dnote.json => legacy-8-dnote.json} (100%) rename migrate/fixtures/{8-dnoterc.yaml => legacy-8-dnoterc.yaml} (100%) rename migrate/fixtures/{8-schema.yaml => legacy-8-schema.yaml} (100%) rename migrate/fixtures/{8-timestamps.yaml => legacy-8-timestamps.yaml} (100%) create mode 100644 migrate/fixtures/local-1-pre-schema.sql create mode 100644 migrate/fixtures/local-5-pre-schema.sql create mode 100644 migrate/fixtures/local-7-pre-schema.sql create mode 100644 migrate/fixtures/remote-1-pre-schema.sql delete mode 100644 testutils/fixtures/dnote1.json delete mode 100644 testutils/fixtures/dnote2.json delete mode 100644 testutils/fixtures/dnote3.json delete mode 100644 testutils/fixtures/dnote4.json diff --git a/client/client.go b/client/client.go new file mode 100644 index 00000000..45765a27 --- /dev/null +++ b/client/client.go @@ -0,0 +1,420 @@ +// Package client provides interfaces for interacting with the Dnote server +// and the data structures for responses +package client + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/dnote/cli/infra" + "github.com/dnote/cli/utils" + "github.com/pkg/errors" +) + +// GetSyncStateResp is the response get sync state endpoint +type GetSyncStateResp struct { + FullSyncBefore int `json:"full_sync_before"` + MaxUSN int `json:"max_usn"` + CurrentTime int64 `json:"current_time"` +} + +// GetSyncState gets the sync state response from the server +func GetSyncState(apiKey string, ctx infra.DnoteCtx) (GetSyncStateResp, error) { + var ret GetSyncStateResp + + res, err := utils.DoAuthorizedReq(ctx, apiKey, "GET", "/v1/sync/state", "") + if err != nil { + return ret, errors.Wrap(err, "constructing http request") + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return ret, errors.Wrap(err, "reading the response body") + } + + if err = json.Unmarshal(body, &ret); err != nil { + return ret, errors.Wrap(err, "unmarshalling the payload") + } + + return ret, nil +} + +// SyncFragNote represents a note in a sync fragment and contains only the necessary information +// for the client to sync the note locally +type SyncFragNote struct { + UUID string `json:"uuid"` + BookUUID string `json:"book_uuid"` + USN int `json:"usn"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"edited_on"` + Content string `json:"content"` + Public bool `json:"public"` + Deleted bool `json:"deleted"` +} + +// SyncFragBook represents a book in a sync fragment and contains only the necessary information +// for the client to sync the note locally +type SyncFragBook struct { + UUID string `json:"uuid"` + USN int `json:"usn"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + AddedOn int64 `json:"added_on"` + Label string `json:"label"` + Deleted bool `json:"deleted"` +} + +// SyncFragment contains a piece of information about the server's state. +type SyncFragment struct { + FragMaxUSN int `json:"frag_max_usn"` + UserMaxUSN int `json:"user_max_usn"` + CurrentTime int64 `json:"current_time"` + Notes []SyncFragNote `json:"notes"` + Books []SyncFragBook `json:"books"` + ExpungedNotes []string `json:"expunged_notes"` + ExpungedBooks []string `json:"expunged_books"` +} + +// GetSyncFragmentResp is the response from the get sync fragment endpoint +type GetSyncFragmentResp struct { + Fragment SyncFragment `json:"fragment"` +} + +// GetSyncFragment gets a sync fragment response from the server +func GetSyncFragment(ctx infra.DnoteCtx, apiKey string, afterUSN int) (GetSyncFragmentResp, error) { + v := url.Values{} + v.Set("after_usn", strconv.Itoa(afterUSN)) + queryStr := v.Encode() + + path := fmt.Sprintf("/v1/sync/fragment?%s", queryStr) + res, err := utils.DoAuthorizedReq(ctx, apiKey, "GET", path, "") + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return GetSyncFragmentResp{}, errors.Wrap(err, "reading the response body") + } + + var resp GetSyncFragmentResp + if err = json.Unmarshal(body, &resp); err != nil { + return resp, errors.Wrap(err, "unmarshalling the payload") + } + + return resp, nil +} + +// RespBook is the book in the response from the create book api +type RespBook struct { + ID int `json:"id"` + UUID string `json:"uuid"` + USN int `json:"usn"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Label string `json:"label"` +} + +// CreateBookPayload is a payload for creating a book +type CreateBookPayload struct { + Name string `json:"name"` +} + +// CreateBookResp is the response from create book api +type CreateBookResp struct { + Book RespBook `json:"book"` +} + +// checkRespErr checks if the given http response indicates an error. It returns a boolean indicating +// if the response is an error, and a decoded error message. +func checkRespErr(res *http.Response) (bool, string, error) { + if res.StatusCode < 400 { + return false, "", nil + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return true, "", errors.Wrapf(err, "server responded with %d but could not read the response body", res.StatusCode) + } + + bodyStr := string(body) + message := fmt.Sprintf(`response %d "%s"`, res.StatusCode, strings.TrimRight(bodyStr, "\n")) + return true, message, nil +} + +// CreateBook creates a new book in the server +func CreateBook(ctx infra.DnoteCtx, apiKey, label string) (CreateBookResp, error) { + payload := CreateBookPayload{ + Name: label, + } + b, err := json.Marshal(payload) + if err != nil { + return CreateBookResp{}, errors.Wrap(err, "marshaling payload") + } + + res, err := utils.DoAuthorizedReq(ctx, apiKey, "POST", "/v1/books", string(b)) + if err != nil { + return CreateBookResp{}, errors.Wrap(err, "posting a book to the server") + } + + ok, message, err := checkRespErr(res) + if err != nil { + return CreateBookResp{}, errors.Wrap(err, "checking repsonse error") + } + if ok { + return CreateBookResp{}, errors.New(message) + } + + var resp CreateBookResp + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return resp, errors.Wrap(err, "decoding response payload") + } + + return resp, nil +} + +type updateBookPayload struct { + Name *string `json:"name"` +} + +// UpdateBookResp is the response from create book api +type UpdateBookResp struct { + Book RespBook `json:"book"` +} + +// UpdateBook updates a book in the server +func UpdateBook(ctx infra.DnoteCtx, apiKey, label, uuid string) (UpdateBookResp, error) { + payload := updateBookPayload{ + Name: &label, + } + b, err := json.Marshal(payload) + if err != nil { + return UpdateBookResp{}, errors.Wrap(err, "marshaling payload") + } + + endpoint := fmt.Sprintf("/v1/books/%s", uuid) + res, err := utils.DoAuthorizedReq(ctx, apiKey, "PATCH", endpoint, string(b)) + if err != nil { + return UpdateBookResp{}, errors.Wrap(err, "posting a book to the server") + } + + ok, message, err := checkRespErr(res) + if err != nil { + return UpdateBookResp{}, errors.Wrap(err, "checking repsonse error") + } + if ok { + return UpdateBookResp{}, errors.New(message) + } + + var resp UpdateBookResp + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return resp, errors.Wrap(err, "decoding payload") + } + + return resp, nil +} + +// DeleteBookResp is the response from create book api +type DeleteBookResp struct { + Status int `json:"status"` + Book RespBook `json:"book"` +} + +// DeleteBook deletes a book in the server +func DeleteBook(ctx infra.DnoteCtx, apiKey, uuid string) (DeleteBookResp, error) { + endpoint := fmt.Sprintf("/v1/books/%s", uuid) + res, err := utils.DoAuthorizedReq(ctx, apiKey, "DELETE", endpoint, "") + if err != nil { + return DeleteBookResp{}, errors.Wrap(err, "deleting a book in the server") + } + + ok, message, err := checkRespErr(res) + if err != nil { + return DeleteBookResp{}, errors.Wrap(err, "checking repsonse error") + } + if ok { + return DeleteBookResp{}, errors.New(message) + } + + var resp DeleteBookResp + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return resp, errors.Wrap(err, "decoding the response") + } + + return resp, nil +} + +// CreateNotePayload is a payload for creating a note +type CreateNotePayload struct { + BookUUID string `json:"book_uuid"` + Content string `json:"content"` +} + +// CreateNoteResp is the response from create note endpoint +type CreateNoteResp struct { + Result RespNote `json:"result"` +} + +type respNoteBook struct { + UUID string `json:"uuid"` + Label string `json:"label"` +} + +type respNoteUser struct { + Name string `json:"name"` +} + +// RespNote is a note in the response +type RespNote struct { + UUID string `json:"uuid"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Content string `json:"content"` + AddedOn int64 `json:"added_on"` + Public bool `json:"public"` + USN int `json:"usn"` + Book respNoteBook `json:"book"` + User respNoteUser `json:"user"` +} + +// CreateNote creates a note in the server +func CreateNote(ctx infra.DnoteCtx, apiKey, bookUUID, content string) (CreateNoteResp, error) { + payload := CreateNotePayload{ + BookUUID: bookUUID, + Content: content, + } + b, err := json.Marshal(payload) + if err != nil { + return CreateNoteResp{}, errors.Wrap(err, "marshaling payload") + } + + res, err := utils.DoAuthorizedReq(ctx, apiKey, "POST", "/v1/notes", string(b)) + if err != nil { + return CreateNoteResp{}, errors.Wrap(err, "posting a book to the server") + } + + ok, message, err := checkRespErr(res) + if err != nil { + return CreateNoteResp{}, errors.Wrap(err, "checking repsonse error") + } + if ok { + return CreateNoteResp{}, errors.New(message) + } + + var resp CreateNoteResp + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return CreateNoteResp{}, errors.Wrap(err, "decoding payload") + } + + return resp, nil +} + +type updateNotePayload struct { + BookUUID *string `json:"book_uuid"` + Content *string `json:"content"` + Public *bool `json:"public"` +} + +// UpdateNoteResp is the response from create book api +type UpdateNoteResp struct { + Status int `json:"status"` + Result RespNote `json:"result"` +} + +// UpdateNote updates a note in the server +func UpdateNote(ctx infra.DnoteCtx, apiKey, uuid, bookUUID, content string, public bool) (UpdateNoteResp, error) { + payload := updateNotePayload{ + BookUUID: &bookUUID, + Content: &content, + Public: &public, + } + b, err := json.Marshal(payload) + if err != nil { + return UpdateNoteResp{}, errors.Wrap(err, "marshaling payload") + } + + endpoint := fmt.Sprintf("/v1/notes/%s", uuid) + res, err := utils.DoAuthorizedReq(ctx, apiKey, "PATCH", endpoint, string(b)) + if err != nil { + return UpdateNoteResp{}, errors.Wrap(err, "patching a note to the server") + } + + ok, message, err := checkRespErr(res) + if err != nil { + return UpdateNoteResp{}, errors.Wrap(err, "checking repsonse error") + } + if ok { + return UpdateNoteResp{}, errors.New(message) + } + + var resp UpdateNoteResp + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return UpdateNoteResp{}, errors.Wrap(err, "decoding payload") + } + + return resp, nil +} + +// DeleteNoteResp is the response from remove note api +type DeleteNoteResp struct { + Status int `json:"status"` + Result RespNote `json:"result"` +} + +// DeleteNote removes a note in the server +func DeleteNote(ctx infra.DnoteCtx, apiKey, uuid string) (DeleteNoteResp, error) { + endpoint := fmt.Sprintf("/v1/notes/%s", uuid) + res, err := utils.DoAuthorizedReq(ctx, apiKey, "DELETE", endpoint, "") + if err != nil { + return DeleteNoteResp{}, errors.Wrap(err, "patching a note to the server") + } + + ok, message, err := checkRespErr(res) + if err != nil { + return DeleteNoteResp{}, errors.Wrap(err, "checking repsonse error") + } + if ok { + return DeleteNoteResp{}, errors.New(message) + } + + var resp DeleteNoteResp + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return DeleteNoteResp{}, errors.Wrap(err, "decoding payload") + } + + return resp, nil +} + +// GetBooksResp is a response from get books endpoint +type GetBooksResp []struct { + UUID string `json:"uuid"` + Label string `json:"label"` +} + +// GetBooks gets books from the server +func GetBooks(ctx infra.DnoteCtx, apiKey string) (GetBooksResp, error) { + res, err := utils.DoAuthorizedReq(ctx, apiKey, "GET", "/v1/books", "") + if err != nil { + return GetBooksResp{}, errors.Wrap(err, "making http request") + } + + ok, message, err := checkRespErr(res) + if err != nil { + return GetBooksResp{}, errors.Wrap(err, "checking repsonse error") + } + if ok { + return GetBooksResp{}, errors.New(message) + } + + var resp GetBooksResp + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return GetBooksResp{}, errors.Wrap(err, "decoding payload") + } + + return resp, nil +} diff --git a/cmd/add/add.go b/cmd/add/add.go index f15ff369..d752deb1 100644 --- a/cmd/add/add.go +++ b/cmd/add/add.go @@ -13,6 +13,8 @@ import ( "github.com/spf13/cobra" ) +var reservedBookNames = []string{"trash", "conflicts"} + var content string var example = ` @@ -47,10 +49,24 @@ func NewCmd(ctx infra.DnoteCtx) *cobra.Command { return cmd } +func isReservedName(name string) bool { + for _, n := range reservedBookNames { + if name == n { + return true + } + } + + return false +} + func newRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { bookName := args[0] + if isReservedName(bookName) { + return errors.Errorf("book name '%s' is reserved", bookName) + } + if content == "" { fpath := core.GetDnoteTmpContentPath(ctx) err := core.GetEditorInput(ctx, fpath, &content) @@ -92,33 +108,25 @@ func writeNote(ctx infra.DnoteCtx, bookLabel string, content string, ts int64) e err = tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID) if err == sql.ErrNoRows { bookUUID = utils.GenerateUUID() - _, err = tx.Exec("INSERT INTO books (uuid, label) VALUES (?, ?)", bookUUID, bookLabel) + + b := core.NewBook(bookUUID, bookLabel, 0, false, true) + err = b.Insert(tx) if err != nil { tx.Rollback() return errors.Wrap(err, "creating the book") } - - err = core.LogActionAddBook(tx, bookLabel) - if err != nil { - tx.Rollback() - return errors.Wrap(err, "logging action") - } } else if err != nil { return errors.Wrap(err, "finding the book") } noteUUID := utils.GenerateUUID() - _, err = tx.Exec(`INSERT INTO notes (uuid, book_uuid, content, added_on, public) - VALUES (?, ?, ?, ?, ?);`, noteUUID, bookUUID, content, ts, false) + n := core.NewNote(noteUUID, bookUUID, content, ts, 0, 0, false, false, true) + + err = n.Insert(tx) if err != nil { tx.Rollback() return errors.Wrap(err, "creating the note") } - err = core.LogActionAddNote(tx, noteUUID, bookLabel, content, ts) - if err != nil { - tx.Rollback() - return errors.Wrap(err, "logging action") - } tx.Commit() diff --git a/cmd/edit/edit.go b/cmd/edit/edit.go index 501613e5..455ee8fb 100644 --- a/cmd/edit/edit.go +++ b/cmd/edit/edit.go @@ -91,20 +91,15 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { if err != nil { return errors.Wrap(err, "beginning a transaction") } + _, err = tx.Exec(`UPDATE notes - SET content = ?, edited_on = ? - WHERE id = ? AND book_uuid = ?`, newContent, ts, noteID, bookUUID) + SET content = ?, edited_on = ?, dirty = ? + WHERE id = ? AND book_uuid = ?`, newContent, ts, true, noteID, bookUUID) if err != nil { tx.Rollback() return errors.Wrap(err, "updating the note") } - err = core.LogActionEditNote(tx, noteUUID, bookLabel, newContent, ts) - if err != nil { - tx.Rollback() - return errors.Wrap(err, "logging an action") - } - tx.Commit() log.Success("edited the note\n") diff --git a/cmd/remove/remove.go b/cmd/remove/remove.go index 005a011a..c40bd87c 100644 --- a/cmd/remove/remove.go +++ b/cmd/remove/remove.go @@ -95,12 +95,9 @@ func removeNote(ctx infra.DnoteCtx, noteID, bookLabel string) error { return errors.Wrap(err, "beginning a transaction") } - if _, err = tx.Exec("DELETE FROM notes WHERE uuid = ? AND book_uuid = ?", noteUUID, bookUUID); err != nil { + if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, content = ? WHERE uuid = ? AND book_uuid = ?", true, true, "", noteUUID, bookUUID); err != nil { return errors.Wrap(err, "removing the note") } - if err = core.LogActionRemoveNote(tx, noteUUID, bookLabel); err != nil { - return errors.Wrap(err, "logging the remove_note action") - } tx.Commit() log.Successf("removed from %s\n", bookLabel) @@ -130,15 +127,15 @@ func removeBook(ctx infra.DnoteCtx, bookLabel string) error { return errors.Wrap(err, "beginning a transaction") } - if _, err = tx.Exec("DELETE FROM notes WHERE book_uuid = ?", bookUUID); err != nil { + if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, content = ? WHERE book_uuid = ?", true, true, "", bookUUID); err != nil { return errors.Wrap(err, "removing notes in the book") } - if _, err = tx.Exec("DELETE FROM books WHERE uuid = ?", bookUUID); err != nil { + + // override the label with a random string + uniqLabel := utils.GenerateUUID() + if _, err = tx.Exec("UPDATE books SET deleted = ?, dirty = ?, label = ? WHERE uuid = ?", true, true, uniqLabel, bookUUID); err != nil { return errors.Wrap(err, "removing the book") } - if err = core.LogActionRemoveBook(tx, bookLabel); err != nil { - return errors.Wrap(err, "loging the remove_book action") - } tx.Commit() diff --git a/cmd/root/root.go b/cmd/root/root.go index 916f2d84..416f36cd 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -34,14 +34,14 @@ func Prepare(ctx infra.DnoteCtx) error { if err := infra.InitDB(ctx); err != nil { return errors.Wrap(err, "initializing database") } - if err := infra.InitSystem(ctx); err != nil { + if err := core.InitSystem(ctx); err != nil { return errors.Wrap(err, "initializing system data") } if err := migrate.Legacy(ctx); err != nil { return errors.Wrap(err, "running legacy migration") } - if err := migrate.Run(ctx, migrate.LocalSequence); err != nil { + if err := migrate.Run(ctx, migrate.LocalSequence, migrate.LocalMode); err != nil { return errors.Wrap(err, "running migration") } diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go index 209d66d4..772b845a 100644 --- a/cmd/sync/sync.go +++ b/cmd/sync/sync.go @@ -1,53 +1,859 @@ package sync import ( - "bytes" - "compress/gzip" "database/sql" - "encoding/json" "fmt" - "io" - "io/ioutil" - "net/http" - "github.com/dnote/actions" + "github.com/dnote/cli/client" "github.com/dnote/cli/core" "github.com/dnote/cli/infra" "github.com/dnote/cli/log" + "github.com/dnote/cli/migrate" "github.com/pkg/errors" "github.com/spf13/cobra" ) +const ( + modeInsert = iota + modeUpdate +) + var example = ` dnote sync` +var isFullSync bool + // NewCmd returns a new sync command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "sync", Aliases: []string{"s"}, - Short: "Sync dnote with the dnote server", + Short: "Sync data with the server", Example: example, RunE: newRun(ctx), } + f := cmd.Flags() + f.BoolVarP(&isFullSync, "full", "f", false, "perform a full sync instead of incrementally syncing only the changed data.") + return cmd } -type responseData struct { - Actions []actions.Action `json:"actions"` - Bookmark int `json:"bookmark"` +func getLastSyncAt(tx *sql.Tx) (int, error) { + var ret int + + err := tx.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastSyncAt).Scan(&ret) + if err != nil { + return ret, errors.Wrap(err, "querying last sync time") + } + + return ret, nil } -type syncPayload struct { - Bookmark int `json:"bookmark"` - Actions []byte `json:"actions"` // gziped +func getLastMaxUSN(tx *sql.Tx) (int, error) { + var ret int + + err := tx.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastMaxUSN).Scan(&ret) + if err != nil { + return ret, errors.Wrap(err, "querying last user max_usn") + } + + return ret, nil +} + +// syncList is an aggregation of resources represented in the sync fragments +type syncList struct { + Notes map[string]client.SyncFragNote + Books map[string]client.SyncFragBook + ExpungedNotes map[string]bool + ExpungedBooks map[string]bool + MaxUSN int + MaxCurrentTime int64 +} + +func (l syncList) getLength() int { + return len(l.Notes) + len(l.Books) + len(l.ExpungedNotes) + len(l.ExpungedBooks) +} + +func newSyncList(fragments []client.SyncFragment) syncList { + notes := map[string]client.SyncFragNote{} + books := map[string]client.SyncFragBook{} + expungedNotes := map[string]bool{} + expungedBooks := map[string]bool{} + var maxUSN int + var maxCurrentTime int64 + + for _, fragment := range fragments { + for _, note := range fragment.Notes { + notes[note.UUID] = note + } + for _, book := range fragment.Books { + books[book.UUID] = book + } + for _, uuid := range fragment.ExpungedBooks { + expungedBooks[uuid] = true + } + for _, uuid := range fragment.ExpungedNotes { + expungedNotes[uuid] = true + } + + if fragment.FragMaxUSN > maxUSN { + maxUSN = fragment.FragMaxUSN + } + if fragment.CurrentTime > maxCurrentTime { + maxCurrentTime = fragment.CurrentTime + } + } + + return syncList{ + Notes: notes, + Books: books, + ExpungedNotes: expungedNotes, + ExpungedBooks: expungedBooks, + MaxUSN: maxUSN, + MaxCurrentTime: maxCurrentTime, + } +} + +// getSyncList gets a list of all sync fragments after the specified usn +// and aggregates them into a syncList data structure +func getSyncList(ctx infra.DnoteCtx, apiKey string, afterUSN int) (syncList, error) { + fragments, err := getSyncFragments(ctx, apiKey, afterUSN) + if err != nil { + return syncList{}, errors.Wrap(err, "getting sync fragments") + } + + ret := newSyncList(fragments) + + return ret, nil +} + +// getSyncFragments repeatedly gets all sync fragments after the specified usn until there is no more new data +// remaining and returns the buffered list +func getSyncFragments(ctx infra.DnoteCtx, apiKey string, afterUSN int) ([]client.SyncFragment, error) { + var buf []client.SyncFragment + + nextAfterUSN := afterUSN + + for { + resp, err := client.GetSyncFragment(ctx, apiKey, nextAfterUSN) + if err != nil { + return buf, errors.Wrap(err, "getting sync fragment") + } + + frag := resp.Fragment + buf = append(buf, frag) + + nextAfterUSN = frag.FragMaxUSN + + // if there is no more data, break + if nextAfterUSN == 0 { + break + } + } + + log.Debug("received sync fragments: %+v\n", buf) + + return buf, nil +} + +// resolveLabel resolves a book label conflict by repeatedly appending an increasing integer +// to the label until it finds a unique label. It returns the first non-conflicting label. +func resolveLabel(tx *sql.Tx, label string) (string, error) { + var ret string + + for i := 2; ; i++ { + ret = fmt.Sprintf("%s (%d)", label, i) + + var cnt int + if err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", ret).Scan(&cnt); err != nil { + return "", errors.Wrapf(err, "checking availability of label %s", ret) + } + + if cnt == 0 { + break + } + } + + return ret, nil +} + +// mergeBook inserts or updates the given book in the local database. +// If a book with a duplicate label exists locally, it renames the duplicate by appending a number. +func mergeBook(tx *sql.Tx, b client.SyncFragBook, mode int) error { + var count int + if err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", b.Label).Scan(&count); err != nil { + return errors.Wrapf(err, "checking for books with a duplicate label %s", b.Label) + } + + // if duplicate exists locally, rename it and mark it dirty + if count > 0 { + newLabel, err := resolveLabel(tx, b.Label) + if err != nil { + return errors.Wrap(err, "getting a new book label for conflict resolution") + } + + if _, err := tx.Exec("UPDATE books SET label = ?, dirty = ? WHERE label = ?", newLabel, true, b.Label); err != nil { + return errors.Wrap(err, "resolving duplicate book label") + } + } + + if mode == modeInsert { + book := core.NewBook(b.UUID, b.Label, b.USN, false, false) + if err := book.Insert(tx); err != nil { + return errors.Wrapf(err, "inserting note with uuid %s", b.UUID) + } + } else if mode == modeUpdate { + // TODO: if the client copy is dirty, perform field-by-field merge and report conflict instead of overwriting + if _, err := tx.Exec("UPDATE books SET usn = ?, uuid = ?, label = ?, deleted = ? WHERE uuid = ?", + b.USN, b.UUID, b.Label, b.Deleted, b.UUID); err != nil { + return errors.Wrapf(err, "updating local book %s", b.UUID) + } + } + + return nil +} + +func stepSyncBook(tx *sql.Tx, b client.SyncFragBook) error { + var localUSN int + var dirty bool + err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", b.UUID).Scan(&localUSN, &dirty) + if err != nil && err != sql.ErrNoRows { + return errors.Wrapf(err, "getting local book %s", b.UUID) + } + + // if book exists in the server and does not exist in the client + if err == sql.ErrNoRows { + if e := mergeBook(tx, b, modeInsert); e != nil { + return errors.Wrapf(e, "resolving book") + } + + return nil + } + + if e := mergeBook(tx, b, modeUpdate); e != nil { + return errors.Wrapf(e, "resolving book") + } + + return nil +} + +func mergeNote(tx *sql.Tx, serverNote client.SyncFragNote, localNote core.Note) error { + var bookDeleted bool + err := tx.QueryRow("SELECT deleted FROM books WHERE uuid = ?", localNote.BookUUID).Scan(&bookDeleted) + if err != nil { + return errors.Wrapf(err, "checking if local book %s is deleted", localNote.BookUUID) + } + + // if the book is deleted, noop + if bookDeleted { + return nil + } + + // if the local copy is deleted, and the it was edited on the server, override with server values and mark it not dirty. + if localNote.Deleted { + if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, content = ?, edited_on = ?, deleted = ?, public = ?, dirty = ? WHERE uuid = ?", + serverNote.USN, serverNote.BookUUID, serverNote.Content, serverNote.EditedOn, serverNote.Deleted, serverNote.Public, false, serverNote.UUID); err != nil { + return errors.Wrapf(err, "updating local note %s", serverNote.UUID) + } + + return nil + } + + // TODO: if the client copy is dirty, perform field-by-field merge and report conflict instead of overwriting + if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, content = ?, edited_on = ?, deleted = ?, public = ? WHERE uuid = ?", + serverNote.USN, serverNote.BookUUID, serverNote.Content, serverNote.EditedOn, serverNote.Deleted, serverNote.Public, serverNote.UUID); err != nil { + return errors.Wrapf(err, "updating local note %s", serverNote.UUID) + } + + return nil +} + +func stepSyncNote(tx *sql.Tx, n client.SyncFragNote) error { + var localNote core.Note + err := tx.QueryRow("SELECT usn, book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID). + Scan(&localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted) + if err != nil && err != sql.ErrNoRows { + return errors.Wrapf(err, "getting local note %s", n.UUID) + } + + // if note exists in the server and does not exist in the client, insert the note. + if err == sql.ErrNoRows { + note := core.NewNote(n.UUID, n.BookUUID, n.Content, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, false) + + if err := note.Insert(tx); err != nil { + return errors.Wrapf(err, "inserting note with uuid %s", n.UUID) + } + } else { + if err := mergeNote(tx, n, localNote); err != nil { + return errors.Wrap(err, "merging local note") + } + } + + return nil +} + +func fullSyncNote(tx *sql.Tx, n client.SyncFragNote) error { + var localNote core.Note + err := tx.QueryRow("SELECT usn,book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID). + Scan(&localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted) + if err != nil && err != sql.ErrNoRows { + return errors.Wrapf(err, "getting local note %s", n.UUID) + } + + // if note exists in the server and does not exist in the client, insert the note. + if err == sql.ErrNoRows { + note := core.NewNote(n.UUID, n.BookUUID, n.Content, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, false) + + if err := note.Insert(tx); err != nil { + return errors.Wrapf(err, "inserting note with uuid %s", n.UUID) + } + } else if n.USN > localNote.USN { + if err := mergeNote(tx, n, localNote); err != nil { + return errors.Wrap(err, "merging local note") + } + } + + return nil +} + +func syncDeleteNote(tx *sql.Tx, noteUUID string) error { + var localUSN int + var dirty bool + err := tx.QueryRow("SELECT usn, dirty FROM notes WHERE uuid = ?", noteUUID).Scan(&localUSN, &dirty) + if err != nil && err != sql.ErrNoRows { + return errors.Wrapf(err, "getting local note %s", noteUUID) + } + + // if note does not exist on client, noop + if err == sql.ErrNoRows { + return nil + } + + // if local copy is not dirty, delete + if !dirty { + _, err = tx.Exec("DELETE FROM notes WHERE uuid = ?", noteUUID) + if err != nil { + return errors.Wrapf(err, "deleting local note %s", noteUUID) + } + } + + return nil +} + +// checkNotesPristine checks that none of the notes in the given book are dirty +func checkNotesPristine(tx *sql.Tx, bookUUID string) (bool, error) { + var count int + if err := tx.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ? AND dirty = ?", bookUUID, true).Scan(&count); err != nil { + return false, errors.Wrapf(err, "counting notes that are dirty in book %s", bookUUID) + } + + if count > 0 { + return false, nil + } + + return true, nil +} + +func syncDeleteBook(tx *sql.Tx, bookUUID string) error { + var localUSN int + var dirty bool + err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", bookUUID).Scan(&localUSN, &dirty) + if err != nil && err != sql.ErrNoRows { + return errors.Wrapf(err, "getting local book %s", bookUUID) + } + + // if book does not exist on client, noop + if err == sql.ErrNoRows { + return nil + } + + // if local copy is dirty, noop. it will be uploaded to the server later + if dirty { + return nil + } + + ok, err := checkNotesPristine(tx, bookUUID) + if err != nil { + return errors.Wrap(err, "checking if any notes are dirty in book") + } + // if the local book is not pristine, do not delete but mark it as dirty + // so that it can be uploaded to the server later and become un-deleted + if !ok { + _, err = tx.Exec("UPDATE books SET dirty = ? WHERE uuid = ?", true, bookUUID) + if err != nil { + return errors.Wrapf(err, "marking a book dirty with uuid %s", bookUUID) + } + + return nil + } + + _, err = tx.Exec("DELETE FROM notes WHERE book_uuid = ?", bookUUID) + if err != nil { + return errors.Wrapf(err, "deleting local notes of the book %s", bookUUID) + } + + _, err = tx.Exec("DELETE FROM books WHERE uuid = ?", bookUUID) + if err != nil { + return errors.Wrapf(err, "deleting local book %s", bookUUID) + } + + return nil +} + +func fullSyncBook(tx *sql.Tx, b client.SyncFragBook) error { + var localUSN int + var dirty bool + err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", b.UUID).Scan(&localUSN, &dirty) + if err != nil && err != sql.ErrNoRows { + return errors.Wrapf(err, "getting local book %s", b.UUID) + } + + // if book exists in the server and does not exist in the client + if err == sql.ErrNoRows { + if e := mergeBook(tx, b, modeInsert); e != nil { + return errors.Wrapf(e, "resolving book") + } + } else if b.USN > localUSN { + if e := mergeBook(tx, b, modeUpdate); e != nil { + return errors.Wrapf(e, "resolving book") + } + } + + return nil +} + +// checkNoteInList checks if the given syncList contains the note with the given uuid +func checkNoteInList(uuid string, list *syncList) bool { + if _, ok := list.Notes[uuid]; ok { + return true + } + + if _, ok := list.ExpungedNotes[uuid]; ok { + return true + } + + return false +} + +// checkBookInList checks if the given syncList contains the book with the given uuid +func checkBookInList(uuid string, list *syncList) bool { + if _, ok := list.Books[uuid]; ok { + return true + } + + if _, ok := list.ExpungedBooks[uuid]; ok { + return true + } + + return false +} + +// cleanLocalNotes deletes from the local database any notes that are in invalid state +// judging by the full list of resources in the server. Concretely, the only acceptable +// situation in which a local note is not present in the server is if it is new and has not been +// uploaded (i.e. dirty and usn is 0). Otherwise, it is a result of some kind of error and should be cleaned. +func cleanLocalNotes(tx *sql.Tx, fullList *syncList) error { + rows, err := tx.Query("SELECT uuid, usn, dirty FROM notes") + if err != nil { + return errors.Wrap(err, "getting local notes") + } + defer rows.Close() + + for rows.Next() { + var note core.Note + if err := rows.Scan(¬e.UUID, ¬e.USN, ¬e.Dirty); err != nil { + return errors.Wrap(err, "scanning a row for local note") + } + + ok := checkNoteInList(note.UUID, fullList) + if !ok && (!note.Dirty || note.USN != 0) { + err = note.Expunge(tx) + if err != nil { + return errors.Wrap(err, "expunging a note") + } + } + } + + return nil +} + +// cleanLocalBooks deletes from the local database any books that are in invalid state +func cleanLocalBooks(tx *sql.Tx, fullList *syncList) error { + rows, err := tx.Query("SELECT uuid, usn, dirty FROM books") + if err != nil { + return errors.Wrap(err, "getting local books") + } + defer rows.Close() + + for rows.Next() { + var book core.Book + if err := rows.Scan(&book.UUID, &book.USN, &book.Dirty); err != nil { + return errors.Wrap(err, "scanning a row for local book") + } + + ok := checkBookInList(book.UUID, fullList) + if !ok && (!book.Dirty || book.USN != 0) { + err = book.Expunge(tx) + if err != nil { + return errors.Wrap(err, "expunging a book") + } + } + } + + return nil +} + +func fullSync(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) error { + log.Debug("performing a full sync\n") + + list, err := getSyncList(ctx, apiKey, 0) + if err != nil { + return errors.Wrap(err, "getting sync list") + } + + log.Infof("resolving delta (total %d).", list.getLength()) + + // clean resources that are in erroneous states + if err := cleanLocalNotes(tx, &list); err != nil { + return errors.Wrap(err, "cleaning up local notes") + } + if err := cleanLocalBooks(tx, &list); err != nil { + return errors.Wrap(err, "cleaning up local books") + } + + for _, note := range list.Notes { + if err := fullSyncNote(tx, note); err != nil { + return errors.Wrap(err, "merging note") + } + } + for _, book := range list.Books { + if err := fullSyncBook(tx, book); err != nil { + return errors.Wrap(err, "merging book") + } + } + + for noteUUID := range list.ExpungedNotes { + if err := syncDeleteNote(tx, noteUUID); err != nil { + return errors.Wrap(err, "deleting note") + } + } + for bookUUID := range list.ExpungedBooks { + if err := syncDeleteBook(tx, bookUUID); err != nil { + return errors.Wrap(err, "deleting book") + } + } + + err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN) + if err != nil { + return errors.Wrap(err, "saving sync state") + } + + fmt.Println(" done.") + + return nil +} + +func stepSync(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string, afterUSN int) error { + log.Debug("performing a step sync\n") + + list, err := getSyncList(ctx, apiKey, afterUSN) + if err != nil { + return errors.Wrap(err, "getting sync list") + } + + log.Infof("resolving delta (total %d).", list.getLength()) + + for _, note := range list.Notes { + if err := stepSyncNote(tx, note); err != nil { + return errors.Wrap(err, "merging note") + } + } + for _, book := range list.Books { + if err := stepSyncBook(tx, book); err != nil { + return errors.Wrap(err, "merging book") + } + } + + for noteUUID := range list.ExpungedNotes { + if err := syncDeleteNote(tx, noteUUID); err != nil { + return errors.Wrap(err, "deleting note") + } + } + for bookUUID := range list.ExpungedBooks { + if err := syncDeleteBook(tx, bookUUID); err != nil { + return errors.Wrap(err, "deleting book") + } + } + + err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN) + if err != nil { + return errors.Wrap(err, "saving sync state") + } + + fmt.Println(" done.") + + return nil +} + +func sendBooks(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) (bool, error) { + isBehind := false + + rows, err := tx.Query("SELECT uuid, label, usn, deleted FROM books WHERE dirty") + if err != nil { + return isBehind, errors.Wrap(err, "getting syncable books") + } + defer rows.Close() + + for rows.Next() { + var book core.Book + + if err = rows.Scan(&book.UUID, &book.Label, &book.USN, &book.Deleted); err != nil { + return isBehind, errors.Wrap(err, "scanning a syncable book") + } + + log.Debug("sending book %s\n", book.UUID) + + var respUSN int + + // if new, create it in the server, or else, update. + if book.USN == 0 { + if book.Deleted { + err = book.Expunge(tx) + if err != nil { + return isBehind, errors.Wrap(err, "expunging a book locally") + } + + continue + } else { + resp, err := client.CreateBook(ctx, apiKey, book.Label) + if err != nil { + return isBehind, errors.Wrap(err, "creating a book") + } + + _, err = tx.Exec("UPDATE notes SET book_uuid = ? WHERE book_uuid = ?", resp.Book.UUID, book.UUID) + if err != nil { + return isBehind, errors.Wrap(err, "updating book_uuids of notes") + } + + book.Dirty = false + book.USN = resp.Book.USN + err = book.Update(tx) + if err != nil { + return isBehind, errors.Wrap(err, "marking book dirty") + } + + err = book.UpdateUUID(tx, resp.Book.UUID) + if err != nil { + return isBehind, errors.Wrap(err, "updating book uuid") + } + + respUSN = resp.Book.USN + } + } else { + if book.Deleted { + resp, err := client.DeleteBook(ctx, apiKey, book.UUID) + if err != nil { + return isBehind, errors.Wrap(err, "deleting a book") + } + + err = book.Expunge(tx) + if err != nil { + return isBehind, errors.Wrap(err, "expunging a book locally") + } + + respUSN = resp.Book.USN + } else { + resp, err := client.UpdateBook(ctx, apiKey, book.Label, book.UUID) + if err != nil { + return isBehind, errors.Wrap(err, "updating a book") + } + + book.Dirty = false + book.USN = resp.Book.USN + err = book.Update(tx) + if err != nil { + return isBehind, errors.Wrap(err, "marking book dirty") + } + + respUSN = resp.Book.USN + } + } + + lastMaxUSN, err := getLastMaxUSN(tx) + if err != nil { + return isBehind, errors.Wrap(err, "getting last max usn") + } + + log.Debug("sent book %s. response USN %d. last max usn: %d\n", book.UUID, respUSN, lastMaxUSN) + + if respUSN == lastMaxUSN+1 { + err = updateLastMaxUSN(tx, lastMaxUSN+1) + if err != nil { + return isBehind, errors.Wrap(err, "updating last max usn") + } + } else { + isBehind = true + } + } + + return isBehind, nil +} + +func sendNotes(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) (bool, error) { + isBehind := false + + rows, err := tx.Query("SELECT uuid, book_uuid, content, public, deleted, usn FROM notes WHERE dirty") + if err != nil { + return isBehind, errors.Wrap(err, "getting syncable notes") + } + defer rows.Close() + + for rows.Next() { + var note core.Note + + if err = rows.Scan(¬e.UUID, ¬e.BookUUID, ¬e.Content, ¬e.Public, ¬e.Deleted, ¬e.USN); err != nil { + return isBehind, errors.Wrap(err, "scanning a syncable note") + } + + log.Debug("sending note %s\n", note.UUID) + + var respUSN int + + // if new, create it in the server, or else, update. + if note.USN == 0 { + if note.Deleted { + // if a note was added and deleted locally, simply expunge + err = note.Expunge(tx) + if err != nil { + return isBehind, errors.Wrap(err, "expunging a note locally") + } + + continue + } else { + resp, err := client.CreateNote(ctx, apiKey, note.BookUUID, note.Content) + if err != nil { + return isBehind, errors.Wrap(err, "creating a note") + } + + note.Dirty = false + note.USN = resp.Result.USN + err = note.Update(tx) + if err != nil { + return isBehind, errors.Wrap(err, "marking note dirty") + } + + err = note.UpdateUUID(tx, resp.Result.UUID) + if err != nil { + return isBehind, errors.Wrap(err, "updating note uuid") + } + + respUSN = resp.Result.USN + } + } else { + if note.Deleted { + resp, err := client.DeleteNote(ctx, apiKey, note.UUID) + if err != nil { + return isBehind, errors.Wrap(err, "deleting a note") + } + + err = note.Expunge(tx) + if err != nil { + return isBehind, errors.Wrap(err, "expunging a note locally") + } + + respUSN = resp.Result.USN + } else { + resp, err := client.UpdateNote(ctx, apiKey, note.UUID, note.BookUUID, note.Content, note.Public) + if err != nil { + return isBehind, errors.Wrap(err, "updating a note") + } + + note.Dirty = false + note.USN = resp.Result.USN + err = note.Update(tx) + if err != nil { + return isBehind, errors.Wrap(err, "marking note dirty") + } + + respUSN = resp.Result.USN + } + } + + lastMaxUSN, err := getLastMaxUSN(tx) + if err != nil { + return isBehind, errors.Wrap(err, "getting last max usn") + } + + log.Debug("sent note %s. response USN %d. last max usn: %d\n", note.UUID, respUSN, lastMaxUSN) + + if respUSN == lastMaxUSN+1 { + err = updateLastMaxUSN(tx, lastMaxUSN+1) + if err != nil { + return isBehind, errors.Wrap(err, "updating last max usn") + } + } else { + isBehind = true + } + } + + return isBehind, nil +} + +func sendChanges(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) (bool, error) { + var delta int + err := tx.QueryRow("SELECT (SELECT count(*) FROM notes WHERE dirty) + (SELECT count(*) FROM books WHERE dirty)").Scan(&delta) + + log.Infof("sending changes (total %d).", delta) + + behind1, err := sendBooks(ctx, tx, apiKey) + if err != nil { + return behind1, errors.Wrap(err, "sending books") + } + + behind2, err := sendNotes(ctx, tx, apiKey) + if err != nil { + return behind2, errors.Wrap(err, "sending notes") + } + + fmt.Println(" done.") + + isBehind := behind1 || behind2 + + return isBehind, nil +} + +func updateLastMaxUSN(tx *sql.Tx, val int) error { + _, err := tx.Exec("UPDATE system SET value = ? WHERE key = ?", val, infra.SystemLastMaxUSN) + if err != nil { + return errors.Wrapf(err, "updating %s", infra.SystemLastMaxUSN) + } + + return nil +} + +func updateLastSyncAt(tx *sql.Tx, val int64) error { + _, err := tx.Exec("UPDATE system SET value = ? WHERE key = ?", val, infra.SystemLastSyncAt) + if err != nil { + return errors.Wrapf(err, "updating %s", infra.SystemLastSyncAt) + } + + return nil +} + +func saveSyncState(tx *sql.Tx, serverTime int64, serverMaxUSN int) error { + if err := updateLastMaxUSN(tx, serverMaxUSN); err != nil { + return errors.Wrap(err, "updating last max usn") + } + if err := updateLastSyncAt(tx, serverTime); err != nil { + return errors.Wrap(err, "updating last sync at") + } + + return nil } func newRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { - db := ctx.DB - config, err := core.ReadConfig(ctx) if err != nil { return errors.Wrap(err, "reading the config") @@ -57,69 +863,70 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { return nil } - var bookmark int - err = db.QueryRow("SELECT value FROM system WHERE key = ?", "bookmark").Scan(&bookmark) - if err != nil { - return errors.Wrap(err, "getting bookmark") - } - - actions, err := getLocalActions(db) - if err != nil { - return errors.Wrap(err, "getting local actions") - } - - payload, err := newPayload(actions, bookmark) - if err != nil { - return errors.Wrap(err, "getting the request payload") - } - - log.Infof("writing changes (total %d).", len(actions)) - resp, err := postActions(ctx, config.APIKey, payload) - if err != nil { - return errors.Wrap(err, "posting to the server") - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return errors.Wrap(err, "reading the response body") - } - - if resp.StatusCode != http.StatusOK { - bodyStr := string(body) - - fmt.Println("") - return errors.Errorf("Server error: %s", bodyStr) - } - - fmt.Println(" done.") - - var respData responseData - if err = json.Unmarshal(body, &respData); err != nil { - return errors.Wrap(err, "unmarshalling the payload") - } - - // First, remove our actions because server has successfully ingested them - if _, err = db.Exec("DELETE FROM actions"); err != nil { - return errors.Wrap(err, "deleting actions") + if err := migrate.Run(ctx, migrate.RemoteSequence, migrate.RemoteMode); err != nil { + return errors.Wrap(err, "running remote migrations") } + db := ctx.DB tx, err := db.Begin() if err != nil { return errors.Wrap(err, "beginning a transaction") } - log.Infof("resolving delta (total %d).", len(respData.Actions)) - if err := core.ReduceAll(ctx, tx, respData.Actions); err != nil { - tx.Rollback() - return errors.Wrap(err, "reducing returned actions") + syncState, err := client.GetSyncState(config.APIKey, ctx) + if err != nil { + return errors.Wrap(err, "getting the sync state from the server") + } + lastSyncAt, err := getLastSyncAt(tx) + if err != nil { + return errors.Wrap(err, "getting the last sync time") + } + lastMaxUSN, err := getLastMaxUSN(tx) + if err != nil { + return errors.Wrap(err, "getting the last max_usn") } - if _, err = tx.Exec("UPDATE system SET value = ? WHERE key = ?", respData.Bookmark, "bookmark"); err != nil { + log.Debug("lastSyncAt: %d, lastMaxUSN: %d, syncState: %+v\n", lastSyncAt, lastMaxUSN, syncState) + + var syncErr error + if isFullSync || lastSyncAt < syncState.FullSyncBefore { + syncErr = fullSync(ctx, tx, config.APIKey) + } else if lastMaxUSN != syncState.MaxUSN { + syncErr = stepSync(ctx, tx, config.APIKey, lastMaxUSN) + } else { + // if no need to sync from the server, simply update the last sync timestamp and proceed to send changes + err = updateLastSyncAt(tx, syncState.CurrentTime) + if err != nil { + return errors.Wrap(err, "updating last sync at") + } + } + if syncErr != nil { tx.Rollback() - return errors.Wrap(err, "updating the bookmark") + return errors.Wrap(err, "syncing changes from the server") } - fmt.Println(" done.") + isBehind, err := sendChanges(ctx, tx, config.APIKey) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "sending changes") + } + + // if server state gets ahead of that of client during the sync, do an additional step sync + if isBehind { + log.Debug("performing another step sync because client is behind\n") + + updatedLastMaxUSN, err := getLastMaxUSN(tx) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "getting the new last max_usn") + } + + err = stepSync(ctx, tx, config.APIKey, updatedLastMaxUSN) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "performing the follow-up step sync") + } + } tx.Commit() @@ -132,91 +939,3 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { return nil } } - -func newPayload(actions []actions.Action, bookmark int) (*bytes.Buffer, error) { - compressedActions, err := compressActions(actions) - if err != nil { - return &bytes.Buffer{}, errors.Wrap(err, "compressing actions") - } - - payload := syncPayload{ - Bookmark: bookmark, - Actions: compressedActions, - } - - b, err := json.Marshal(payload) - if err != nil { - return &bytes.Buffer{}, errors.Wrap(err, "marshalling paylaod into JSON") - } - - ret := bytes.NewBuffer(b) - return ret, nil -} - -func compressActions(actions []actions.Action) ([]byte, error) { - b, err := json.Marshal(&actions) - if err != nil { - return nil, errors.Wrap(err, "marshalling actions into JSON") - } - - var buf bytes.Buffer - g := gzip.NewWriter(&buf) - - _, err = g.Write(b) - if err != nil { - return nil, errors.Wrap(err, "writing to gzip writer") - } - - if err = g.Close(); err != nil { - return nil, errors.Wrap(err, "closing gzip writer") - } - - return buf.Bytes(), nil -} - -func postActions(ctx infra.DnoteCtx, APIKey string, payload io.Reader) (*http.Response, error) { - endpoint := fmt.Sprintf("%s/v1/sync", ctx.APIEndpoint) - req, err := http.NewRequest("POST", endpoint, payload) - if err != nil { - return &http.Response{}, errors.Wrap(err, "forming an HTTP request") - } - - req.Header.Set("Authorization", APIKey) - req.Header.Set("CLI-Version", ctx.Version) - - client := http.Client{} - resp, err := client.Do(req) - if err != nil { - return &http.Response{}, errors.Wrap(err, "making a request") - } - - return resp, nil -} - -func getLocalActions(db *sql.DB) ([]actions.Action, error) { - ret := []actions.Action{} - - rows, err := db.Query("SELECT uuid, schema, type, data, timestamp FROM actions") - if err != nil { - return ret, errors.Wrap(err, "querying actions") - } - defer rows.Close() - - for rows.Next() { - var action actions.Action - - err = rows.Scan(&action.UUID, &action.Schema, &action.Type, &action.Data, &action.Timestamp) - if err != nil { - return ret, errors.Wrap(err, "scanning a row") - } - - ret = append(ret, action) - } - - err = rows.Err() - if err != nil { - return ret, errors.Wrap(err, "scanning rows") - } - - return ret, nil -} diff --git a/cmd/sync/sync_test.go b/cmd/sync/sync_test.go new file mode 100644 index 00000000..f4ffc913 --- /dev/null +++ b/cmd/sync/sync_test.go @@ -0,0 +1,3001 @@ +package sync + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + + "github.com/dnote/cli/client" + "github.com/dnote/cli/core" + "github.com/dnote/cli/infra" + "github.com/dnote/cli/testutils" + "github.com/dnote/cli/utils" + "github.com/pkg/errors" +) + +func TestGetLastSyncAt(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + testutils.MustExec(t, "setting up last_sync_at", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastSyncAt, 1541108743) + + // exec + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + got, err := getLastSyncAt(tx) + if err != nil { + t.Fatalf(errors.Wrap(err, "getting last_sync_at").Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, got, 1541108743, "last_sync_at mismatch") +} + +func TestGetLastMaxUSN(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + testutils.MustExec(t, "setting up last_max_usn", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, 20001) + + // exec + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + got, err := getLastMaxUSN(tx) + if err != nil { + t.Fatalf(errors.Wrap(err, "getting last_max_usn").Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, got, 20001, "last_max_usn mismatch") +} + +func TestResolveLabel(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + { + input: "js", + expected: "js (2)", + }, + { + input: "css", + expected: "css (3)", + }, + { + input: "linux", + expected: "linux (4)", + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", "b1-uuid", "js") + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", "b2-uuid", "css (2)") + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", "b3-uuid", "linux (1)") + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", "b4-uuid", "linux (2)") + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", "b5-uuid", "linux (3)") + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + got, err := resolveLabel(tx, tc.input) + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + tx.Rollback() + + testutils.AssertEqual(t, got, tc.expected, fmt.Sprintf("output mismatch for test case %d", idx)) + }() + } +} + +func TestSyncDeleteNote(t *testing.T) { + t.Run("exists on server only", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if err := syncDeleteNote(tx, "nonexistent-note-uuid"); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 0, "book count mismatch") + }) + + t.Run("local copy is dirty", func(t *testing.T) { + b1UUID := utils.GenerateUUID() + + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting b1 for test case %d", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + testutils.MustExec(t, "inserting n1 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 content", 1541108743, false, true) + testutils.MustExec(t, "inserting n2 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 11, "n2 content", 1541108743, false, true) + + var n1 core.Note + testutils.MustScan(t, "getting n1 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Content, &n1.Public, &n1.Deleted, &n1.Dirty) + var n2 core.Note + testutils.MustScan(t, "getting n2 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", "n2-uuid"), + &n2.UUID, &n2.BookUUID, &n2.USN, &n2.AddedOn, &n2.EditedOn, &n2.Content, &n2.Public, &n2.Deleted, &n2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction for test case").Error()) + } + + if err := syncDeleteNote(tx, "n1-uuid"); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes for test case", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books for test case", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + // do not delete note if local copy is dirty + testutils.AssertEqualf(t, noteCount, 2, "note count mismatch for test case") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch for test case") + + var n1Record core.Note + testutils.MustScan(t, "getting n1 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n1.UUID), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.Content, &n1Record.Public, &n1Record.Deleted, &n1Record.Dirty) + var n2Record core.Note + testutils.MustScan(t, "getting n2 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.USN, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.Content, &n2Record.Public, &n2Record.Deleted, &n2Record.Dirty) + + testutils.AssertEqual(t, n1Record.UUID, n1.UUID, "n1 UUID mismatch for test case") + testutils.AssertEqual(t, n1Record.BookUUID, n1.BookUUID, "n1 BookUUID mismatch for test case") + testutils.AssertEqual(t, n1Record.USN, n1.USN, "n1 USN mismatch for test case") + testutils.AssertEqual(t, n1Record.AddedOn, n1.AddedOn, "n1 AddedOn mismatch for test case") + testutils.AssertEqual(t, n1Record.EditedOn, n1.EditedOn, "n1 EditedOn mismatch for test case") + testutils.AssertEqual(t, n1Record.Content, n1.Content, "n1 Content mismatch for test case") + testutils.AssertEqual(t, n1Record.Public, n1.Public, "n1 Public mismatch for test case") + testutils.AssertEqual(t, n1Record.Deleted, n1.Deleted, "n1 Deleted mismatch for test case") + testutils.AssertEqual(t, n1Record.Dirty, n1.Dirty, "n1 Dirty mismatch for test case") + + testutils.AssertEqual(t, n2Record.UUID, n2.UUID, "n2 UUID mismatch for test case") + testutils.AssertEqual(t, n2Record.BookUUID, n2.BookUUID, "n2 BookUUID mismatch for test case") + testutils.AssertEqual(t, n2Record.USN, n2.USN, "n2 USN mismatch for test case") + testutils.AssertEqual(t, n2Record.AddedOn, n2.AddedOn, "n2 AddedOn mismatch for test case") + testutils.AssertEqual(t, n2Record.EditedOn, n2.EditedOn, "n2 EditedOn mismatch for test case") + testutils.AssertEqual(t, n2Record.Content, n2.Content, "n2 Content mismatch for test case") + testutils.AssertEqual(t, n2Record.Public, n2.Public, "n2 Public mismatch for test case") + testutils.AssertEqual(t, n2Record.Deleted, n2.Deleted, "n2 Deleted mismatch for test case") + testutils.AssertEqual(t, n2Record.Dirty, n2.Dirty, "n2 Dirty mismatch for test case") + }) + + t.Run("local copy is not dirty", func(t *testing.T) { + b1UUID := utils.GenerateUUID() + + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting b1 for test case %d", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + testutils.MustExec(t, "inserting n1 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 content", 1541108743, false, false) + testutils.MustExec(t, "inserting n2 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 11, "n2 content", 1541108743, false, false) + + var n1 core.Note + testutils.MustScan(t, "getting n1 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Content, &n1.Public, &n1.Deleted, &n1.Dirty) + var n2 core.Note + testutils.MustScan(t, "getting n2 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", "n2-uuid"), + &n2.UUID, &n2.BookUUID, &n2.USN, &n2.AddedOn, &n2.EditedOn, &n2.Content, &n2.Public, &n2.Deleted, &n2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction for test case").Error()) + } + + if err := syncDeleteNote(tx, "n1-uuid"); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes for test case", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books for test case", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch for test case") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch for test case") + + var n2Record core.Note + testutils.MustScan(t, "getting n2 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.USN, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.Content, &n2Record.Public, &n2Record.Deleted, &n2Record.Dirty) + + testutils.AssertEqual(t, n2Record.UUID, n2.UUID, "n2 UUID mismatch for test case") + testutils.AssertEqual(t, n2Record.BookUUID, n2.BookUUID, "n2 BookUUID mismatch for test case") + testutils.AssertEqual(t, n2Record.USN, n2.USN, "n2 USN mismatch for test case") + testutils.AssertEqual(t, n2Record.AddedOn, n2.AddedOn, "n2 AddedOn mismatch for test case") + testutils.AssertEqual(t, n2Record.EditedOn, n2.EditedOn, "n2 EditedOn mismatch for test case") + testutils.AssertEqual(t, n2Record.Content, n2.Content, "n2 Content mismatch for test case") + testutils.AssertEqual(t, n2Record.Public, n2.Public, "n2 Public mismatch for test case") + testutils.AssertEqual(t, n2Record.Deleted, n2.Deleted, "n2 Deleted mismatch for test case") + testutils.AssertEqual(t, n2Record.Dirty, n2.Dirty, "n2 Dirty mismatch for test case") + }) +} + +func TestSyncDeleteBook(t *testing.T) { + t.Run("exists on server only", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + testutils.MustExec(t, "inserting b1 for test case %d", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", "b1-uuid", "b1-label") + + var b1 core.Book + testutils.MustScan(t, "getting b1 for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b1-uuid"), + &b1.UUID, &b1.Label, &b1.USN, &b1.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if err := syncDeleteBook(tx, "nonexistent-book-uuid"); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + + var b1Record core.Book + testutils.MustScan(t, "getting b1 for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b1-uuid"), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + + testutils.AssertEqual(t, b1Record.UUID, b1.UUID, "b1 UUID mismatch for test case") + testutils.AssertEqual(t, b1Record.Label, b1.Label, "b1 Label mismatch for test case") + testutils.AssertEqual(t, b1Record.USN, b1.USN, "b1 USN mismatch for test case") + testutils.AssertEqual(t, b1Record.Dirty, b1.Dirty, "b1 Dirty mismatch for test case") + }) + + t.Run("local copy is dirty", func(t *testing.T) { + b1UUID := utils.GenerateUUID() + + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting b1 for test case %d", db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", b1UUID, "b1-label", 12, true) + testutils.MustExec(t, "inserting n1 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 content", 1541108743, false, true) + + var b1 core.Book + testutils.MustScan(t, "getting b1 for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b1UUID), + &b1.UUID, &b1.Label, &b1.USN, &b1.Dirty) + var n1 core.Note + testutils.MustScan(t, "getting n1 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Content, &n1.Public, &n1.Deleted, &n1.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction for test case").Error()) + } + + if err := syncDeleteBook(tx, b1UUID); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes for test case", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books for test case", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + // do not delete note if local copy is dirty + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch for test case") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch for test case") + + var b1Record core.Book + testutils.MustScan(t, "getting b1Record for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b1UUID), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + var n1Record core.Note + testutils.MustScan(t, "getting n1 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n1.UUID), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.Content, &n1Record.Public, &n1Record.Deleted, &n1Record.Dirty) + + testutils.AssertEqual(t, b1Record.UUID, b1.UUID, "b1 UUID mismatch for test case") + testutils.AssertEqual(t, b1Record.Label, b1.Label, "b1 Label mismatch for test case") + testutils.AssertEqual(t, b1Record.USN, b1.USN, "b1 USN mismatch for test case") + testutils.AssertEqual(t, b1Record.Dirty, b1.Dirty, "b1 Dirty mismatch for test case") + + testutils.AssertEqual(t, n1Record.UUID, n1.UUID, "n1 UUID mismatch for test case") + testutils.AssertEqual(t, n1Record.BookUUID, n1.BookUUID, "n1 BookUUID mismatch for test case") + testutils.AssertEqual(t, n1Record.USN, n1.USN, "n1 USN mismatch for test case") + testutils.AssertEqual(t, n1Record.AddedOn, n1.AddedOn, "n1 AddedOn mismatch for test case") + testutils.AssertEqual(t, n1Record.EditedOn, n1.EditedOn, "n1 EditedOn mismatch for test case") + testutils.AssertEqual(t, n1Record.Content, n1.Content, "n1 Content mismatch for test case") + testutils.AssertEqual(t, n1Record.Public, n1.Public, "n1 Public mismatch for test case") + testutils.AssertEqual(t, n1Record.Deleted, n1.Deleted, "n1 Deleted mismatch for test case") + testutils.AssertEqual(t, n1Record.Dirty, n1.Dirty, "n1 Dirty mismatch for test case") + }) + + t.Run("local copy is not dirty", func(t *testing.T) { + b1UUID := utils.GenerateUUID() + b2UUID := utils.GenerateUUID() + + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting b1 for test case %d", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + testutils.MustExec(t, "inserting n1 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 content", 1541108743, false, false) + testutils.MustExec(t, "inserting b2 for test case %d", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "b2-label") + testutils.MustExec(t, "inserting n2 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b2UUID, 11, "n2 content", 1541108743, false, false) + + var b2 core.Book + testutils.MustScan(t, "getting b2 for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b2UUID), + &b2.UUID, &b2.Label, &b2.USN, &b2.Dirty) + var n2 core.Note + testutils.MustScan(t, "getting n2 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", "n2-uuid"), + &n2.UUID, &n2.BookUUID, &n2.USN, &n2.AddedOn, &n2.EditedOn, &n2.Content, &n2.Public, &n2.Deleted, &n2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction for test case").Error()) + } + + if err := syncDeleteBook(tx, b1UUID); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes for test case", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books for test case", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch for test case") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch for test case") + + var b2Record core.Book + testutils.MustScan(t, "getting b2 for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b2UUID), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Dirty) + var n2Record core.Note + testutils.MustScan(t, "getting n2 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.USN, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.Content, &n2Record.Public, &n2Record.Deleted, &n2Record.Dirty) + + testutils.AssertEqual(t, b2Record.UUID, b2.UUID, "b2 UUID mismatch for test case") + testutils.AssertEqual(t, b2Record.Label, b2.Label, "b2 Label mismatch for test case") + testutils.AssertEqual(t, b2Record.USN, b2.USN, "b2 USN mismatch for test case") + testutils.AssertEqual(t, b2Record.Dirty, b2.Dirty, "b2 Dirty mismatch for test case") + + testutils.AssertEqual(t, n2Record.UUID, n2.UUID, "n2 UUID mismatch for test case") + testutils.AssertEqual(t, n2Record.BookUUID, n2.BookUUID, "n2 BookUUID mismatch for test case") + testutils.AssertEqual(t, n2Record.USN, n2.USN, "n2 USN mismatch for test case") + testutils.AssertEqual(t, n2Record.AddedOn, n2.AddedOn, "n2 AddedOn mismatch for test case") + testutils.AssertEqual(t, n2Record.EditedOn, n2.EditedOn, "n2 EditedOn mismatch for test case") + testutils.AssertEqual(t, n2Record.Content, n2.Content, "n2 Content mismatch for test case") + testutils.AssertEqual(t, n2Record.Public, n2.Public, "n2 Public mismatch for test case") + testutils.AssertEqual(t, n2Record.Deleted, n2.Deleted, "n2 Deleted mismatch for test case") + testutils.AssertEqual(t, n2Record.Dirty, n2.Dirty, "n2 Dirty mismatch for test case") + }) + + t.Run("local copy has at least one note that is dirty", func(t *testing.T) { + b1UUID := utils.GenerateUUID() + + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting b1 for test case %d", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + testutils.MustExec(t, "inserting n1 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 content", 1541108743, false, true) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction for test case").Error()) + } + + if err := syncDeleteBook(tx, b1UUID); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes for test case", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books for test case", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch for test case") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch for test case") + + var b1Record core.Book + testutils.MustScan(t, "getting b1 for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b1UUID), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + var n1Record core.Note + testutils.MustScan(t, "getting n1 for test case", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, content,deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.Content, &n1Record.Deleted, &n1Record.Dirty) + + testutils.AssertEqual(t, b1Record.UUID, b1UUID, "b1 UUID mismatch for test case") + testutils.AssertEqual(t, b1Record.Label, "b1-label", "b1 Label mismatch for test case") + testutils.AssertEqual(t, b1Record.Dirty, true, "b1 Dirty mismatch for test case") + + testutils.AssertEqual(t, n1Record.UUID, "n1-uuid", "n1 UUID mismatch for test case") + testutils.AssertEqual(t, n1Record.BookUUID, b1UUID, "n1 BookUUID mismatch for test case") + testutils.AssertEqual(t, n1Record.USN, 10, "n1 USN mismatch for test case") + testutils.AssertEqual(t, n1Record.AddedOn, int64(1541108743), "n1 AddedOn mismatch for test case") + testutils.AssertEqual(t, n1Record.Content, "n1 content", "n1 Content mismatch for test case") + testutils.AssertEqual(t, n1Record.Deleted, false, "n1 Deleted mismatch for test case") + testutils.AssertEqual(t, n1Record.Dirty, true, "n1 Dirty mismatch for test case") + }) +} + +func TestFullSyncNote(t *testing.T) { + t.Run("exists on server only", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + n := client.SyncFragNote{ + UUID: "n1-uuid", + BookUUID: b1UUID, + USN: 128, + AddedOn: 1541232118, + EditedOn: 1541219321, + Content: "n1-content", + Public: true, + Deleted: false, + } + + if err := fullSyncNote(tx, n); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + + var n1 core.Note + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Content, &n1.Public, &n1.Deleted, &n1.Dirty) + + testutils.AssertEqual(t, n1.UUID, n.UUID, "n1 UUID mismatch") + testutils.AssertEqual(t, n1.BookUUID, n.BookUUID, "n1 BookUUID mismatch") + testutils.AssertEqual(t, n1.USN, n.USN, "n1 USN mismatch") + testutils.AssertEqual(t, n1.AddedOn, n.AddedOn, "n1 AddedOn mismatch") + testutils.AssertEqual(t, n1.EditedOn, n.EditedOn, "n1 EditedOn mismatch") + testutils.AssertEqual(t, n1.Content, n.Content, "n1 Content mismatch") + testutils.AssertEqual(t, n1.Public, n.Public, "n1 Public mismatch") + testutils.AssertEqual(t, n1.Deleted, n.Deleted, "n1 Deleted mismatch") + testutils.AssertEqual(t, n1.Dirty, false, "n1 Dirty mismatch") + }) + + t.Run("exists on server and client", func(t *testing.T) { + b1UUID := utils.GenerateUUID() + b2UUID := utils.GenerateUUID() + + testCases := []struct { + addedOn int64 + clientUSN int + clientEditedOn int64 + clientContent string + clientPublic bool + clientDeleted bool + clientBookUUID string + clientDirty bool + serverUSN int + serverEditedOn int64 + serverContent string + serverPublic bool + serverDeleted bool + serverBookUUID string + expectedUSN int + expectedAddedOn int64 + expectedEditedOn int64 + expectedContent string + expectedPublic bool + expectedDeleted bool + expectedBookUUID string + expectedDirty bool + }{ + // server has higher usn and client is dirty + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 0, + clientContent: "n1 content", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: true, + }, + // server has higher usn and client deleted locally + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 0, + clientContent: "", + clientPublic: false, + clientDeleted: true, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content server", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content server", + expectedPublic: false, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + // server has higher usn and client is not dirty + { + clientDirty: false, + clientUSN: 1, + clientEditedOn: 0, + clientContent: "n1 content", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + // they're in sync + { + clientDirty: true, + clientUSN: 21, + clientEditedOn: 1541219321, + clientContent: "n1 content", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b2UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content", + expectedPublic: false, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: true, + }, + // they have the same usn but client is dirty + // not sure if this is a possible scenario but if it happens, the local copy will + // be uploaded to the server anyway. + { + clientDirty: true, + clientUSN: 21, + clientEditedOn: 1541219320, + clientContent: "n1 content client", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b2UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content server", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219320, + expectedContent: "n1 content client", + expectedPublic: false, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + testutils.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "b2-label") + n1UUID := utils.GenerateUUID() + testutils.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientContent, tc.clientPublic, tc.clientDeleted, tc.clientDirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + // update all fields but uuid and bump usn + n := client.SyncFragNote{ + UUID: n1UUID, + BookUUID: tc.serverBookUUID, + USN: tc.serverUSN, + AddedOn: tc.addedOn, + EditedOn: tc.serverEditedOn, + Content: tc.serverContent, + Public: tc.serverPublic, + Deleted: tc.serverDeleted, + } + + if err := fullSyncNote(tx, n); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, fmt.Sprintf("counting notes for test case %d", idx), db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, fmt.Sprintf("counting books for test case %d", idx), db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 1, fmt.Sprintf("note count mismatch for test case %d", idx)) + testutils.AssertEqualf(t, bookCount, 2, fmt.Sprintf("book count mismatch for test case %d", idx)) + + var n1 core.Note + testutils.MustScan(t, fmt.Sprintf("getting n1 for test case %d", idx), + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Content, &n1.Public, &n1.Deleted, &n1.Dirty) + + testutils.AssertEqual(t, n1.UUID, n.UUID, fmt.Sprintf("n1 UUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.BookUUID, tc.expectedBookUUID, fmt.Sprintf("n1 BookUUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.USN, tc.expectedUSN, fmt.Sprintf("n1 USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.AddedOn, tc.expectedAddedOn, fmt.Sprintf("n1 AddedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.EditedOn, tc.expectedEditedOn, fmt.Sprintf("n1 EditedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Content, tc.expectedContent, fmt.Sprintf("n1 Content mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Public, tc.expectedPublic, fmt.Sprintf("n1 Public mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Deleted, tc.expectedDeleted, fmt.Sprintf("n1 Deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Dirty, tc.expectedDirty, fmt.Sprintf("n1 Dirty mismatch for test case %d", idx)) + }() + } + }) +} + +func TestFullSyncBook(t *testing.T) { + t.Run("exists on server only", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 555, "b1-label", true, false) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b2UUID := utils.GenerateUUID() + b := client.SyncFragBook{ + UUID: b2UUID, + USN: 1, + AddedOn: 1541108743, + Label: "b2-label", + Deleted: false, + } + + if err := fullSyncBook(tx, b); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 2, "book count mismatch") + + var b1, b2 core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, usn, label, dirty, deleted FROM books WHERE uuid = ?", b1UUID), + &b1.UUID, &b1.USN, &b1.Label, &b1.Dirty, &b1.Deleted) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, usn, label, dirty, deleted FROM books WHERE uuid = ?", b2UUID), + &b2.UUID, &b2.USN, &b2.Label, &b2.Dirty, &b2.Deleted) + + testutils.AssertEqual(t, b1.UUID, b1UUID, "b1 UUID mismatch") + testutils.AssertEqual(t, b1.USN, 555, "b1 USN mismatch") + testutils.AssertEqual(t, b1.Label, "b1-label", "b1 Label mismatch") + testutils.AssertEqual(t, b1.Dirty, true, "b1 Dirty mismatch") + testutils.AssertEqual(t, b1.Deleted, false, "b1 Deleted mismatch") + + testutils.AssertEqual(t, b2.UUID, b2UUID, "b2 UUID mismatch") + testutils.AssertEqual(t, b2.USN, b.USN, "b2 USN mismatch") + testutils.AssertEqual(t, b2.Label, b.Label, "b2 Label mismatch") + testutils.AssertEqual(t, b2.Dirty, false, "b2 Dirty mismatch") + testutils.AssertEqual(t, b2.Deleted, b.Deleted, "b2 Deleted mismatch") + }) + + t.Run("exists on server and client", func(t *testing.T) { + testCases := []struct { + clientDirty bool + clientUSN int + clientLabel string + clientDeleted bool + serverUSN int + serverLabel string + serverDeleted bool + expectedUSN int + expectedLabel string + expectedDeleted bool + }{ + // server has higher usn and client is dirty + { + clientDirty: true, + clientUSN: 1, + clientLabel: "b2-label", + clientDeleted: false, + serverUSN: 3, + serverLabel: "b2-label-updated", + serverDeleted: false, + expectedUSN: 3, + expectedLabel: "b2-label-updated", + expectedDeleted: false, + }, + { + clientDirty: true, + clientUSN: 1, + clientLabel: "b2-label", + clientDeleted: false, + serverUSN: 3, + serverLabel: "", + serverDeleted: true, + expectedUSN: 3, + expectedLabel: "", + expectedDeleted: true, + }, + // server has higher usn and client is not dirty + { + clientDirty: false, + clientUSN: 1, + clientLabel: "b2-label", + clientDeleted: false, + serverUSN: 3, + serverLabel: "b2-label-updated", + serverDeleted: false, + expectedUSN: 3, + expectedLabel: "b2-label-updated", + expectedDeleted: false, + }, + // they are in sync + { + clientDirty: false, + clientUSN: 3, + clientLabel: "b2-label", + clientDeleted: false, + serverUSN: 3, + serverLabel: "b2-label", + serverDeleted: false, + expectedUSN: 3, + expectedLabel: "b2-label", + expectedDeleted: false, + }, + // they have the same usn but client is dirty + { + clientDirty: true, + clientUSN: 3, + clientLabel: "b2-label-client", + clientDeleted: false, + serverUSN: 3, + serverLabel: "b2-label", + serverDeleted: false, + expectedUSN: 3, + expectedLabel: "b2-label-client", + expectedDeleted: false, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, tc.clientUSN, tc.clientLabel, tc.clientDirty, tc.clientDeleted) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + // update all fields but uuid and bump usn + b := client.SyncFragBook{ + UUID: b1UUID, + USN: tc.serverUSN, + Label: tc.serverLabel, + Deleted: tc.serverDeleted, + } + + if err := fullSyncBook(tx, b); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, fmt.Sprintf("counting notes for test case %d", idx), db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, fmt.Sprintf("counting books for test case %d", idx), db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, fmt.Sprintf("note count mismatch for test case %d", idx)) + testutils.AssertEqualf(t, bookCount, 1, fmt.Sprintf("book count mismatch for test case %d", idx)) + + var b1 core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, usn, label, dirty, deleted FROM books WHERE uuid = ?", b1UUID), + &b1.UUID, &b1.USN, &b1.Label, &b1.Dirty, &b1.Deleted) + + testutils.AssertEqual(t, b1.UUID, b1UUID, fmt.Sprintf("b1 UUID mismatch for idx %d", idx)) + testutils.AssertEqual(t, b1.USN, tc.expectedUSN, fmt.Sprintf("b1 USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1.Label, tc.expectedLabel, fmt.Sprintf("b1 Label mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1.Dirty, tc.clientDirty, fmt.Sprintf("b1 Dirty mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1.Deleted, tc.expectedDeleted, fmt.Sprintf("b1 Deleted mismatch for test case %d", idx)) + }() + } + }) +} + +func TestStepSyncNote(t *testing.T) { + t.Run("exists on server only", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + n := client.SyncFragNote{ + UUID: "n1-uuid", + BookUUID: b1UUID, + USN: 128, + AddedOn: 1541232118, + EditedOn: 1541219321, + Content: "n1-content", + Public: true, + Deleted: false, + } + + if err := stepSyncNote(tx, n); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + + var n1 core.Note + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Content, &n1.Public, &n1.Deleted, &n1.Dirty) + + testutils.AssertEqual(t, n1.UUID, n.UUID, "n1 UUID mismatch") + testutils.AssertEqual(t, n1.BookUUID, n.BookUUID, "n1 BookUUID mismatch") + testutils.AssertEqual(t, n1.USN, n.USN, "n1 USN mismatch") + testutils.AssertEqual(t, n1.AddedOn, n.AddedOn, "n1 AddedOn mismatch") + testutils.AssertEqual(t, n1.EditedOn, n.EditedOn, "n1 EditedOn mismatch") + testutils.AssertEqual(t, n1.Content, n.Content, "n1 Content mismatch") + testutils.AssertEqual(t, n1.Public, n.Public, "n1 Public mismatch") + testutils.AssertEqual(t, n1.Deleted, n.Deleted, "n1 Deleted mismatch") + testutils.AssertEqual(t, n1.Dirty, false, "n1 Dirty mismatch") + }) + + t.Run("exists on server and client", func(t *testing.T) { + b1UUID := utils.GenerateUUID() + b2UUID := utils.GenerateUUID() + + testCases := []struct { + addedOn int64 + clientUSN int + clientEditedOn int64 + clientContent string + clientPublic bool + clientDeleted bool + clientBookUUID string + clientDirty bool + serverUSN int + serverEditedOn int64 + serverContent string + serverPublic bool + serverDeleted bool + serverBookUUID string + expectedUSN int + expectedAddedOn int64 + expectedEditedOn int64 + expectedContent string + expectedPublic bool + expectedDeleted bool + expectedBookUUID string + expectedDirty bool + }{ + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 0, + clientContent: "n1 content", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: true, + }, + // if deleted locally, resurrect it + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 1541219321, + clientContent: "", + clientPublic: false, + clientDeleted: true, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content edited", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content edited", + expectedPublic: false, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + { + clientDirty: false, + clientUSN: 1, + clientEditedOn: 0, + clientContent: "n1 content", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") + testutils.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "b2-label") + n1UUID := utils.GenerateUUID() + testutils.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientContent, tc.clientPublic, tc.clientDeleted, tc.clientDirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + // update all fields but uuid and bump usn + n := client.SyncFragNote{ + UUID: n1UUID, + BookUUID: tc.serverBookUUID, + USN: tc.serverUSN, + AddedOn: tc.addedOn, + EditedOn: tc.serverEditedOn, + Content: tc.serverContent, + Public: tc.serverPublic, + Deleted: tc.serverDeleted, + } + + if err := stepSyncNote(tx, n); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, fmt.Sprintf("counting notes for test case %d", idx), db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, fmt.Sprintf("counting books for test case %d", idx), db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 1, fmt.Sprintf("note count mismatch for test case %d", idx)) + testutils.AssertEqualf(t, bookCount, 2, fmt.Sprintf("book count mismatch for test case %d", idx)) + + var n1 core.Note + testutils.MustScan(t, fmt.Sprintf("getting n1 for test case %d", idx), + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Content, &n1.Public, &n1.Deleted, &n1.Dirty) + + testutils.AssertEqual(t, n1.UUID, n.UUID, fmt.Sprintf("n1 UUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.BookUUID, tc.expectedBookUUID, fmt.Sprintf("n1 BookUUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.USN, tc.expectedUSN, fmt.Sprintf("n1 USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.AddedOn, tc.expectedAddedOn, fmt.Sprintf("n1 AddedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.EditedOn, tc.expectedEditedOn, fmt.Sprintf("n1 EditedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Content, tc.expectedContent, fmt.Sprintf("n1 Content mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Public, tc.expectedPublic, fmt.Sprintf("n1 Public mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Deleted, tc.expectedDeleted, fmt.Sprintf("n1 Deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1.Dirty, tc.expectedDirty, fmt.Sprintf("n1 Dirty mismatch for test case %d", idx)) + }() + } + }) +} + +func TestStepSyncBook(t *testing.T) { + t.Run("exists on server only", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 555, "b1-label", true, false) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b2UUID := utils.GenerateUUID() + b := client.SyncFragBook{ + UUID: b2UUID, + USN: 1, + AddedOn: 1541108743, + Label: "b2-label", + Deleted: false, + } + + if err := stepSyncBook(tx, b); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 2, "book count mismatch") + + var b1, b2 core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, usn, label, dirty, deleted FROM books WHERE uuid = ?", b1UUID), + &b1.UUID, &b1.USN, &b1.Label, &b1.Dirty, &b1.Deleted) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, usn, label, dirty, deleted FROM books WHERE uuid = ?", b2UUID), + &b2.UUID, &b2.USN, &b2.Label, &b2.Dirty, &b2.Deleted) + + testutils.AssertEqual(t, b1.UUID, b1UUID, "b1 UUID mismatch") + testutils.AssertEqual(t, b1.USN, 555, "b1 USN mismatch") + testutils.AssertEqual(t, b1.Label, "b1-label", "b1 Label mismatch") + testutils.AssertEqual(t, b1.Dirty, true, "b1 Dirty mismatch") + testutils.AssertEqual(t, b1.Deleted, false, "b1 Deleted mismatch") + + testutils.AssertEqual(t, b2.UUID, b2UUID, "b2 UUID mismatch") + testutils.AssertEqual(t, b2.USN, b.USN, "b2 USN mismatch") + testutils.AssertEqual(t, b2.Label, b.Label, "b2 Label mismatch") + testutils.AssertEqual(t, b2.Dirty, false, "b2 Dirty mismatch") + testutils.AssertEqual(t, b2.Deleted, b.Deleted, "b2 Deleted mismatch") + }) + + t.Run("exists on server and client", func(t *testing.T) { + testCases := []struct { + clientDirty bool + clientUSN int + clientLabel string + clientDeleted bool + serverUSN int + serverLabel string + serverDeleted bool + expectedUSN int + expectedLabel string + expectedDeleted bool + anotherBookLabel string + expectedAnotherBookLabel string + expectedAnotherBookDirty bool + }{ + { + clientDirty: true, + clientUSN: 1, + clientLabel: "b2-label", + clientDeleted: false, + serverUSN: 3, + serverLabel: "b2-label-updated", + serverDeleted: false, + expectedUSN: 3, + expectedLabel: "b2-label-updated", + expectedDeleted: false, + anotherBookLabel: "foo", + expectedAnotherBookLabel: "foo", + expectedAnotherBookDirty: false, + }, + { + clientDirty: false, + clientUSN: 1, + clientLabel: "b2-label", + clientDeleted: false, + serverUSN: 3, + serverLabel: "b2-label-updated", + serverDeleted: false, + expectedUSN: 3, + expectedLabel: "b2-label-updated", + expectedDeleted: false, + anotherBookLabel: "foo", + expectedAnotherBookLabel: "foo", + expectedAnotherBookDirty: false, + }, + { + clientDirty: false, + clientUSN: 1, + clientLabel: "b2-label", + clientDeleted: false, + serverUSN: 3, + serverLabel: "foo", + serverDeleted: false, + expectedUSN: 3, + expectedLabel: "foo", + expectedDeleted: false, + anotherBookLabel: "foo", + expectedAnotherBookLabel: "foo (2)", + expectedAnotherBookDirty: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, tc.clientUSN, tc.clientLabel, tc.clientDirty, tc.clientDeleted) + b2UUID := utils.GenerateUUID() + testutils.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b2UUID, 2, tc.anotherBookLabel, false, false) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + // update all fields but uuid and bump usn + b := client.SyncFragBook{ + UUID: b1UUID, + USN: tc.serverUSN, + Label: tc.serverLabel, + Deleted: tc.serverDeleted, + } + + if err := fullSyncBook(tx, b); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, fmt.Sprintf("counting notes for test case %d", idx), db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, fmt.Sprintf("counting books for test case %d", idx), db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, fmt.Sprintf("note count mismatch for test case %d", idx)) + testutils.AssertEqualf(t, bookCount, 2, fmt.Sprintf("book count mismatch for test case %d", idx)) + + var b1Record, b2Record core.Book + testutils.MustScan(t, "getting b1Record", + db.QueryRow("SELECT uuid, usn, label, dirty, deleted FROM books WHERE uuid = ?", b1UUID), + &b1Record.UUID, &b1Record.USN, &b1Record.Label, &b1Record.Dirty, &b1Record.Deleted) + testutils.MustScan(t, "getting b2Record", + db.QueryRow("SELECT uuid, usn, label, dirty, deleted FROM books WHERE uuid = ?", b2UUID), + &b2Record.UUID, &b2Record.USN, &b2Record.Label, &b2Record.Dirty, &b2Record.Deleted) + + testutils.AssertEqual(t, b1Record.UUID, b1UUID, fmt.Sprintf("b1Record UUID mismatch for idx %d", idx)) + testutils.AssertEqual(t, b1Record.USN, tc.expectedUSN, fmt.Sprintf("b1Record USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Label, tc.expectedLabel, fmt.Sprintf("b1Record Label mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Dirty, tc.clientDirty, fmt.Sprintf("b1Record Dirty mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Deleted, tc.expectedDeleted, fmt.Sprintf("b1Record Deleted mismatch for test case %d", idx)) + + testutils.AssertEqual(t, b2Record.UUID, b2UUID, fmt.Sprintf("b2Record UUID mismatch for idx %d", idx)) + testutils.AssertEqual(t, b2Record.USN, 2, fmt.Sprintf("b2Record USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Label, tc.expectedAnotherBookLabel, fmt.Sprintf("b2Record Label mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Dirty, tc.expectedAnotherBookDirty, fmt.Sprintf("b2Record Dirty mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Deleted, false, fmt.Sprintf("b2Record Deleted mismatch for test case %d", idx)) + }() + } + }) +} + +func TestMergeBook(t *testing.T) { + t.Run("insert, no duplicates", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + // test + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b1 := client.SyncFragBook{ + UUID: "b1-uuid", + USN: 12, + AddedOn: 1541108743, + Label: "b1-label", + Deleted: false, + } + + if err := mergeBook(tx, b1, modeInsert); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // execute + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + + var b1Record core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b1-uuid"), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + + testutils.AssertEqual(t, b1Record.UUID, b1.UUID, "b1 UUID mismatch") + testutils.AssertEqual(t, b1Record.Label, b1.Label, "b1 Label mismatch") + testutils.AssertEqual(t, b1Record.USN, b1.USN, "b1 USN mismatch") + }) + + t.Run("insert, 1 duplicate", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b1-uuid", 1, "foo", false, false) + + // test + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b := client.SyncFragBook{ + UUID: "b2-uuid", + USN: 12, + AddedOn: 1541108743, + Label: "foo", + Deleted: false, + } + + if err := mergeBook(tx, b, modeInsert); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // execute + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 2, "book count mismatch") + + var b1Record, b2Record core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b1-uuid"), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b2-uuid"), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Dirty) + + testutils.AssertEqual(t, b1Record.Label, "foo (2)", "b1 Label mismatch") + testutils.AssertEqual(t, b1Record.USN, 1, "b1 USN mismatch") + testutils.AssertEqual(t, b1Record.Dirty, true, "b1 should have been marked dirty") + + testutils.AssertEqual(t, b2Record.Label, "foo", "b2 Label mismatch") + testutils.AssertEqual(t, b2Record.USN, 12, "b2 USN mismatch") + testutils.AssertEqual(t, b2Record.Dirty, false, "b2 Dirty mismatch") + }) + + t.Run("insert, 3 duplicates", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b1-uuid", 1, "foo", false, false) + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b2-uuid", 2, "foo (2)", true, false) + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b3-uuid", 3, "foo (3)", false, false) + + // test + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b := client.SyncFragBook{ + UUID: "b4-uuid", + USN: 12, + AddedOn: 1541108743, + Label: "foo", + Deleted: false, + } + + if err := mergeBook(tx, b, modeInsert); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // execute + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 4, "book count mismatch") + + var b1Record, b2Record, b3Record, b4Record core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b1-uuid"), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b2-uuid"), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Dirty) + testutils.MustScan(t, "getting b3", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b3-uuid"), + &b3Record.UUID, &b3Record.Label, &b3Record.USN, &b3Record.Dirty) + testutils.MustScan(t, "getting b4", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b4-uuid"), + &b4Record.UUID, &b4Record.Label, &b4Record.USN, &b4Record.Dirty) + + testutils.AssertEqual(t, b1Record.Label, "foo (4)", "b1 Label mismatch") + testutils.AssertEqual(t, b1Record.USN, 1, "b1 USN mismatch") + testutils.AssertEqual(t, b1Record.Dirty, true, "b1 Dirty mismatch") + + testutils.AssertEqual(t, b2Record.Label, "foo (2)", "b2 Label mismatch") + testutils.AssertEqual(t, b2Record.USN, 2, "b2 USN mismatch") + testutils.AssertEqual(t, b2Record.Dirty, true, "b2 Dirty mismatch") + + testutils.AssertEqual(t, b3Record.Label, "foo (3)", "b3 Label mismatch") + testutils.AssertEqual(t, b3Record.USN, 3, "b3 USN mismatch") + testutils.AssertEqual(t, b3Record.Dirty, false, "b3 Dirty mismatch") + + testutils.AssertEqual(t, b4Record.Label, "foo", "b4 Label mismatch") + testutils.AssertEqual(t, b4Record.USN, 12, "b4 USN mismatch") + testutils.AssertEqual(t, b4Record.Dirty, false, "b4 Dirty mismatch") + }) + + t.Run("update, no duplicates", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + // test + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 1, "b1-label", false, false) + + b1 := client.SyncFragBook{ + UUID: b1UUID, + USN: 12, + AddedOn: 1541108743, + Label: "b1-label-edited", + Deleted: false, + } + + if err := mergeBook(tx, b1, modeUpdate); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // execute + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + + var b1Record core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b1UUID), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + + testutils.AssertEqual(t, b1Record.UUID, b1UUID, "b1 UUID mismatch") + testutils.AssertEqual(t, b1Record.Label, "b1-label-edited", "b1 Label mismatch") + testutils.AssertEqual(t, b1Record.USN, 12, "b1 USN mismatch") + }) + + t.Run("update, 1 duplicate", func(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b1-uuid", 1, "foo", false, false) + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b2-uuid", 2, "bar", false, false) + + // test + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b := client.SyncFragBook{ + UUID: "b1-uuid", + USN: 12, + AddedOn: 1541108743, + Label: "bar", + Deleted: false, + } + + if err := mergeBook(tx, b, modeUpdate); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // execute + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 2, "book count mismatch") + + var b1Record, b2Record core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b1-uuid"), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b2-uuid"), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Dirty) + + testutils.AssertEqual(t, b1Record.Label, "bar", "b1 Label mismatch") + testutils.AssertEqual(t, b1Record.USN, 12, "b1 USN mismatch") + testutils.AssertEqual(t, b1Record.Dirty, false, "b1 Dirty mismatch") + + testutils.AssertEqual(t, b2Record.Label, "bar (2)", "b2 Label mismatch") + testutils.AssertEqual(t, b2Record.USN, 2, "b2 USN mismatch") + testutils.AssertEqual(t, b2Record.Dirty, true, "b2 Dirty mismatch") + }) + + t.Run("update, 3 duplicate", func(t *testing.T) { + // set uj + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b1-uuid", 1, "foo", false, false) + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b2-uuid", 2, "bar", false, false) + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b3-uuid", 3, "bar (2)", true, false) + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", "b4-uuid", 4, "bar (3)", false, false) + + // test + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + b := client.SyncFragBook{ + UUID: "b1-uuid", + USN: 12, + AddedOn: 1541108743, + Label: "bar", + Deleted: false, + } + + if err := mergeBook(tx, b, modeUpdate); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // execute + var noteCount, bookCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 0, "note count mismatch") + testutils.AssertEqualf(t, bookCount, 4, "book count mismatch") + + var b1Record, b2Record, b3Record, b4Record core.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b1-uuid"), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b2-uuid"), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Dirty) + testutils.MustScan(t, "getting b3", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b3-uuid"), + &b3Record.UUID, &b3Record.Label, &b3Record.USN, &b3Record.Dirty) + testutils.MustScan(t, "getting b4", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", "b4-uuid"), + &b4Record.UUID, &b4Record.Label, &b4Record.USN, &b4Record.Dirty) + + testutils.AssertEqual(t, b1Record.Label, "bar", "b1 Label mismatch") + testutils.AssertEqual(t, b1Record.USN, 12, "b1 USN mismatch") + testutils.AssertEqual(t, b1Record.Dirty, false, "b1 Dirty mismatch") + + testutils.AssertEqual(t, b2Record.Label, "bar (4)", "b2 Label mismatch") + testutils.AssertEqual(t, b2Record.USN, 2, "b2 USN mismatch") + testutils.AssertEqual(t, b2Record.Dirty, true, "b2 Dirty mismatch") + + testutils.AssertEqual(t, b3Record.Label, "bar (2)", "b3 Label mismatch") + testutils.AssertEqual(t, b3Record.USN, 3, "b3 USN mismatch") + testutils.AssertEqual(t, b3Record.Dirty, true, "b3 Dirty mismatch") + + testutils.AssertEqual(t, b4Record.Label, "bar (3)", "b4 Label mismatch") + testutils.AssertEqual(t, b4Record.USN, 4, "b4 USN mismatch") + testutils.AssertEqual(t, b4Record.Dirty, false, "b4 Dirty mismatch") + }) +} + +func TestSaveServerState(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting last synced at", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastSyncAt, int64(1231108742)) + testutils.MustExec(t, "inserting last max usn", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, 8) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + serverTime := int64(1541108743) + serverMaxUSN := 100 + + err = saveSyncState(tx, serverTime, serverMaxUSN) + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var lastSyncedAt int64 + var lastMaxUSN int + + testutils.MustScan(t, "getting system value", + db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastSyncAt), &lastSyncedAt) + testutils.MustScan(t, "getting system value", + db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastMaxUSN), &lastMaxUSN) + + testutils.AssertEqual(t, lastSyncedAt, serverTime, "last synced at mismatch") + testutils.AssertEqual(t, lastMaxUSN, serverMaxUSN, "last max usn mismatch") +} + +// TestSendBooks tests that books are put to correct 'buckets' by running a test server and recording the +// uuid from the incoming data. It also tests that the uuid of the created books and book_uuids of their notes +// are updated accordingly based on the server response. +func TestSendBooks(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting last max usn", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, 0) + + // should be ignored + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 1, false, false) + testutils.MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b2-uuid", "b2-label", 2, false, false) + // should be created + testutils.MustExec(t, "inserting b3", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b3-uuid", "b3-label", 0, false, true) + testutils.MustExec(t, "inserting b4", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b4-uuid", "b4-label", 0, false, true) + // should be only expunged locally without syncing to server + testutils.MustExec(t, "inserting b5", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b5-uuid", "b5-label", 0, true, true) + // should be deleted + testutils.MustExec(t, "inserting b6", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b6-uuid", "b6-label", 10, true, true) + // should be updated + testutils.MustExec(t, "inserting b7", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b7-uuid", "b7-label", 11, false, true) + testutils.MustExec(t, "inserting b8", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b8-uuid", "b8-label", 18, false, true) + + // some random notes + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 10, "n1 content", 1541108743, false, false) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", "b5-uuid", 10, "n2 content", 1541108743, false, false) + testutils.MustExec(t, "inserting n3", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n3-uuid", "b6-uuid", 10, "n3 content", 1541108743, false, false) + testutils.MustExec(t, "inserting n4", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n4-uuid", "b7-uuid", 10, "n4 content", 1541108743, false, false) + // notes that belong to the created book. Their book_uuid should be updated. + testutils.MustExec(t, "inserting n5", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n5-uuid", "b3-uuid", 10, "n5 content", 1541108743, false, false) + testutils.MustExec(t, "inserting n6", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n6-uuid", "b3-uuid", 10, "n6 content", 1541108743, false, false) + testutils.MustExec(t, "inserting n7", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n7-uuid", "b4-uuid", 10, "n7 content", 1541108743, false, false) + + var createdLabels []string + var updatesUUIDs []string + var deletedUUIDs []string + + // fire up a test server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/v1/books" && r.Method == "POST" { + var payload client.CreateBookPayload + + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + t.Fatalf(errors.Wrap(err, "decoding payload in the test server").Error()) + return + } + + createdLabels = append(createdLabels, payload.Name) + + resp := client.CreateBookResp{ + Book: client.RespBook{ + UUID: fmt.Sprintf("server-%s-uuid", payload.Name), + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + + p := strings.Split(r.URL.Path, "/") + if len(p) == 4 && p[0] == "" && p[1] == "v1" && p[2] == "books" { + if r.Method == "PATCH" { + uuid := p[3] + updatesUUIDs = append(updatesUUIDs, uuid) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{}")) + return + } else if r.Method == "DELETE" { + uuid := p[3] + deletedUUIDs = append(deletedUUIDs, uuid) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{}")) + return + } + } + + t.Fatalf("unrecognized endpoint reached Method: %s Path: %s", r.Method, r.URL.Path) + })) + defer ts.Close() + + ctx.APIEndpoint = ts.URL + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if _, err := sendBooks(ctx, tx, "mockAPIKey"); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + sort.SliceStable(createdLabels, func(i, j int) bool { + return strings.Compare(createdLabels[i], createdLabels[j]) < 0 + }) + + testutils.AssertDeepEqual(t, createdLabels, []string{"b3-label", "b4-label"}, "createdLabels mismatch") + testutils.AssertDeepEqual(t, updatesUUIDs, []string{"b7-uuid", "b8-uuid"}, "updatesUUIDs mismatch") + testutils.AssertDeepEqual(t, deletedUUIDs, []string{"b6-uuid"}, "deletedUUIDs mismatch") + + var b1, b2, b3, b4, b7, b8 core.Book + testutils.MustScan(t, "getting b1", db.QueryRow("SELECT uuid, dirty FROM books WHERE label = ?", "b1-label"), &b1.UUID, &b1.Dirty) + testutils.MustScan(t, "getting b2", db.QueryRow("SELECT uuid, dirty FROM books WHERE label = ?", "b2-label"), &b2.UUID, &b2.Dirty) + testutils.MustScan(t, "getting b3", db.QueryRow("SELECT uuid, dirty FROM books WHERE label = ?", "b3-label"), &b3.UUID, &b3.Dirty) + testutils.MustScan(t, "getting b4", db.QueryRow("SELECT uuid, dirty FROM books WHERE label = ?", "b4-label"), &b4.UUID, &b4.Dirty) + testutils.MustScan(t, "getting b7", db.QueryRow("SELECT uuid, dirty FROM books WHERE label = ?", "b7-label"), &b7.UUID, &b7.Dirty) + testutils.MustScan(t, "getting b8", db.QueryRow("SELECT uuid, dirty FROM books WHERE label = ?", "b8-label"), &b8.UUID, &b8.Dirty) + + var bookCount int + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.AssertEqualf(t, bookCount, 6, "book count mismatch") + + testutils.AssertEqual(t, b1.Dirty, false, "b1 Dirty mismatch") + testutils.AssertEqual(t, b2.Dirty, false, "b2 Dirty mismatch") + testutils.AssertEqual(t, b3.Dirty, false, "b3 Dirty mismatch") + testutils.AssertEqual(t, b4.Dirty, false, "b4 Dirty mismatch") + testutils.AssertEqual(t, b7.Dirty, false, "b7 Dirty mismatch") + testutils.AssertEqual(t, b8.Dirty, false, "b8 Dirty mismatch") + testutils.AssertEqual(t, b1.UUID, "b1-uuid", "b1 UUID mismatch") + testutils.AssertEqual(t, b2.UUID, "b2-uuid", "b2 UUID mismatch") + // uuids of created books should have been updated + testutils.AssertEqual(t, b3.UUID, "server-b3-label-uuid", "b3 UUID mismatch") + testutils.AssertEqual(t, b4.UUID, "server-b4-label-uuid", "b4 UUID mismatch") + testutils.AssertEqual(t, b7.UUID, "b7-uuid", "b7 UUID mismatch") + testutils.AssertEqual(t, b8.UUID, "b8-uuid", "b8 UUID mismatch") + + var n1, n2, n3, n4, n5, n6, n7 core.Note + testutils.MustScan(t, "getting n1", db.QueryRow("SELECT book_uuid FROM notes WHERE content = ?", "n1 content"), &n1.BookUUID) + testutils.MustScan(t, "getting n2", db.QueryRow("SELECT book_uuid FROM notes WHERE content = ?", "n2 content"), &n2.BookUUID) + testutils.MustScan(t, "getting n3", db.QueryRow("SELECT book_uuid FROM notes WHERE content = ?", "n3 content"), &n3.BookUUID) + testutils.MustScan(t, "getting n4", db.QueryRow("SELECT book_uuid FROM notes WHERE content = ?", "n4 content"), &n4.BookUUID) + testutils.MustScan(t, "getting n5", db.QueryRow("SELECT book_uuid FROM notes WHERE content = ?", "n5 content"), &n5.BookUUID) + testutils.MustScan(t, "getting n6", db.QueryRow("SELECT book_uuid FROM notes WHERE content = ?", "n6 content"), &n6.BookUUID) + testutils.MustScan(t, "getting n7", db.QueryRow("SELECT book_uuid FROM notes WHERE content = ?", "n7 content"), &n7.BookUUID) + testutils.AssertEqual(t, n1.BookUUID, "b1-uuid", "n1 bookUUID mismatch") + testutils.AssertEqual(t, n2.BookUUID, "b5-uuid", "n2 bookUUID mismatch") + testutils.AssertEqual(t, n3.BookUUID, "b6-uuid", "n3 bookUUID mismatch") + testutils.AssertEqual(t, n4.BookUUID, "b7-uuid", "n4 bookUUID mismatch") + testutils.AssertEqual(t, n5.BookUUID, "server-b3-label-uuid", "n5 bookUUID mismatch") + testutils.AssertEqual(t, n6.BookUUID, "server-b3-label-uuid", "n6 bookUUID mismatch") + testutils.AssertEqual(t, n7.BookUUID, "server-b4-label-uuid", "n7 bookUUID mismatch") +} + +func TestSendBooks_isBehind(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/v1/books" && r.Method == "POST" { + var payload client.CreateBookPayload + + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + t.Fatalf(errors.Wrap(err, "decoding payload in the test server").Error()) + return + } + + resp := client.CreateBookResp{ + Book: client.RespBook{ + USN: 11, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + + p := strings.Split(r.URL.Path, "/") + if len(p) == 4 && p[0] == "" && p[1] == "v1" && p[2] == "books" { + if r.Method == "PATCH" { + resp := client.UpdateBookResp{ + Book: client.RespBook{ + USN: 11, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } else if r.Method == "DELETE" { + resp := client.DeleteBookResp{ + Book: client.RespBook{ + USN: 11, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + } + + t.Fatalf("unrecognized endpoint reached Method: %s Path: %s", r.Method, r.URL.Path) + })) + defer ts.Close() + + t.Run("create book", func(t *testing.T) { + testCases := []struct { + systemLastMaxUSN int + expectedIsBehind bool + }{ + { + systemLastMaxUSN: 10, + expectedIsBehind: false, + }, + { + systemLastMaxUSN: 9, + expectedIsBehind: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + ctx.APIEndpoint = ts.URL + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting last max usn for test case %d", idx), db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, tc.systemLastMaxUSN) + testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 0, false, true) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + isBehind, err := sendBooks(ctx, tx, "mockAPIKey") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, isBehind, tc.expectedIsBehind, fmt.Sprintf("isBehind mismatch for test case %d", idx)) + }() + } + }) + + t.Run("delete book", func(t *testing.T) { + testCases := []struct { + systemLastMaxUSN int + expectedIsBehind bool + }{ + { + systemLastMaxUSN: 10, + expectedIsBehind: false, + }, + { + systemLastMaxUSN: 9, + expectedIsBehind: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + ctx.APIEndpoint = ts.URL + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting last max usn for test case %d", idx), db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, tc.systemLastMaxUSN) + testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 1, true, true) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + isBehind, err := sendBooks(ctx, tx, "mockAPIKey") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, isBehind, tc.expectedIsBehind, fmt.Sprintf("isBehind mismatch for test case %d", idx)) + }() + } + }) + + t.Run("update book", func(t *testing.T) { + testCases := []struct { + systemLastMaxUSN int + expectedIsBehind bool + }{ + { + systemLastMaxUSN: 10, + expectedIsBehind: false, + }, + { + systemLastMaxUSN: 9, + expectedIsBehind: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + ctx.APIEndpoint = ts.URL + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting last max usn for test case %d", idx), db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, tc.systemLastMaxUSN) + testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 11, false, true) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + isBehind, err := sendBooks(ctx, tx, "mockAPIKey") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, isBehind, tc.expectedIsBehind, fmt.Sprintf("isBehind mismatch for test case %d", idx)) + }() + } + }) +} + +// TestSendNotes tests that notes are put to correct 'buckets' by running a test server and recording the +// uuid from the incoming data. +func TestSendNotes(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting last max usn", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, 0) + + b1UUID := "b1-uuid" + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1UUID, "b1-label", 1, false, false) + + // should be ignored + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1-content", 1541108743, false, false) + // should be created + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 0, "n2-content", 1541108743, false, true) + // should be updated + testutils.MustExec(t, "inserting n3", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n3-uuid", b1UUID, 11, "n3-content", 1541108743, false, true) + // should be only expunged locally without syncing to server + testutils.MustExec(t, "inserting n4", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n4-uuid", b1UUID, 0, "n4-content", 1541108743, true, true) + // should be deleted + testutils.MustExec(t, "inserting n5", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n5-uuid", b1UUID, 17, "n5-content", 1541108743, true, true) + // should be created + testutils.MustExec(t, "inserting n6", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n6-uuid", b1UUID, 0, "n6-content", 1541108743, false, true) + // should be ignored + testutils.MustExec(t, "inserting n7", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n7-uuid", b1UUID, 12, "n7-content", 1541108743, false, false) + // should be updated + testutils.MustExec(t, "inserting n8", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n8-uuid", b1UUID, 17, "n8-content", 1541108743, false, true) + // should be deleted + testutils.MustExec(t, "inserting n9", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n9-uuid", b1UUID, 17, "n9-content", 1541108743, true, true) + // should be created + testutils.MustExec(t, "inserting n10", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n10-uuid", b1UUID, 0, "n10-content", 1541108743, false, true) + + var createdContents []string + var updatedUUIDs []string + var deletedUUIDs []string + + // fire up a test server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/v1/notes" && r.Method == "POST" { + var payload client.CreateNotePayload + + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + t.Fatalf(errors.Wrap(err, "decoding payload in the test server").Error()) + return + } + + createdContents = append(createdContents, payload.Content) + + resp := client.CreateNoteResp{ + Result: client.RespNote{ + UUID: fmt.Sprintf("server-%s-uuid", payload.Content), + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + + p := strings.Split(r.URL.Path, "/") + if len(p) == 4 && p[0] == "" && p[1] == "v1" && p[2] == "notes" { + if r.Method == "PATCH" { + uuid := p[3] + updatedUUIDs = append(updatedUUIDs, uuid) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{}")) + return + } else if r.Method == "DELETE" { + uuid := p[3] + deletedUUIDs = append(deletedUUIDs, uuid) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{}")) + return + } + } + + t.Fatalf("unrecognized endpoint reached Method: %s Path: %s", r.Method, r.URL.Path) + })) + defer ts.Close() + + ctx.APIEndpoint = ts.URL + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if _, err := sendNotes(ctx, tx, "mockAPIKey"); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + sort.SliceStable(createdContents, func(i, j int) bool { + return strings.Compare(createdContents[i], createdContents[j]) < 0 + }) + + testutils.AssertDeepEqual(t, createdContents, []string{"n10-content", "n2-content", "n6-content"}, "createdContents mismatch") + testutils.AssertDeepEqual(t, updatedUUIDs, []string{"n3-uuid", "n8-uuid"}, "updatedUUIDs mismatch") + testutils.AssertDeepEqual(t, deletedUUIDs, []string{"n5-uuid", "n9-uuid"}, "deletedUUIDs mismatch") + + var noteCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.AssertEqualf(t, noteCount, 7, "note count mismatch") + + var n1, n2, n3, n6, n7, n8, n10 core.Note + testutils.MustScan(t, "getting n1", db.QueryRow("SELECT uuid, dirty FROM notes WHERE content = ?", "n1-content"), &n1.UUID, &n1.Dirty) + testutils.MustScan(t, "getting n2", db.QueryRow("SELECT uuid, dirty FROM notes WHERE content = ?", "n2-content"), &n2.UUID, &n2.Dirty) + testutils.MustScan(t, "getting n3", db.QueryRow("SELECT uuid, dirty FROM notes WHERE content = ?", "n3-content"), &n3.UUID, &n3.Dirty) + testutils.MustScan(t, "getting n6", db.QueryRow("SELECT uuid, dirty FROM notes WHERE content = ?", "n6-content"), &n6.UUID, &n6.Dirty) + testutils.MustScan(t, "getting n7", db.QueryRow("SELECT uuid, dirty FROM notes WHERE content = ?", "n7-content"), &n7.UUID, &n7.Dirty) + testutils.MustScan(t, "getting n8", db.QueryRow("SELECT uuid, dirty FROM notes WHERE content = ?", "n8-content"), &n8.UUID, &n8.Dirty) + testutils.MustScan(t, "getting n10", db.QueryRow("SELECT uuid, dirty FROM notes WHERE content = ?", "n10-content"), &n10.UUID, &n10.Dirty) + + testutils.AssertEqualf(t, noteCount, 7, "note count mismatch") + + testutils.AssertEqual(t, n1.Dirty, false, "n1 Dirty mismatch") + testutils.AssertEqual(t, n2.Dirty, false, "n2 Dirty mismatch") + testutils.AssertEqual(t, n3.Dirty, false, "n3 Dirty mismatch") + testutils.AssertEqual(t, n6.Dirty, false, "n6 Dirty mismatch") + testutils.AssertEqual(t, n7.Dirty, false, "n7 Dirty mismatch") + testutils.AssertEqual(t, n8.Dirty, false, "n8 Dirty mismatch") + testutils.AssertEqual(t, n10.Dirty, false, "n10 Dirty mismatch") + + // UUIDs of created notes should have been updated with those from the server response + testutils.AssertEqual(t, n1.UUID, "n1-uuid", "n1 UUID mismatch") + testutils.AssertEqual(t, n2.UUID, "server-n2-content-uuid", "n2 UUID mismatch") + testutils.AssertEqual(t, n3.UUID, "n3-uuid", "n3 UUID mismatch") + testutils.AssertEqual(t, n6.UUID, "server-n6-content-uuid", "n6 UUID mismatch") + testutils.AssertEqual(t, n7.UUID, "n7-uuid", "n7 UUID mismatch") + testutils.AssertEqual(t, n8.UUID, "n8-uuid", "n8 UUID mismatch") + testutils.AssertEqual(t, n10.UUID, "server-n10-content-uuid", "n10 UUID mismatch") +} + +func TestSendNotes_isBehind(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/v1/notes" && r.Method == "POST" { + var payload client.CreateBookPayload + + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + t.Fatalf(errors.Wrap(err, "decoding payload in the test server").Error()) + return + } + + resp := client.CreateNoteResp{ + Result: client.RespNote{ + USN: 11, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + + p := strings.Split(r.URL.Path, "/") + if len(p) == 4 && p[0] == "" && p[1] == "v1" && p[2] == "notes" { + if r.Method == "PATCH" { + resp := client.UpdateNoteResp{ + Result: client.RespNote{ + USN: 11, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } else if r.Method == "DELETE" { + resp := client.DeleteNoteResp{ + Result: client.RespNote{ + USN: 11, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + } + + t.Fatalf("unrecognized endpoint reached Method: %s Path: %s", r.Method, r.URL.Path) + })) + defer ts.Close() + + t.Run("create note", func(t *testing.T) { + testCases := []struct { + systemLastMaxUSN int + expectedIsBehind bool + }{ + { + systemLastMaxUSN: 10, + expectedIsBehind: false, + }, + { + systemLastMaxUSN: 9, + expectedIsBehind: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + ctx.APIEndpoint = ts.URL + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting last max usn for test case %d", idx), db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, tc.systemLastMaxUSN) + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 1, false, false) + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 1, "n1 content", 1541108743, false, true) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + isBehind, err := sendNotes(ctx, tx, "mockAPIKey") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, isBehind, tc.expectedIsBehind, fmt.Sprintf("isBehind mismatch for test case %d", idx)) + }() + } + }) + + t.Run("delete note", func(t *testing.T) { + testCases := []struct { + systemLastMaxUSN int + expectedIsBehind bool + }{ + { + systemLastMaxUSN: 10, + expectedIsBehind: false, + }, + { + systemLastMaxUSN: 9, + expectedIsBehind: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + ctx.APIEndpoint = ts.URL + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting last max usn for test case %d", idx), db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, tc.systemLastMaxUSN) + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 1, false, false) + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 2, "n1 content", 1541108743, true, true) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + isBehind, err := sendNotes(ctx, tx, "mockAPIKey") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, isBehind, tc.expectedIsBehind, fmt.Sprintf("isBehind mismatch for test case %d", idx)) + }() + } + }) + + t.Run("update note", func(t *testing.T) { + testCases := []struct { + systemLastMaxUSN int + expectedIsBehind bool + }{ + { + systemLastMaxUSN: 10, + expectedIsBehind: false, + }, + { + systemLastMaxUSN: 9, + expectedIsBehind: true, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + ctx.APIEndpoint = ts.URL + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting last max usn for test case %d", idx), db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemLastMaxUSN, tc.systemLastMaxUSN) + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 1, false, false) + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 8, "n1 content", 1541108743, false, true) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + isBehind, err := sendNotes(ctx, tx, "mockAPIKey") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, isBehind, tc.expectedIsBehind, fmt.Sprintf("isBehind mismatch for test case %d", idx)) + }() + } + }) +} + +func TestMergeNote(t *testing.T) { + b1UUID := "b1-uuid" + b2UUID := "b2-uuid" + + testCases := []struct { + addedOn int64 + clientUSN int + clientEditedOn int64 + clientContent string + clientPublic bool + clientDeleted bool + clientBookUUID string + clientDirty bool + serverUSN int + serverEditedOn int64 + serverContent string + serverPublic bool + serverDeleted bool + serverBookUUID string + expectedUSN int + expectedAddedOn int64 + expectedEditedOn int64 + expectedContent string + expectedPublic bool + expectedDeleted bool + expectedBookUUID string + expectedDirty bool + }{ + { + clientDirty: false, + clientUSN: 1, + clientEditedOn: 0, + clientContent: "n1 content", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + // deleted locally and edited on server + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 1541219321, + clientContent: "", + clientPublic: false, + clientDeleted: true, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverContent: "n1 content edited", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedContent: "n1 content edited", + expectedPublic: false, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + } + + for idx, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", b1UUID, "b1-label", 5, false) + testutils.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", b2UUID, "b2-label", 6, false) + n1UUID := utils.GenerateUUID() + testutils.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, b1UUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientContent, tc.clientPublic, tc.clientDeleted, tc.clientDirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + // update all fields but uuid and bump usn + fragNote := client.SyncFragNote{ + UUID: n1UUID, + BookUUID: tc.serverBookUUID, + USN: tc.serverUSN, + AddedOn: tc.addedOn, + EditedOn: tc.serverEditedOn, + Content: tc.serverContent, + Public: tc.serverPublic, + Deleted: tc.serverDeleted, + } + var localNote core.Note + testutils.MustScan(t, fmt.Sprintf("getting localNote for test case %d", idx), + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n1UUID), + &localNote.UUID, &localNote.BookUUID, &localNote.USN, &localNote.AddedOn, &localNote.EditedOn, &localNote.Content, &localNote.Public, &localNote.Deleted, &localNote.Dirty) + + if err := mergeNote(tx, fragNote, localNote); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var noteCount, bookCount int + testutils.MustScan(t, fmt.Sprintf("counting notes for test case %d", idx), db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, fmt.Sprintf("counting books for test case %d", idx), db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, noteCount, 1, fmt.Sprintf("note count mismatch for test case %d", idx)) + testutils.AssertEqualf(t, bookCount, 2, fmt.Sprintf("book count mismatch for test case %d", idx)) + + var n1Record core.Note + testutils.MustScan(t, fmt.Sprintf("getting n1Record for test case %d", idx), + db.QueryRow("SELECT uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty FROM notes WHERE uuid = ?", n1UUID), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.Content, &n1Record.Public, &n1Record.Deleted, &n1Record.Dirty) + var b1Record core.Book + testutils.MustScan(t, "getting b1Record for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b1UUID), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Dirty) + var b2Record core.Book + testutils.MustScan(t, "getting b2Record for test case", + db.QueryRow("SELECT uuid, label, usn, dirty FROM books WHERE uuid = ?", b2UUID), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Dirty) + + testutils.AssertEqual(t, b1Record.UUID, b1UUID, fmt.Sprintf("b1Record UUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Label, "b1-label", fmt.Sprintf("b1Record Label mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.USN, 5, fmt.Sprintf("b1Record USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Dirty, false, fmt.Sprintf("b1Record Dirty mismatch for test case %d", idx)) + + testutils.AssertEqual(t, b2Record.UUID, b2UUID, fmt.Sprintf("b2Record UUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Label, "b2-label", fmt.Sprintf("b2Record Label mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.USN, 6, fmt.Sprintf("b2Record USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Dirty, false, fmt.Sprintf("b2Record Dirty mismatch for test case %d", idx)) + + testutils.AssertEqual(t, n1Record.UUID, n1UUID, fmt.Sprintf("n1Record UUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.BookUUID, tc.expectedBookUUID, fmt.Sprintf("n1Record BookUUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.USN, tc.expectedUSN, fmt.Sprintf("n1Record USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.AddedOn, tc.expectedAddedOn, fmt.Sprintf("n1Record AddedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.EditedOn, tc.expectedEditedOn, fmt.Sprintf("n1Record EditedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Content, tc.expectedContent, fmt.Sprintf("n1Record Content mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Public, tc.expectedPublic, fmt.Sprintf("n1Record Public mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Deleted, tc.expectedDeleted, fmt.Sprintf("n1Record Deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Dirty, tc.expectedDirty, fmt.Sprintf("n1Record Dirty mismatch for test case %d", idx)) + }() + } +} + +func TestCheckBookPristine(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", "b1-uuid", "b1-label", 5, false) + testutils.MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", "b2-uuid", "b2-label", 6, false) + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, added_on, content, dirty) VALUES (?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 1541108743, "n1 content", false) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, added_on, content, dirty) VALUES (?, ?, ?, ?, ?)", "n2-uuid", "b1-uuid", 1541108743, "n2 content", false) + testutils.MustExec(t, "inserting n3", db, "INSERT INTO notes (uuid, book_uuid, added_on, content, dirty) VALUES (?, ?, ?, ?, ?)", "n3-uuid", "b1-uuid", 1541108743, "n3 content", true) + testutils.MustExec(t, "inserting n4", db, "INSERT INTO notes (uuid, book_uuid, added_on, content, dirty) VALUES (?, ?, ?, ?, ?)", "n4-uuid", "b2-uuid", 1541108743, "n4 content", false) + testutils.MustExec(t, "inserting n5", db, "INSERT INTO notes (uuid, book_uuid, added_on, content, dirty) VALUES (?, ?, ?, ?, ?)", "n5-uuid", "b2-uuid", 1541108743, "n5 content", false) + + t.Run("b1", func(t *testing.T) { + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + got, err := checkNotesPristine(tx, "b1-uuid") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, got, false, "b1 should not be pristine") + }) + + t.Run("b2", func(t *testing.T) { + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + got, err := checkNotesPristine(tx, "b2-uuid") + if err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + testutils.AssertEqual(t, got, true, "b2 should be pristine") + }) +} + +func TestCheckNoteInList(t *testing.T) { + list := syncList{ + Notes: map[string]client.SyncFragNote{ + "n1-uuid": client.SyncFragNote{ + UUID: "n1-uuid", + }, + "n2-uuid": client.SyncFragNote{ + UUID: "n2-uuid", + }, + }, + Books: map[string]client.SyncFragBook{ + "b1-uuid": client.SyncFragBook{ + UUID: "b1-uuid", + }, + "b2-uuid": client.SyncFragBook{ + UUID: "b2-uuid", + }, + }, + ExpungedNotes: map[string]bool{ + "n3-uuid": true, + "n4-uuid": true, + }, + ExpungedBooks: map[string]bool{ + "b3-uuid": true, + "b4-uuid": true, + }, + MaxUSN: 1, + MaxCurrentTime: 2, + } + + testCases := []struct { + uuid string + expected bool + }{ + { + uuid: "n1-uuid", + expected: true, + }, + { + uuid: "n2-uuid", + expected: true, + }, + { + uuid: "n3-uuid", + expected: true, + }, + { + uuid: "n4-uuid", + expected: true, + }, + { + uuid: "nonexistent-note-uuid", + expected: false, + }, + } + + for idx, tc := range testCases { + got := checkNoteInList(tc.uuid, &list) + testutils.AssertEqual(t, got, tc.expected, fmt.Sprintf("result mismatch for test case %d", idx)) + } +} + +func TestCheckBookInList(t *testing.T) { + list := syncList{ + Notes: map[string]client.SyncFragNote{ + "n1-uuid": client.SyncFragNote{ + UUID: "n1-uuid", + }, + "n2-uuid": client.SyncFragNote{ + UUID: "n2-uuid", + }, + }, + Books: map[string]client.SyncFragBook{ + "b1-uuid": client.SyncFragBook{ + UUID: "b1-uuid", + }, + "b2-uuid": client.SyncFragBook{ + UUID: "b2-uuid", + }, + }, + ExpungedNotes: map[string]bool{ + "n3-uuid": true, + "n4-uuid": true, + }, + ExpungedBooks: map[string]bool{ + "b3-uuid": true, + "b4-uuid": true, + }, + MaxUSN: 1, + MaxCurrentTime: 2, + } + + testCases := []struct { + uuid string + expected bool + }{ + { + uuid: "b1-uuid", + expected: true, + }, + { + uuid: "b2-uuid", + expected: true, + }, + { + uuid: "b3-uuid", + expected: true, + }, + { + uuid: "b4-uuid", + expected: true, + }, + { + uuid: "nonexistent-book-uuid", + expected: false, + }, + } + + for idx, tc := range testCases { + got := checkBookInList(tc.uuid, &list) + testutils.AssertEqual(t, got, tc.expected, fmt.Sprintf("result mismatch for test case %d", idx)) + } +} + +func TestCleanLocalNotes(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + list := syncList{ + Notes: map[string]client.SyncFragNote{ + "n1-uuid": client.SyncFragNote{ + UUID: "n1-uuid", + }, + "n2-uuid": client.SyncFragNote{ + UUID: "n2-uuid", + }, + }, + Books: map[string]client.SyncFragBook{ + "b1-uuid": client.SyncFragBook{ + UUID: "b1-uuid", + }, + "b2-uuid": client.SyncFragBook{ + UUID: "b2-uuid", + }, + }, + ExpungedNotes: map[string]bool{ + "n3-uuid": true, + "n4-uuid": true, + }, + ExpungedBooks: map[string]bool{ + "b3-uuid": true, + "b4-uuid": true, + }, + MaxUSN: 1, + MaxCurrentTime: 2, + } + + b1UUID := "b1-uuid" + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1UUID, "b1-label", 1, false, false) + + // exists in the list + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 content", 1541108743, false, false) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 0, "n2 content", 1541108743, false, true) + // non-existent in the list but in valid state + // (created in the cli and hasn't been uploaded) + testutils.MustExec(t, "inserting n6", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n6-uuid", b1UUID, 0, "n6 content", 1541108743, false, true) + // non-existent in the list and in an invalid state + testutils.MustExec(t, "inserting n5", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n5-uuid", b1UUID, 7, "n5 content", 1541108743, true, true) + testutils.MustExec(t, "inserting n9", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n9-uuid", b1UUID, 17, "n9 content", 1541108743, true, false) + testutils.MustExec(t, "inserting n10", db, "INSERT INTO notes (uuid, book_uuid, usn, content, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n10-uuid", b1UUID, 0, "n10 content", 1541108743, false, false) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if err := cleanLocalNotes(tx, &list); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.AssertEqual(t, noteCount, 3, "note count mismatch") + + var n1, n2, n6 core.Note + testutils.MustScan(t, "getting n1", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", "n1-uuid"), &n1.Dirty) + testutils.MustScan(t, "getting n2", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", "n2-uuid"), &n2.Dirty) + testutils.MustScan(t, "getting n6", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", "n6-uuid"), &n6.Dirty) +} + +func TestCleanLocalBooks(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../../tmp", "../../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + list := syncList{ + Notes: map[string]client.SyncFragNote{ + "n1-uuid": client.SyncFragNote{ + UUID: "n1-uuid", + }, + "n2-uuid": client.SyncFragNote{ + UUID: "n2-uuid", + }, + }, + Books: map[string]client.SyncFragBook{ + "b1-uuid": client.SyncFragBook{ + UUID: "b1-uuid", + }, + "b2-uuid": client.SyncFragBook{ + UUID: "b2-uuid", + }, + }, + ExpungedNotes: map[string]bool{ + "n3-uuid": true, + "n4-uuid": true, + }, + ExpungedBooks: map[string]bool{ + "b3-uuid": true, + "b4-uuid": true, + }, + MaxUSN: 1, + MaxCurrentTime: 2, + } + + // existent in the server + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 1, false, false) + testutils.MustExec(t, "inserting b3", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b3-uuid", "b3-label", 0, false, true) + // non-existent in the server but in valid state + testutils.MustExec(t, "inserting b5", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b5-uuid", "b5-label", 0, true, true) + // non-existent in the server and in an invalid state + testutils.MustExec(t, "inserting b6", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b6-uuid", "b6-label", 10, true, true) + testutils.MustExec(t, "inserting b7", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b7-uuid", "b7-label", 11, false, false) + testutils.MustExec(t, "inserting b8", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b8-uuid", "b8-label", 0, false, false) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if err := cleanLocalBooks(tx, &list); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var bookCount int + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.AssertEqual(t, bookCount, 3, "note count mismatch") + + var b1, b3, b5 core.Book + testutils.MustScan(t, "getting b1", db.QueryRow("SELECT label FROM books WHERE uuid = ?", "b1-uuid"), &b1.Label) + testutils.MustScan(t, "getting b3", db.QueryRow("SELECT label FROM books WHERE uuid = ?", "b3-uuid"), &b3.Label) + testutils.MustScan(t, "getting b5", db.QueryRow("SELECT label FROM books WHERE uuid = ?", "b5-uuid"), &b5.Label) +} diff --git a/cmd/version/version.go b/cmd/version/version.go index 63c87f68..3a51db5e 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" ) +// NewCmd returns a new version command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "version", diff --git a/cmd/view/view.go b/cmd/view/view.go index aba68c64..a5a51c49 100644 --- a/cmd/view/view.go +++ b/cmd/view/view.go @@ -29,6 +29,7 @@ func preRun(cmd *cobra.Command, args []string) error { return nil } +// NewCmd returns a new view command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "view ", diff --git a/core/action.go b/core/action.go deleted file mode 100644 index 3a16a644..00000000 --- a/core/action.go +++ /dev/null @@ -1,98 +0,0 @@ -package core - -import ( - "database/sql" - "encoding/json" - "time" - - "github.com/dnote/actions" - "github.com/pkg/errors" -) - -// LogActionAddNote logs an action for adding a note -func LogActionAddNote(tx *sql.Tx, noteUUID, bookName, content string, timestamp int64) error { - b, err := json.Marshal(actions.AddNoteDataV2{ - NoteUUID: noteUUID, - BookName: bookName, - Content: content, - // TODO: support adding a public note - Public: false, - }) - if err != nil { - return errors.Wrap(err, "marshalling data into JSON") - } - - if err := LogAction(tx, 2, actions.ActionAddNote, string(b), timestamp); err != nil { - return errors.Wrapf(err, "logging action") - } - - return nil -} - -// LogActionRemoveNote logs an action for removing a book -func LogActionRemoveNote(tx *sql.Tx, noteUUID, bookName string) error { - b, err := json.Marshal(actions.RemoveNoteDataV2{ - NoteUUID: noteUUID, - }) - if err != nil { - return errors.Wrap(err, "marshalling data into JSON") - } - - ts := time.Now().UnixNano() - if err := LogAction(tx, 2, actions.ActionRemoveNote, string(b), ts); err != nil { - return errors.Wrapf(err, "logging action") - } - - return nil -} - -// LogActionEditNote logs an action for editing a note -func LogActionEditNote(tx *sql.Tx, noteUUID, bookName, content string, ts int64) error { - b, err := json.Marshal(actions.EditNoteDataV3{ - NoteUUID: noteUUID, - Content: &content, - BookName: nil, - Public: nil, - }) - if err != nil { - return errors.Wrap(err, "marshalling data into JSON") - } - - if err := LogAction(tx, 3, actions.ActionEditNote, string(b), ts); err != nil { - return errors.Wrapf(err, "logging action") - } - - return nil -} - -// LogActionAddBook logs an action for adding a book -func LogActionAddBook(tx *sql.Tx, name string) error { - b, err := json.Marshal(actions.AddBookDataV1{ - BookName: name, - }) - if err != nil { - return errors.Wrap(err, "marshalling data into JSON") - } - - ts := time.Now().UnixNano() - if err := LogAction(tx, 1, actions.ActionAddBook, string(b), ts); err != nil { - return errors.Wrapf(err, "logging action") - } - - return nil -} - -// LogActionRemoveBook logs an action for removing book -func LogActionRemoveBook(tx *sql.Tx, name string) error { - b, err := json.Marshal(actions.RemoveBookDataV1{BookName: name}) - if err != nil { - return errors.Wrap(err, "marshalling data into JSON") - } - - ts := time.Now().UnixNano() - if err := LogAction(tx, 1, actions.ActionRemoveBook, string(b), ts); err != nil { - return errors.Wrapf(err, "logging action") - } - - return nil -} diff --git a/core/action_test.go b/core/action_test.go deleted file mode 100644 index 20511a80..00000000 --- a/core/action_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package core - -import ( - "encoding/json" - "testing" - - "github.com/dnote/actions" - "github.com/dnote/cli/testutils" - "github.com/pkg/errors" -) - -func TestLogActionEditNote(t *testing.T) { - // Setup - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - // Execute - db := ctx.DB - tx, err := db.Begin() - if err != nil { - panic(errors.Wrap(err, "beginning a transaction")) - } - - if err := LogActionEditNote(tx, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "js", "updated content", 1536168581); err != nil { - t.Fatalf("Failed to perform %s", err.Error()) - } - - tx.Commit() - - // Test - var actionCount int - if err := db.QueryRow("SELECT count(*) FROM actions;").Scan(&actionCount); err != nil { - panic(errors.Wrap(err, "counting actions")) - } - var action actions.Action - if err := db.QueryRow("SELECT uuid, schema, type, timestamp, data FROM actions"). - Scan(&action.UUID, &action.Schema, &action.Type, &action.Timestamp, &action.Data); err != nil { - panic(errors.Wrap(err, "querying action")) - } - var actionData actions.EditNoteDataV3 - if err := json.Unmarshal(action.Data, &actionData); err != nil { - panic(errors.Wrap(err, "unmarshalling action data")) - } - - if actionCount != 1 { - t.Fatalf("action count mismatch. got %d", actionCount) - } - testutils.AssertNotEqual(t, action.UUID, "", "action uuid mismatch") - testutils.AssertEqual(t, action.Schema, 3, "action schema mismatch") - testutils.AssertEqual(t, action.Type, actions.ActionEditNote, "action type mismatch") - testutils.AssertNotEqual(t, action.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, actionData.NoteUUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "action data note_uuid mismatch") - testutils.AssertEqual(t, *actionData.Content, "updated content", "action data content mismatch") - testutils.AssertEqual(t, actionData.BookName, (*string)(nil), "action data book_name mismatch") - testutils.AssertEqual(t, actionData.Public, (*bool)(nil), "action data public mismatch") -} diff --git a/core/core.go b/core/core.go index 3b2a0582..85c03c59 100644 --- a/core/core.go +++ b/core/core.go @@ -6,7 +6,9 @@ import ( "io/ioutil" "os" "os/exec" + "strconv" "strings" + "time" "github.com/dnote/cli/infra" "github.com/dnote/cli/utils" @@ -260,3 +262,47 @@ func GetEditorInput(ctx infra.DnoteCtx, fpath string, content *string) error { return nil } + +func initSystemKV(tx *sql.Tx, key string, val string) error { + var count int + if err := tx.QueryRow("SELECT count(*) FROM system WHERE key = ?", key).Scan(&count); err != nil { + return errors.Wrapf(err, "counting %s", key) + } + + if count > 0 { + return nil + } + + if _, err := tx.Exec("INSERT INTO system (key, value) VALUES (?, ?)", key, val); err != nil { + tx.Rollback() + return errors.Wrapf(err, "inserting %s %s", key, val) + + } + + return nil +} + +// InitSystem inserts system data if missing +func InitSystem(ctx infra.DnoteCtx) error { + db := ctx.DB + + tx, err := db.Begin() + if err != nil { + return errors.Wrap(err, "beginning a transaction") + } + + nowStr := strconv.FormatInt(time.Now().Unix(), 10) + if err := initSystemKV(tx, infra.SystemLastUpgrade, nowStr); err != nil { + return errors.Wrapf(err, "initializing system config for %s", infra.SystemLastUpgrade) + } + if err := initSystemKV(tx, infra.SystemLastMaxUSN, "0"); err != nil { + return errors.Wrapf(err, "initializing system config for %s", infra.SystemLastMaxUSN) + } + if err := initSystemKV(tx, infra.SystemLastSyncAt, "0"); err != nil { + return errors.Wrapf(err, "initializing system config for %s", infra.SystemLastSyncAt) + } + + tx.Commit() + + return nil +} diff --git a/core/core_test.go b/core/core_test.go new file mode 100644 index 00000000..5681a386 --- /dev/null +++ b/core/core_test.go @@ -0,0 +1,77 @@ +package core + +import ( + "testing" + + "github.com/dnote/cli/testutils" + "github.com/pkg/errors" +) + +func TestInitSystemKV(t *testing.T) { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + var originalCount int + testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &originalCount) + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + if err := initSystemKV(tx, "testKey", "testVal"); err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "executing")) + } + + tx.Commit() + + // Test + var count int + testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &count) + testutils.AssertEqual(t, count, originalCount+1, "system count mismatch") + + var val string + testutils.MustScan(t, "getting system value", + db.QueryRow("SELECT value FROM system WHERE key = ?", "testKey"), &val) + testutils.AssertEqual(t, val, "testVal", "system value mismatch") +} + +func TestInitSystemKV_existing(t *testing.T) { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + testutils.MustExec(t, "inserting a system config", db, "INSERT INTO system (key, value) VALUES (?, ?)", "testKey", "testVal") + + var originalCount int + testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &originalCount) + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + if err := initSystemKV(tx, "testKey", "newTestVal"); err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "executing")) + } + + tx.Commit() + + // Test + var count int + testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &count) + testutils.AssertEqual(t, count, originalCount, "system count mismatch") + + var val string + testutils.MustScan(t, "getting system value", + db.QueryRow("SELECT value FROM system WHERE key = ?", "testKey"), &val) + testutils.AssertEqual(t, val, "testVal", "system value should not have been updated") +} diff --git a/core/models.go b/core/models.go new file mode 100644 index 00000000..f9c49815 --- /dev/null +++ b/core/models.go @@ -0,0 +1,150 @@ +package core + +import ( + "database/sql" + + "github.com/pkg/errors" +) + +// Book holds a metadata and its notes +type Book struct { + UUID string `json:"uuid"` + Label string `json:"label"` + USN int `json:"usn"` + Notes []Note `json:"notes"` + Deleted bool `json:"deleted"` + Dirty bool `json:"dirty"` +} + +// Note represents a note +type Note struct { + UUID string `json:"uuid"` + BookUUID string `json:"book_uuid"` + Content string `json:"content"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"edited_on"` + USN int `json:"usn"` + Public bool `json:"public"` + Deleted bool `json:"deleted"` + Dirty bool `json:"dirty"` +} + +// NewNote constructs a note with the given data +func NewNote(uuid, bookUUID, content string, addedOn, editedOn int64, usn int, public, deleted, dirty bool) Note { + return Note{ + UUID: uuid, + BookUUID: bookUUID, + Content: content, + AddedOn: addedOn, + EditedOn: editedOn, + USN: usn, + Public: public, + Deleted: deleted, + Dirty: dirty, + } +} + +// Insert inserts a new note +func (n Note) Insert(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO notes (uuid, book_uuid, content, added_on, edited_on, usn, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + n.UUID, n.BookUUID, n.Content, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, n.Dirty) + + if err != nil { + return errors.Wrapf(err, "inserting note with uuid %s", n.UUID) + } + + return nil +} + +// Update updates the note with the given data +func (n Note) Update(tx *sql.Tx) error { + _, err := tx.Exec("UPDATE notes SET book_uuid = ?, content = ?, added_on = ?, edited_on = ?, usn = ?, public = ?, deleted = ?, dirty = ? WHERE uuid = ?", + n.BookUUID, n.Content, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, n.Dirty, n.UUID) + + if err != nil { + return errors.Wrapf(err, "updating the note with uuid %s", n.UUID) + } + + return nil +} + +// UpdateUUID updates the uuid of a book +func (n *Note) UpdateUUID(tx *sql.Tx, newUUID string) error { + _, err := tx.Exec("UPDATE notes SET uuid = ? WHERE uuid = ?", newUUID, n.UUID) + + if err != nil { + return errors.Wrapf(err, "updating note uuid from '%s' to '%s'", n.UUID, newUUID) + } + + n.UUID = newUUID + + return nil +} + +// Expunge hard-deletes the note from the database +func (n Note) Expunge(tx *sql.Tx) error { + _, err := tx.Exec("DELETE FROM notes WHERE uuid = ?", n.UUID) + if err != nil { + return errors.Wrap(err, "expunging a note locally") + } + + return nil +} + +// NewBook constructs a book with the given data +func NewBook(uuid, label string, usn int, deleted, dirty bool) Book { + return Book{ + UUID: uuid, + Label: label, + USN: usn, + Deleted: deleted, + Dirty: dirty, + } +} + +// Insert inserts a new book +func (b Book) Insert(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO books (uuid, label, usn, dirty, deleted) VALUES (?, ?, ?, ?, ?)", + b.UUID, b.Label, b.USN, b.Dirty, b.Deleted) + + if err != nil { + return errors.Wrapf(err, "inserting book with uuid %s", b.UUID) + } + + return nil +} + +// Update updates the book with the given data +func (b Book) Update(tx *sql.Tx) error { + _, err := tx.Exec("UPDATE books SET label = ?, usn = ?, dirty = ?, deleted = ? WHERE uuid = ?", + b.Label, b.USN, b.Dirty, b.Deleted, b.UUID) + + if err != nil { + return errors.Wrapf(err, "updating the book with uuid %s", b.UUID) + } + + return nil +} + +// UpdateUUID updates the uuid of a book +func (b *Book) UpdateUUID(tx *sql.Tx, newUUID string) error { + _, err := tx.Exec("UPDATE books SET uuid = ? WHERE uuid = ?", newUUID, b.UUID) + + if err != nil { + return errors.Wrapf(err, "updating book uuid from '%s' to '%s'", b.UUID, newUUID) + } + + b.UUID = newUUID + + return nil +} + +// Expunge hard-deletes the book from the database +func (b Book) Expunge(tx *sql.Tx) error { + _, err := tx.Exec("DELETE FROM books WHERE uuid = ?", b.UUID) + if err != nil { + return errors.Wrap(err, "expunging a book locally") + } + + return nil +} diff --git a/core/models_test.go b/core/models_test.go new file mode 100644 index 00000000..2cbdbac1 --- /dev/null +++ b/core/models_test.go @@ -0,0 +1,795 @@ +package core + +import ( + "fmt" + "testing" + + "github.com/dnote/cli/testutils" + "github.com/pkg/errors" +) + +func TestNewNote(t *testing.T) { + testCases := []struct { + uuid string + bookUUID string + content string + addedOn int64 + editedOn int64 + usn int + public bool + deleted bool + dirty bool + }{ + { + uuid: "n1-uuid", + bookUUID: "b1-uuid", + content: "n1-content", + addedOn: 1542058875, + editedOn: 0, + usn: 0, + public: false, + deleted: false, + dirty: false, + }, + { + uuid: "n2-uuid", + bookUUID: "b2-uuid", + content: "n2-content", + addedOn: 1542058875, + editedOn: 1542058876, + usn: 1008, + public: true, + deleted: true, + dirty: true, + }, + } + + for idx, tc := range testCases { + got := NewNote(tc.uuid, tc.bookUUID, tc.content, tc.addedOn, tc.editedOn, tc.usn, tc.public, tc.deleted, tc.dirty) + + testutils.AssertEqual(t, got.UUID, tc.uuid, fmt.Sprintf("UUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.BookUUID, tc.bookUUID, fmt.Sprintf("BookUUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.Content, tc.content, fmt.Sprintf("Content mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.AddedOn, tc.addedOn, fmt.Sprintf("AddedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.EditedOn, tc.editedOn, fmt.Sprintf("EditedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.USN, tc.usn, fmt.Sprintf("USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.Public, tc.public, fmt.Sprintf("Public mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.Deleted, tc.deleted, fmt.Sprintf("Deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.Dirty, tc.dirty, fmt.Sprintf("Dirty mismatch for test case %d", idx)) + } +} + +func TestNoteInsert(t *testing.T) { + testCases := []struct { + uuid string + bookUUID string + content string + addedOn int64 + editedOn int64 + usn int + public bool + deleted bool + dirty bool + }{ + { + uuid: "n1-uuid", + bookUUID: "b1-uuid", + content: "n1-content", + addedOn: 1542058875, + editedOn: 0, + usn: 0, + public: false, + deleted: false, + dirty: false, + }, + { + uuid: "n2-uuid", + bookUUID: "b2-uuid", + content: "n2-content", + addedOn: 1542058875, + editedOn: 1542058876, + usn: 1008, + public: true, + deleted: true, + dirty: true, + }, + } + + for idx, tc := range testCases { + func() { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + n := Note{ + UUID: tc.uuid, + BookUUID: tc.bookUUID, + Content: tc.content, + AddedOn: tc.addedOn, + EditedOn: tc.editedOn, + USN: tc.usn, + Public: tc.public, + Deleted: tc.deleted, + Dirty: tc.dirty, + } + + // execute + db := ctx.DB + + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + if err := n.Insert(tx); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var uuid, bookUUID, content string + var addedOn, editedOn int64 + var usn int + var public, deleted, dirty bool + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, book_uuid, content, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", tc.uuid), + &uuid, &bookUUID, &content, &addedOn, &editedOn, &usn, &public, &deleted, &dirty) + + testutils.AssertEqual(t, uuid, tc.uuid, fmt.Sprintf("uuid mismatch for test case %d", idx)) + testutils.AssertEqual(t, bookUUID, tc.bookUUID, fmt.Sprintf("bookUUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, content, tc.content, fmt.Sprintf("content mismatch for test case %d", idx)) + testutils.AssertEqual(t, addedOn, tc.addedOn, fmt.Sprintf("addedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, editedOn, tc.editedOn, fmt.Sprintf("editedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, usn, tc.usn, fmt.Sprintf("usn mismatch for test case %d", idx)) + testutils.AssertEqual(t, public, tc.public, fmt.Sprintf("public mismatch for test case %d", idx)) + testutils.AssertEqual(t, deleted, tc.deleted, fmt.Sprintf("deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, dirty, tc.dirty, fmt.Sprintf("dirty mismatch for test case %d", idx)) + }() + } +} + +func TestNoteUpdate(t *testing.T) { + testCases := []struct { + uuid string + bookUUID string + content string + addedOn int64 + editedOn int64 + usn int + public bool + deleted bool + dirty bool + newBookUUID string + newContent string + newEditedOn int64 + newUSN int + newPublic bool + newDeleted bool + newDirty bool + }{ + { + uuid: "n1-uuid", + bookUUID: "b1-uuid", + content: "n1-content", + addedOn: 1542058875, + editedOn: 0, + usn: 0, + public: false, + deleted: false, + dirty: false, + newBookUUID: "b1-uuid", + newContent: "n1-content edited", + newEditedOn: 1542058879, + newUSN: 0, + newPublic: false, + newDeleted: false, + newDirty: false, + }, + { + uuid: "n1-uuid", + bookUUID: "b1-uuid", + content: "n1-content", + addedOn: 1542058875, + editedOn: 0, + usn: 0, + public: false, + deleted: false, + dirty: true, + newBookUUID: "b2-uuid", + newContent: "n1-content", + newEditedOn: 1542058879, + newUSN: 0, + newPublic: true, + newDeleted: false, + newDirty: false, + }, + { + uuid: "n1-uuid", + bookUUID: "b1-uuid", + content: "n1-content", + addedOn: 1542058875, + editedOn: 0, + usn: 10, + public: false, + deleted: false, + dirty: false, + newBookUUID: "", + newContent: "", + newEditedOn: 1542058879, + newUSN: 151, + newPublic: false, + newDeleted: true, + newDirty: false, + }, + { + uuid: "n1-uuid", + bookUUID: "b1-uuid", + content: "n1-content", + addedOn: 1542058875, + editedOn: 0, + usn: 0, + public: false, + deleted: false, + dirty: false, + newBookUUID: "", + newContent: "", + newEditedOn: 1542058879, + newUSN: 15, + newPublic: false, + newDeleted: true, + newDirty: false, + }, + } + + for idx, tc := range testCases { + func() { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + n1 := Note{ + UUID: tc.uuid, + BookUUID: tc.bookUUID, + Content: tc.content, + AddedOn: tc.addedOn, + EditedOn: tc.editedOn, + USN: tc.usn, + Public: tc.public, + Deleted: tc.deleted, + Dirty: tc.dirty, + } + n2 := Note{ + UUID: "n2-uuid", + BookUUID: "b10-uuid", + Content: "n2 content", + AddedOn: 1542058875, + EditedOn: 0, + USN: 39, + Public: false, + Deleted: false, + Dirty: false, + } + + db := ctx.DB + testutils.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.USN, n1.AddedOn, n1.EditedOn, n1.Content, n1.Public, n1.Deleted, n1.Dirty) + testutils.MustExec(t, fmt.Sprintf("inserting n2 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.USN, n2.AddedOn, n2.EditedOn, n2.Content, n2.Public, n2.Deleted, n2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + n1.BookUUID = tc.newBookUUID + n1.Content = tc.newContent + n1.EditedOn = tc.newEditedOn + n1.USN = tc.newUSN + n1.Public = tc.newPublic + n1.Deleted = tc.newDeleted + n1.Dirty = tc.newDirty + + if err := n1.Update(tx); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var n1Record, n2Record Note + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, book_uuid, content, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", tc.uuid), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.Content, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.USN, &n1Record.Public, &n1Record.Deleted, &n1Record.Dirty) + testutils.MustScan(t, "getting n2", + db.QueryRow("SELECT uuid, book_uuid, content, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.Content, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.USN, &n2Record.Public, &n2Record.Deleted, &n2Record.Dirty) + + testutils.AssertEqual(t, n1Record.UUID, n1.UUID, fmt.Sprintf("n1 uuid mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.BookUUID, tc.newBookUUID, fmt.Sprintf("n1 bookUUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Content, tc.newContent, fmt.Sprintf("n1 content mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.AddedOn, n1.AddedOn, fmt.Sprintf("n1 addedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.EditedOn, tc.newEditedOn, fmt.Sprintf("n1 editedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.USN, tc.newUSN, fmt.Sprintf("n1 usn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Public, tc.newPublic, fmt.Sprintf("n1 public mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Deleted, tc.newDeleted, fmt.Sprintf("n1 deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, n1Record.Dirty, tc.newDirty, fmt.Sprintf("n1 dirty mismatch for test case %d", idx)) + + testutils.AssertEqual(t, n2Record.UUID, n2.UUID, fmt.Sprintf("n2 uuid mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.BookUUID, n2.BookUUID, fmt.Sprintf("n2 bookUUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.Content, n2.Content, fmt.Sprintf("n2 content mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.AddedOn, n2.AddedOn, fmt.Sprintf("n2 addedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.EditedOn, n2.EditedOn, fmt.Sprintf("n2 editedOn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.USN, n2.USN, fmt.Sprintf("n2 usn mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.Public, n2.Public, fmt.Sprintf("n2 public mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.Deleted, n2.Deleted, fmt.Sprintf("n2 deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, n2Record.Dirty, n2.Dirty, fmt.Sprintf("n2 dirty mismatch for test case %d", idx)) + }() + } +} + +func TestNoteUpdateUUID(t *testing.T) { + testCases := []struct { + newUUID string + }{ + { + newUUID: "n1-new-uuid", + }, + { + newUUID: "n2-new-uuid", + }, + } + + for idx, tc := range testCases { + t.Run(fmt.Sprintf("testCase%d", idx), func(t *testing.T) { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + n1 := Note{ + UUID: "n1-uuid", + BookUUID: "b1-uuid", + AddedOn: 1542058874, + Content: "n1-content", + USN: 1, + Deleted: true, + Dirty: false, + } + n2 := Note{ + UUID: "n2-uuid", + BookUUID: "b1-uuid", + AddedOn: 1542058874, + Content: "n2-content", + USN: 1, + Deleted: true, + Dirty: false, + } + + db := ctx.DB + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, content, added_on, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.Content, n1.AddedOn, n1.USN, n1.Deleted, n1.Dirty) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, content, added_on, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.Content, n2.AddedOn, n2.USN, n2.Deleted, n2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + if err := n1.UpdateUUID(tx, tc.newUUID); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var n1Record, n2Record Note + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, content, usn, deleted, dirty FROM notes WHERE content = ?", "n1-content"), + &n1Record.UUID, &n1Record.Content, &n1Record.USN, &n1Record.Deleted, &n1Record.Dirty) + testutils.MustScan(t, "getting n2", + db.QueryRow("SELECT uuid, content, usn, deleted, dirty FROM notes WHERE content = ?", "n2-content"), + &n2Record.UUID, &n2Record.Content, &n2Record.USN, &n2Record.Deleted, &n2Record.Dirty) + + testutils.AssertEqual(t, n1.UUID, tc.newUUID, "n1 original reference uuid mismatch") + testutils.AssertEqual(t, n1Record.UUID, tc.newUUID, "n1 uuid mismatch") + testutils.AssertEqual(t, n2Record.UUID, n2.UUID, "n2 uuid mismatch") + }) + } +} + +func TestNoteExpunge(t *testing.T) { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + n1 := Note{ + UUID: "n1-uuid", + BookUUID: "b9-uuid", + Content: "n1 content", + AddedOn: 1542058874, + EditedOn: 0, + USN: 22, + Public: false, + Deleted: false, + Dirty: false, + } + n2 := Note{ + UUID: "n2-uuid", + BookUUID: "b10-uuid", + Content: "n2 content", + AddedOn: 1542058875, + EditedOn: 0, + USN: 39, + Public: false, + Deleted: false, + Dirty: false, + } + + db := ctx.DB + testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.USN, n1.AddedOn, n1.EditedOn, n1.Content, n1.Public, n1.Deleted, n1.Dirty) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, content, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.USN, n2.AddedOn, n2.EditedOn, n2.Content, n2.Public, n2.Deleted, n2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if err := n1.Expunge(tx); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var noteCount int + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch") + + var n2Record Note + testutils.MustScan(t, "getting n2", + db.QueryRow("SELECT uuid, book_uuid, content, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.Content, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.USN, &n2Record.Public, &n2Record.Deleted, &n2Record.Dirty) + + testutils.AssertEqual(t, n2Record.UUID, n2.UUID, "n2 uuid mismatch") + testutils.AssertEqual(t, n2Record.BookUUID, n2.BookUUID, "n2 bookUUID mismatch") + testutils.AssertEqual(t, n2Record.Content, n2.Content, "n2 content mismatch") + testutils.AssertEqual(t, n2Record.AddedOn, n2.AddedOn, "n2 addedOn mismatch") + testutils.AssertEqual(t, n2Record.EditedOn, n2.EditedOn, "n2 editedOn mismatch") + testutils.AssertEqual(t, n2Record.USN, n2.USN, "n2 usn mismatch") + testutils.AssertEqual(t, n2Record.Public, n2.Public, "n2 public mismatch") + testutils.AssertEqual(t, n2Record.Deleted, n2.Deleted, "n2 deleted mismatch") + testutils.AssertEqual(t, n2Record.Dirty, n2.Dirty, "n2 dirty mismatch") +} + +func TestNewBook(t *testing.T) { + testCases := []struct { + uuid string + label string + usn int + deleted bool + dirty bool + }{ + { + uuid: "b1-uuid", + label: "b1-label", + usn: 0, + deleted: false, + dirty: false, + }, + { + uuid: "b2-uuid", + label: "b2-label", + usn: 1008, + deleted: false, + dirty: true, + }, + } + + for idx, tc := range testCases { + got := NewBook(tc.uuid, tc.label, tc.usn, tc.deleted, tc.dirty) + + testutils.AssertEqual(t, got.UUID, tc.uuid, fmt.Sprintf("UUID mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.Label, tc.label, fmt.Sprintf("Label mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.USN, tc.usn, fmt.Sprintf("USN mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.Deleted, tc.deleted, fmt.Sprintf("Deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, got.Dirty, tc.dirty, fmt.Sprintf("Dirty mismatch for test case %d", idx)) + } +} + +func TestBookInsert(t *testing.T) { + testCases := []struct { + uuid string + label string + usn int + deleted bool + dirty bool + }{ + { + uuid: "b1-uuid", + label: "b1-label", + usn: 10808, + deleted: false, + dirty: false, + }, + { + uuid: "b1-uuid", + label: "b1-label", + usn: 10808, + deleted: false, + dirty: true, + }, + } + + for idx, tc := range testCases { + func() { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + b := Book{ + UUID: tc.uuid, + Label: tc.label, + USN: tc.usn, + Dirty: tc.dirty, + Deleted: tc.deleted, + } + + // execute + db := ctx.DB + + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + if err := b.Insert(tx); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var uuid, label string + var usn int + var deleted, dirty bool + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", tc.uuid), + &uuid, &label, &usn, &deleted, &dirty) + + testutils.AssertEqual(t, uuid, tc.uuid, fmt.Sprintf("uuid mismatch for test case %d", idx)) + testutils.AssertEqual(t, label, tc.label, fmt.Sprintf("label mismatch for test case %d", idx)) + testutils.AssertEqual(t, usn, tc.usn, fmt.Sprintf("usn mismatch for test case %d", idx)) + testutils.AssertEqual(t, deleted, tc.deleted, fmt.Sprintf("deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, dirty, tc.dirty, fmt.Sprintf("dirty mismatch for test case %d", idx)) + }() + } +} + +func TestBookUpdate(t *testing.T) { + testCases := []struct { + uuid string + label string + usn int + deleted bool + dirty bool + newLabel string + newUSN int + newDeleted bool + newDirty bool + }{ + { + uuid: "b1-uuid", + label: "b1-label", + usn: 0, + deleted: false, + dirty: false, + newLabel: "b1-label-edited", + newUSN: 0, + newDeleted: false, + newDirty: true, + }, + { + uuid: "b1-uuid", + label: "b1-label", + usn: 0, + deleted: false, + dirty: false, + newLabel: "", + newUSN: 10, + newDeleted: true, + newDirty: false, + }, + } + + for idx, tc := range testCases { + func() { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + b1 := Book{ + UUID: "b1-uuid", + Label: "b1-label", + USN: 1, + Deleted: true, + Dirty: false, + } + b2 := Book{ + UUID: "b2-uuid", + Label: "b2-label", + USN: 1, + Deleted: true, + Dirty: false, + } + + db := ctx.DB + testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty) + testutils.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error()) + } + + b1.Label = tc.newLabel + b1.USN = tc.newUSN + b1.Deleted = tc.newDeleted + b1.Dirty = tc.newDirty + + if err := b1.Update(tx); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error()) + } + + tx.Commit() + + // test + var b1Record, b2Record Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", tc.uuid), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Deleted, &b1Record.Dirty) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", b2.UUID), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty) + + testutils.AssertEqual(t, b1Record.UUID, b1.UUID, fmt.Sprintf("b1 uuid mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Label, tc.newLabel, fmt.Sprintf("b1 label mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.USN, tc.newUSN, fmt.Sprintf("b1 usn mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Deleted, tc.newDeleted, fmt.Sprintf("b1 deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, b1Record.Dirty, tc.newDirty, fmt.Sprintf("b1 dirty mismatch for test case %d", idx)) + + testutils.AssertEqual(t, b2Record.UUID, b2.UUID, fmt.Sprintf("b2 uuid mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Label, b2.Label, fmt.Sprintf("b2 label mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.USN, b2.USN, fmt.Sprintf("b2 usn mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Deleted, b2.Deleted, fmt.Sprintf("b2 deleted mismatch for test case %d", idx)) + testutils.AssertEqual(t, b2Record.Dirty, b2.Dirty, fmt.Sprintf("b2 dirty mismatch for test case %d", idx)) + }() + } +} + +func TestBookUpdateUUID(t *testing.T) { + testCases := []struct { + newUUID string + }{ + { + newUUID: "b1-new-uuid", + }, + { + newUUID: "b2-new-uuid", + }, + } + + for idx, tc := range testCases { + t.Run(fmt.Sprintf("testCase%d", idx), func(t *testing.T) { + + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + b1 := Book{ + UUID: "b1-uuid", + Label: "b1-label", + USN: 1, + Deleted: true, + Dirty: false, + } + b2 := Book{ + UUID: "b2-uuid", + Label: "b2-label", + USN: 1, + Deleted: true, + Dirty: false, + } + + db := ctx.DB + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty) + testutils.MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + if err := b1.UpdateUUID(tx, tc.newUUID); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var b1Record, b2Record Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE label = ?", "b1-label"), + &b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Deleted, &b1Record.Dirty) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE label = ?", "b2-label"), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty) + + testutils.AssertEqual(t, b1.UUID, tc.newUUID, "b1 original reference uuid mismatch") + testutils.AssertEqual(t, b1Record.UUID, tc.newUUID, "b1 uuid mismatch") + testutils.AssertEqual(t, b2Record.UUID, b2.UUID, "b2 uuid mismatch") + }) + } +} + +func TestBookExpunge(t *testing.T) { + // Setup + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) + defer testutils.TeardownEnv(ctx) + + b1 := Book{ + UUID: "b1-uuid", + Label: "b1-label", + USN: 1, + Deleted: true, + Dirty: false, + } + b2 := Book{ + UUID: "b2-uuid", + Label: "b2-label", + USN: 1, + Deleted: true, + Dirty: false, + } + + db := ctx.DB + testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty) + testutils.MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) + } + + if err := b1.Expunge(tx); err != nil { + tx.Rollback() + t.Fatalf(errors.Wrap(err, "executing").Error()) + } + + tx.Commit() + + // test + var bookCount int + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + + var b2Record Book + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", "b2-uuid"), + &b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty) + + testutils.AssertEqual(t, b2Record.UUID, b2.UUID, "b2 uuid mismatch") + testutils.AssertEqual(t, b2Record.Label, b2.Label, "b2 label mismatch") + testutils.AssertEqual(t, b2Record.USN, b2.USN, "b2 usn mismatch") + testutils.AssertEqual(t, b2Record.Deleted, b2.Deleted, "b2 deleted mismatch") + testutils.AssertEqual(t, b2Record.Dirty, b2.Dirty, "b2 dirty mismatch") +} diff --git a/core/reducer.go b/core/reducer.go deleted file mode 100644 index 85ca6409..00000000 --- a/core/reducer.go +++ /dev/null @@ -1,257 +0,0 @@ -package core - -import ( - "database/sql" - "encoding/json" - "fmt" - - "github.com/dnote/actions" - "github.com/dnote/cli/infra" - "github.com/dnote/cli/log" - "github.com/dnote/cli/utils" - "github.com/pkg/errors" -) - -// ReduceAll reduces all actions -func ReduceAll(ctx infra.DnoteCtx, tx *sql.Tx, actionSlice []actions.Action) error { - for _, action := range actionSlice { - if err := Reduce(ctx, tx, action); err != nil { - return errors.Wrap(err, "reducing action") - } - } - - return nil -} - -// Reduce transitions the local dnote state by consuming the action returned -// from the server -func Reduce(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { - log.Debug("reducing %s. uuid: %s, schema: %d, timestamp: %d\n", action.Type, action.UUID, action.Schema, action.Timestamp) - - var err error - - switch action.Type { - case actions.ActionAddNote: - err = handleAddNote(ctx, tx, action) - case actions.ActionRemoveNote: - err = handleRemoveNote(ctx, tx, action) - case actions.ActionEditNote: - err = handleEditNote(ctx, tx, action) - case actions.ActionAddBook: - err = handleAddBook(ctx, tx, action) - case actions.ActionRemoveBook: - err = handleRemoveBook(ctx, tx, action) - default: - return errors.Errorf("Unsupported action %s", action.Type) - } - - if err != nil { - return errors.Wrapf(err, "reducing %s", action.Type) - } - - return nil -} - -func getBookUUIDWithTx(tx *sql.Tx, bookLabel string) (string, error) { - var ret string - err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&ret) - if err == sql.ErrNoRows { - return ret, errors.Errorf("book '%s' not found", bookLabel) - } else if err != nil { - return ret, errors.Wrap(err, "querying the book") - } - - return ret, nil -} - -func handleAddNote(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { - if action.Schema != 2 { - return errors.Errorf("data schema '%d' not supported", action.Schema) - } - - var data actions.AddNoteDataV2 - if err := json.Unmarshal(action.Data, &data); err != nil { - return errors.Wrap(err, "parsing the action data") - } - - log.Debug("data: %+v\n", data) - - bookUUID, err := getBookUUIDWithTx(tx, data.BookName) - if err != nil { - return errors.Wrap(err, "getting book uuid") - } - - var noteCount int - if err := tx. - QueryRow("SELECT count(uuid) FROM notes WHERE uuid = ? AND book_uuid = ?", data.NoteUUID, bookUUID). - Scan(¬eCount); err != nil { - return errors.Wrap(err, "counting note") - } - - if noteCount > 0 { - // if a duplicate exists, it is because the same action has been previously synced to the server - // but the client did not bring the bookmark up-to-date at the time because it had error reducing - // the returned actions. - // noop so that the client can update bookmark - return nil - } - - _, err = tx.Exec(`INSERT INTO notes - (uuid, book_uuid, content, added_on, public) - VALUES (?, ?, ?, ?, ?)`, data.NoteUUID, bookUUID, data.Content, action.Timestamp, data.Public) - if err != nil { - return errors.Wrap(err, "inserting a note") - } - - return nil -} - -func handleRemoveNote(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { - if action.Schema != 2 { - return errors.Errorf("data schema '%d' not supported", action.Schema) - } - - var data actions.RemoveNoteDataV2 - if err := json.Unmarshal(action.Data, &data); err != nil { - return errors.Wrap(err, "parsing the action data") - } - - log.Debug("data: %+v\n", data) - - _, err := tx.Exec("DELETE FROM notes WHERE uuid = ?", data.NoteUUID) - if err != nil { - return errors.Wrap(err, "removing a note") - } - - return nil -} - -func buildEditNoteQuery(ctx infra.DnoteCtx, tx *sql.Tx, noteUUID string, ts int64, data actions.EditNoteDataV3) (string, []interface{}, error) { - setTmpl := "edited_on = ?" - queryArgs := []interface{}{ts} - - if data.Content != nil { - setTmpl = fmt.Sprintf("%s, content = ?", setTmpl) - queryArgs = append(queryArgs, *data.Content) - } - if data.Public != nil { - setTmpl = fmt.Sprintf("%s, public = ?", setTmpl) - queryArgs = append(queryArgs, *data.Public) - } - if data.BookName != nil { - setTmpl = fmt.Sprintf("%s, book_uuid = ?", setTmpl) - - bookUUID, err := getBookUUIDWithTx(tx, *data.BookName) - if err != nil { - return setTmpl, queryArgs, errors.Wrap(err, "getting book uuid") - } - - queryArgs = append(queryArgs, bookUUID) - } - - queryTmpl := fmt.Sprintf("UPDATE notes SET %s WHERE uuid = ?", setTmpl) - queryArgs = append(queryArgs, noteUUID) - - return queryTmpl, queryArgs, nil -} - -func handleEditNote(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { - if action.Schema != 3 { - return errors.Errorf("data schema '%d' not supported", action.Schema) - } - - var data actions.EditNoteDataV3 - err := json.Unmarshal(action.Data, &data) - if err != nil { - return errors.Wrap(err, "parsing the action data") - } - - log.Debug("data: %+v\n", data) - - queryTmpl, queryArgs, err := buildEditNoteQuery(ctx, tx, data.NoteUUID, action.Timestamp, data) - if err != nil { - return errors.Wrap(err, "building edit note query") - } - _, err = tx.Exec(queryTmpl, queryArgs...) - if err != nil { - return errors.Wrap(err, "updating a note") - } - - return nil -} - -func handleAddBook(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { - if action.Schema != 1 { - return errors.Errorf("data schema '%d' not supported", action.Schema) - } - - var data actions.AddBookDataV1 - err := json.Unmarshal(action.Data, &data) - if err != nil { - return errors.Wrap(err, "parsing the action data") - } - - log.Debug("data: %+v\n", data) - - var bookCount int - err = tx.QueryRow("SELECT count(uuid) FROM books WHERE label = ?", data.BookName).Scan(&bookCount) - if err != nil { - return errors.Wrap(err, "counting books") - } - - if bookCount > 0 { - // If book already exists, another machine added a book with the same name. - // noop - return nil - } - - _, err = tx.Exec("INSERT INTO books (uuid, label) VALUES (?, ?)", utils.GenerateUUID(), data.BookName) - if err != nil { - return errors.Wrap(err, "inserting a book") - } - - return nil -} - -func handleRemoveBook(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { - if action.Schema != 1 { - return errors.Errorf("data schema '%d' not supported", action.Schema) - } - - var data actions.RemoveBookDataV1 - if err := json.Unmarshal(action.Data, &data); err != nil { - return errors.Wrap(err, "parsing the action data") - } - - log.Debug("data: %+v\n", data) - - var bookCount int - if err := tx. - QueryRow("SELECT count(uuid) FROM books WHERE label = ?", data.BookName). - Scan(&bookCount); err != nil { - return errors.Wrap(err, "counting note") - } - - if bookCount == 0 { - // If book does not exist, another client added and removed the book, making the add_book action - // obsolete. noop. - return nil - } - - bookUUID, err := getBookUUIDWithTx(tx, data.BookName) - if err != nil { - return errors.Wrap(err, "getting book uuid") - } - - _, err = tx.Exec("DELETE FROM notes WHERE book_uuid = ?", bookUUID) - if err != nil { - return errors.Wrap(err, "removing notes") - } - - _, err = tx.Exec("DELETE FROM books WHERE uuid = ?", bookUUID) - if err != nil { - return errors.Wrap(err, "removing a book") - } - - return nil -} diff --git a/core/reducer_test.go b/core/reducer_test.go deleted file mode 100644 index ca1b0ab6..00000000 --- a/core/reducer_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package core - -import ( - "encoding/json" - "testing" - - "github.com/dnote/actions" - "github.com/dnote/cli/infra" - "github.com/dnote/cli/testutils" - "github.com/pkg/errors" -) - -func TestReduceAddNote(t *testing.T) { - // Setup - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - testutils.Setup1(t, ctx) - - // Execute - b, err := json.Marshal(&actions.AddNoteDataV2{ - Content: "new content", - BookName: "js", - NoteUUID: "06896551-8a06-4996-89cc-0d866308b0f6", - Public: false, - }) - action := actions.Action{ - Type: actions.ActionAddNote, - Schema: 2, - Data: b, - Timestamp: 1517629805, - } - - db := ctx.DB - tx, err := db.Begin() - if err != nil { - panic(errors.Wrap(err, "beginning a transaction")) - } - if err = Reduce(ctx, tx, action); err != nil { - tx.Rollback() - t.Fatal(errors.Wrap(err, "processing action")) - } - tx.Commit() - - // Test - var noteCount, jsNoteCount, linuxNoteCount int - testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) - testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - testutils.AssertEqual(t, noteCount, 2, "notes length mismatch") - testutils.AssertEqual(t, jsNoteCount, 2, "js notes length mismatch") - testutils.AssertEqual(t, linuxNoteCount, 0, "linux notes length mismatch") - - var existingNote, newNote infra.Note - testutils.MustScan(t, "scanning existing note", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &existingNote.UUID, &existingNote.Content) - testutils.MustScan(t, "scanning new note", db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE uuid = ?", "06896551-8a06-4996-89cc-0d866308b0f6"), &newNote.UUID, &newNote.Content, &newNote.AddedOn) - - testutils.AssertEqual(t, existingNote.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "existing note uuid mismatch") - testutils.AssertEqual(t, existingNote.Content, "Booleans have toString()", "existing note content mismatch") - testutils.AssertEqual(t, newNote.UUID, "06896551-8a06-4996-89cc-0d866308b0f6", "new note uuid mismatch") - testutils.AssertEqual(t, newNote.Content, "new content", "new note content mismatch") - testutils.AssertEqual(t, newNote.AddedOn, int64(1517629805), "new note added_on mismatch") -} - -func TestReduceRemoveNote(t *testing.T) { - // Setup - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - testutils.Setup2(t, ctx) - - // Execute - b, err := json.Marshal(&actions.RemoveNoteDataV2{ - NoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - }) - action := actions.Action{ - Type: actions.ActionRemoveNote, - Schema: 2, - Data: b, - Timestamp: 1517629805, - } - - db := ctx.DB - tx, err := db.Begin() - if err != nil { - panic(errors.Wrap(err, "beginning a transaction")) - } - if err = Reduce(ctx, tx, action); err != nil { - tx.Rollback() - t.Fatal(errors.Wrap(err, "processing action")) - } - tx.Commit() - - // Test - var bookCount, noteCount, jsNoteCount, linuxNoteCount int - testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) - testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) - testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - - var n1, n2 infra.Note - testutils.MustScan(t, "scanning note 1", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content) - testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "3e065d55-6d47-42f2-a6bf-f5844130b2d2"), &n2.UUID, &n2.Content) - - testutils.AssertEqual(t, bookCount, 2, "number of books mismatch") - testutils.AssertEqual(t, jsNoteCount, 1, "target book notes length mismatch") - testutils.AssertEqual(t, linuxNoteCount, 1, "other book notes length mismatch") - testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, n2.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") - testutils.AssertEqual(t, n2.Content, "wc -l to count words", "other book remaining note content mismatch") -} - -func TestReduceEditNote(t *testing.T) { - testCases := []struct { - data string - expectedNoteUUID string - expectedNoteBookUUID string - expectedNoteContent string - expectedNoteAddedOn int64 - expectedNoteEditedOn int64 - expectedNotePublic bool - expectedJsNoteCount int - expectedLinuxNoteCount int - }{ - { - data: `{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "content": "updated content"}`, - expectedNoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - expectedNoteBookUUID: "js-book-uuid", - expectedNoteContent: "updated content", - expectedNoteAddedOn: int64(1515199951), - expectedNoteEditedOn: int64(1517629805), - expectedNotePublic: false, - expectedJsNoteCount: 2, - expectedLinuxNoteCount: 1, - }, - { - data: `{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "public": true}`, - expectedNoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - expectedNoteBookUUID: "js-book-uuid", - expectedNoteContent: "Date object implements mathematical comparisons", - expectedNoteAddedOn: int64(1515199951), - expectedNoteEditedOn: int64(1517629805), - expectedNotePublic: true, - expectedJsNoteCount: 2, - expectedLinuxNoteCount: 1, - }, - { - data: `{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "book_name": "linux", "content": "updated content"}`, - expectedNoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - expectedNoteBookUUID: "linux-book-uuid", - expectedNoteContent: "updated content", - expectedNoteAddedOn: int64(1515199951), - expectedNoteEditedOn: int64(1517629805), - expectedNotePublic: false, - expectedJsNoteCount: 1, - expectedLinuxNoteCount: 2, - }} - - for _, tc := range testCases { - // Setup - func() { - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - testutils.Setup2(t, ctx) - db := ctx.DB - - // Execute - action := actions.Action{ - Type: actions.ActionEditNote, - Data: json.RawMessage(tc.data), - Schema: 3, - Timestamp: 1517629805, - } - - tx, err := db.Begin() - if err != nil { - panic(errors.Wrap(err, "beginning a transaction")) - } - err = Reduce(ctx, tx, action) - if err != nil { - tx.Rollback() - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - - tx.Commit() - - // Test - var bookCount, noteCount, jsNoteCount, linuxNoteCount int - testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) - testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) - testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - - var n1, n2, n3 infra.Note - testutils.MustScan(t, "scanning note 1", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content) - testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "3e065d55-6d47-42f2-a6bf-f5844130b2d2"), &n2.UUID, &n2.Content) - testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n3.UUID, &n3.Content, &n3.AddedOn, &n3.EditedOn, &n3.Public) - - testutils.AssertEqual(t, bookCount, 2, "number of books mismatch") - testutils.AssertEqual(t, noteCount, 3, "number of notes mismatch") - testutils.AssertEqual(t, jsNoteCount, tc.expectedJsNoteCount, "js book notes length mismatch") - testutils.AssertEqual(t, linuxNoteCount, tc.expectedLinuxNoteCount, "linux book notes length mismatch") - - testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n1 mismatch") - testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "n1 content mismatch") - testutils.AssertEqual(t, n2.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "n2 uuid mismatch") - testutils.AssertEqual(t, n2.Content, "wc -l to count words", "n2 content mismatch") - testutils.AssertEqual(t, n3.UUID, tc.expectedNoteUUID, "edited note uuid mismatch") - testutils.AssertEqual(t, n3.Content, tc.expectedNoteContent, "edited note content mismatch") - testutils.AssertEqual(t, n3.AddedOn, tc.expectedNoteAddedOn, "edited note added_on mismatch") - testutils.AssertEqual(t, n3.EditedOn, tc.expectedNoteEditedOn, "edited note edited_on mismatch") - testutils.AssertEqual(t, n3.Public, tc.expectedNotePublic, "edited note public mismatch") - }() - } -} - -func TestReduceAddBook(t *testing.T) { - // Setup - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - testutils.Setup1(t, ctx) - - // Execute - b, err := json.Marshal(&actions.AddBookDataV1{BookName: "new_book"}) - action := actions.Action{ - Type: actions.ActionAddBook, - Schema: 1, - Data: b, - Timestamp: 1517629805, - } - db := ctx.DB - tx, err := db.Begin() - if err != nil { - panic(errors.Wrap(err, "beginning a transaction")) - } - if err = Reduce(ctx, tx, action); err != nil { - tx.Rollback() - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - tx.Commit() - - // Test - var bookCount, newBookNoteCount int - testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) - testutils.MustScan(t, "counting note in the new book", db.QueryRow("SELECT count(*) FROM notes INNER JOIN books ON books.uuid = notes.book_uuid WHERE books.label = ?", "new_book"), &newBookNoteCount) - - testutils.AssertEqual(t, bookCount, 3, "number of books mismatch") - testutils.AssertEqual(t, newBookNoteCount, 0, "new book number of notes mismatch") -} - -func TestReduceRemoveBook(t *testing.T) { - // Setup - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - testutils.Setup2(t, ctx) - - // Execute - b, err := json.Marshal(&actions.RemoveBookDataV1{BookName: "linux"}) - action := actions.Action{ - Type: actions.ActionRemoveBook, - Schema: 1, - Data: b, - Timestamp: 1517629805, - } - - db := ctx.DB - tx, err := db.Begin() - if err != nil { - panic(errors.Wrap(err, "beginning a transaction")) - } - if err = Reduce(ctx, tx, action); err != nil { - tx.Rollback() - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - tx.Commit() - - // Test - var bookCount, noteCount, jsNoteCount, linuxNoteCount int - var jsBookLabel string - testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) - testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) - testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - testutils.MustScan(t, "scanning book", db.QueryRow("SELECT label FROM books WHERE uuid = ?", "js-book-uuid"), &jsBookLabel) - - var n1, n2 infra.Note - testutils.MustScan(t, "scanning note 1", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content) - testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n2.UUID, &n2.Content) - - testutils.AssertEqual(t, bookCount, 1, "number of books mismatch") - testutils.AssertEqual(t, noteCount, 2, "number of notes mismatch") - testutils.AssertEqual(t, jsNoteCount, 2, "js note count mismatch") - testutils.AssertEqual(t, linuxNoteCount, 0, "linux note count mismatch") - testutils.AssertEqual(t, jsBookLabel, "js", "remaining book name mismatch") - - testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, n2.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") - testutils.AssertEqual(t, n2.Content, "Date object implements mathematical comparisons", "edited note content mismatch") -} diff --git a/core/upgrade.go b/core/upgrade.go index 10985e3c..b6506bd0 100644 --- a/core/upgrade.go +++ b/core/upgrade.go @@ -20,7 +20,7 @@ func shouldCheckUpdate(ctx infra.DnoteCtx) (bool, error) { db := ctx.DB var lastUpgrade int64 - err := db.QueryRow("SELECT value FROM system WHERE key = ?", "last_upgrade").Scan(&lastUpgrade) + err := db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastUpgrade).Scan(&lastUpgrade) if err != nil { return false, errors.Wrap(err, "getting last_udpate") } @@ -34,7 +34,7 @@ func touchLastUpgrade(ctx infra.DnoteCtx) error { db := ctx.DB now := time.Now().Unix() - _, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", now, "last_upgrade") + _, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", now, infra.SystemLastUpgrade) if err != nil { return errors.Wrap(err, "updating last_upgrade") } diff --git a/infra/main.go b/infra/main.go index 4a5d77a7..74d329ba 100644 --- a/infra/main.go +++ b/infra/main.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "os/user" - "time" // use sqlite _ "github.com/mattn/go-sqlite3" @@ -20,6 +19,14 @@ var ( // SystemSchema is the key for schema in the system table SystemSchema = "schema" + // SystemRemoteSchema is the key for remote schema in the system table + SystemRemoteSchema = "remote_schema" + // SystemLastSyncAt is the timestamp of the server at the last sync + SystemLastSyncAt = "last_sync_time" + // SystemLastMaxUSN is the user's max_usn from the server at the alst sync + SystemLastMaxUSN = "last_max_usn" + // SystemLastUpgrade is the timestamp at which the system more recently checked for an upgrade + SystemLastUpgrade = "last_upgrade" ) // DnoteCtx is a context holding the information of the current runtime @@ -37,33 +44,6 @@ type Config struct { APIKey string } -// Dnote holds the whole dnote data -type Dnote map[string]Book - -// Book holds a metadata and its notes -type Book struct { - Name string `json:"name"` - Notes []Note `json:"notes"` -} - -// Note represents a single microlesson -type Note struct { - UUID string `json:"uuid"` - Content string `json:"content"` - AddedOn int64 `json:"added_on"` - EditedOn int64 `json:"edited_on"` - Public bool `json:"public"` -} - -// Timestamp holds time information -type Timestamp struct { - LastUpgrade int64 `yaml:"last_upgrade"` - // id of the most recent action synced from the server - Bookmark int `yaml:"bookmark"` - // timestamp of the most recent action performed by the cli - LastAction int64 `yaml:"last_action"` -} - // NewCtx returns a new dnote context func NewCtx(apiEndpoint, versionTag string) (DnoteCtx, error) { homeDir, err := getHomeDir() @@ -145,6 +125,15 @@ func InitDB(ctx DnoteCtx) error { return errors.Wrap(err, "creating books table") } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS system + ( + key string NOT NULL, + value text NOT NULL + )`) + if err != nil { + return errors.Wrap(err, "creating system table") + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS actions ( uuid text PRIMARY KEY, @@ -157,15 +146,6 @@ func InitDB(ctx DnoteCtx) error { return errors.Wrap(err, "creating actions table") } - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS system - ( - key string NOT NULL, - value text NOT NULL - )`) - if err != nil { - return errors.Wrap(err, "creating system table") - } - _, err = db.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_books_label ON books(label); CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_uuid ON notes(uuid); @@ -178,43 +158,3 @@ func InitDB(ctx DnoteCtx) error { return nil } - -// InitSystem inserts system data if missing -func InitSystem(ctx DnoteCtx) error { - db := ctx.DB - - tx, err := db.Begin() - if err != nil { - return errors.Wrap(err, "beginning a transaction") - } - - var bookmarkCount, lastUpgradeCount int - if err := db.QueryRow("SELECT count(*) FROM system WHERE key = ?", "bookmark"). - Scan(&bookmarkCount); err != nil { - return errors.Wrap(err, "counting bookmarks") - } - if bookmarkCount == 0 { - _, err := tx.Exec("INSERT INTO system (key, value) VALUES (?, ?)", "bookmark", 0) - if err != nil { - tx.Rollback() - return errors.Wrap(err, "inserting bookmark") - } - } - - if err := db.QueryRow("SELECT count(*) FROM system WHERE key = ?", "last_upgrade"). - Scan(&lastUpgradeCount); err != nil { - return errors.Wrap(err, "counting last_upgrade") - } - if lastUpgradeCount == 0 { - now := time.Now().Unix() - _, err := tx.Exec("INSERT INTO system (key, value) VALUES (?, ?)", "last_upgrade", now) - if err != nil { - tx.Rollback() - return errors.Wrap(err, "inserting bookmark") - } - } - - tx.Commit() - - return nil -} diff --git a/main_test.go b/main_test.go index 866dbc53..bfb30be9 100644 --- a/main_test.go +++ b/main_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "log" "os" @@ -10,7 +9,6 @@ import ( "github.com/pkg/errors" - "github.com/dnote/actions" "github.com/dnote/cli/core" "github.com/dnote/cli/infra" "github.com/dnote/cli/testutils" @@ -30,7 +28,7 @@ func TestMain(m *testing.M) { func TestInit(t *testing.T) { // Set up - ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) // Execute @@ -46,25 +44,35 @@ func TestInit(t *testing.T) { db := ctx.DB - var notesTableCount, booksTableCount, actionsTableCount, systemTableCount int + var notesTableCount, booksTableCount, systemTableCount int testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "notes"), ¬esTableCount) testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "books"), &booksTableCount) - testutils.MustScan(t, "counting actions", - db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "actions"), &actionsTableCount) testutils.MustScan(t, "counting system", db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "system"), &systemTableCount) testutils.AssertEqual(t, notesTableCount, 1, "notes table count mismatch") testutils.AssertEqual(t, booksTableCount, 1, "books table count mismatch") - testutils.AssertEqual(t, actionsTableCount, 1, "actions table count mismatch") testutils.AssertEqual(t, systemTableCount, 1, "system table count mismatch") + + // test that all default system configurations are generated + var lastUpgrade, lastMaxUSN, lastSyncAt string + testutils.MustScan(t, "scanning last upgrade", + db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastUpgrade), &lastUpgrade) + testutils.MustScan(t, "scanning last max usn", + db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastMaxUSN), &lastMaxUSN) + testutils.MustScan(t, "scanning last sync at", + db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastSyncAt), &lastSyncAt) + + testutils.AssertNotEqual(t, lastUpgrade, "", "last upgrade should not be empty") + testutils.AssertNotEqual(t, lastMaxUSN, "", "last max usn should not be empty") + testutils.AssertNotEqual(t, lastSyncAt, "", "last sync at should not be empty") } func TestAddNote_NewBook_ContentFlag(t *testing.T) { // Set up - ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) // Execute @@ -73,49 +81,30 @@ func TestAddNote_NewBook_ContentFlag(t *testing.T) { // Test db := ctx.DB - var actionCount, noteCount, bookCount int - testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + var noteCount, bookCount int testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - testutils.AssertEqualf(t, actionCount, 2, "action count mismatch") testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") testutils.AssertEqualf(t, noteCount, 1, "note count mismatch") - var jsBookUUID string - testutils.MustScan(t, "getting js book uuid", db.QueryRow("SELECT uuid FROM books where label = ?", "js"), &jsBookUUID) - var note infra.Note + var book core.Book + testutils.MustScan(t, "getting book", db.QueryRow("SELECT uuid, dirty FROM books where label = ?", "js"), &book.UUID, &book.Dirty) + var note core.Note testutils.MustScan(t, "getting note", - db.QueryRow("SELECT uuid, content, added_on FROM notes where book_uuid = ?", jsBookUUID), ¬e.UUID, ¬e.Content, ¬e.AddedOn) - var bookAction, noteAction actions.Action - testutils.MustScan(t, "getting book action", - db.QueryRow("SELECT data, timestamp FROM actions where type = ?", actions.ActionAddBook), &bookAction.Data, &bookAction.Timestamp) - testutils.MustScan(t, "getting note action", - db.QueryRow("SELECT data, timestamp FROM actions where type = ?", actions.ActionAddNote), ¬eAction.Data, ¬eAction.Timestamp) + db.QueryRow("SELECT uuid, content, added_on, dirty FROM notes where book_uuid = ?", book.UUID), ¬e.UUID, ¬e.Content, ¬e.AddedOn, ¬e.Dirty) - var noteActionData actions.AddNoteDataV1 - var bookActionData actions.AddBookDataV1 - if err := json.Unmarshal(bookAction.Data, &bookActionData); err != nil { - log.Fatalf("unmarshalling the action data: %s", err) - } - if err := json.Unmarshal(noteAction.Data, ¬eActionData); err != nil { - log.Fatalf("unmarshalling the action data: %s", err) - } + testutils.AssertEqual(t, book.Dirty, true, "Book dirty mismatch") - testutils.AssertNotEqual(t, bookActionData.BookName, "", "bookAction data note_uuid mismatch") - testutils.AssertNotEqual(t, bookAction.Timestamp, 0, "bookAction timestamp mismatch") - testutils.AssertEqual(t, noteActionData.Content, "foo", "noteAction data name mismatch") - testutils.AssertNotEqual(t, noteActionData.NoteUUID, nil, "noteAction data note_uuid mismatch") - testutils.AssertNotEqual(t, noteActionData.BookName, "", "noteAction data note_uuid mismatch") - testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "noteAction timestamp mismatch") testutils.AssertNotEqual(t, note.UUID, "", "Note should have UUID") testutils.AssertEqual(t, note.Content, "foo", "Note content mismatch") + testutils.AssertEqual(t, note.Dirty, true, "Note dirty mismatch") testutils.AssertNotEqual(t, note.AddedOn, int64(0), "Note added_on mismatch") } func TestAddNote_ExistingBook_ContentFlag(t *testing.T) { // Set up - ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) testutils.Setup3(t, ctx) @@ -126,43 +115,37 @@ func TestAddNote_ExistingBook_ContentFlag(t *testing.T) { // Test db := ctx.DB - var actionCount, noteCount, bookCount int - testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + var noteCount, bookCount int testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") testutils.AssertEqualf(t, noteCount, 2, "note count mismatch") - var n1, n2 infra.Note + var n1, n2 core.Note testutils.MustScan(t, "getting n1", - db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content, &n1.AddedOn) + db.QueryRow("SELECT uuid, content, added_on, dirty FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content, &n1.AddedOn, &n1.Dirty) testutils.MustScan(t, "getting n2", - db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE book_uuid = ? AND content = ?", "js-book-uuid", "foo"), &n2.UUID, &n2.Content, &n2.AddedOn) - var noteAction actions.Action - testutils.MustScan(t, "getting note action", - db.QueryRow("SELECT data, timestamp FROM actions WHERE type = ?", actions.ActionAddNote), ¬eAction.Data, ¬eAction.Timestamp) + db.QueryRow("SELECT uuid, content, added_on, dirty FROM notes WHERE book_uuid = ? AND content = ?", "js-book-uuid", "foo"), &n2.UUID, &n2.Content, &n2.AddedOn, &n2.Dirty) - var noteActionData actions.AddNoteDataV1 - if err := json.Unmarshal(noteAction.Data, ¬eActionData); err != nil { - log.Fatalf("unmarshalling the action data: %s", err) - } + var book core.Book + testutils.MustScan(t, "getting book", db.QueryRow("SELECT dirty FROM books where label = ?", "js"), &book.Dirty) - testutils.AssertEqual(t, noteActionData.Content, "foo", "action data name mismatch") - testutils.AssertNotEqual(t, noteActionData.NoteUUID, "", "action data note_uuid mismatch") - testutils.AssertEqual(t, noteActionData.BookName, "js", "action data book_name mismatch") - testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "action timestamp mismatch") - testutils.AssertNotEqual(t, n1.UUID, "", "Note should have UUID") - testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "Note content mismatch") - testutils.AssertEqual(t, n1.AddedOn, int64(1515199943), "Note added_on mismatch") - testutils.AssertNotEqual(t, n2.UUID, "", "Note should have UUID") - testutils.AssertEqual(t, n2.Content, "foo", "Note content mismatch") + testutils.AssertEqual(t, book.Dirty, false, "Book dirty mismatch") + + testutils.AssertNotEqual(t, n1.UUID, "", "n1 should have UUID") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "n1 content mismatch") + testutils.AssertEqual(t, n1.AddedOn, int64(1515199943), "n1 added_on mismatch") + testutils.AssertEqual(t, n1.Dirty, false, "n1 dirty mismatch") + + testutils.AssertNotEqual(t, n2.UUID, "", "n2 should have UUID") + testutils.AssertEqual(t, n2.Content, "foo", "n2 content mismatch") + testutils.AssertEqual(t, n2.Dirty, true, "n2 dirty mismatch") } func TestEditNote_ContentFlag(t *testing.T) { // Set up - ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) testutils.Setup4(t, ctx) @@ -173,46 +156,32 @@ func TestEditNote_ContentFlag(t *testing.T) { // Test db := ctx.DB - var actionCount, noteCount, bookCount int - testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + var noteCount, bookCount int testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") testutils.AssertEqualf(t, noteCount, 2, "note count mismatch") - var n1, n2 infra.Note + var n1, n2 core.Note testutils.MustScan(t, "getting n1", - db.QueryRow("SELECT uuid, content, added_on FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content, &n1.AddedOn) + db.QueryRow("SELECT uuid, content, added_on, dirty FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content, &n1.AddedOn, &n1.Dirty) testutils.MustScan(t, "getting n2", - db.QueryRow("SELECT uuid, content, added_on FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n2.UUID, &n2.Content, &n2.AddedOn) - var noteAction actions.Action - testutils.MustScan(t, "getting note action", - db.QueryRow("SELECT data, type, schema FROM actions where type = ?", actions.ActionEditNote), - ¬eAction.Data, ¬eAction.Type, ¬eAction.Schema) + db.QueryRow("SELECT uuid, content, added_on, dirty FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n2.UUID, &n2.Content, &n2.AddedOn, &n2.Dirty) - var actionData actions.EditNoteDataV3 - if err := json.Unmarshal(noteAction.Data, &actionData); err != nil { - log.Fatalf("Failed to unmarshal the action data: %s", err) - } + testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n1 should have UUID") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "n1 content mismatch") + testutils.AssertEqual(t, n1.Dirty, false, "n1 dirty mismatch") - testutils.AssertEqual(t, noteAction.Type, actions.ActionEditNote, "action type mismatch") - testutils.AssertEqual(t, noteAction.Schema, 3, "action schema mismatch") - testutils.AssertEqual(t, *actionData.Content, "foo bar", "action data name mismatch") - testutils.AssertEqual(t, actionData.BookName, (*string)(nil), "action data book_name mismatch") - testutils.AssertEqual(t, actionData.NoteUUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "action data note_uuis mismatch") - testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "Note should have UUID") - testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "Note content mismatch") testutils.AssertEqual(t, n2.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "Note should have UUID") testutils.AssertEqual(t, n2.Content, "foo bar", "Note content mismatch") + testutils.AssertEqual(t, n2.Dirty, true, "n2 dirty mismatch") testutils.AssertNotEqual(t, n2.EditedOn, 0, "Note edited_on mismatch") } func TestRemoveNote(t *testing.T) { // Set up - ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) testutils.Setup2(t, ctx) @@ -223,53 +192,67 @@ func TestRemoveNote(t *testing.T) { // Test db := ctx.DB - var actionCount, noteCount, bookCount, jsNoteCount, linuxNoteCount int - testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + var noteCount, bookCount, jsNoteCount, linuxNoteCount int testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) testutils.MustScan(t, "counting js notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) testutils.MustScan(t, "counting linux notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") testutils.AssertEqualf(t, bookCount, 2, "book count mismatch") - testutils.AssertEqualf(t, noteCount, 2, "note count mismatch") - testutils.AssertEqual(t, jsNoteCount, 1, "Book should have one note") - testutils.AssertEqual(t, linuxNoteCount, 1, "Other book should have one note") + testutils.AssertEqualf(t, noteCount, 3, "note count mismatch") + testutils.AssertEqual(t, jsNoteCount, 2, "js book should have 2 notes") + testutils.AssertEqual(t, linuxNoteCount, 1, "linux book book should have 1 note") - var b1, b2 infra.Book - var n1 infra.Note + var b1, b2 core.Book + var n1, n2, n3 core.Note testutils.MustScan(t, "getting b1", - db.QueryRow("SELECT label FROM books WHERE uuid = ?", "js-book-uuid"), - &b1.Name) + db.QueryRow("SELECT label, deleted, usn FROM books WHERE uuid = ?", "js-book-uuid"), + &b1.Label, &b1.Deleted, &b1.USN) testutils.MustScan(t, "getting b2", - db.QueryRow("SELECT label FROM books WHERE uuid = ?", "linux-book-uuid"), - &b2.Name) + db.QueryRow("SELECT label, deleted, usn FROM books WHERE uuid = ?", "linux-book-uuid"), + &b2.Label, &b2.Deleted, &b2.USN) testutils.MustScan(t, "getting n1", - db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE book_uuid = ? AND id = ?", "js-book-uuid", 2), - &n1.UUID, &n1.Content, &n1.AddedOn) + db.QueryRow("SELECT uuid, content, added_on, deleted, dirty, usn FROM notes WHERE book_uuid = ? AND id = ?", "js-book-uuid", 1), + &n1.UUID, &n1.Content, &n1.AddedOn, &n1.Deleted, &n1.Dirty, &n1.USN) + testutils.MustScan(t, "getting n2", + db.QueryRow("SELECT uuid, content, added_on, deleted, dirty, usn FROM notes WHERE book_uuid = ? AND id = ?", "js-book-uuid", 2), + &n2.UUID, &n2.Content, &n2.AddedOn, &n2.Deleted, &n2.Dirty, &n2.USN) + testutils.MustScan(t, "getting n3", + db.QueryRow("SELECT uuid, content, added_on, deleted, dirty, usn FROM notes WHERE book_uuid = ? AND id = ?", "linux-book-uuid", 3), + &n3.UUID, &n3.Content, &n3.AddedOn, &n3.Deleted, &n3.Dirty, &n3.USN) - var noteAction actions.Action - testutils.MustScan(t, "getting note action", - db.QueryRow("SELECT type, schema, data FROM actions WHERE type = ?", actions.ActionRemoveNote), ¬eAction.Type, ¬eAction.Schema, ¬eAction.Data) + testutils.AssertEqual(t, b1.Label, "js", "b1 label mismatch") + testutils.AssertEqual(t, b1.Deleted, false, "b1 deleted mismatch") + testutils.AssertEqual(t, b1.Dirty, false, "b1 Dirty mismatch") + testutils.AssertEqual(t, b1.USN, 111, "b1 usn mismatch") - var actionData actions.RemoveNoteDataV1 - if err := json.Unmarshal(noteAction.Data, &actionData); err != nil { - log.Fatalf("unmarshalling the action data: %s", err) - } + testutils.AssertEqual(t, b2.Label, "linux", "b2 label mismatch") + testutils.AssertEqual(t, b2.Deleted, false, "b2 deleted mismatch") + testutils.AssertEqual(t, b2.Dirty, false, "b2 Dirty mismatch") + testutils.AssertEqual(t, b2.USN, 122, "b2 usn mismatch") - testutils.AssertEqual(t, b1.Name, "js", "b1 label mismatch") - testutils.AssertEqual(t, b2.Name, "linux", "b2 label mismatch") - testutils.AssertEqual(t, noteAction.Schema, 2, "action schema mismatch") - testutils.AssertEqual(t, noteAction.Type, actions.ActionRemoveNote, "action type mismatch") - testutils.AssertEqual(t, actionData.NoteUUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "action data note_uuid mismatch") - testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "Note should have UUID") - testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "Note content mismatch") + testutils.AssertEqual(t, n1.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "n1 should have UUID") + testutils.AssertEqual(t, n1.Content, "", "n1 content mismatch") + testutils.AssertEqual(t, n1.Deleted, true, "n1 deleted mismatch") + testutils.AssertEqual(t, n1.Dirty, true, "n1 Dirty mismatch") + testutils.AssertEqual(t, n1.USN, 11, "n1 usn mismatch") + + testutils.AssertEqual(t, n2.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n2 should have UUID") + testutils.AssertEqual(t, n2.Content, "n2 content", "n2 content mismatch") + testutils.AssertEqual(t, n2.Deleted, false, "n2 deleted mismatch") + testutils.AssertEqual(t, n2.Dirty, false, "n2 Dirty mismatch") + testutils.AssertEqual(t, n2.USN, 12, "n2 usn mismatch") + + testutils.AssertEqual(t, n3.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "n3 should have UUID") + testutils.AssertEqual(t, n3.Content, "n3 content", "n3 content mismatch") + testutils.AssertEqual(t, n3.Deleted, false, "n3 deleted mismatch") + testutils.AssertEqual(t, n3.Dirty, false, "n3 Dirty mismatch") + testutils.AssertEqual(t, n3.USN, 13, "n3 usn mismatch") } func TestRemoveBook(t *testing.T) { // Set up - ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) testutils.Setup2(t, ctx) @@ -280,35 +263,60 @@ func TestRemoveBook(t *testing.T) { // Test db := ctx.DB - var actionCount, noteCount, bookCount, jsNoteCount, linuxNoteCount int - testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + var noteCount, bookCount, jsNoteCount, linuxNoteCount int testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) testutils.MustScan(t, "counting js notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) testutils.MustScan(t, "counting linux notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") - testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") - testutils.AssertEqualf(t, noteCount, 1, "note count mismatch") - testutils.AssertEqual(t, jsNoteCount, 0, "some notes in book were not deleted") - testutils.AssertEqual(t, linuxNoteCount, 1, "Other book should have one note") + testutils.AssertEqualf(t, bookCount, 2, "book count mismatch") + testutils.AssertEqualf(t, noteCount, 3, "note count mismatch") + testutils.AssertEqual(t, jsNoteCount, 2, "js book should have 2 notes") + testutils.AssertEqual(t, linuxNoteCount, 1, "linux book book should have 1 note") - var b1 infra.Book + var b1, b2 core.Book + var n1, n2, n3 core.Note testutils.MustScan(t, "getting b1", - db.QueryRow("SELECT label FROM books WHERE uuid = ?", "linux-book-uuid"), - &b1.Name) + db.QueryRow("SELECT label, dirty, deleted, usn FROM books WHERE uuid = ?", "js-book-uuid"), + &b1.Label, &b1.Dirty, &b1.Deleted, &b1.USN) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT label, dirty, deleted, usn FROM books WHERE uuid = ?", "linux-book-uuid"), + &b2.Label, &b2.Dirty, &b2.Deleted, &b2.USN) + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, content, added_on, dirty, deleted, usn FROM notes WHERE book_uuid = ? AND id = ?", "js-book-uuid", 1), + &n1.UUID, &n1.Content, &n1.AddedOn, &n1.Deleted, &n1.Dirty, &n1.USN) + testutils.MustScan(t, "getting n2", + db.QueryRow("SELECT uuid, content, added_on, dirty, deleted, usn FROM notes WHERE book_uuid = ? AND id = ?", "js-book-uuid", 2), + &n2.UUID, &n2.Content, &n2.AddedOn, &n2.Deleted, &n2.Dirty, &n2.USN) + testutils.MustScan(t, "getting n3", + db.QueryRow("SELECT uuid, content, added_on, dirty, deleted, usn FROM notes WHERE book_uuid = ? AND id = ?", "linux-book-uuid", 3), + &n3.UUID, &n3.Content, &n3.AddedOn, &n3.Deleted, &n3.Dirty, &n3.USN) - var action actions.Action - testutils.MustScan(t, "getting an action", - db.QueryRow("SELECT type, schema, data FROM actions WHERE type = ?", actions.ActionRemoveBook), &action.Type, &action.Schema, &action.Data) + testutils.AssertNotEqual(t, b1.Label, "js", "b1 label mismatch") + testutils.AssertEqual(t, b1.Dirty, true, "b1 Dirty mismatch") + testutils.AssertEqual(t, b1.Deleted, true, "b1 deleted mismatch") + testutils.AssertEqual(t, b1.USN, 111, "b1 usn mismatch") - var actionData actions.RemoveBookDataV1 - if err := json.Unmarshal(action.Data, &actionData); err != nil { - log.Fatalf("unmarshalling the action data: %s", err) - } + testutils.AssertEqual(t, b2.Label, "linux", "b2 label mismatch") + testutils.AssertEqual(t, b2.Dirty, false, "b2 Dirty mismatch") + testutils.AssertEqual(t, b2.Deleted, false, "b2 deleted mismatch") + testutils.AssertEqual(t, b2.USN, 122, "b2 usn mismatch") - testutils.AssertEqual(t, action.Type, actions.ActionRemoveBook, "action type mismatch") - testutils.AssertEqual(t, actionData.BookName, "js", "action data name mismatch") - testutils.AssertNotEqual(t, action.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, b1.Name, "linux", "Remaining book name mismatch") + testutils.AssertEqual(t, n1.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "n1 should have UUID") + testutils.AssertEqual(t, n1.Content, "", "n1 content mismatch") + testutils.AssertEqual(t, n1.Dirty, true, "n1 Dirty mismatch") + testutils.AssertEqual(t, n1.Deleted, true, "n1 deleted mismatch") + testutils.AssertEqual(t, n1.USN, 11, "n1 usn mismatch") + + testutils.AssertEqual(t, n2.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n2 should have UUID") + testutils.AssertEqual(t, n2.Content, "", "n2 content mismatch") + testutils.AssertEqual(t, n2.Dirty, true, "n2 Dirty mismatch") + testutils.AssertEqual(t, n2.Deleted, true, "n2 deleted mismatch") + testutils.AssertEqual(t, n2.USN, 12, "n2 usn mismatch") + + testutils.AssertEqual(t, n3.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "n3 should have UUID") + testutils.AssertEqual(t, n3.Content, "n3 content", "n3 content mismatch") + testutils.AssertEqual(t, n3.Dirty, false, "n3 Dirty mismatch") + testutils.AssertEqual(t, n3.Deleted, false, "n3 deleted mismatch") + testutils.AssertEqual(t, n3.USN, 13, "n3 usn mismatch") } diff --git a/migrate/fixtures/2-pre-dnote.json b/migrate/fixtures/legacy-2-pre-dnote.json similarity index 100% rename from migrate/fixtures/2-pre-dnote.json rename to migrate/fixtures/legacy-2-pre-dnote.json diff --git a/migrate/fixtures/3-pre-dnote.json b/migrate/fixtures/legacy-3-pre-dnote.json similarity index 100% rename from migrate/fixtures/3-pre-dnote.json rename to migrate/fixtures/legacy-3-pre-dnote.json diff --git a/migrate/fixtures/4-pre-dnoterc.yaml b/migrate/fixtures/legacy-4-pre-dnoterc.yaml similarity index 100% rename from migrate/fixtures/4-pre-dnoterc.yaml rename to migrate/fixtures/legacy-4-pre-dnoterc.yaml diff --git a/migrate/fixtures/5-post-actions.json b/migrate/fixtures/legacy-5-post-actions.json similarity index 100% rename from migrate/fixtures/5-post-actions.json rename to migrate/fixtures/legacy-5-post-actions.json diff --git a/migrate/fixtures/5-pre-actions.json b/migrate/fixtures/legacy-5-pre-actions.json similarity index 100% rename from migrate/fixtures/5-pre-actions.json rename to migrate/fixtures/legacy-5-pre-actions.json diff --git a/migrate/fixtures/6-post-dnote.json b/migrate/fixtures/legacy-6-post-dnote.json similarity index 100% rename from migrate/fixtures/6-post-dnote.json rename to migrate/fixtures/legacy-6-post-dnote.json diff --git a/migrate/fixtures/6-pre-dnote.json b/migrate/fixtures/legacy-6-pre-dnote.json similarity index 100% rename from migrate/fixtures/6-pre-dnote.json rename to migrate/fixtures/legacy-6-pre-dnote.json diff --git a/migrate/fixtures/7-post-actions.json b/migrate/fixtures/legacy-7-post-actions.json similarity index 100% rename from migrate/fixtures/7-post-actions.json rename to migrate/fixtures/legacy-7-post-actions.json diff --git a/migrate/fixtures/7-pre-actions.json b/migrate/fixtures/legacy-7-pre-actions.json similarity index 100% rename from migrate/fixtures/7-pre-actions.json rename to migrate/fixtures/legacy-7-pre-actions.json diff --git a/migrate/fixtures/8-actions.json b/migrate/fixtures/legacy-8-actions.json similarity index 100% rename from migrate/fixtures/8-actions.json rename to migrate/fixtures/legacy-8-actions.json diff --git a/migrate/fixtures/8-dnote.json b/migrate/fixtures/legacy-8-dnote.json similarity index 100% rename from migrate/fixtures/8-dnote.json rename to migrate/fixtures/legacy-8-dnote.json diff --git a/migrate/fixtures/8-dnoterc.yaml b/migrate/fixtures/legacy-8-dnoterc.yaml similarity index 100% rename from migrate/fixtures/8-dnoterc.yaml rename to migrate/fixtures/legacy-8-dnoterc.yaml diff --git a/migrate/fixtures/8-schema.yaml b/migrate/fixtures/legacy-8-schema.yaml similarity index 100% rename from migrate/fixtures/8-schema.yaml rename to migrate/fixtures/legacy-8-schema.yaml diff --git a/migrate/fixtures/8-timestamps.yaml b/migrate/fixtures/legacy-8-timestamps.yaml similarity index 100% rename from migrate/fixtures/8-timestamps.yaml rename to migrate/fixtures/legacy-8-timestamps.yaml diff --git a/migrate/fixtures/local-1-pre-schema.sql b/migrate/fixtures/local-1-pre-schema.sql new file mode 100644 index 00000000..1e2a4025 --- /dev/null +++ b/migrate/fixtures/local-1-pre-schema.sql @@ -0,0 +1,35 @@ +-- local-1-pre-schema.sql is the schema in which 'dirty' flags do not exist + +CREATE TABLE notes + ( + id integer PRIMARY KEY AUTOINCREMENT, + uuid text NOT NULL, + book_uuid text NOT NULL, + content text NOT NULL, + added_on integer NOT NULL, + edited_on integer DEFAULT 0, + public bool DEFAULT false + ); +CREATE TABLE books + ( + uuid text PRIMARY KEY, + label text NOT NULL + ); +CREATE TABLE actions + ( + uuid text PRIMARY KEY, + schema integer NOT NULL, + type text NOT NULL, + data text NOT NULL, + timestamp integer NOT NULL + ); +CREATE TABLE system + ( + key string NOT NULL, + value text NOT NULL + ); +CREATE UNIQUE INDEX idx_books_label ON books(label); +CREATE UNIQUE INDEX idx_notes_uuid ON notes(uuid); +CREATE UNIQUE INDEX idx_books_uuid ON books(uuid); +CREATE UNIQUE INDEX idx_notes_id ON notes(id); +CREATE INDEX idx_notes_book_uuid ON notes(book_uuid); diff --git a/migrate/fixtures/local-5-pre-schema.sql b/migrate/fixtures/local-5-pre-schema.sql new file mode 100644 index 00000000..3927dbed --- /dev/null +++ b/migrate/fixtures/local-5-pre-schema.sql @@ -0,0 +1,35 @@ +-- local-5-pre-schema.sql is the schema in which the actions table is present + +CREATE TABLE notes + ( + id integer PRIMARY KEY AUTOINCREMENT, + uuid text NOT NULL, + book_uuid text NOT NULL, + content text NOT NULL, + added_on integer NOT NULL, + edited_on integer DEFAULT 0, + public bool DEFAULT false + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL); +CREATE TABLE books + ( + uuid text PRIMARY KEY, + label text NOT NULL + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL); +CREATE TABLE actions + ( + uuid text PRIMARY KEY, + schema integer NOT NULL, + type text NOT NULL, + data text NOT NULL, + timestamp integer NOT NULL + ); +CREATE TABLE system + ( + key string NOT NULL, + value text NOT NULL + ); +CREATE UNIQUE INDEX idx_books_label ON books(label); +CREATE UNIQUE INDEX idx_notes_uuid ON notes(uuid); +CREATE UNIQUE INDEX idx_books_uuid ON books(uuid); +CREATE UNIQUE INDEX idx_notes_id ON notes(id); +CREATE INDEX idx_notes_book_uuid ON notes(book_uuid); diff --git a/migrate/fixtures/local-7-pre-schema.sql b/migrate/fixtures/local-7-pre-schema.sql new file mode 100644 index 00000000..c0e908b6 --- /dev/null +++ b/migrate/fixtures/local-7-pre-schema.sql @@ -0,0 +1,25 @@ +CREATE TABLE notes + ( + id integer PRIMARY KEY AUTOINCREMENT, + uuid text NOT NULL, + book_uuid text NOT NULL, + content text NOT NULL, + added_on integer NOT NULL, + edited_on integer DEFAULT 0, + public bool DEFAULT false + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL, deleted bool DEFAULT false); +CREATE TABLE books + ( + uuid text PRIMARY KEY, + label text NOT NULL + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL, deleted bool DEFAULT false); +CREATE TABLE system + ( + key string NOT NULL, + value text NOT NULL + ); +CREATE UNIQUE INDEX idx_books_label ON books(label); +CREATE UNIQUE INDEX idx_notes_uuid ON notes(uuid); +CREATE UNIQUE INDEX idx_books_uuid ON books(uuid); +CREATE UNIQUE INDEX idx_notes_id ON notes(id); +CREATE INDEX idx_notes_book_uuid ON notes(book_uuid); diff --git a/migrate/fixtures/remote-1-pre-schema.sql b/migrate/fixtures/remote-1-pre-schema.sql new file mode 100644 index 00000000..6e867043 --- /dev/null +++ b/migrate/fixtures/remote-1-pre-schema.sql @@ -0,0 +1,35 @@ +-- 5-pre-schema.sql is the schema in which the actions table is present + +CREATE TABLE notes + ( + id integer PRIMARY KEY AUTOINCREMENT, + uuid text NOT NULL, + book_uuid text NOT NULL, + content text NOT NULL, + added_on integer NOT NULL, + edited_on integer DEFAULT 0, + public bool DEFAULT false + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL); +CREATE TABLE books + ( + uuid text PRIMARY KEY, + label text NOT NULL + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL); +CREATE TABLE actions + ( + uuid text PRIMARY KEY, + schema integer NOT NULL, + type text NOT NULL, + data text NOT NULL, + timestamp integer NOT NULL + ); +CREATE TABLE system + ( + key string NOT NULL, + value text NOT NULL + ); +CREATE UNIQUE INDEX idx_books_label ON books(label); +CREATE UNIQUE INDEX idx_notes_uuid ON notes(uuid); +CREATE UNIQUE INDEX idx_books_uuid ON books(uuid); +CREATE UNIQUE INDEX idx_notes_id ON notes(id); +CREATE INDEX idx_notes_book_uuid ON notes(book_uuid); diff --git a/migrate/legacy_test.go b/migrate/legacy_test.go index 600ae5f6..71ab4f58 100644 --- a/migrate/legacy_test.go +++ b/migrate/legacy_test.go @@ -19,7 +19,7 @@ func TestMigrateToV1(t *testing.T) { t.Run("yaml exists", func(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived")) @@ -41,7 +41,7 @@ func TestMigrateToV1(t *testing.T) { t.Run("yaml does not exist", func(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived")) @@ -62,10 +62,10 @@ func TestMigrateToV1(t *testing.T) { } func TestMigrateToV2(t *testing.T) { - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) - testutils.CopyFixture(ctx, "./fixtures/2-pre-dnote.json", "dnote") + testutils.CopyFixture(ctx, "./fixtures/legacy-2-pre-dnote.json", "dnote") // execute if err := migrateToV2(ctx); err != nil { @@ -96,10 +96,10 @@ func TestMigrateToV2(t *testing.T) { func TestMigrateToV3(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) - testutils.CopyFixture(ctx, "./fixtures/3-pre-dnote.json", "dnote") + testutils.CopyFixture(ctx, "./fixtures/legacy-3-pre-dnote.json", "dnote") // execute if err := migrateToV3(ctx); err != nil { @@ -130,11 +130,11 @@ func TestMigrateToV3(t *testing.T) { func TestMigrateToV4(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) defer os.Setenv("EDITOR", "") - testutils.CopyFixture(ctx, "./fixtures/4-pre-dnoterc.yaml", "dnoterc") + testutils.CopyFixture(ctx, "./fixtures/legacy-4-pre-dnoterc.yaml", "dnoterc") // execute os.Setenv("EDITOR", "vim") @@ -155,10 +155,10 @@ func TestMigrateToV4(t *testing.T) { func TestMigrateToV5(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) - testutils.CopyFixture(ctx, "./fixtures/5-pre-actions.json", "actions") + testutils.CopyFixture(ctx, "./fixtures/legacy-5-pre-actions.json", "actions") // execute if err := migrateToV5(ctx); err != nil { @@ -167,7 +167,7 @@ func TestMigrateToV5(t *testing.T) { // test var oldActions []migrateToV5PreAction - testutils.ReadJSON("./fixtures/5-pre-actions.json", &oldActions) + testutils.ReadJSON("./fixtures/legacy-5-pre-actions.json", &oldActions) b := testutils.ReadFile(ctx, "actions") var migratedActions []migrateToV5PostAction @@ -252,10 +252,10 @@ func TestMigrateToV5(t *testing.T) { func TestMigrateToV6(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) - testutils.CopyFixture(ctx, "./fixtures/6-pre-dnote.json", "dnote") + testutils.CopyFixture(ctx, "./fixtures/legacy-6-pre-dnote.json", "dnote") // execute if err := migrateToV6(ctx); err != nil { @@ -269,7 +269,7 @@ func TestMigrateToV6(t *testing.T) { t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) } - b = testutils.ReadFileAbs("./fixtures/6-post-dnote.json") + b = testutils.ReadFileAbs("./fixtures/legacy-6-post-dnote.json") var expected migrateToV6PostDnote if err := json.Unmarshal(b, &expected); err != nil { t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) @@ -282,10 +282,10 @@ func TestMigrateToV6(t *testing.T) { func TestMigrateToV7(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true) defer testutils.TeardownEnv(ctx) - testutils.CopyFixture(ctx, "./fixtures/7-pre-actions.json", "actions") + testutils.CopyFixture(ctx, "./fixtures/legacy-7-pre-actions.json", "actions") // execute if err := migrateToV7(ctx); err != nil { @@ -299,7 +299,7 @@ func TestMigrateToV7(t *testing.T) { t.Fatal(errors.Wrap(err, "unmarshalling the result").Error()) } - b2 := testutils.ReadFileAbs("./fixtures/7-post-actions.json") + b2 := testutils.ReadFileAbs("./fixtures/legacy-7-post-actions.json") var expected []migrateToV7Action if err := json.Unmarshal(b, &expected); err != nil { t.Fatal(errors.Wrap(err, "unmarshalling the result into Dnote").Error()) @@ -316,15 +316,15 @@ func TestMigrateToV7(t *testing.T) { } func TestMigrateToV8(t *testing.T) { - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false) defer testutils.TeardownEnv(ctx) // set up - testutils.CopyFixture(ctx, "./fixtures/8-actions.json", "actions") - testutils.CopyFixture(ctx, "./fixtures/8-dnote.json", "dnote") - testutils.CopyFixture(ctx, "./fixtures/8-dnoterc.yaml", "dnoterc") - testutils.CopyFixture(ctx, "./fixtures/8-schema.yaml", "schema") - testutils.CopyFixture(ctx, "./fixtures/8-timestamps.yaml", "timestamps") + testutils.CopyFixture(ctx, "./fixtures/legacy-8-actions.json", "actions") + testutils.CopyFixture(ctx, "./fixtures/legacy-8-dnote.json", "dnote") + testutils.CopyFixture(ctx, "./fixtures/legacy-8-dnoterc.yaml", "dnoterc") + testutils.CopyFixture(ctx, "./fixtures/legacy-8-schema.yaml", "schema") + testutils.CopyFixture(ctx, "./fixtures/legacy-8-timestamps.yaml", "timestamps") // execute if err := migrateToV8(ctx); err != nil { diff --git a/migrate/migrate.go b/migrate/migrate.go index 2288343b..0b5856b1 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -8,19 +8,34 @@ import ( "github.com/pkg/errors" ) +const ( + // LocalMode is a local migration mode + LocalMode = iota + // RemoteMode is a remote migration mode + RemoteMode +) + // LocalSequence is a list of local migrations to be run var LocalSequence = []migration{ lm1, lm2, lm3, + lm4, + lm5, + lm6, } -func initSchema(ctx infra.DnoteCtx) (int, error) { +// RemoteSequence is a list of remote migrations to be run +var RemoteSequence = []migration{ + rm1, +} + +func initSchema(ctx infra.DnoteCtx, schemaKey string) (int, error) { // schemaVersion is the index of the latest run migration in the sequence schemaVersion := 0 db := ctx.DB - _, err := db.Exec("INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSchema, schemaVersion) + _, err := db.Exec("INSERT INTO system (key, value) VALUES (?, ?)", schemaKey, schemaVersion) if err != nil { return schemaVersion, errors.Wrap(err, "inserting schema") } @@ -28,13 +43,25 @@ func initSchema(ctx infra.DnoteCtx) (int, error) { return schemaVersion, nil } -func getSchema(ctx infra.DnoteCtx) (int, error) { +func getSchemaKey(mode int) (string, error) { + if mode == LocalMode { + return infra.SystemSchema, nil + } + + if mode == RemoteMode { + return infra.SystemRemoteSchema, nil + } + + return "", errors.Errorf("unsupported migration type '%d'", mode) +} + +func getSchema(ctx infra.DnoteCtx, schemaKey string) (int, error) { var ret int db := ctx.DB - err := db.QueryRow("SELECT value FROM system where key = ?", infra.SystemSchema).Scan(&ret) + err := db.QueryRow("SELECT value FROM system where key = ?", schemaKey).Scan(&ret) if err == sql.ErrNoRows { - ret, err = initSchema(ctx) + ret, err = initSchema(ctx, schemaKey) if err != nil { return ret, errors.Wrap(err, "initializing schema") @@ -46,7 +73,7 @@ func getSchema(ctx infra.DnoteCtx) (int, error) { return ret, nil } -func execute(ctx infra.DnoteCtx, m migration) error { +func execute(ctx infra.DnoteCtx, m migration, schemaKey string) error { log.Debug("running migration %s\n", m.name) tx, err := ctx.DB.Begin() @@ -57,17 +84,17 @@ func execute(ctx infra.DnoteCtx, m migration) error { err = m.run(ctx, tx) if err != nil { tx.Rollback() - return errors.Wrapf(err, "running migration '%s'", m.name) + return errors.Wrapf(err, "running '%s'", m.name) } var currentSchema int - err = tx.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemSchema).Scan(¤tSchema) + err = tx.QueryRow("SELECT value FROM system WHERE key = ?", schemaKey).Scan(¤tSchema) if err != nil { tx.Rollback() return errors.Wrap(err, "getting current schema") } - _, err = tx.Exec("UPDATE system SET value = ? WHERE key = ?", currentSchema+1, infra.SystemSchema) + _, err = tx.Exec("UPDATE system SET value = value + 1 WHERE key = ?", schemaKey) if err != nil { tx.Rollback() return errors.Wrap(err, "incrementing schema") @@ -79,8 +106,13 @@ func execute(ctx infra.DnoteCtx, m migration) error { } // Run performs unrun migrations -func Run(ctx infra.DnoteCtx, migrations []migration) error { - schema, err := getSchema(ctx) +func Run(ctx infra.DnoteCtx, migrations []migration, mode int) error { + schemaKey, err := getSchemaKey(mode) + if err != nil { + return errors.Wrap(err, "getting schema key") + } + + schema, err := getSchema(ctx, schemaKey) if err != nil { return errors.Wrap(err, "getting the current schema") } @@ -90,8 +122,8 @@ func Run(ctx infra.DnoteCtx, migrations []migration) error { toRun := migrations[schema:] for _, m := range toRun { - if err := execute(ctx, m); err != nil { - return errors.Wrapf(err, "running migration %s", m.name) + if err := execute(ctx, m, schemaKey); err != nil { + return errors.Wrap(err, "running migration") } } diff --git a/migrate/migrate_test.go b/migrate/migrate_test.go index 46103288..279e45af 100644 --- a/migrate/migrate_test.go +++ b/migrate/migrate_test.go @@ -2,8 +2,12 @@ package migrate import ( "database/sql" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "testing" + "time" "github.com/dnote/actions" "github.com/dnote/cli/infra" @@ -13,212 +17,281 @@ import ( ) func TestExecute_bump_schema(t *testing.T) { - // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - db := ctx.DB - testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSchema, 8) - - m1 := migration{ - name: "noop", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - return nil + testCases := []struct { + schemaKey string + }{ + { + schemaKey: infra.SystemSchema, }, - } - m2 := migration{ - name: "noop", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - return nil + { + schemaKey: infra.SystemRemoteSchema, }, } - // execute - err := execute(ctx, m1) - if err != nil { - t.Fatal(errors.Wrap(err, "failed to execute")) - } - err = execute(ctx, m2) - if err != nil { - t.Fatal(errors.Wrap(err, "failed to execute")) - } + for _, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false) + defer testutils.TeardownEnv(ctx) - // test - var schema int - testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemSchema), &schema) - testutils.AssertEqual(t, schema, 10, "schema was not incremented properly") + db := ctx.DB + testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", tc.schemaKey, 8) + + m1 := migration{ + name: "noop", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + return nil + }, + } + m2 := migration{ + name: "noop", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + return nil + }, + } + + // execute + err := execute(ctx, m1, tc.schemaKey) + if err != nil { + t.Fatal(errors.Wrap(err, "failed to execute")) + } + err = execute(ctx, m2, tc.schemaKey) + if err != nil { + t.Fatal(errors.Wrap(err, "failed to execute")) + } + + // test + var schema int + testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema) + testutils.AssertEqual(t, schema, 10, "schema was not incremented properly") + }() + } } func TestRun_nonfresh(t *testing.T) { - // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - db := ctx.DB - testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSchema, 2) - testutils.MustExec(t, "creating a temporary table for testing", db, - "CREATE TABLE migrate_run_test ( name string )") - - sequence := []migration{ - migration{ - name: "v1", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1") - return nil - }, + testCases := []struct { + mode int + schemaKey string + }{ + { + mode: LocalMode, + schemaKey: infra.SystemSchema, }, - migration{ - name: "v2", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2") - return nil - }, - }, - migration{ - name: "v3", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3") - return nil - }, - }, - migration{ - name: "v4", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v4 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v4") - return nil - }, + { + mode: RemoteMode, + schemaKey: infra.SystemRemoteSchema, }, } - // execute - err := Run(ctx, sequence) - if err != nil { - t.Fatal(errors.Wrap(err, "failed to run")) + for _, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", tc.schemaKey, 2) + testutils.MustExec(t, "creating a temporary table for testing", db, + "CREATE TABLE migrate_run_test ( name string )") + + sequence := []migration{ + migration{ + name: "v1", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1") + return nil + }, + }, + migration{ + name: "v2", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2") + return nil + }, + }, + migration{ + name: "v3", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3") + return nil + }, + }, + migration{ + name: "v4", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v4 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v4") + return nil + }, + }, + } + + // execute + err := Run(ctx, sequence, tc.mode) + if err != nil { + t.Fatal(errors.Wrap(err, "failed to run")) + } + + // test + var schema int + testutils.MustScan(t, fmt.Sprintf("getting schema for %s", tc.schemaKey), db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema) + testutils.AssertEqual(t, schema, 4, fmt.Sprintf("schema was not updated for %s", tc.schemaKey)) + + var testRunCount int + testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount) + testutils.AssertEqual(t, testRunCount, 2, "test run count mismatch") + + var testRun1, testRun2 string + testutils.MustScan(t, "finding test run 1", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v3"), &testRun1) + testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v4"), &testRun2) + }() } - // test - var schema int - testutils.MustScan(t, fmt.Sprintf("getting schema for %s", infra.SystemSchema), db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemSchema), &schema) - testutils.AssertEqual(t, schema, 4, fmt.Sprintf("schema was not updated for %s", infra.SystemSchema)) - - var testRunCount int - testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount) - testutils.AssertEqual(t, testRunCount, 2, "test run count mismatch") - - var testRun1, testRun2 string - testutils.MustScan(t, "finding test run 1", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v3"), &testRun1) - testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v4"), &testRun2) - } func TestRun_fresh(t *testing.T) { - // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - db := ctx.DB - testutils.MustExec(t, "creating a temporary table for testing", db, - "CREATE TABLE migrate_run_test ( name string )") - - sequence := []migration{ - migration{ - name: "v1", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1") - return nil - }, + testCases := []struct { + mode int + schemaKey string + }{ + { + mode: LocalMode, + schemaKey: infra.SystemSchema, }, - migration{ - name: "v2", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2") - return nil - }, - }, - migration{ - name: "v3", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3") - return nil - }, + { + mode: RemoteMode, + schemaKey: infra.SystemRemoteSchema, }, } - // execute - err := Run(ctx, sequence) - if err != nil { - t.Fatal(errors.Wrap(err, "failed to run")) + for _, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + testutils.MustExec(t, "creating a temporary table for testing", db, + "CREATE TABLE migrate_run_test ( name string )") + + sequence := []migration{ + migration{ + name: "v1", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1") + return nil + }, + }, + migration{ + name: "v2", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2") + return nil + }, + }, + migration{ + name: "v3", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3") + return nil + }, + }, + } + + // execute + err := Run(ctx, sequence, tc.mode) + if err != nil { + t.Fatal(errors.Wrap(err, "failed to run")) + } + + // test + var schema int + testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema) + testutils.AssertEqual(t, schema, 3, "schema was not updated") + + var testRunCount int + testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount) + testutils.AssertEqual(t, testRunCount, 3, "test run count mismatch") + + var testRun1, testRun2, testRun3 string + testutils.MustScan(t, "finding test run 1", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v1"), &testRun1) + testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v2"), &testRun2) + testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v3"), &testRun3) + }() } - - // test - var schema int - testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemSchema), &schema) - testutils.AssertEqual(t, schema, 3, "schema was not updated") - - var testRunCount int - testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount) - testutils.AssertEqual(t, testRunCount, 3, "test run count mismatch") - - var testRun1, testRun2, testRun3 string - testutils.MustScan(t, "finding test run 1", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v1"), &testRun1) - testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v2"), &testRun2) - testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v3"), &testRun3) } func TestRun_up_to_date(t *testing.T) { - // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") - defer testutils.TeardownEnv(ctx) - - db := ctx.DB - testutils.MustExec(t, "creating a temporary table for testing", db, - "CREATE TABLE migrate_run_test ( name string )") - - testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSchema, 3) - - sequence := []migration{ - migration{ - name: "v1", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1") - return nil - }, + testCases := []struct { + mode int + schemaKey string + }{ + { + mode: LocalMode, + schemaKey: infra.SystemSchema, }, - migration{ - name: "v2", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2") - return nil - }, - }, - migration{ - name: "v3", - run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { - testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3") - return nil - }, + { + mode: RemoteMode, + schemaKey: infra.SystemRemoteSchema, }, } - // execute - err := Run(ctx, sequence) - if err != nil { - t.Fatal(errors.Wrap(err, "failed to run")) + for _, tc := range testCases { + func() { + // set up + ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + testutils.MustExec(t, "creating a temporary table for testing", db, + "CREATE TABLE migrate_run_test ( name string )") + + testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", tc.schemaKey, 3) + + sequence := []migration{ + migration{ + name: "v1", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1") + return nil + }, + }, + migration{ + name: "v2", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2") + return nil + }, + }, + migration{ + name: "v3", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3") + return nil + }, + }, + } + + // execute + err := Run(ctx, sequence, tc.mode) + if err != nil { + t.Fatal(errors.Wrap(err, "failed to run")) + } + + // test + var schema int + testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema) + testutils.AssertEqual(t, schema, 3, "schema was not updated") + + var testRunCount int + testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount) + testutils.AssertEqual(t, testRunCount, 0, "test run count mismatch") + }() } - - // test - var schema int - testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemSchema), &schema) - testutils.AssertEqual(t, schema, 3, "schema was not updated") - - var testRunCount int - testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount) - testutils.AssertEqual(t, testRunCount, 0, "test run count mismatch") } func TestLocalMigration1(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false) defer testutils.TeardownEnv(ctx) db := ctx.DB @@ -295,7 +368,7 @@ func TestLocalMigration1(t *testing.T) { func TestLocalMigration2(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false) defer testutils.TeardownEnv(ctx) db := ctx.DB @@ -381,7 +454,7 @@ func TestLocalMigration2(t *testing.T) { func TestLocalMigration3(t *testing.T) { // set up - ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false) defer testutils.TeardownEnv(ctx) db := ctx.DB @@ -452,3 +525,314 @@ func TestLocalMigration3(t *testing.T) { testutils.AssertEqual(t, a3.Timestamp, int64(1537829463), "a3 timestamp mismatch") testutils.AssertEqual(t, a3Data.NoteUUID, "note-2-uuid", "a3 data note_uuid mismatch") } + +func TestLocalMigration4(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css") + n1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano()) + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = lm4.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // Test + var n1Dirty, b1Dirty bool + var n1Deleted, b1Deleted bool + var n1USN, b1USN int + testutils.MustScan(t, "scanning the newly added dirty flag of n1", db.QueryRow("SELECT dirty, deleted, usn FROM notes WHERE uuid = ?", n1UUID), &n1Dirty, &n1Deleted, &n1USN) + testutils.MustScan(t, "scanning the newly added dirty flag of b1", db.QueryRow("SELECT dirty, deleted, usn FROM books WHERE uuid = ?", b1UUID), &b1Dirty, &b1Deleted, &b1USN) + + testutils.AssertEqual(t, n1Dirty, false, "n1 dirty flag should be false by default") + testutils.AssertEqual(t, b1Dirty, false, "b1 dirty flag should be false by default") + + testutils.AssertEqual(t, n1Deleted, false, "n1 deleted flag should be false by default") + testutils.AssertEqual(t, b1Deleted, false, "b1 deleted flag should be false by default") + + testutils.AssertEqual(t, n1USN, 0, "n1 usn flag should be 0 by default") + testutils.AssertEqual(t, b1USN, 0, "b1 usn flag should be 0 by default") +} + +func TestLocalMigration5(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-5-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css") + b2UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting js book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "js") + + n1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano()) + n2UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n2UUID, b1UUID, "n2 content", time.Now().UnixNano()) + n3UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n3UUID, b1UUID, "n3 content", time.Now().UnixNano()) + + data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"}) + testutils.MustExec(t, "inserting a1", db, + "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", "a1-uuid", 1, "add_book", string(data), 1537829463) + + data = testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: n1UUID, BookName: "css", Content: "n1 content", Public: false}) + testutils.MustExec(t, "inserting a2", db, + "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", "a2-uuid", 1, "add_note", string(data), 1537829463) + + updatedContent := "updated content" + data = testutils.MustMarshalJSON(t, actions.EditNoteDataV3{NoteUUID: n2UUID, BookName: (*string)(nil), Content: &updatedContent, Public: (*bool)(nil)}) + testutils.MustExec(t, "inserting a3", db, + "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", "a3-uuid", 1, "edit_note", string(data), 1537829463) + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = lm5.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // Test + var b1Dirty, b2Dirty, n1Dirty, n2Dirty, n3Dirty bool + testutils.MustScan(t, "scanning the newly added dirty flag of b1", db.QueryRow("SELECT dirty FROM books WHERE uuid = ?", b1UUID), &b1Dirty) + testutils.MustScan(t, "scanning the newly added dirty flag of b2", db.QueryRow("SELECT dirty FROM books WHERE uuid = ?", b2UUID), &b2Dirty) + testutils.MustScan(t, "scanning the newly added dirty flag of n1", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", n1UUID), &n1Dirty) + testutils.MustScan(t, "scanning the newly added dirty flag of n2", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", n2UUID), &n2Dirty) + testutils.MustScan(t, "scanning the newly added dirty flag of n3", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", n3UUID), &n3Dirty) + + testutils.AssertEqual(t, b1Dirty, false, "b1 dirty flag should be false by default") + testutils.AssertEqual(t, b2Dirty, true, "b2 dirty flag should be false by default") + testutils.AssertEqual(t, n1Dirty, true, "n1 dirty flag should be false by default") + testutils.AssertEqual(t, n2Dirty, true, "n2 dirty flag should be false by default") + testutils.AssertEqual(t, n3Dirty, false, "n3 dirty flag should be false by default") +} + +func TestLocalMigration6(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-5-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"}) + a1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting action", db, + "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 1, "add_book", string(data), 1537829463) + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = lm5.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // Test + var count int + err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name = ?;", "actions").Scan(&count) + testutils.AssertEqual(t, count, 0, "actions table should have been deleted") +} + +func TestLocalMigration7_trash(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-7-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting trash book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "trash") + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = lm7.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // Test + var b1Label string + var b1Dirty bool + testutils.MustScan(t, "scanning b1 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b1UUID), &b1Label, &b1Dirty) + testutils.AssertEqual(t, b1Label, "trash (2)", "b1 label was not migrated") + testutils.AssertEqual(t, b1Dirty, true, "b1 was not marked dirty") +} + +func TestLocalMigration7_conflicts(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-7-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts") + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = lm7.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // Test + var b1Label string + var b1Dirty bool + testutils.MustScan(t, "scanning b1 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b1UUID), &b1Label, &b1Dirty) + testutils.AssertEqual(t, b1Label, "conflicts (2)", "b1 label was not migrated") + testutils.AssertEqual(t, b1Dirty, true, "b1 was not marked dirty") +} + +func TestLocalMigration7_conflicts_dup(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-7-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts") + b2UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "conflicts (2)") + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = lm7.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // Test + var b1Label, b2Label string + var b1Dirty, b2Dirty bool + testutils.MustScan(t, "scanning b1 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b1UUID), &b1Label, &b1Dirty) + testutils.MustScan(t, "scanning b2 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b2UUID), &b2Label, &b2Dirty) + testutils.AssertEqual(t, b1Label, "conflicts (3)", "b1 label was not migrated") + testutils.AssertEqual(t, b2Label, "conflicts (2)", "b1 label was not migrated") + testutils.AssertEqual(t, b1Dirty, true, "b1 was not marked dirty") + testutils.AssertEqual(t, b2Dirty, false, "b2 should not have been marked dirty") +} + +func TestRemoteMigration1(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/remote-1-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + JSBookUUID := "existing-js-book-uuid" + CSSBookUUID := "existing-css-book-uuid" + linuxBookUUID := "existing-linux-book-uuid" + newJSBookUUID := "new-js-book-uuid" + newCSSBookUUID := "new-css-book-uuid" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/v1/books" { + res := []struct { + UUID string `json:"uuid"` + Label string `json:"label"` + }{ + { + UUID: newJSBookUUID, + Label: "js", + }, + { + UUID: newCSSBookUUID, + Label: "css", + }, + // book that only exists on the server. client must ignore. + { + UUID: "golang-book-uuid", + Label: "golang", + }, + } + + if err := json.NewEncoder(w).Encode(res); err != nil { + t.Fatal(errors.Wrap(err, "encoding response")) + } + } + })) + defer server.Close() + + ctx.APIEndpoint = server.URL + + confStr := fmt.Sprintf("apikey: mock_api_key") + testutils.WriteFile(ctx, []byte(confStr), "dnoterc") + + db := ctx.DB + + testutils.MustExec(t, "inserting js book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", JSBookUUID, "js") + testutils.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", CSSBookUUID, "css") + testutils.MustExec(t, "inserting linux book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", linuxBookUUID, "linux") + + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = rm1.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // test + var postJSBookUUID, postCSSBookUUID, postLinuxBookUUID string + testutils.MustScan(t, "getting js book uuid", db.QueryRow("SELECT uuid FROM books WHERE label = ?", "js"), &postJSBookUUID) + testutils.MustScan(t, "getting css book uuid", db.QueryRow("SELECT uuid FROM books WHERE label = ?", "css"), &postCSSBookUUID) + testutils.MustScan(t, "getting linux book uuid", db.QueryRow("SELECT uuid FROM books WHERE label = ?", "linux"), &postLinuxBookUUID) + + testutils.AssertEqual(t, postJSBookUUID, newJSBookUUID, "js book uuid was not updated correctly") + testutils.AssertEqual(t, postCSSBookUUID, newCSSBookUUID, "css book uuid was not updated correctly") + testutils.AssertEqual(t, postLinuxBookUUID, linuxBookUUID, "linux book uuid changed") +} diff --git a/migrate/migrations.go b/migrate/migrations.go index da58d7ef..abf6dc09 100644 --- a/migrate/migrations.go +++ b/migrate/migrations.go @@ -3,9 +3,13 @@ package migrate import ( "database/sql" "encoding/json" + "fmt" "github.com/dnote/actions" + "github.com/dnote/cli/client" + "github.com/dnote/cli/core" "github.com/dnote/cli/infra" + "github.com/dnote/cli/log" "github.com/pkg/errors" ) @@ -148,3 +152,223 @@ var lm3 = migration{ return nil }, } + +var lm4 = migration{ + name: "add-dirty-usn-deleted-to-notes-and-books", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + _, err := tx.Exec("ALTER TABLE books ADD COLUMN dirty bool DEFAULT false") + if err != nil { + return errors.Wrap(err, "adding dirty column to books") + } + + _, err = tx.Exec("ALTER TABLE books ADD COLUMN usn int DEFAULT 0 NOT NULL") + if err != nil { + return errors.Wrap(err, "adding usn column to books") + } + + _, err = tx.Exec("ALTER TABLE books ADD COLUMN deleted bool DEFAULT false") + if err != nil { + return errors.Wrap(err, "adding deleted column to books") + } + + _, err = tx.Exec("ALTER TABLE notes ADD COLUMN dirty bool DEFAULT false") + if err != nil { + return errors.Wrap(err, "adding dirty column to notes") + } + + _, err = tx.Exec("ALTER TABLE notes ADD COLUMN usn int DEFAULT 0 NOT NULL") + if err != nil { + return errors.Wrap(err, "adding usn column to notes") + } + + _, err = tx.Exec("ALTER TABLE notes ADD COLUMN deleted bool DEFAULT false") + if err != nil { + return errors.Wrap(err, "adding deleted column to notes") + } + + return nil + }, +} + +var lm5 = migration{ + name: "mark-action-targets-dirty", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + rows, err := tx.Query("SELECT uuid, data, type FROM actions") + if err != nil { + return errors.Wrap(err, "querying rows") + } + defer rows.Close() + + for rows.Next() { + var uuid, dat, actionType string + + err = rows.Scan(&uuid, &dat, &actionType) + if err != nil { + return errors.Wrap(err, "scanning a row") + } + + // removed notes and removed books cannot be reliably derived retrospectively + // because books did not use to have uuid. Users will find locally deleted + // notes and books coming back to existence if they have not synced the change. + // But there will be no data loss. + switch actionType { + case "add_note": + var data actions.AddNoteDataV2 + err = json.Unmarshal([]byte(dat), &data) + if err != nil { + return errors.Wrap(err, "unmarshalling existing data") + } + + _, err := tx.Exec("UPDATE notes SET dirty = true WHERE uuid = ?", data.NoteUUID) + if err != nil { + return errors.Wrapf(err, "markig note dirty '%s'", data.NoteUUID) + } + case "edit_note": + var data actions.EditNoteDataV3 + err = json.Unmarshal([]byte(dat), &data) + if err != nil { + return errors.Wrap(err, "unmarshalling existing data") + } + + _, err := tx.Exec("UPDATE notes SET dirty = true WHERE uuid = ?", data.NoteUUID) + if err != nil { + return errors.Wrapf(err, "markig note dirty '%s'", data.NoteUUID) + } + case "add_book": + var data actions.AddBookDataV1 + err = json.Unmarshal([]byte(dat), &data) + if err != nil { + return errors.Wrap(err, "unmarshalling existing data") + } + + _, err := tx.Exec("UPDATE books SET dirty = true WHERE label = ?", data.BookName) + if err != nil { + return errors.Wrapf(err, "markig note dirty '%s'", data.BookName) + } + } + } + + return nil + }, +} + +var lm6 = migration{ + name: "drop-actions", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + _, err := tx.Exec("DROP TABLE actions;") + if err != nil { + return errors.Wrap(err, "dropping the actions table") + } + + return nil + }, +} + +var lm7 = migration{ + name: "resolve-conflicts-with-reserved-book-names", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + migrateBook := func(name string) error { + var uuid string + + err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", name).Scan(&uuid) + if err == sql.ErrNoRows { + // if not found, noop + return nil + } else if err != nil { + return errors.Wrap(err, "finding trash book") + } + + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s (%d)", name, i) + + var count int + err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", candidate).Scan(&count) + if err != nil { + return errors.Wrap(err, "counting candidate") + } + + if count == 0 { + _, err := tx.Exec("UPDATE books SET label = ?, dirty = ? WHERE uuid = ?", candidate, true, uuid) + if err != nil { + return errors.Wrapf(err, "updating book '%s'", name) + } + + break + } + } + + return nil + } + + if err := migrateBook("trash"); err != nil { + return errors.Wrap(err, "migrating trash book") + } + if err := migrateBook("conflicts"); err != nil { + return errors.Wrap(err, "migrating conflicts book") + } + + return nil + }, +} + +var rm1 = migration{ + name: "sync-book-uuids-from-server", + run: func(ctx infra.DnoteCtx, tx *sql.Tx) error { + config, err := core.ReadConfig(ctx) + if err != nil { + return errors.Wrap(err, "reading the config") + } + if config.APIKey == "" { + return errors.New("login required") + } + + resp, err := client.GetBooks(ctx, config.APIKey) + if err != nil { + return errors.Wrap(err, "getting books from the server") + } + log.Debug("book details from the server: %+v\n", resp) + + UUIDMap := map[string]string{} + for _, book := range resp { + // Build a map from uuid to label + UUIDMap[book.Label] = book.UUID + } + + for _, book := range resp { + // update uuid in the books table + log.Debug("Updating book %s\n", book.Label) + + //todo if does not exist, then continue loop + var count int + if err := tx. + QueryRow("SELECT count(*) FROM books WHERE label = ?", book.Label). + Scan(&count); err != nil { + return errors.Wrapf(err, "checking if book exists: %s", book.Label) + } + + if count == 0 { + continue + } + + var originalUUID string + if err := tx. + QueryRow("SELECT uuid FROM books WHERE label = ?", book.Label). + Scan(&originalUUID); err != nil { + return errors.Wrapf(err, "scanning the orignal uuid of the book %s", book.Label) + } + log.Debug("original uuid: %s. new_uuid %s\n", originalUUID, book.UUID) + + _, err := tx.Exec("UPDATE books SET uuid = ? WHERE label = ?", book.UUID, book.Label) + if err != nil { + return errors.Wrapf(err, "updating book '%s'", book.Label) + } + + _, err = tx.Exec("UPDATE notes SET book_uuid = ? WHERE book_uuid = ?", book.UUID, originalUUID) + if err != nil { + return errors.Wrapf(err, "updating book_uuids of notes") + } + } + + return nil + }, +} diff --git a/scripts/dev.sh b/scripts/dev.sh index 41dbbcbc..b47d610c 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -2,6 +2,9 @@ # dev.sh builds a new binary and replaces the old one in the PATH with it -rm "$(which dnote)" $GOPATH/bin/cli -go install -ldflags "-X main.apiEndpoint=http://127.0.0.1:5000" --tags "darwin" . -ln -s $GOPATH/bin/cli /usr/local/bin/dnote +sudo rm "$(which dnote)" $GOPATH/bin/cli + +# change tags to darwin if on macos +go install -ldflags "-X main.apiEndpoint=http://127.0.0.1:5000" --tags "linux" . + +sudo ln -s $GOPATH/bin/cli /usr/local/bin/dnote diff --git a/scripts/test.sh b/scripts/test.sh index c39d6e96..66df3309 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -3,5 +3,9 @@ # run_server_test.sh runs server test files sequentially # https://stackoverflow.com/questions/23715302/go-how-to-run-tests-for-multiple-packages +# clear tmp dir in case not properly torn down +rm -rf ./tmp + +# run test go test ./... -p 1 diff --git a/testutils/fixtures/dnote1.json b/testutils/fixtures/dnote1.json deleted file mode 100644 index 147cd63e..00000000 --- a/testutils/fixtures/dnote1.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "js": { - "name": "js", - "notes": [ - { - "uuid": "43827b9a-c2b0-4c06-a290-97991c896653", - "content": "Booleans have toString()", - "added_on": 1515199943, - "edited_on": 0 - } - ] - } -} diff --git a/testutils/fixtures/dnote2.json b/testutils/fixtures/dnote2.json deleted file mode 100644 index b9f60c9d..00000000 --- a/testutils/fixtures/dnote2.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "js": { - "name": "js", - "notes": [ - { - "uuid": "43827b9a-c2b0-4c06-a290-97991c896653", - "content": "Booleans have toString()", - "added_on": 1515199943, - "edited_on": 0 - }, - { - "uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - "content": "Date object implements mathematical comparisons", - "added_on": 1515199951, - "edited_on": 0 - } - ] - } -} diff --git a/testutils/fixtures/dnote3.json b/testutils/fixtures/dnote3.json deleted file mode 100644 index 89a6883f..00000000 --- a/testutils/fixtures/dnote3.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "js": { - "name": "js", - "notes": [ - { - "uuid": "43827b9a-c2b0-4c06-a290-97991c896653", - "content": "Booleans have toString()", - "added_on": 1515199943, - "edited_on": 0 - }, - { - "uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - "content": "Date object implements mathematical comparisons", - "added_on": 1515199951, - "edited_on": 0 - } - ] - }, - "linux": { - "name": "linux", - "notes": [ - { - "uuid": "3e065d55-6d47-42f2-a6bf-f5844130b2d2", - "content": "wc -l to count words", - "added_on": 1515199961, - "edited_on": 0 - } - ] - } -} diff --git a/testutils/fixtures/dnote4.json b/testutils/fixtures/dnote4.json deleted file mode 100644 index 0fbb1e24..00000000 --- a/testutils/fixtures/dnote4.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "js": { - "name": "js", - "notes": [ - { - "uuid": "43827b9a-c2b0-4c06-a290-97991c896653", - "content": "Booleans have toString()", - "added_on": 1515199943, - "edited_on": 0 - } - ] - }, - "linux": { - "name": "linux", - "notes": [] - } -} diff --git a/testutils/fixtures/schema.sql b/testutils/fixtures/schema.sql index 88e700d0..c0e908b6 100644 --- a/testutils/fixtures/schema.sql +++ b/testutils/fixtures/schema.sql @@ -7,20 +7,12 @@ CREATE TABLE notes added_on integer NOT NULL, edited_on integer DEFAULT 0, public bool DEFAULT false - ); + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL, deleted bool DEFAULT false); CREATE TABLE books ( uuid text PRIMARY KEY, label text NOT NULL - ); -CREATE TABLE actions - ( - uuid text PRIMARY KEY, - schema integer NOT NULL, - type text NOT NULL, - data text NOT NULL, - timestamp integer NOT NULL - ); + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL, deleted bool DEFAULT false); CREATE TABLE system ( key string NOT NULL, diff --git a/testutils/main.go b/testutils/main.go index 61c06c86..d5b980c9 100644 --- a/testutils/main.go +++ b/testutils/main.go @@ -21,30 +21,41 @@ import ( ) // InitEnv sets up a test env and returns a new dnote context -func InitEnv(relPath string, relFixturePath string) infra.DnoteCtx { +func InitEnv(t *testing.T, relPath string, relFixturePath string, migrated bool) infra.DnoteCtx { path, err := filepath.Abs(relPath) if err != nil { - panic(errors.Wrap(err, "pasrsing path").Error()) + t.Fatal(errors.Wrap(err, "pasrsing path")) } os.Setenv("DNOTE_HOME_DIR", path) ctx, err := infra.NewCtx("", "") if err != nil { - panic(errors.Wrap(err, "getting new ctx").Error()) + t.Fatal(errors.Wrap(err, "getting new ctx")) } - // set up directory and db + // set up directory if err := os.MkdirAll(ctx.DnoteDir, 0755); err != nil { - panic(err) + t.Fatal(err) } + // set up db b := ReadFileAbs(relFixturePath) setupSQL := string(b) db := ctx.DB - _, err = db.Exec(setupSQL) - if err != nil { - panic(errors.Wrap(err, "running schema sql").Error()) + if _, err := db.Exec(setupSQL); err != nil { + t.Fatal(errors.Wrap(err, "running schema sql")) + } + + if migrated { + // mark migrations as done. When adding new migrations, bump the numbers here. + if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", infra.SystemSchema, 6); err != nil { + t.Fatal(errors.Wrap(err, "inserting schema")) + } + + if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", infra.SystemRemoteSchema, 1); err != nil { + t.Fatal(errors.Wrap(err, "inserting remote schema")) + } } return ctx diff --git a/testutils/setup.go b/testutils/setup.go index f469ded5..03e59b4d 100644 --- a/testutils/setup.go +++ b/testutils/setup.go @@ -27,12 +27,12 @@ func Setup2(t *testing.T, ctx infra.DnoteCtx) { b1UUID := "js-book-uuid" b2UUID := "linux-book-uuid" - MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js") - MustExec(t, "setting up book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "linux") + MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label, usn) VALUES (?, ?, ?)", b1UUID, "js", 111) + MustExec(t, "setting up book 2", db, "INSERT INTO books (uuid, label, usn) VALUES (?, ?, ?)", b2UUID, "linux", 122) - MustExec(t, "setting up note 2", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 1, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", b1UUID, "Date object implements mathematical comparisons", 1515199951) - MustExec(t, "setting up note 1", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 2, "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943) - MustExec(t, "setting up note 3", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 3, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", b2UUID, "wc -l to count words", 1515199961) + MustExec(t, "setting up note 1", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on, usn) VALUES (?, ?, ?, ?, ?, ?)", 1, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", b1UUID, "n1 content", 1515199951, 11) + MustExec(t, "setting up note 2", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on, usn) VALUES (?, ?, ?, ?, ?, ?)", 2, "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "n2 content", 1515199943, 12) + MustExec(t, "setting up note 3", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on, usn) VALUES (?, ?, ?, ?, ?, ?)", 3, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", b2UUID, "n3 content", 1515199961, 13) } // Setup3 sets up a dnote env #1 diff --git a/utils/utils.go b/utils/utils.go index 2df1d6b0..29c2592e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -2,11 +2,15 @@ package utils import ( "bufio" + "fmt" "io" "io/ioutil" + "net/http" "os" "path/filepath" + "strings" + "github.com/dnote/cli/infra" "github.com/dnote/cli/log" "github.com/pkg/errors" "github.com/satori/go.uuid" @@ -142,3 +146,24 @@ func CopyDir(src, dest string) error { return nil } + +// DoAuthorizedReq does a http request to the given path in the api endpoint as a user, +// with the appropriate headers. The given path should include the preceding slash. +func DoAuthorizedReq(ctx infra.DnoteCtx, apiKey, method, path, body string) (*http.Response, error) { + endpoint := fmt.Sprintf("%s%s", ctx.APIEndpoint, path) + req, err := http.NewRequest(method, endpoint, strings.NewReader(body)) + if err != nil { + return nil, errors.Wrap(err, "constructing http request") + } + + req.Header.Set("Authorization", apiKey) + req.Header.Set("CLI-Version", ctx.Version) + + hc := http.Client{} + res, err := hc.Do(req) + if err != nil { + return res, errors.Wrap(err, "making http request") + } + + return res, nil +}