Allow to upload all data to an empty server (#690)

* Handle server switch

* Avoid losing data in case of race

* Simplify
This commit is contained in:
Sung 2025-10-12 12:03:20 -07:00 committed by GitHub
commit 24491bc68a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1049 additions and 63 deletions

View file

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

View file

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

View file

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

View file

@ -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(&noteCount); 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.`

View file

@ -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"), &note.USN, &note.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")
}