diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..50468a04 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: go +go: + - 1.11.x +install: + - curl -L https://github.com/golang/dep/releases/download/v0.3.2/dep-linux-amd64 -o ./dep && chmod +x ./dep + - ./dep ensure +script: + - ./scripts/test.sh diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 00000000..efef5ea8 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,130 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + digest = "1:cd0089a5b5d872ac1b772087c7ee0ff2e71de50aa3a51826be64a63963a85287" + name = "github.com/dnote/actions" + packages = ["."] + pruneopts = "" + revision = "60e81aff027d39f4494c5ee5c1db9c3efc015ccf" + version = "v0.2.0" + +[[projects]] + digest = "1:e988ed0ca0d81f4d28772760c02ee95084961311291bdfefc1b04617c178b722" + name = "github.com/dnote/color" + packages = ["."] + pruneopts = "" + revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" + version = "v1.7.0" + +[[projects]] + branch = "master" + digest = "1:fe99ddb68e996f2f9f7995e9765bc283ceef12dbe30de17922900c1cfa9dfc09" + name = "github.com/google/go-github" + packages = ["github"] + pruneopts = "" + revision = "b7b480f79db7ae436e87bef80ff47596139af8f2" + +[[projects]] + digest = "1:cea4aa2038169ee558bf507d5ea02c94ca85bcca28a4c7bb99fd59b31e43a686" + name = "github.com/google/go-querystring" + packages = ["query"] + pruneopts = "" + revision = "44c6ddd0a2342c386950e880b658017258da92fc" + version = "v1.0.0" + +[[projects]] + digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" + name = "github.com/inconshreveable/mousetrap" + packages = ["."] + pruneopts = "" + revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" + version = "v1.0" + +[[projects]] + digest = "1:9ea83adf8e96d6304f394d40436f2eb44c1dc3250d223b74088cc253a6cd0a1c" + name = "github.com/mattn/go-colorable" + packages = ["."] + pruneopts = "" + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + digest = "1:3140e04675a6a91d2a20ea9d10bdadf6072085502e6def6768361260aee4b967" + name = "github.com/mattn/go-isatty" + packages = ["."] + pruneopts = "" + revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c" + version = "v0.0.4" + +[[projects]] + digest = "1:8bbdb2b3dce59271877770d6fe7dcbb8362438fa7d2e1e1f688e4bf2aac72706" + name = "github.com/mattn/go-sqlite3" + packages = ["."] + pruneopts = "" + revision = "c7c4067b79cc51e6dfdcef5c702e74b1e0fa7c75" + version = "v1.10.0" + +[[projects]] + digest = "1:1d7e1867c49a6dd9856598ef7c3123604ea3daabf5b83f303ff457bcbc410b1d" + name = "github.com/pkg/errors" + packages = ["."] + pruneopts = "" + revision = "ba968bfe8b2f7e042a574c888954fccecfa385b4" + version = "v0.8.1" + +[[projects]] + digest = "1:7f569d906bdd20d906b606415b7d794f798f91a62fcfb6a4daa6d50690fb7a3f" + name = "github.com/satori/go.uuid" + packages = ["."] + pruneopts = "" + revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" + version = "v1.2.0" + +[[projects]] + digest = "1:a1403cc8a94b8d7956ee5e9694badef0e7b051af289caad1cf668331e3ffa4f6" + name = "github.com/spf13/cobra" + packages = ["."] + pruneopts = "" + revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" + version = "v0.0.3" + +[[projects]] + digest = "1:cbaf13cdbfef0e4734ed8a7504f57fe893d471d62a35b982bf6fb3f036449a66" + name = "github.com/spf13/pflag" + packages = ["."] + pruneopts = "" + revision = "298182f68c66c05229eb03ac171abe6e309ee79a" + version = "v1.0.3" + +[[projects]] + branch = "master" + digest = "1:7e3b61f51ebcb58b3894928ed7c63aae68820dec1dd57166e5d6e65ef2868f40" + name = "golang.org/x/sys" + packages = ["unix"] + pruneopts = "" + revision = "b90733256f2e882e81d52f9126de08df5615afd9" + +[[projects]] + branch = "v2" + digest = "1:cedccf16b71e86db87a24f8d4c70b0a855872eb967cb906a66b95de56aefbd0d" + name = "gopkg.in/yaml.v2" + packages = ["."] + pruneopts = "" + revision = "51d6538a90f86fe93ac480b35f37b2be17fef232" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = [ + "github.com/dnote/actions", + "github.com/dnote/color", + "github.com/google/go-github/github", + "github.com/mattn/go-sqlite3", + "github.com/pkg/errors", + "github.com/satori/go.uuid", + "github.com/spf13/cobra", + "gopkg.in/yaml.v2", + ] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/cmd/edit/edit.go b/cmd/edit/edit.go new file mode 100644 index 00000000..8dedd7aa --- /dev/null +++ b/cmd/edit/edit.go @@ -0,0 +1,112 @@ +package edit + +import ( + "database/sql" + "fmt" + "io/ioutil" + "time" + + "github.com/dnote/cli/core" + "github.com/dnote/cli/infra" + "github.com/dnote/cli/log" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var newContent string + +var example = ` + * Edit the note by index in a book + dnote edit js 3 + + * Skip the prompt by providing new content directly + dnote edit js 3 -c "new content"` + +// NewCmd returns a new edit command +func NewCmd(ctx infra.DnoteCtx) *cobra.Command { + cmd := &cobra.Command{ + Use: "edit", + Short: "Edit a note or a book", + Aliases: []string{"e"}, + Example: example, + PreRunE: preRun, + RunE: newRun(ctx), + } + + f := cmd.Flags() + f.StringVarP(&newContent, "content", "c", "", "The new content for the note") + + return cmd +} + +func preRun(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + return errors.New("Incorrect number of argument") + } + + return nil +} + +func newRun(ctx infra.DnoteCtx) core.RunEFunc { + return func(cmd *cobra.Command, args []string) error { + db := ctx.DB + bookLabel := args[0] + noteRowID := args[1] + + bookUUID, err := core.GetBookUUID(ctx, bookLabel) + if err != nil { + return errors.Wrap(err, "finding book uuid") + } + + var noteUUID, oldContent string + err = db.QueryRow("SELECT uuid, body FROM notes WHERE rowid = ? AND book_uuid = ?", noteRowID, bookUUID).Scan(¬eUUID, &oldContent) + if err == sql.ErrNoRows { + return errors.Errorf("note %s not found in the book '%s'", noteRowID, bookLabel) + } else if err != nil { + return errors.Wrap(err, "querying the book") + } + + if newContent == "" { + fpath := core.GetDnoteTmpContentPath(ctx) + + e := ioutil.WriteFile(fpath, []byte(oldContent), 0644) + if e != nil { + return errors.Wrap(e, "preparing tmp content file") + } + + e = core.GetEditorInput(ctx, fpath, &newContent) + if e != nil { + return errors.Wrap(err, "getting editor input") + } + } + + if oldContent == newContent { + return errors.New("Nothing changed") + } + + ts := time.Now().UnixNano() + newContent = core.SanitizeContent(newContent) + + tx, err := db.Begin() + if err != nil { + return errors.Wrap(err, "beginning a transaction") + } + + _, err = tx.Exec(`UPDATE notes + SET body = ?, edited_on = ?, dirty = ? + WHERE rowid = ? AND book_uuid = ?`, newContent, ts, true, noteRowID, bookUUID) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "updating the note") + } + + tx.Commit() + + log.Success("edited the note\n") + fmt.Printf("\n------------------------content------------------------\n") + fmt.Printf("%s", newContent) + fmt.Printf("\n-------------------------------------------------------\n") + + return nil + } +} diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go new file mode 100644 index 00000000..6968e1c5 --- /dev/null +++ b/cmd/sync/sync.go @@ -0,0 +1,946 @@ +package sync + +import ( + "database/sql" + "fmt" + + "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 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 +} + +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 +} + +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 = ?, body = ?, edited_on = ?, deleted = ?, public = ?, dirty = ? WHERE uuid = ?", + serverNote.USN, serverNote.BookUUID, serverNote.Body, 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 = ?, body = ?, edited_on = ?, deleted = ?, public = ? WHERE uuid = ?", + serverNote.USN, serverNote.BookUUID, serverNote.Body, 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.Body, 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.Body, 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") + log.Info("resolving delta.") + + list, err := getSyncList(ctx, apiKey, 0) + if err != nil { + return errors.Wrap(err, "getting sync list") + } + + fmt.Printf(" (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") + + log.Info("resolving delta.") + + list, err := getSyncList(ctx, apiKey, afterUSN) + if err != nil { + return errors.Wrap(err, "getting sync list") + } + + fmt.Printf(" (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, body, 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.Body, ¬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.Body) + 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.Body, 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) { + log.Info("sending changes.") + + var delta int + err := tx.QueryRow("SELECT (SELECT count(*) FROM notes WHERE dirty) + (SELECT count(*) FROM books WHERE dirty)").Scan(&delta) + + fmt.Printf(" (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 { + config, err := core.ReadConfig(ctx) + if err != nil { + return errors.Wrap(err, "reading the config") + } + if config.APIKey == "" { + log.Error("login required. please run `dnote login`\n") + return nil + } + + 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") + } + + 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") + } + + 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, "syncing changes from the server") + } + + 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() + + log.Success("success\n") + + if err := core.CheckUpdate(ctx); err != nil { + log.Error(errors.Wrap(err, "automatically checking updates").Error()) + } + + return nil + } +} diff --git a/cmd/sync/sync_test.go b/cmd/sync/sync_test.go new file mode 100644 index 00000000..9ca9b44a --- /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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 body", 1541108743, false, true) + testutils.MustExec(t, "inserting n2 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 11, "n2 body", 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", "n2-uuid"), + &n2.UUID, &n2.BookUUID, &n2.USN, &n2.AddedOn, &n2.EditedOn, &n2.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n1.UUID), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.USN, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.Body, &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.Body, n1.Body, "n1 Body 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.Body, n2.Body, "n2 Body 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 body", 1541108743, false, false) + testutils.MustExec(t, "inserting n2 for test case %d", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 11, "n2 body", 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", "n2-uuid"), + &n2.UUID, &n2.BookUUID, &n2.USN, &n2.AddedOn, &n2.EditedOn, &n2.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.USN, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.Body, &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.Body, n2.Body, "n2 Body 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 body", 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n1.UUID), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.Body, &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.Body, n1.Body, "n1 Body 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b2UUID, 11, "n2 body", 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", "n2-uuid"), + &n2.UUID, &n2.BookUUID, &n2.USN, &n2.AddedOn, &n2.EditedOn, &n2.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID), + &n2Record.UUID, &n2Record.BookUUID, &n2Record.USN, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.Body, &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.Body, n2.Body, "n2 Body 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 body", 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, body,deleted, dirty FROM notes WHERE uuid = ?", "n1-uuid"), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.Body, &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.Body, "n1 body", "n1 Body 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, + Body: "n1-body", + 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Body, &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.Body, n.Body, "n1 Body 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 + clientBody string + clientPublic bool + clientDeleted bool + clientBookUUID string + clientDirty bool + serverUSN int + serverEditedOn int64 + serverBody string + serverPublic bool + serverDeleted bool + serverBookUUID string + expectedUSN int + expectedAddedOn int64 + expectedEditedOn int64 + expectedBody string + expectedPublic bool + expectedDeleted bool + expectedBookUUID string + expectedDirty bool + }{ + // server has higher usn and client is dirty + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 0, + clientBody: "n1 body", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: true, + }, + // server has higher usn and client deleted locally + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 0, + clientBody: "", + clientPublic: false, + clientDeleted: true, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body server", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body server", + expectedPublic: false, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + // server has higher usn and client is not dirty + { + clientDirty: false, + clientUSN: 1, + clientEditedOn: 0, + clientBody: "n1 body", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + // they're in sync + { + clientDirty: true, + clientUSN: 21, + clientEditedOn: 1541219321, + clientBody: "n1 body", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b2UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body", + 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, + clientBody: "n1 body client", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b2UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body server", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219320, + expectedBody: "n1 body 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, body, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, 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, + Body: tc.serverBody, + 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Body, &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.Body, tc.expectedBody, fmt.Sprintf("n1 Body 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, + Body: "n1-body", + 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Body, &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.Body, n.Body, "n1 Body 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 + clientBody string + clientPublic bool + clientDeleted bool + clientBookUUID string + clientDirty bool + serverUSN int + serverEditedOn int64 + serverBody string + serverPublic bool + serverDeleted bool + serverBookUUID string + expectedUSN int + expectedAddedOn int64 + expectedEditedOn int64 + expectedBody string + expectedPublic bool + expectedDeleted bool + expectedBookUUID string + expectedDirty bool + }{ + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 0, + clientBody: "n1 body", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: true, + }, + // if deleted locally, resurrect it + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 1541219321, + clientBody: "", + clientPublic: false, + clientDeleted: true, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body edited", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body edited", + expectedPublic: false, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + { + clientDirty: false, + clientUSN: 1, + clientEditedOn: 0, + clientBody: "n1 body", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body 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, body, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, 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, + Body: tc.serverBody, + 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n.UUID), + &n1.UUID, &n1.BookUUID, &n1.USN, &n1.AddedOn, &n1.EditedOn, &n1.Body, &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.Body, tc.expectedBody, fmt.Sprintf("n1 Body 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 10, "n1 body", 1541108743, false, false) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", "b5-uuid", 10, "n2 body", 1541108743, false, false) + testutils.MustExec(t, "inserting n3", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n3-uuid", "b6-uuid", 10, "n3 body", 1541108743, false, false) + testutils.MustExec(t, "inserting n4", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n4-uuid", "b7-uuid", 10, "n4 body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n5-uuid", "b3-uuid", 10, "n5 body", 1541108743, false, false) + testutils.MustExec(t, "inserting n6", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n6-uuid", "b3-uuid", 10, "n6 body", 1541108743, false, false) + testutils.MustExec(t, "inserting n7", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n7-uuid", "b4-uuid", 10, "n7 body", 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("Body-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("Body-Type", "application/json") + w.Write([]byte("{}")) + return + } else if r.Method == "DELETE" { + uuid := p[3] + deletedUUIDs = append(deletedUUIDs, uuid) + + w.Header().Set("Body-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 body = ?", "n1 body"), &n1.BookUUID) + testutils.MustScan(t, "getting n2", db.QueryRow("SELECT book_uuid FROM notes WHERE body = ?", "n2 body"), &n2.BookUUID) + testutils.MustScan(t, "getting n3", db.QueryRow("SELECT book_uuid FROM notes WHERE body = ?", "n3 body"), &n3.BookUUID) + testutils.MustScan(t, "getting n4", db.QueryRow("SELECT book_uuid FROM notes WHERE body = ?", "n4 body"), &n4.BookUUID) + testutils.MustScan(t, "getting n5", db.QueryRow("SELECT book_uuid FROM notes WHERE body = ?", "n5 body"), &n5.BookUUID) + testutils.MustScan(t, "getting n6", db.QueryRow("SELECT book_uuid FROM notes WHERE body = ?", "n6 body"), &n6.BookUUID) + testutils.MustScan(t, "getting n7", db.QueryRow("SELECT book_uuid FROM notes WHERE body = ?", "n7 body"), &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("Body-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("Body-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("Body-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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1-body", 1541108743, false, false) + // should be created + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 0, "n2-body", 1541108743, false, true) + // should be updated + testutils.MustExec(t, "inserting n3", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n3-uuid", b1UUID, 11, "n3-body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n4-uuid", b1UUID, 0, "n4-body", 1541108743, true, true) + // should be deleted + testutils.MustExec(t, "inserting n5", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n5-uuid", b1UUID, 17, "n5-body", 1541108743, true, true) + // should be created + testutils.MustExec(t, "inserting n6", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n6-uuid", b1UUID, 0, "n6-body", 1541108743, false, true) + // should be ignored + testutils.MustExec(t, "inserting n7", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n7-uuid", b1UUID, 12, "n7-body", 1541108743, false, false) + // should be updated + testutils.MustExec(t, "inserting n8", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n8-uuid", b1UUID, 17, "n8-body", 1541108743, false, true) + // should be deleted + testutils.MustExec(t, "inserting n9", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n9-uuid", b1UUID, 17, "n9-body", 1541108743, true, true) + // should be created + testutils.MustExec(t, "inserting n10", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n10-uuid", b1UUID, 0, "n10-body", 1541108743, false, true) + + var createdBodys []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 + } + + createdBodys = append(createdBodys, payload.Body) + + resp := client.CreateNoteResp{ + Result: client.RespNote{ + UUID: fmt.Sprintf("server-%s-uuid", payload.Body), + }, + } + + w.Header().Set("Body-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("Body-Type", "application/json") + w.Write([]byte("{}")) + return + } else if r.Method == "DELETE" { + uuid := p[3] + deletedUUIDs = append(deletedUUIDs, uuid) + + w.Header().Set("Body-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(createdBodys, func(i, j int) bool { + return strings.Compare(createdBodys[i], createdBodys[j]) < 0 + }) + + testutils.AssertDeepEqual(t, createdBodys, []string{"n10-body", "n2-body", "n6-body"}, "createdBodys 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 body = ?", "n1-body"), &n1.UUID, &n1.Dirty) + testutils.MustScan(t, "getting n2", db.QueryRow("SELECT uuid, dirty FROM notes WHERE body = ?", "n2-body"), &n2.UUID, &n2.Dirty) + testutils.MustScan(t, "getting n3", db.QueryRow("SELECT uuid, dirty FROM notes WHERE body = ?", "n3-body"), &n3.UUID, &n3.Dirty) + testutils.MustScan(t, "getting n6", db.QueryRow("SELECT uuid, dirty FROM notes WHERE body = ?", "n6-body"), &n6.UUID, &n6.Dirty) + testutils.MustScan(t, "getting n7", db.QueryRow("SELECT uuid, dirty FROM notes WHERE body = ?", "n7-body"), &n7.UUID, &n7.Dirty) + testutils.MustScan(t, "getting n8", db.QueryRow("SELECT uuid, dirty FROM notes WHERE body = ?", "n8-body"), &n8.UUID, &n8.Dirty) + testutils.MustScan(t, "getting n10", db.QueryRow("SELECT uuid, dirty FROM notes WHERE body = ?", "n10-body"), &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-body-uuid", "n2 UUID mismatch") + testutils.AssertEqual(t, n3.UUID, "n3-uuid", "n3 UUID mismatch") + testutils.AssertEqual(t, n6.UUID, "server-n6-body-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-body-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("Body-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("Body-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("Body-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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 1, "n1 body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 2, "n1 body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 8, "n1 body", 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 + clientBody string + clientPublic bool + clientDeleted bool + clientBookUUID string + clientDirty bool + serverUSN int + serverEditedOn int64 + serverBody string + serverPublic bool + serverDeleted bool + serverBookUUID string + expectedUSN int + expectedAddedOn int64 + expectedEditedOn int64 + expectedBody string + expectedPublic bool + expectedDeleted bool + expectedBookUUID string + expectedDirty bool + }{ + { + clientDirty: false, + clientUSN: 1, + clientEditedOn: 0, + clientBody: "n1 body", + clientPublic: false, + clientDeleted: false, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body edited", + serverPublic: true, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body edited", + expectedPublic: true, + expectedDeleted: false, + expectedBookUUID: b2UUID, + expectedDirty: false, + }, + // deleted locally and edited on server + { + clientDirty: true, + clientUSN: 1, + clientEditedOn: 1541219321, + clientBody: "", + clientPublic: false, + clientDeleted: true, + clientBookUUID: b1UUID, + addedOn: 1541232118, + serverUSN: 21, + serverEditedOn: 1541219321, + serverBody: "n1 body edited", + serverPublic: false, + serverDeleted: false, + serverBookUUID: b2UUID, + expectedUSN: 21, + expectedAddedOn: 1541232118, + expectedEditedOn: 1541219321, + expectedBody: "n1 body 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, body, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, b1UUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, 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, + Body: tc.serverBody, + 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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n1UUID), + &localNote.UUID, &localNote.BookUUID, &localNote.USN, &localNote.AddedOn, &localNote.EditedOn, &localNote.Body, &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, body, public, deleted, dirty FROM notes WHERE uuid = ?", n1UUID), + &n1Record.UUID, &n1Record.BookUUID, &n1Record.USN, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.Body, &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.Body, tc.expectedBody, fmt.Sprintf("n1Record Body 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, body, dirty) VALUES (?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", 1541108743, "n1 body", false) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, added_on, body, dirty) VALUES (?, ?, ?, ?, ?)", "n2-uuid", "b1-uuid", 1541108743, "n2 body", false) + testutils.MustExec(t, "inserting n3", db, "INSERT INTO notes (uuid, book_uuid, added_on, body, dirty) VALUES (?, ?, ?, ?, ?)", "n3-uuid", "b1-uuid", 1541108743, "n3 body", true) + testutils.MustExec(t, "inserting n4", db, "INSERT INTO notes (uuid, book_uuid, added_on, body, dirty) VALUES (?, ?, ?, ?, ?)", "n4-uuid", "b2-uuid", 1541108743, "n4 body", false) + testutils.MustExec(t, "inserting n5", db, "INSERT INTO notes (uuid, book_uuid, added_on, body, dirty) VALUES (?, ?, ?, ?, ?)", "n5-uuid", "b2-uuid", 1541108743, "n5 body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", b1UUID, 10, "n1 body", 1541108743, false, false) + testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", b1UUID, 0, "n2 body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n6-uuid", b1UUID, 0, "n6 body", 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, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n5-uuid", b1UUID, 7, "n5 body", 1541108743, true, true) + testutils.MustExec(t, "inserting n9", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n9-uuid", b1UUID, 17, "n9 body", 1541108743, true, false) + testutils.MustExec(t, "inserting n10", db, "INSERT INTO notes (uuid, book_uuid, usn, body, added_on, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", "n10-uuid", b1UUID, 0, "n10 body", 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/log/log.go b/log/log.go index ba99c087..5cf648dc 100644 --- a/log/log.go +++ b/log/log.go @@ -2,9 +2,8 @@ package log import ( "fmt" - "os" - "github.com/dnote/color" + "os" ) var (