mirror of
https://github.com/dnote/dnote
synced 2026-03-15 06:55:49 +01:00
163 lines
6 KiB
Go
163 lines
6 KiB
Go
/* Copyright 2025 Dnote Authors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package sync
|
|
|
|
import (
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/dnote/dnote/pkg/assert"
|
|
"github.com/dnote/dnote/pkg/cli/consts"
|
|
cliDatabase "github.com/dnote/dnote/pkg/cli/database"
|
|
clitest "github.com/dnote/dnote/pkg/cli/testutils"
|
|
"github.com/google/uuid"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
// TestSync_EmptyFragmentPreservesLastMaxUSN verifies that last_max_usn is not reset to 0
|
|
// when sync receives an empty response from the server.
|
|
//
|
|
// Scenario: Client has orphaned note (references non-existent book). During sync:
|
|
// 1. Downloads data successfully (last_max_usn=3)
|
|
// 2. Upload fails (orphaned note -> 500 error, triggers retry stepSync)
|
|
// 3. Retry stepSync gets 0 fragments (already at latest USN)
|
|
// 4. last_max_usn should stay at 3, not reset to 0
|
|
func TestSync_EmptyFragmentPreservesLastMaxUSN(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
user := setupUserAndLogin(t, env)
|
|
|
|
// Create data on server (max_usn=3)
|
|
bookUUID := apiCreateBook(t, env, user, "javascript", "creating book via API")
|
|
apiCreateNote(t, env, user, bookUUID, "note1 content", "creating note1 via API")
|
|
apiCreateNote(t, env, user, bookUUID, "note2 content", "creating note2 via API")
|
|
|
|
// Create orphaned note locally (will fail to upload)
|
|
orphanedNote := cliDatabase.Note{
|
|
UUID: uuid.New().String(),
|
|
BookUUID: uuid.New().String(), // non-existent book
|
|
Body: "orphaned note content",
|
|
AddedOn: 1234567890,
|
|
EditedOn: 0,
|
|
USN: 0,
|
|
Deleted: false,
|
|
Dirty: true,
|
|
}
|
|
if err := orphanedNote.Insert(env.DB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Run sync (downloads data, upload fails, retry gets 0 fragments)
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "sync")
|
|
|
|
// Verify last_max_usn is preserved at 3, not reset to 0
|
|
var lastMaxUSN int
|
|
cliDatabase.MustScan(t, "finding system last_max_usn",
|
|
env.DB.QueryRow("SELECT value FROM system WHERE key = ?", consts.SystemLastMaxUSN),
|
|
&lastMaxUSN)
|
|
|
|
assert.Equal(t, lastMaxUSN, 3, "last_max_usn should be 3 after syncing")
|
|
}
|
|
|
|
// TestSync_ConcurrentInitialSync reproduces the issue where two clients with identical
|
|
// local data syncing simultaneously to an empty server results in 500 errors.
|
|
//
|
|
// This demonstrates the race condition:
|
|
// - Client1 starts sync to empty server, gets empty server state
|
|
// - Client2 syncs.
|
|
// - Client1 tries to create same books → 409 "duplicate"
|
|
// - Client1 tries to create notes with wrong UUIDs → 500 "record not found"
|
|
// - stepSync recovers by renaming local books with _2 suffix
|
|
func TestSync_ConcurrentInitialSync(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
user := setupUserAndLogin(t, env)
|
|
|
|
// Step 1: Create local data and sync
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "add", "javascript", "-c", "js note from client1")
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "sync")
|
|
checkState(t, env.DB, user, env.ServerDB, systemState{
|
|
clientNoteCount: 1,
|
|
clientBookCount: 1,
|
|
clientLastMaxUSN: 2,
|
|
clientLastSyncAt: serverTime.Unix(),
|
|
serverNoteCount: 1,
|
|
serverBookCount: 1,
|
|
serverUserMaxUSN: 2,
|
|
})
|
|
|
|
// Step 2: Switch to new empty server to simulate concurrent initial sync scenario
|
|
switchToEmptyServer(t, &env)
|
|
user = setupUserAndLogin(t, env)
|
|
|
|
// Set up client2 with separate database
|
|
client2DB, client2DBPath := cliDatabase.InitTestFileDB(t)
|
|
login(t, client2DB, env.ServerDB, user)
|
|
client2DB.Close() // Close so CLI can access the database
|
|
|
|
// Step 3: Client1 syncs to empty server, but during sync Client2 uploads same data
|
|
// This simulates the race condition deterministically
|
|
raceCallback := func(stdout io.Reader, stdin io.WriteCloser) error {
|
|
// Wait for empty server prompt to ensure Client1 has called GetSyncState
|
|
clitest.MustWaitForPrompt(t, stdout, clitest.PromptEmptyServer)
|
|
|
|
// Now Client2 creates the same book and note via CLI (creating the race condition)
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "--dbPath", client2DBPath, "add", "javascript", "-c", "js note from client2")
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "--dbPath", client2DBPath, "sync")
|
|
|
|
// User confirms sync
|
|
if _, err := io.WriteString(stdin, "y\n"); err != nil {
|
|
return errors.Wrap(err, "confirming sync")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Client1 continues sync - will hit 409 conflict, then 500 error, then recover
|
|
clitest.MustWaitDnoteCmd(t, env.CmdOpts, raceCallback, cliBinaryName, "sync")
|
|
|
|
// After sync:
|
|
// - Server has 2 books: "javascript" (from client2) and "javascript_2" (from client1 renamed)
|
|
// - Server has 2 notes
|
|
// - Both clients should converge to the same state
|
|
expectedState := systemState{
|
|
clientNoteCount: 2, // both notes
|
|
clientBookCount: 2, // javascript and javascript_2
|
|
clientLastMaxUSN: 4, // 2 books + 2 notes
|
|
clientLastSyncAt: serverTime.Unix(),
|
|
serverNoteCount: 2,
|
|
serverBookCount: 2,
|
|
serverUserMaxUSN: 4,
|
|
}
|
|
checkState(t, env.DB, user, env.ServerDB, expectedState)
|
|
|
|
// Client2 syncs again to download client1's data
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "--dbPath", client2DBPath, "sync")
|
|
client2DB = clitest.MustOpenDatabase(t, client2DBPath)
|
|
defer client2DB.Close()
|
|
|
|
// Client2 should have converged to the same state as client1
|
|
checkState(t, client2DB, user, env.ServerDB, expectedState)
|
|
|
|
// Verify no orphaned notes on server
|
|
var orphanedCount int
|
|
if err := env.ServerDB.Raw(`
|
|
SELECT COUNT(*) FROM notes
|
|
WHERE book_uuid NOT IN (SELECT uuid FROM books)
|
|
`).Scan(&orphanedCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assert.Equal(t, orphanedCount, 0, "server should have no orphaned notes after sync")
|
|
}
|