From 24491bc68a561a70b61d934958d627db32af711b Mon Sep 17 00:00:00 2001 From: Sung <8265228+sungwoncho@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:03:20 -0700 Subject: [PATCH] Allow to upload all data to an empty server (#690) * Handle server switch * Avoid losing data in case of race * Simplify --- pkg/cli/client/client.go | 20 +- pkg/cli/client/client_test.go | 18 + pkg/cli/cmd/root/root.go | 16 + pkg/cli/cmd/sync/sync.go | 100 +++++- pkg/cli/cmd/sync/sync_test.go | 67 ++++ pkg/cli/infra/init.go | 25 +- pkg/cli/infra/init_test.go | 49 +++ pkg/cli/main.go | 11 +- pkg/cli/main_test.go | 6 +- pkg/cli/migrate/migrations.go | 5 +- pkg/cli/testutils/main.go | 176 ++++++++-- pkg/e2e/sync_test.go | 621 ++++++++++++++++++++++++++++++++-- 12 files changed, 1050 insertions(+), 64 deletions(-) diff --git a/pkg/cli/client/client.go b/pkg/cli/client/client.go index b24c432e..021afe7d 100644 --- a/pkg/cli/client/client.go +++ b/pkg/cli/client/client.go @@ -42,6 +42,21 @@ var ErrInvalidLogin = errors.New("wrong credentials") // ErrContentTypeMismatch is an error for invalid credentials for login var ErrContentTypeMismatch = errors.New("content type mismatch") +// HTTPError represents an HTTP error response from the server +type HTTPError struct { + StatusCode int + Message string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf(`response %d "%s"`, e.StatusCode, e.Message) +} + +// IsConflict returns true if the error is a 409 Conflict error +func (e *HTTPError) IsConflict() bool { + return e.StatusCode == 409 +} + var contentTypeApplicationJSON = "application/json" var contentTypeNone = "" @@ -137,7 +152,10 @@ func checkRespErr(res *http.Response) error { } bodyStr := string(body) - return errors.Errorf(`response %d "%s"`, res.StatusCode, strings.TrimRight(bodyStr, "\n")) + return &HTTPError{ + StatusCode: res.StatusCode, + Message: strings.TrimRight(bodyStr, "\n"), + } } func checkContentType(res *http.Response, options *requestOptions) error { diff --git a/pkg/cli/client/client_test.go b/pkg/cli/client/client_test.go index c5467ad6..31b6d6c8 100644 --- a/pkg/cli/client/client_test.go +++ b/pkg/cli/client/client_test.go @@ -205,3 +205,21 @@ func TestRateLimitedTransport(t *testing.T) { assert.Equal(t, int(requestCount.Load()), 10, "request count mismatch") } + +func TestHTTPError(t *testing.T) { + t.Run("IsConflict returns true for 409", func(t *testing.T) { + conflictErr := &HTTPError{ + StatusCode: 409, + Message: "Conflict", + } + + assert.Equal(t, conflictErr.IsConflict(), true, "IsConflict() should return true for 409") + + notFoundErr := &HTTPError{ + StatusCode: 404, + Message: "Not Found", + } + + assert.Equal(t, notFoundErr.IsConflict(), false, "IsConflict() should return false for 404") + }) +} diff --git a/pkg/cli/cmd/root/root.go b/pkg/cli/cmd/root/root.go index 81e0c58a..750629e4 100644 --- a/pkg/cli/cmd/root/root.go +++ b/pkg/cli/cmd/root/root.go @@ -22,6 +22,8 @@ import ( "github.com/spf13/cobra" ) +var apiEndpointFlag string + var root = &cobra.Command{ Use: "dnote", Short: "Dnote - a simple command line notebook", @@ -32,6 +34,20 @@ var root = &cobra.Command{ }, } +func init() { + root.PersistentFlags().StringVar(&apiEndpointFlag, "api-endpoint", "", "the API endpoint to connect to (defaults to value in config)") +} + +// GetRoot returns the root command +func GetRoot() *cobra.Command { + return root +} + +// GetAPIEndpointFlag returns the value of the --api-endpoint flag +func GetAPIEndpointFlag() string { + return apiEndpointFlag +} + // Register adds a new command func Register(cmd *cobra.Command) { root.AddCommand(cmd) diff --git a/pkg/cli/cmd/sync/sync.go b/pkg/cli/cmd/sync/sync.go index 1d511d98..ff8e8a5f 100644 --- a/pkg/cli/cmd/sync/sync.go +++ b/pkg/cli/cmd/sync/sync.go @@ -29,6 +29,7 @@ import ( "github.com/dnote/dnote/pkg/cli/infra" "github.com/dnote/dnote/pkg/cli/log" "github.com/dnote/dnote/pkg/cli/migrate" + "github.com/dnote/dnote/pkg/cli/ui" "github.com/dnote/dnote/pkg/cli/upgrade" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -629,6 +630,20 @@ func stepSync(ctx context.DnoteCtx, tx *database.DB, afterUSN int) error { return nil } +// isConflictError checks if an error is a 409 Conflict error from the server +func isConflictError(err error) bool { + if err == nil { + return false + } + + var httpErr *client.HTTPError + if errors.As(err, &httpErr) { + return httpErr.IsConflict() + } + + return false +} + func sendBooks(ctx context.DnoteCtx, tx *database.DB) (bool, error) { isBehind := false @@ -661,6 +676,12 @@ func sendBooks(ctx context.DnoteCtx, tx *database.DB) (bool, error) { } else { resp, err := client.CreateBook(ctx, book.Label) if err != nil { + // If we get a 409 conflict, it means another client uploaded data. + if isConflictError(err) { + log.Debug("409 conflict creating book %s, will retry after sync\n", book.Label) + isBehind = true + continue + } return isBehind, errors.Wrap(err, "creating a book") } @@ -766,7 +787,10 @@ func sendNotes(ctx context.DnoteCtx, tx *database.DB) (bool, error) { } else { resp, err := client.CreateNote(ctx, note.BookUUID, note.Body) if err != nil { - return isBehind, errors.Wrap(err, "creating a note") + // If we get a 409 conflict, it means another client uploaded data. + log.Debug("error creating note (will retry after sync): %v\n", err) + isBehind = true + continue } note.Dirty = false @@ -885,6 +909,26 @@ func saveSyncState(tx *database.DB, serverTime int64, serverMaxUSN int) error { return nil } +// prepareEmptyServerSync marks all local books and notes as dirty when syncing to an empty server. +// This is typically used when switching to a new empty server but wanting to upload existing local data. +// Returns true if preparation was done, false otherwise. +func prepareEmptyServerSync(tx *database.DB) error { + // Mark all books and notes as dirty and reset USN to 0 + if _, err := tx.Exec("UPDATE books SET usn = 0, dirty = 1 WHERE deleted = 0"); err != nil { + return errors.Wrap(err, "marking books as dirty") + } + if _, err := tx.Exec("UPDATE notes SET usn = 0, dirty = 1 WHERE deleted = 0"); err != nil { + return errors.Wrap(err, "marking notes as dirty") + } + + // Reset lastMaxUSN to 0 to match the server + if err := updateLastMaxUSN(tx, 0); err != nil { + return errors.Wrap(err, "resetting last max usn") + } + + return nil +} + func newRun(ctx context.DnoteCtx) infra.RunEFunc { return func(cmd *cobra.Command, args []string) error { if ctx.SessionKey == "" { @@ -915,6 +959,52 @@ func newRun(ctx context.DnoteCtx) infra.RunEFunc { log.Debug("lastSyncAt: %d, lastMaxUSN: %d, syncState: %+v\n", lastSyncAt, lastMaxUSN, syncState) + // Handle a case where server has MaxUSN=0 but local has data (server switch) + var bookCount, noteCount int + if err := tx.QueryRow("SELECT count(*) FROM books WHERE deleted = 0").Scan(&bookCount); err != nil { + return errors.Wrap(err, "counting local books") + } + if err := tx.QueryRow("SELECT count(*) FROM notes WHERE deleted = 0").Scan(¬eCount); err != nil { + return errors.Wrap(err, "counting local notes") + } + + // If a client has previously synced (lastMaxUSN > 0) but the server was never synced to (MaxUSN = 0), + // and the client has undeleted books or notes, allow to upload all data to the server. + // The client might have switched servers or the server might need to be restored for any reasons. + if syncState.MaxUSN == 0 && lastMaxUSN > 0 && (bookCount > 0 || noteCount > 0) { + log.Debug("empty server detected: server.MaxUSN=%d, local.MaxUSN=%d, books=%d, notes=%d\n", + syncState.MaxUSN, lastMaxUSN, bookCount, noteCount) + + log.Warnf("The server is empty but you have local data. Maybe you switched servers?\n") + log.Debug("server state: MaxUSN = 0 (empty)\n") + log.Debug("local state: %d books, %d notes (MaxUSN = %d)\n", bookCount, noteCount, lastMaxUSN) + + confirmed, err := ui.Confirm(fmt.Sprintf("Upload %d books and %d notes to the server?", bookCount, noteCount), false) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "getting user confirmation") + } + + if !confirmed { + tx.Rollback() + return errors.New("sync cancelled by user") + } + + fmt.Println() // Add newline after confirmation. + + if err := prepareEmptyServerSync(tx); err != nil { + return errors.Wrap(err, "preparing for empty server sync") + } + + // Re-fetch lastMaxUSN after prepareEmptyServerSync + lastMaxUSN, err = getLastMaxUSN(tx) + if err != nil { + return errors.Wrap(err, "getting the last max_usn after prepare") + } + + log.Debug("prepared empty server sync: marked %d books and %d notes as dirty\n", bookCount, noteCount) + } + var syncErr error if isFullSync || lastSyncAt < syncState.FullSyncBefore { syncErr = fullSync(ctx, tx) @@ -953,6 +1043,14 @@ func newRun(ctx context.DnoteCtx) infra.RunEFunc { tx.Rollback() return errors.Wrap(err, "performing the follow-up step sync") } + + // After syncing server changes (which resolves conflicts), send local changes again + // This uploads books/notes that were skipped due to 409 conflicts + _, err = sendChanges(ctx, tx) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "sending changes after conflict resolution") + } } tx.Commit() diff --git a/pkg/cli/cmd/sync/sync_test.go b/pkg/cli/cmd/sync/sync_test.go index 0cfb87e4..34f0c9df 100644 --- a/pkg/cli/cmd/sync/sync_test.go +++ b/pkg/cli/cmd/sync/sync_test.go @@ -3170,3 +3170,70 @@ func TestCleanLocalBooks(t *testing.T) { database.MustScan(t, "getting b3", db.QueryRow("SELECT label FROM books WHERE uuid = ?", "b3-uuid"), &b3.Label) database.MustScan(t, "getting b5", db.QueryRow("SELECT label FROM books WHERE uuid = ?", "b5-uuid"), &b5.Label) } + +func TestPrepareEmptyServerSync(t *testing.T) { + // set up + db := database.InitTestDB(t, "../../tmp/.dnote", nil) + defer database.TeardownTestDB(t, db) + + // Setup: local has synced data (usn > 0, dirty = false) and some deleted items + database.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b1-uuid", "b1-label", 5, false, false) + database.MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b2-uuid", "b2-label", 8, false, false) + database.MustExec(t, "inserting b3 deleted", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", "b3-uuid", "b3-label", 6, true, false) + database.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, body, usn, deleted, dirty, added_on) VALUES (?, ?, ?, ?, ?, ?, ?)", "n1-uuid", "b1-uuid", "note 1", 6, false, false, 1541108743) + database.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, body, usn, deleted, dirty, added_on) VALUES (?, ?, ?, ?, ?, ?, ?)", "n2-uuid", "b2-uuid", "note 2", 9, false, false, 1541108743) + database.MustExec(t, "inserting n3 deleted", db, "INSERT INTO notes (uuid, book_uuid, body, usn, deleted, dirty, added_on) VALUES (?, ?, ?, ?, ?, ?, ?)", "n3-uuid", "b1-uuid", "note 3", 7, true, false, 1541108743) + database.MustExec(t, "setting last_max_usn", db, "INSERT INTO system (key, value) VALUES (?, ?)", consts.SystemLastMaxUSN, 9) + + // execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning transaction")) + } + + if err := prepareEmptyServerSync(tx); err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "executing prepareEmptyServerSync")) + } + + tx.Commit() + + // test - verify non-deleted items are marked dirty with usn=0, deleted items unchanged + var b1, b2, b3 database.Book + database.MustScan(t, "getting b1", db.QueryRow("SELECT usn, dirty, deleted FROM books WHERE uuid = ?", "b1-uuid"), &b1.USN, &b1.Dirty, &b1.Deleted) + database.MustScan(t, "getting b2", db.QueryRow("SELECT usn, dirty, deleted FROM books WHERE uuid = ?", "b2-uuid"), &b2.USN, &b2.Dirty, &b2.Deleted) + database.MustScan(t, "getting b3", db.QueryRow("SELECT usn, dirty, deleted FROM books WHERE uuid = ?", "b3-uuid"), &b3.USN, &b3.Dirty, &b3.Deleted) + + assert.Equal(t, b1.USN, 0, "b1 USN should be reset to 0") + assert.Equal(t, b1.Dirty, true, "b1 should be marked dirty") + assert.Equal(t, b1.Deleted, false, "b1 should not be deleted") + + assert.Equal(t, b2.USN, 0, "b2 USN should be reset to 0") + assert.Equal(t, b2.Dirty, true, "b2 should be marked dirty") + assert.Equal(t, b2.Deleted, false, "b2 should not be deleted") + + assert.Equal(t, b3.USN, 6, "b3 USN should remain unchanged (deleted item)") + assert.Equal(t, b3.Dirty, false, "b3 should not be marked dirty (deleted item)") + assert.Equal(t, b3.Deleted, true, "b3 should remain deleted") + + var n1, n2, n3 database.Note + database.MustScan(t, "getting n1", db.QueryRow("SELECT usn, dirty, deleted FROM notes WHERE uuid = ?", "n1-uuid"), &n1.USN, &n1.Dirty, &n1.Deleted) + database.MustScan(t, "getting n2", db.QueryRow("SELECT usn, dirty, deleted FROM notes WHERE uuid = ?", "n2-uuid"), &n2.USN, &n2.Dirty, &n2.Deleted) + database.MustScan(t, "getting n3", db.QueryRow("SELECT usn, dirty, deleted FROM notes WHERE uuid = ?", "n3-uuid"), &n3.USN, &n3.Dirty, &n3.Deleted) + + assert.Equal(t, n1.USN, 0, "n1 USN should be reset to 0") + assert.Equal(t, n1.Dirty, true, "n1 should be marked dirty") + assert.Equal(t, n1.Deleted, false, "n1 should not be deleted") + + assert.Equal(t, n2.USN, 0, "n2 USN should be reset to 0") + assert.Equal(t, n2.Dirty, true, "n2 should be marked dirty") + assert.Equal(t, n2.Deleted, false, "n2 should not be deleted") + + assert.Equal(t, n3.USN, 7, "n3 USN should remain unchanged (deleted item)") + assert.Equal(t, n3.Dirty, false, "n3 should not be marked dirty (deleted item)") + assert.Equal(t, n3.Deleted, true, "n3 should remain deleted") + + var lastMaxUSN int + database.MustScan(t, "getting last_max_usn", db.QueryRow("SELECT value FROM system WHERE key = ?", consts.SystemLastMaxUSN), &lastMaxUSN) + assert.Equal(t, lastMaxUSN, 0, "last_max_usn should be reset to 0") +} diff --git a/pkg/cli/infra/init.go b/pkg/cli/infra/init.go index 2b6c9618..532f1f1e 100644 --- a/pkg/cli/infra/init.go +++ b/pkg/cli/infra/init.go @@ -68,7 +68,10 @@ func getDBPath(paths context.Paths) string { return fmt.Sprintf("%s/%s/%s", paths.Data, consts.DnoteDirName, consts.DnoteDBFileName) } -func newCtx(versionTag string) (context.DnoteCtx, error) { +// newBaseCtx creates a minimal context with paths and database connection. +// This base context is used for file and database initialization before +// being enriched with config values by setupCtx. +func newBaseCtx(versionTag string) (context.DnoteCtx, error) { dnoteDir := getLegacyDnotePath(dirs.Home) paths := context.Paths{ Home: dirs.Home, @@ -95,8 +98,8 @@ func newCtx(versionTag string) (context.DnoteCtx, error) { } // Init initializes the Dnote environment and returns a new dnote context -func Init(apiEndpoint, versionTag string) (*context.DnoteCtx, error) { - ctx, err := newCtx(versionTag) +func Init(versionTag, apiEndpoint string) (*context.DnoteCtx, error) { + ctx, err := newBaseCtx(versionTag) if err != nil { return nil, errors.Wrap(err, "initializing a context") } @@ -119,7 +122,7 @@ func Init(apiEndpoint, versionTag string) (*context.DnoteCtx, error) { return nil, errors.Wrap(err, "running migration") } - ctx, err = SetupCtx(ctx) + ctx, err = setupCtx(ctx, apiEndpoint) if err != nil { return nil, errors.Wrap(err, "setting up the context") } @@ -129,8 +132,10 @@ func Init(apiEndpoint, versionTag string) (*context.DnoteCtx, error) { return &ctx, nil } -// SetupCtx populates the context and returns a new context -func SetupCtx(ctx context.DnoteCtx) (context.DnoteCtx, error) { +// setupCtx enriches the base context with values from config file and database. +// This is called after files and database have been initialized. +// If apiEndpoint is provided, it overrides the value from config. +func setupCtx(ctx context.DnoteCtx, apiEndpoint string) (context.DnoteCtx, error) { db := ctx.DB var sessionKey string @@ -150,13 +155,19 @@ func SetupCtx(ctx context.DnoteCtx) (context.DnoteCtx, error) { return ctx, errors.Wrap(err, "reading config") } + // Use override if provided, otherwise use config value + endpoint := cf.APIEndpoint + if apiEndpoint != "" { + endpoint = apiEndpoint + } + ret := context.DnoteCtx{ Paths: ctx.Paths, Version: ctx.Version, DB: ctx.DB, SessionKey: sessionKey, SessionKeyExpiry: sessionKeyExpiry, - APIEndpoint: cf.APIEndpoint, + APIEndpoint: endpoint, Editor: cf.Editor, Clock: clock.New(), EnableUpgradeCheck: cf.EnableUpgradeCheck, diff --git a/pkg/cli/infra/init_test.go b/pkg/cli/infra/init_test.go index 08f25139..cb50a95f 100644 --- a/pkg/cli/infra/init_test.go +++ b/pkg/cli/infra/init_test.go @@ -19,9 +19,12 @@ package infra import ( + "fmt" + "os" "testing" "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/cli/config" "github.com/dnote/dnote/pkg/cli/database" "github.com/pkg/errors" ) @@ -91,3 +94,49 @@ func TestInitSystemKV_existing(t *testing.T) { db.QueryRow("SELECT value FROM system WHERE key = ?", "testKey"), &val) assert.Equal(t, val, "testVal", "system value should not have been updated") } + +func TestInit_APIEndpointChange(t *testing.T) { + // Create a temporary directory for test + tmpDir, err := os.MkdirTemp("", "dnote-init-test-*") + if err != nil { + t.Fatal(errors.Wrap(err, "creating temp dir")) + } + defer os.RemoveAll(tmpDir) + + // Set up environment to use our temp directory + t.Setenv("XDG_CONFIG_HOME", fmt.Sprintf("%s/config", tmpDir)) + t.Setenv("XDG_DATA_HOME", fmt.Sprintf("%s/data", tmpDir)) + t.Setenv("XDG_CACHE_HOME", fmt.Sprintf("%s/cache", tmpDir)) + + // First init. + endpoint1 := "http://127.0.0.1:3001" + ctx, err := Init("test-version", endpoint1) + if err != nil { + t.Fatal(errors.Wrap(err, "initializing")) + } + defer ctx.DB.Close() + assert.Equal(t, ctx.APIEndpoint, endpoint1, "should use endpoint1 API endpoint") + + // Test that config was written with endpoint1. + cf, err := config.Read(*ctx) + if err != nil { + t.Fatal(errors.Wrap(err, "reading config")) + } + + // Second init with different endpoint. + endpoint2 := "http://127.0.0.1:3002" + ctx2, err := Init("test-version", endpoint2) + if err != nil { + t.Fatal(errors.Wrap(err, "initializing with override")) + } + defer ctx2.DB.Close() + // Context must be using that endpoint. + assert.Equal(t, ctx2.APIEndpoint, endpoint2, "should use endpoint2 API endpoint") + + // The config file shouldn't have been modified. + cf2, err := config.Read(*ctx2) + if err != nil { + t.Fatal(errors.Wrap(err, "reading config after override")) + } + assert.Equal(t, cf2.APIEndpoint, cf.APIEndpoint, "config should still have original endpoint, not endpoint2") +} diff --git a/pkg/cli/main.go b/pkg/cli/main.go index 518e0ee0..31c81dd5 100644 --- a/pkg/cli/main.go +++ b/pkg/cli/main.go @@ -46,7 +46,16 @@ var apiEndpoint string var versionTag = "master" func main() { - ctx, err := infra.Init(apiEndpoint, versionTag) + // Parse flags early to check if --api-endpoint was provided + root.GetRoot().ParseFlags(os.Args[1:]) + + // Use flag value if provided, otherwise use ldflags value + endpoint := apiEndpoint + if flagValue := root.GetAPIEndpointFlag(); flagValue != "" { + endpoint = flagValue + } + + ctx, err := infra.Init(versionTag, endpoint) if err != nil { panic(errors.Wrap(err, "initializing context")) } diff --git a/pkg/cli/main_test.go b/pkg/cli/main_test.go index 05badb02..151d4cbf 100644 --- a/pkg/cli/main_test.go +++ b/pkg/cli/main_test.go @@ -109,7 +109,7 @@ func TestAddNote(t *testing.T) { t.Run("new book", func(t *testing.T) { // Set up and execute testutils.RunDnoteCmd(t, opts, binaryName, "add", "js", "-c", "foo") - testutils.WaitDnoteCmd(t, opts, testutils.UserContent, binaryName, "add", "js") + testutils.MustWaitDnoteCmd(t, opts, testutils.UserContent, binaryName, "add", "js") defer testutils.RemoveDir(t, testDir) @@ -349,7 +349,7 @@ func TestRemoveNote(t *testing.T) { if tc.yesFlag { testutils.RunDnoteCmd(t, opts, binaryName, "remove", "-y", "1") } else { - testutils.WaitDnoteCmd(t, opts, testutils.UserConfirm, binaryName, "remove", "1") + testutils.MustWaitDnoteCmd(t, opts, testutils.ConfirmRemoveNote, binaryName, "remove", "1") } defer testutils.RemoveDir(t, testDir) @@ -436,7 +436,7 @@ func TestRemoveBook(t *testing.T) { if tc.yesFlag { testutils.RunDnoteCmd(t, opts, binaryName, "remove", "-y", "js") } else { - testutils.WaitDnoteCmd(t, opts, testutils.UserConfirm, binaryName, "remove", "js") + testutils.MustWaitDnoteCmd(t, opts, testutils.ConfirmRemoveBook, binaryName, "remove", "js") } defer testutils.RemoveDir(t, testDir) diff --git a/pkg/cli/migrate/migrations.go b/pkg/cli/migrate/migrations.go index 0357dd2a..af5ae86e 100644 --- a/pkg/cli/migrate/migrations.go +++ b/pkg/cli/migrate/migrations.go @@ -539,7 +539,10 @@ var lm12 = migration{ return errors.Wrap(err, "reading config") } - cf.APIEndpoint = "https://api.getdnote.com" + // Only set if not already configured + if cf.APIEndpoint == "" { + cf.APIEndpoint = "https://api.getdnote.com" + } err = config.Write(ctx, cf) if err != nil { diff --git a/pkg/cli/testutils/main.go b/pkg/cli/testutils/main.go index afc12f59..9b4e945d 100644 --- a/pkg/cli/testutils/main.go +++ b/pkg/cli/testutils/main.go @@ -20,6 +20,7 @@ package testutils import ( + "bufio" "bytes" "encoding/json" "io" @@ -37,6 +38,16 @@ import ( "github.com/pkg/errors" ) +// Prompts for user input +const ( + PromptRemoveNote = "remove this note?" + PromptDeleteBook = "delete book" + PromptEmptyServer = "The server is empty but you have local data" +) + +// Timeout for waiting for prompts in tests +const promptTimeout = 10 * time.Second + // Login simulates a logged in user by inserting credentials in the local database func Login(t *testing.T, ctx *context.DnoteCtx) { db := ctx.DB @@ -153,58 +164,167 @@ func RunDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, binaryName string, arg . t.Logf("\n%s", stdout) } -// WaitDnoteCmd runs a dnote command and waits until the command is exited -func WaitDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, runFunc func(io.WriteCloser) error, binaryName string, arg ...string) { +// WaitDnoteCmd runs a dnote command and passes stdout to the callback. +func WaitDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, runFunc func(io.Reader, io.WriteCloser) error, binaryName string, arg ...string) (string, error) { t.Logf("running: %s %s", binaryName, strings.Join(arg, " ")) - cmd, stderr, stdout, err := NewDnoteCmd(opts, binaryName, arg...) + binaryPath, err := filepath.Abs(binaryName) if err != nil { - t.Logf("\n%s", stdout) - t.Fatal(errors.Wrap(err, "getting command").Error()) + return "", errors.Wrap(err, "getting absolute path to test binary") + } + + cmd := exec.Command(binaryPath, arg...) + cmd.Env = opts.Env + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", errors.Wrap(err, "getting stdout pipe") } stdin, err := cmd.StdinPipe() if err != nil { - t.Logf("\n%s", stdout) - t.Fatal(errors.Wrap(err, "getting stdin %s")) + return "", errors.Wrap(err, "getting stdin") } defer stdin.Close() - // Start the program - err = cmd.Start() - if err != nil { - t.Logf("\n%s", stdout) - t.Fatal(errors.Wrap(err, "starting command")) + if err = cmd.Start(); err != nil { + return "", errors.Wrap(err, "starting command") } - err = runFunc(stdin) + var output bytes.Buffer + tee := io.TeeReader(stdout, &output) + + err = runFunc(tee, stdin) if err != nil { - t.Logf("\n%s", stdout) - t.Fatal(errors.Wrap(err, "running with stdin")) + t.Logf("\n%s", output.String()) + return output.String(), errors.Wrap(err, "running callback") } - err = cmd.Wait() - if err != nil { - t.Logf("\n%s", stdout) - t.Fatal(errors.Wrapf(err, "running command %s", stderr.String())) + io.Copy(&output, stdout) + + if err := cmd.Wait(); err != nil { + t.Logf("\n%s", output.String()) + return output.String(), errors.Wrapf(err, "command failed: %s", stderr.String()) } - // Print stdout if and only if test fails later - t.Logf("\n%s", stdout) + t.Logf("\n%s", output.String()) + return output.String(), nil } -// UserConfirm simulates confirmation from the user by writing to stdin -func UserConfirm(stdin io.WriteCloser) error { - // confirm - if _, err := io.WriteString(stdin, "y\n"); err != nil { - return errors.Wrap(err, "indicating confirmation in stdin") +func MustWaitDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, runFunc func(io.Reader, io.WriteCloser) error, binaryName string, arg ...string) string { + output, err := WaitDnoteCmd(t, opts, runFunc, binaryName, arg...) + if err != nil { + t.Fatal(err) + } + + return output +} + +// waitForPrompt waits for an expected prompt to appear in stdout with a timeout. +// Returns an error if the prompt is not found within the timeout period. +// Handles prompts with or without newlines by reading character by character. +func waitForPrompt(stdout io.Reader, expectedPrompt string, timeout time.Duration) error { + type result struct { + found bool + err error + } + resultCh := make(chan result, 1) + + go func() { + reader := bufio.NewReader(stdout) + var buffer strings.Builder + found := false + + for { + b, err := reader.ReadByte() + if err != nil { + resultCh <- result{found: found, err: err} + return + } + + buffer.WriteByte(b) + if strings.Contains(buffer.String(), expectedPrompt) { + found = true + break + } + } + + resultCh <- result{found: found, err: nil} + }() + + select { + case res := <-resultCh: + if res.err != nil && res.err != io.EOF { + return errors.Wrap(res.err, "reading stdout") + } + if !res.found { + return errors.Errorf("expected prompt '%s' not found in stdout", expectedPrompt) + } + return nil + case <-time.After(timeout): + return errors.Errorf("timeout waiting for prompt '%s'", expectedPrompt) + } +} + +// MustWaitForPrompt waits for an expected prompt with a default timeout. +// Fails the test if the prompt is not found or an error occurs. +func MustWaitForPrompt(t *testing.T, stdout io.Reader, expectedPrompt string) { + if err := waitForPrompt(stdout, expectedPrompt, promptTimeout); err != nil { + t.Fatal(err) + } +} + +// userRespondToPrompt is a helper that waits for a prompt and sends a response. +func userRespondToPrompt(stdout io.Reader, stdin io.WriteCloser, expectedPrompt, response, action string) error { + if err := waitForPrompt(stdout, expectedPrompt, promptTimeout); err != nil { + return err + } + + if _, err := io.WriteString(stdin, response); err != nil { + return errors.Wrapf(err, "indicating %s in stdin", action) } return nil } -// UserContent simulates content from the user by writing to stdin -func UserContent(stdin io.WriteCloser) error { +// userConfirmOutput simulates confirmation from the user by writing to stdin. +// It waits for the expected prompt with a timeout to prevent deadlocks. +func userConfirmOutput(stdout io.Reader, stdin io.WriteCloser, expectedPrompt string) error { + return userRespondToPrompt(stdout, stdin, expectedPrompt, "y\n", "confirmation") +} + +// userCancelOutput simulates cancellation from the user by writing to stdin. +// It waits for the expected prompt with a timeout to prevent deadlocks. +func userCancelOutput(stdout io.Reader, stdin io.WriteCloser, expectedPrompt string) error { + return userRespondToPrompt(stdout, stdin, expectedPrompt, "n\n", "cancellation") +} + +// ConfirmRemoveNote waits for prompt for removing a note and confirms. +func ConfirmRemoveNote(stdout io.Reader, stdin io.WriteCloser) error { + return userConfirmOutput(stdout, stdin, PromptRemoveNote) +} + +// ConfirmRemoveBook waits for prompt for deleting a book confirms. +func ConfirmRemoveBook(stdout io.Reader, stdin io.WriteCloser) error { + return userConfirmOutput(stdout, stdin, PromptDeleteBook) +} + +// UserConfirmEmptyServerSync waits for an empty server prompt and confirms. +func UserConfirmEmptyServerSync(stdout io.Reader, stdin io.WriteCloser) error { + return userConfirmOutput(stdout, stdin, PromptEmptyServer) +} + +// UserCancelEmptyServerSync waits for an empty server prompt and confirms. +func UserCancelEmptyServerSync(stdout io.Reader, stdin io.WriteCloser) error { + return userCancelOutput(stdout, stdin, PromptEmptyServer) +} + +// UserContent simulates content from the user by writing to stdin. +// This is used for piped input where no prompt is shown. +func UserContent(stdout io.Reader, stdin io.WriteCloser) error { longText := `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.` diff --git a/pkg/e2e/sync_test.go b/pkg/e2e/sync_test.go index 2ddf373e..b4fec91f 100644 --- a/pkg/e2e/sync_test.go +++ b/pkg/e2e/sync_test.go @@ -82,10 +82,9 @@ func clearTmp(t *testing.T) { } } -func TestMain(m *testing.M) { - // Set up server database - use file-based DB for e2e tests - dbPath := fmt.Sprintf("%s/server.db", testDir) - serverDb = apitest.InitDB(dbPath) +// setupTestServer creates a test server with its own database +func setupTestServer(dbPath string, serverTime time.Time) (*httptest.Server, *gorm.DB, error) { + db := apitest.InitDB(dbPath) mockClock := clock.NewMock() mockClock.SetNow(serverTime) @@ -94,12 +93,24 @@ func TestMain(m *testing.M) { a.Clock = mockClock a.EmailTemplates = mailer.Templates{} a.EmailBackend = &apitest.MockEmailbackendImplementation{} - a.DB = serverDb + a.DB = db + + server, err := controllers.NewServer(&a) + if err != nil { + return nil, nil, errors.Wrap(err, "initializing server") + } + + return server, db, nil +} + +func TestMain(m *testing.M) { + // Set up server database - use file-based DB for e2e tests + dbPath := fmt.Sprintf("%s/server.db", testDir) var err error - server, err = controllers.NewServer(&a) + server, serverDb, err = setupTestServer(dbPath, serverTime) if err != nil { - panic(errors.Wrap(err, "initializing router")) + panic(err) } defer server.Close() @@ -234,6 +245,10 @@ type systemState struct { // checkState compares the state of the client and the server with the given system state func checkState(t *testing.T, ctx context.DnoteCtx, user database.User, expected systemState) { + checkStateWithDB(t, ctx, user, serverDb, expected) +} + +func checkStateWithDB(t *testing.T, ctx context.DnoteCtx, user database.User, db *gorm.DB, expected systemState) { clientDB := ctx.DB var clientBookCount, clientNoteCount int @@ -250,12 +265,12 @@ func checkState(t *testing.T, ctx context.DnoteCtx, user database.User, expected assert.Equal(t, clientLastSyncAt, expected.clientLastSyncAt, "client last_sync_at mismatch") var serverBookCount, serverNoteCount int64 - apitest.MustExec(t, serverDb.Model(&database.Note{}).Count(&serverNoteCount), "counting server notes") - apitest.MustExec(t, serverDb.Model(&database.Book{}).Count(&serverBookCount), "counting api notes") + apitest.MustExec(t, db.Model(&database.Note{}).Count(&serverNoteCount), "counting server notes") + apitest.MustExec(t, db.Model(&database.Book{}).Count(&serverBookCount), "counting api notes") assert.Equal(t, serverNoteCount, expected.serverNoteCount, "server note count mismatch") assert.Equal(t, serverBookCount, expected.serverBookCount, "server book count mismatch") var serverUser database.User - apitest.MustExec(t, serverDb.Where("id = ?", user.ID).First(&serverUser), "finding user") + apitest.MustExec(t, db.Where("id = ?", user.ID).First(&serverUser), "finding user") assert.Equal(t, serverUser.MaxUSN, expected.serverUserMaxUSN, "user max_usn mismatch") } @@ -412,7 +427,7 @@ func TestSync_oneway(t *testing.T) { cliDatabase.MustScan(t, "getting id of note to delete", cliDB.QueryRow("SELECT rowid FROM notes WHERE body = ?", "css2"), &nid2) clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "edit", "js", nid, "-c", "js3-edited") - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "css", nid2) + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveNote, cliBinaryName, "remove", "css", nid2) clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css3") clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css4") @@ -777,9 +792,9 @@ func TestSync_twoway(t *testing.T) { var nid string cliDatabase.MustScan(t, "getting id of note to remove", cliDB.QueryRow("SELECT rowid FROM notes WHERE body = ?", "js3"), &nid) - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "algorithms") + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveBook, cliBinaryName, "remove", "algorithms") clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css4") - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js", nid) + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveNote, cliBinaryName, "remove", "js", nid) return map[string]string{ "jsBookUUID": jsBookUUID, @@ -989,7 +1004,7 @@ func TestSync_twoway(t *testing.T) { // 2. on cli clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "js", "-c", "js2") - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js") + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveBook, cliBinaryName, "remove", "js") clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "math", "-c", "math1") var nid string @@ -1337,7 +1352,7 @@ func TestSync(t *testing.T) { // 2. on cli clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js") + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveBook, cliBinaryName, "remove", "js") return map[string]string{ "jsBookUUID": jsBookUUID, @@ -1391,7 +1406,7 @@ func TestSync(t *testing.T) { clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") var nid string cliDatabase.MustScan(t, "getting id of note to remove", cliDB.QueryRow("SELECT rowid FROM notes WHERE uuid = ?", jsNote1UUID), &nid) - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js", nid) + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveNote, cliBinaryName, "remove", "js", nid) return map[string]string{ "jsBookUUID": jsBookUUID, @@ -2009,7 +2024,7 @@ func TestSync(t *testing.T) { apiDeleteBook(t, user, jsBookUUID, "deleting js book") // 4. on cli - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js") + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveBook, cliBinaryName, "remove", "js") return map[string]string{ "jsBookUUID": jsBookUUID, @@ -2069,7 +2084,7 @@ func TestSync(t *testing.T) { // 4. on cli var nid string cliDatabase.MustScan(t, "getting id of note to remove", cliDB.QueryRow("SELECT rowid FROM notes WHERE body = ?", "js1"), &nid) - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js", nid) + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveNote, cliBinaryName, "remove", "js", nid) return map[string]string{ "jsBookUUID": jsBookUUID, @@ -2614,7 +2629,7 @@ func TestSync(t *testing.T) { clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") var nid string cliDatabase.MustScan(t, "getting id of note to remove", cliDB.QueryRow("SELECT rowid FROM notes WHERE uuid = ?", jsNote1UUID), &nid) - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js", nid) + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveNote, cliBinaryName, "remove", "js", nid) // 3. on server apiPatchNote(t, user, jsNote1UUID, fmt.Sprintf(`{"content": "%s"}`, "js1-edited"), "editing js note 1") @@ -2688,7 +2703,7 @@ func TestSync(t *testing.T) { clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") var nid string cliDatabase.MustScan(t, "getting id of note to remove", cliDB.QueryRow("SELECT rowid FROM notes WHERE uuid = ?", jsNote1UUID), &nid) - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js", nid) + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveNote, cliBinaryName, "remove", "js", nid) // 3. on server apiPatchNote(t, user, jsNote1UUID, fmt.Sprintf(`{"book_uuid": "%s"}`, cssBookUUID), "moving js note 1 to css book") @@ -2989,7 +3004,7 @@ func TestSync(t *testing.T) { // 2. on cli clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js") + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveBook, cliBinaryName, "remove", "js") // 3. on server apiPatchBook(t, user, jsBookUUID, fmt.Sprintf(`{"name": "%s"}`, "js-edited"), "editing js book") @@ -3060,7 +3075,7 @@ func TestSync(t *testing.T) { apiPatchNote(t, user, jsNote1UUID, fmt.Sprintf(`{"content": "%s"}`, "js1-edited"), "editing js1 note") // 4. on cli - clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirm, cliBinaryName, "remove", "js") + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.ConfirmRemoveBook, cliBinaryName, "remove", "js") return map[string]string{ "jsBookUUID": jsBookUUID, @@ -3862,3 +3877,565 @@ func TestFullSync(t *testing.T) { }) }) } + +func TestSync_EmptyServer(t *testing.T) { + t.Run("sync to empty server after syncing to non-empty server", func(t *testing.T) { + // Test server data loss/wipe scenario (disaster recovery): + // Verify empty server detection works when the server loses all its data + + // clean up + apitest.ClearData(serverDb) + defer apitest.ClearData(serverDb) + + clearTmp(t) + + ctx := context.InitTestCtx(t, paths, nil) + defer context.TeardownTestCtx(t, ctx) + + user := setupUser(t, &ctx) + + // Step 1: Create local data and sync to server + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "js", "-c", "js1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") + + // Verify sync succeeded + checkState(t, ctx, user, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Step 2: Clear all server data to simulate switching to a completely new empty server + apitest.ClearData(serverDb) + // Recreate user and session (simulating a new server) + user = setupUser(t, &ctx) + + // Step 3: Sync again - should detect empty server and prompt user + // User confirms with "y" + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirmEmptyServerSync, cliBinaryName, "sync") + + // Step 4: Verify data was uploaded to the empty server + checkState(t, ctx, user, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Verify the content is correct on both client and server + var cliNote1JS, cliNote1CSS cliDatabase.Note + var cliBookJS, cliBookCSS cliDatabase.Book + cliDatabase.MustScan(t, "finding cliNote1JS", ctx.DB.QueryRow("SELECT uuid, body FROM notes WHERE body = ?", "js1"), &cliNote1JS.UUID, &cliNote1JS.Body) + cliDatabase.MustScan(t, "finding cliNote1CSS", ctx.DB.QueryRow("SELECT uuid, body FROM notes WHERE body = ?", "css1"), &cliNote1CSS.UUID, &cliNote1CSS.Body) + cliDatabase.MustScan(t, "finding cliBookJS", ctx.DB.QueryRow("SELECT uuid, label FROM books WHERE label = ?", "js"), &cliBookJS.UUID, &cliBookJS.Label) + cliDatabase.MustScan(t, "finding cliBookCSS", ctx.DB.QueryRow("SELECT uuid, label FROM books WHERE label = ?", "css"), &cliBookCSS.UUID, &cliBookCSS.Label) + + assert.Equal(t, cliNote1JS.Body, "js1", "js note body mismatch") + assert.Equal(t, cliNote1CSS.Body, "css1", "css note body mismatch") + assert.Equal(t, cliBookJS.Label, "js", "js book label mismatch") + assert.Equal(t, cliBookCSS.Label, "css", "css book label mismatch") + + // Verify on server side + var serverNoteJS, serverNoteCSS database.Note + var serverBookJS, serverBookCSS database.Book + apitest.MustExec(t, serverDb.Where("body = ?", "js1").First(&serverNoteJS), "finding server note js1") + apitest.MustExec(t, serverDb.Where("body = ?", "css1").First(&serverNoteCSS), "finding server note css1") + apitest.MustExec(t, serverDb.Where("label = ?", "js").First(&serverBookJS), "finding server book js") + apitest.MustExec(t, serverDb.Where("label = ?", "css").First(&serverBookCSS), "finding server book css") + + assert.Equal(t, serverNoteJS.Body, "js1", "server js note body mismatch") + assert.Equal(t, serverNoteCSS.Body, "css1", "server css note body mismatch") + assert.Equal(t, serverBookJS.Label, "js", "server js book label mismatch") + assert.Equal(t, serverBookCSS.Label, "css", "server css book label mismatch") + }) + + t.Run("user cancels empty server prompt", func(t *testing.T) { + // clean up + apitest.ClearData(serverDb) + defer apitest.ClearData(serverDb) + + clearTmp(t) + + ctx := context.InitTestCtx(t, paths, nil) + defer context.TeardownTestCtx(t, ctx) + + user := setupUser(t, &ctx) + + // Step 1: Create local data and sync to server + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "js", "-c", "js1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") + + // Verify initial sync succeeded + checkState(t, ctx, user, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Step 2: Clear all server data + apitest.ClearData(serverDb) + user = setupUser(t, &ctx) + + // Step 3: Sync again but user cancels with "n" + output, err := clitest.WaitDnoteCmd(t, dnoteCmdOpts, clitest.UserCancelEmptyServerSync, cliBinaryName, "sync") + if err == nil { + t.Fatal("Expected sync to fail when user cancels, but it succeeded") + } + + // Verify the prompt appeared + if !strings.Contains(output, clitest.PromptEmptyServer) { + t.Fatalf("Expected empty server warning in output, got: %s", output) + } + + // Step 4: Verify local state unchanged (transaction rolled back) + checkState(t, ctx, user, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 0, + serverBookCount: 0, + serverUserMaxUSN: 0, + }) + + // Verify items still have original USN and dirty=false + var book cliDatabase.Book + var note cliDatabase.Note + cliDatabase.MustScan(t, "checking book state", ctx.DB.QueryRow("SELECT usn, dirty FROM books WHERE label = ?", "js"), &book.USN, &book.Dirty) + cliDatabase.MustScan(t, "checking note state", ctx.DB.QueryRow("SELECT usn, dirty FROM notes WHERE body = ?", "js1"), ¬e.USN, ¬e.Dirty) + + assert.NotEqual(t, book.USN, 0, "book USN should not be reset") + assert.NotEqual(t, note.USN, 0, "note USN should not be reset") + assert.Equal(t, book.Dirty, false, "book should not be marked dirty") + assert.Equal(t, note.Dirty, false, "note should not be marked dirty") + }) + + t.Run("all local data is marked deleted - should not upload", func(t *testing.T) { + // Test edge case: Server MaxUSN=0, local MaxUSN>0, but all items are deleted=true + // Should NOT prompt because there's nothing to upload + + // clean up + apitest.ClearData(serverDb) + defer apitest.ClearData(serverDb) + + clearTmp(t) + + ctx := context.InitTestCtx(t, paths, nil) + defer context.TeardownTestCtx(t, ctx) + + user := setupUser(t, &ctx) + + // Step 1: Create local data and sync to server + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "js", "-c", "js1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") + + // Verify initial sync succeeded + checkState(t, ctx, user, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Step 2: Delete all local notes and books (mark as deleted) + cliDatabase.MustExec(t, "marking all books deleted", ctx.DB, "UPDATE books SET deleted = 1") + cliDatabase.MustExec(t, "marking all notes deleted", ctx.DB, "UPDATE notes SET deleted = 1") + + // Step 3: Clear server data to simulate switching to empty server + apitest.ClearData(serverDb) + user = setupUser(t, &ctx) + + // Step 4: Sync - should NOT prompt because bookCount=0 and noteCount=0 (counting only deleted=0) + // This should complete without user interaction + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") + + // Verify no data was uploaded (server still empty, but client still has deleted items) + // Check server is empty + var serverNoteCount, serverBookCount int64 + apitest.MustExec(t, serverDb.Model(&database.Note{}).Count(&serverNoteCount), "counting server notes") + apitest.MustExec(t, serverDb.Model(&database.Book{}).Count(&serverBookCount), "counting server books") + assert.Equal(t, serverNoteCount, int64(0), "server should have no notes") + assert.Equal(t, serverBookCount, int64(0), "server should have no books") + + // Check client still has the deleted items locally + var clientNoteCount, clientBookCount int + cliDatabase.MustScan(t, "counting client notes", ctx.DB.QueryRow("SELECT count(*) FROM notes WHERE deleted = 1"), &clientNoteCount) + cliDatabase.MustScan(t, "counting client books", ctx.DB.QueryRow("SELECT count(*) FROM books WHERE deleted = 1"), &clientBookCount) + assert.Equal(t, clientNoteCount, 2, "client should still have 2 deleted notes") + assert.Equal(t, clientBookCount, 2, "client should still have 2 deleted books") + + // Verify lastMaxUSN was reset to 0 + var lastMaxUSN int + cliDatabase.MustScan(t, "getting lastMaxUSN", ctx.DB.QueryRow("SELECT value FROM system WHERE key = ?", consts.SystemLastMaxUSN), &lastMaxUSN) + assert.Equal(t, lastMaxUSN, 0, "lastMaxUSN should be reset to 0") + }) + + t.Run("race condition - other client uploads first", func(t *testing.T) { + // This test exercises a race condition that can occur during sync: + // While Client A is waiting for user input, Client B uploads data to the server. + // + // The empty server scenario is the natural place to test this because + // an empty server detection triggers a prompt, at which point the test + // can make client B upload data. We trigger the race condition deterministically. + // + // Test flow: + // - Client A detects empty server and prompts user + // - While waiting for confirmation, Client B uploads the same data via API + // - Client A continues and handles the 409 conflict gracefully by: + // 1. Detecting the 409 error when trying to CREATE books that already exist + // 2. Running stepSync to pull the server's books (js, css) + // 3. mergeBook renames local conflicts (js→js_2, css→css_2) + // 4. Retrying sendChanges to upload the renamed books + // - Result: Both clients' data is preserved (4 books total) + + // Clean up + apitest.ClearData(serverDb) + defer apitest.ClearData(serverDb) + clearTmp(t) + + ctx := context.InitTestCtx(t, paths, nil) + defer context.TeardownTestCtx(t, ctx) + + user := setupUser(t, &ctx) + + // Step 1: Create local data and sync to establish lastMaxUSN > 0 + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "js", "-c", "js1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") + + // Verify initial sync succeeded + checkState(t, ctx, user, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Step 2: Clear server to simulate switching to empty server + apitest.ClearData(serverDb) + user = setupUser(t, &ctx) + + // Step 3: Trigger sync which will detect empty server and prompt user + // Inside the callback (before confirming), we simulate Client B uploading via API. + // We wait for the empty server prompt to ensure Client B uploads AFTER + // GetSyncState but BEFORE the sync decision, creating the race condition deterministically + raceCallback := func(stdout io.Reader, stdin io.WriteCloser) error { + // First, wait for the prompt to ensure Client A has obtained the sync state from the server. + clitest.MustWaitForPrompt(t, stdout, clitest.PromptEmptyServer) + + // Now Client B uploads the same data via API (after Client A got the sync state from the server + // but before its sync decision) + // This creates the race condition: Client A thinks server is empty, but Client B uploads data + jsBookUUID := apiCreateBook(t, user, "js", "client B creating js book") + cssBookUUID := apiCreateBook(t, user, "css", "client B creating css book") + apiCreateNote(t, user, jsBookUUID, "js1", "client B creating js note") + apiCreateNote(t, user, cssBookUUID, "css1", "client B creating css note") + + // Now user confirms + if _, err := io.WriteString(stdin, "y\n"); err != nil { + return errors.Wrap(err, "confirming sync") + } + + return nil + } + + // Step 4: Client A runs sync with race condition + // The 409 conflict is automatically handled: + // - When 409 is detected, isBehind flag is set + // - stepSync pulls Client B's data + // - mergeBook renames Client A's books to js_2, css_2 + // - Renamed books are uploaded + // - Both clients' data is preserved. + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, raceCallback, cliBinaryName, "sync") + + // Verify final state - both clients' data preserved + checkStateWithDB(t, ctx, user, serverDb, systemState{ + clientNoteCount: 4, // Both clients' notes + clientBookCount: 4, // js, css, js_2, css_2 + clientLastMaxUSN: 8, // 4 from Client B + 4 from Client A's renamed books/notes + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 4, + serverBookCount: 4, + serverUserMaxUSN: 8, + }) + + // Verify server has both clients' books + var svrBookJS, svrBookCSS, svrBookJS2, svrBookCSS2 database.Book + apitest.MustExec(t, serverDb.Where("label = ?", "js").First(&svrBookJS), "finding server book 'js'") + apitest.MustExec(t, serverDb.Where("label = ?", "css").First(&svrBookCSS), "finding server book 'css'") + apitest.MustExec(t, serverDb.Where("label = ?", "js_2").First(&svrBookJS2), "finding server book 'js_2'") + apitest.MustExec(t, serverDb.Where("label = ?", "css_2").First(&svrBookCSS2), "finding server book 'css_2'") + + assert.Equal(t, svrBookJS.Label, "js", "server should have book 'js' (Client B)") + assert.Equal(t, svrBookCSS.Label, "css", "server should have book 'css' (Client B)") + assert.Equal(t, svrBookJS2.Label, "js_2", "server should have book 'js_2' (Client A renamed)") + assert.Equal(t, svrBookCSS2.Label, "css_2", "server should have book 'css_2' (Client A renamed)") + + // Verify client has all books + var cliBookJS, cliBookCSS, cliBookJS2, cliBookCSS2 cliDatabase.Book + cliDatabase.MustScan(t, "finding client book 'js'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "js"), &cliBookJS.UUID, &cliBookJS.Label, &cliBookJS.USN) + cliDatabase.MustScan(t, "finding client book 'css'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "css"), &cliBookCSS.UUID, &cliBookCSS.Label, &cliBookCSS.USN) + cliDatabase.MustScan(t, "finding client book 'js_2'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "js_2"), &cliBookJS2.UUID, &cliBookJS2.Label, &cliBookJS2.USN) + cliDatabase.MustScan(t, "finding client book 'css_2'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "css_2"), &cliBookCSS2.UUID, &cliBookCSS2.Label, &cliBookCSS2.USN) + + // Verify client UUIDs match server + assert.Equal(t, cliBookJS.UUID, svrBookJS.UUID, "client 'js' UUID should match server") + assert.Equal(t, cliBookCSS.UUID, svrBookCSS.UUID, "client 'css' UUID should match server") + assert.Equal(t, cliBookJS2.UUID, svrBookJS2.UUID, "client 'js_2' UUID should match server") + assert.Equal(t, cliBookCSS2.UUID, svrBookCSS2.UUID, "client 'css_2' UUID should match server") + + // Verify all items have non-zero USN (synced successfully) + assert.NotEqual(t, cliBookJS.USN, 0, "client 'js' should have non-zero USN") + assert.NotEqual(t, cliBookCSS.USN, 0, "client 'css' should have non-zero USN") + assert.NotEqual(t, cliBookJS2.USN, 0, "client 'js_2' should have non-zero USN") + assert.NotEqual(t, cliBookCSS2.USN, 0, "client 'css_2' should have non-zero USN") + }) + + t.Run("sync to server A, then B, then back to A, then back to B", func(t *testing.T) { + // Test switching between two actual servers to verify: + // 1. Empty server detection works when switching to empty server + // 2. No false detection when switching back to non-empty servers + // 3. Both servers maintain independent state across multiple switches + + // Clean up + clearTmp(t) + + ctx := context.InitTestCtx(t, paths, nil) + defer context.TeardownTestCtx(t, ctx) + + // Create Server A with its own database + dbPathA := fmt.Sprintf("%s/serverA.db", testDir) + defer os.Remove(dbPathA) + + serverA, serverDbA, err := setupTestServer(dbPathA, serverTime) + if err != nil { + t.Fatal(errors.Wrap(err, "setting up server A")) + } + defer serverA.Close() + + // Create Server B with its own database + dbPathB := fmt.Sprintf("%s/serverB.db", testDir) + defer os.Remove(dbPathB) + + serverB, serverDbB, err := setupTestServer(dbPathB, serverTime) + if err != nil { + t.Fatal(errors.Wrap(err, "setting up server B")) + } + defer serverB.Close() + + // Step 1: Set up user on Server A and sync + apiEndpointA := fmt.Sprintf("%s/api", serverA.URL) + + userA := apitest.SetupUserData(serverDbA) + apitest.SetupAccountData(serverDbA, userA, "alice@example.com", "pass1234") + sessionA := apitest.SetupSession(serverDbA, userA) + cliDatabase.MustExec(t, "inserting session_key", ctx.DB, "INSERT INTO system (key, value) VALUES (?, ?)", consts.SystemSessionKey, sessionA.Key) + cliDatabase.MustExec(t, "inserting session_key_expiry", ctx.DB, "INSERT INTO system (key, value) VALUES (?, ?)", consts.SystemSessionKeyExpiry, sessionA.ExpiresAt.Unix()) + + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "js", "-c", "js1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "--api-endpoint", apiEndpointA, "sync") + + // Verify sync to Server A succeeded + checkStateWithDB(t, ctx, userA, serverDbA, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Step 2: Switch to Server B (empty) and sync + apiEndpointB := fmt.Sprintf("%s/api", serverB.URL) + + // Set up user on Server B + userB := apitest.SetupUserData(serverDbB) + apitest.SetupAccountData(serverDbB, userB, "alice@example.com", "pass1234") + sessionB := apitest.SetupSession(serverDbB, userB) + cliDatabase.MustExec(t, "updating session_key for B", ctx.DB, "UPDATE system SET value = ? WHERE key = ?", sessionB.Key, consts.SystemSessionKey) + cliDatabase.MustExec(t, "updating session_key_expiry for B", ctx.DB, "UPDATE system SET value = ? WHERE key = ?", sessionB.ExpiresAt.Unix(), consts.SystemSessionKeyExpiry) + + // Should detect empty server and prompt + clitest.MustWaitDnoteCmd(t, dnoteCmdOpts, clitest.UserConfirmEmptyServerSync, cliBinaryName, "--api-endpoint", apiEndpointB, "sync") + + // Verify Server B now has data + checkStateWithDB(t, ctx, userB, serverDbB, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Step 3: Switch back to Server A and sync + cliDatabase.MustExec(t, "updating session_key back to A", ctx.DB, "UPDATE system SET value = ? WHERE key = ?", sessionA.Key, consts.SystemSessionKey) + cliDatabase.MustExec(t, "updating session_key_expiry back to A", ctx.DB, "UPDATE system SET value = ? WHERE key = ?", sessionA.ExpiresAt.Unix(), consts.SystemSessionKeyExpiry) + + // Should NOT trigger empty server detection (Server A has MaxUSN > 0) + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "--api-endpoint", apiEndpointA, "sync") + + // Verify Server A still has its data + checkStateWithDB(t, ctx, userA, serverDbA, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + + // Step 4: Switch back to Server B and sync again + cliDatabase.MustExec(t, "updating session_key back to B", ctx.DB, "UPDATE system SET value = ? WHERE key = ?", sessionB.Key, consts.SystemSessionKey) + cliDatabase.MustExec(t, "updating session_key_expiry back to B", ctx.DB, "UPDATE system SET value = ? WHERE key = ?", sessionB.ExpiresAt.Unix(), consts.SystemSessionKeyExpiry) + + // Should NOT trigger empty server detection (Server B now has MaxUSN > 0 from Step 2) + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "--api-endpoint", apiEndpointB, "sync") + + // Verify both servers maintain independent state + checkStateWithDB(t, ctx, userB, serverDbB, systemState{ + clientNoteCount: 2, + clientBookCount: 2, + clientLastMaxUSN: 4, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 2, + serverBookCount: 2, + serverUserMaxUSN: 4, + }) + }) +} + +func TestSync_FreshClientConcurrent(t *testing.T) { + // Test the core issue: Fresh client (never synced, lastMaxUSN=0) syncing to a server + // that already has data uploaded by another client. + // + // Scenario: + // 1. Client A creates local notes (never synced, lastMaxUSN=0, lastSyncAt=0) + // 2. Client B uploads same book names to server first + // 3. Client A syncs + // + // Expected: Client A should pull server data first, detect duplicate book names, + // rename local books to avoid conflicts (js→js_2), then upload successfully. + + // Clean up + apitest.ClearData(serverDb) + defer apitest.ClearData(serverDb) + clearTmp(t) + + ctx := context.InitTestCtx(t, paths, nil) + defer context.TeardownTestCtx(t, ctx) + + user := setupUser(t, &ctx) + + // Client A: Create local data (never sync) + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "js", "-c", "js1") + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "add", "css", "-c", "css1") + + // Client B: Upload same book names to server via API + jsBookUUID := apiCreateBook(t, user, "js", "client B creating js book") + cssBookUUID := apiCreateBook(t, user, "css", "client B creating css book") + apiCreateNote(t, user, jsBookUUID, "js2", "client B note") + apiCreateNote(t, user, cssBookUUID, "css2", "client B note") + + // Client A syncs - should handle the conflict gracefully + // Expected: pulls server data, renames local books to js_2/css_2, uploads successfully + clitest.RunDnoteCmd(t, dnoteCmdOpts, cliBinaryName, "sync") + + // Verify: Should have 4 books and 4 notes on both client and server + // USN breakdown: 2 books + 2 notes from Client B (USN 1-4), then 2 books + 2 notes from Client A (USN 5-8) + checkStateWithDB(t, ctx, user, serverDb, systemState{ + clientNoteCount: 4, + clientBookCount: 4, + clientLastMaxUSN: 8, + clientLastSyncAt: serverTime.Unix(), + serverNoteCount: 4, + serverBookCount: 4, + serverUserMaxUSN: 8, + }) + + // Verify server has all 4 books with correct names + var svrBookJS, svrBookCSS, svrBookJS2, svrBookCSS2 database.Book + apitest.MustExec(t, serverDb.Where("label = ?", "js").First(&svrBookJS), "finding server book 'js'") + apitest.MustExec(t, serverDb.Where("label = ?", "css").First(&svrBookCSS), "finding server book 'css'") + apitest.MustExec(t, serverDb.Where("label = ?", "js_2").First(&svrBookJS2), "finding server book 'js_2'") + apitest.MustExec(t, serverDb.Where("label = ?", "css_2").First(&svrBookCSS2), "finding server book 'css_2'") + + assert.Equal(t, svrBookJS.Label, "js", "server should have book 'js' (Client B)") + assert.Equal(t, svrBookCSS.Label, "css", "server should have book 'css' (Client B)") + assert.Equal(t, svrBookJS2.Label, "js_2", "server should have book 'js_2' (Client A renamed)") + assert.Equal(t, svrBookCSS2.Label, "css_2", "server should have book 'css_2' (Client A renamed)") + + // Verify server has all 4 notes with correct content + var svrNoteJS1, svrNoteJS2, svrNoteCSS1, svrNoteCSS2 database.Note + apitest.MustExec(t, serverDb.Where("body = ?", "js1").First(&svrNoteJS1), "finding server note 'js1'") + apitest.MustExec(t, serverDb.Where("body = ?", "js2").First(&svrNoteJS2), "finding server note 'js2'") + apitest.MustExec(t, serverDb.Where("body = ?", "css1").First(&svrNoteCSS1), "finding server note 'css1'") + apitest.MustExec(t, serverDb.Where("body = ?", "css2").First(&svrNoteCSS2), "finding server note 'css2'") + + assert.Equal(t, svrNoteJS1.BookUUID, svrBookJS2.UUID, "note 'js1' should belong to book 'js_2' (Client A)") + assert.Equal(t, svrNoteJS2.BookUUID, svrBookJS.UUID, "note 'js2' should belong to book 'js' (Client B)") + assert.Equal(t, svrNoteCSS1.BookUUID, svrBookCSS2.UUID, "note 'css1' should belong to book 'css_2' (Client A)") + assert.Equal(t, svrNoteCSS2.BookUUID, svrBookCSS.UUID, "note 'css2' should belong to book 'css' (Client B)") + + // Verify client has all 4 books + var cliBookJS, cliBookCSS, cliBookJS2, cliBookCSS2 cliDatabase.Book + cliDatabase.MustScan(t, "finding client book 'js'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "js"), &cliBookJS.UUID, &cliBookJS.Label, &cliBookJS.USN) + cliDatabase.MustScan(t, "finding client book 'css'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "css"), &cliBookCSS.UUID, &cliBookCSS.Label, &cliBookCSS.USN) + cliDatabase.MustScan(t, "finding client book 'js_2'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "js_2"), &cliBookJS2.UUID, &cliBookJS2.Label, &cliBookJS2.USN) + cliDatabase.MustScan(t, "finding client book 'css_2'", ctx.DB.QueryRow("SELECT uuid, label, usn FROM books WHERE label = ?", "css_2"), &cliBookCSS2.UUID, &cliBookCSS2.Label, &cliBookCSS2.USN) + + // Verify client UUIDs match server + assert.Equal(t, cliBookJS.UUID, svrBookJS.UUID, "client 'js' UUID should match server") + assert.Equal(t, cliBookCSS.UUID, svrBookCSS.UUID, "client 'css' UUID should match server") + assert.Equal(t, cliBookJS2.UUID, svrBookJS2.UUID, "client 'js_2' UUID should match server") + assert.Equal(t, cliBookCSS2.UUID, svrBookCSS2.UUID, "client 'css_2' UUID should match server") + + // Verify all books have non-zero USN (synced successfully) + assert.NotEqual(t, cliBookJS.USN, 0, "client 'js' should have non-zero USN") + assert.NotEqual(t, cliBookCSS.USN, 0, "client 'css' should have non-zero USN") + assert.NotEqual(t, cliBookJS2.USN, 0, "client 'js_2' should have non-zero USN") + assert.NotEqual(t, cliBookCSS2.USN, 0, "client 'css_2' should have non-zero USN") + + // Verify client has all 4 notes + var cliNoteJS1, cliNoteJS2, cliNoteCSS1, cliNoteCSS2 cliDatabase.Note + cliDatabase.MustScan(t, "finding client note 'js1'", ctx.DB.QueryRow("SELECT uuid, body, usn FROM notes WHERE body = ?", "js1"), &cliNoteJS1.UUID, &cliNoteJS1.Body, &cliNoteJS1.USN) + cliDatabase.MustScan(t, "finding client note 'js2'", ctx.DB.QueryRow("SELECT uuid, body, usn FROM notes WHERE body = ?", "js2"), &cliNoteJS2.UUID, &cliNoteJS2.Body, &cliNoteJS2.USN) + cliDatabase.MustScan(t, "finding client note 'css1'", ctx.DB.QueryRow("SELECT uuid, body, usn FROM notes WHERE body = ?", "css1"), &cliNoteCSS1.UUID, &cliNoteCSS1.Body, &cliNoteCSS1.USN) + cliDatabase.MustScan(t, "finding client note 'css2'", ctx.DB.QueryRow("SELECT uuid, body, usn FROM notes WHERE body = ?", "css2"), &cliNoteCSS2.UUID, &cliNoteCSS2.Body, &cliNoteCSS2.USN) + + // Verify client note UUIDs match server + assert.Equal(t, cliNoteJS1.UUID, svrNoteJS1.UUID, "client note 'js1' UUID should match server") + assert.Equal(t, cliNoteJS2.UUID, svrNoteJS2.UUID, "client note 'js2' UUID should match server") + assert.Equal(t, cliNoteCSS1.UUID, svrNoteCSS1.UUID, "client note 'css1' UUID should match server") + assert.Equal(t, cliNoteCSS2.UUID, svrNoteCSS2.UUID, "client note 'css2' UUID should match server") + + // Verify all notes have non-zero USN (synced successfully) + assert.NotEqual(t, cliNoteJS1.USN, 0, "client note 'js1' should have non-zero USN") + assert.NotEqual(t, cliNoteJS2.USN, 0, "client note 'js2' should have non-zero USN") + assert.NotEqual(t, cliNoteCSS1.USN, 0, "client note 'css1' should have non-zero USN") + assert.NotEqual(t, cliNoteCSS2.USN, 0, "client note 'css2' should have non-zero USN") +}