mirror of
https://github.com/dnote/dnote
synced 2026-03-14 14:35:50 +01:00
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:
parent
e0f68fc8d8
commit
24491bc68a
12 changed files with 1049 additions and 63 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue