mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
295 lines
11 KiB
Go
295 lines
11 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 (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/dnote/dnote/pkg/clock"
|
|
"github.com/dnote/dnote/pkg/server/app"
|
|
"github.com/dnote/dnote/pkg/server/controllers"
|
|
"github.com/dnote/dnote/pkg/server/database"
|
|
apitest "github.com/dnote/dnote/pkg/server/testutils"
|
|
"github.com/pkg/errors"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// testEnv holds the test environment for a single test
|
|
type testEnv struct {
|
|
DB *cliDatabase.DB
|
|
CmdOpts clitest.RunDnoteCmdOptions
|
|
Server *httptest.Server
|
|
ServerDB *gorm.DB
|
|
TmpDir string
|
|
}
|
|
|
|
// setupTestEnv creates an isolated test environment with its own database and temp directory
|
|
func setupTestEnv(t *testing.T) testEnv {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create .dnote directory
|
|
dnoteDir := filepath.Join(tmpDir, consts.DnoteDirName)
|
|
if err := os.MkdirAll(dnoteDir, 0755); err != nil {
|
|
t.Fatal(errors.Wrap(err, "creating dnote directory"))
|
|
}
|
|
|
|
// Create database at the expected path
|
|
dbPath := filepath.Join(dnoteDir, consts.DnoteDBFileName)
|
|
db := cliDatabase.InitTestFileDBRaw(t, dbPath)
|
|
|
|
// Create server
|
|
server, serverDB := setupNewServer(t)
|
|
|
|
// Create config file with this server's endpoint
|
|
apiEndpoint := fmt.Sprintf("%s/api", server.URL)
|
|
updateConfigAPIEndpoint(t, tmpDir, apiEndpoint)
|
|
|
|
// Create command options with XDG paths pointing to temp dir
|
|
cmdOpts := clitest.RunDnoteCmdOptions{
|
|
Env: []string{
|
|
fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpDir),
|
|
fmt.Sprintf("XDG_DATA_HOME=%s", tmpDir),
|
|
fmt.Sprintf("XDG_CACHE_HOME=%s", tmpDir),
|
|
},
|
|
}
|
|
|
|
return testEnv{
|
|
DB: db,
|
|
CmdOpts: cmdOpts,
|
|
Server: server,
|
|
ServerDB: serverDB,
|
|
TmpDir: tmpDir,
|
|
}
|
|
}
|
|
|
|
// setupTestServer creates a test server with its own database
|
|
func setupTestServer(t *testing.T, serverTime time.Time) (*httptest.Server, *gorm.DB, error) {
|
|
db := apitest.InitMemoryDB(t)
|
|
|
|
mockClock := clock.NewMock()
|
|
mockClock.SetNow(serverTime)
|
|
|
|
a := app.NewTest()
|
|
a.Clock = mockClock
|
|
a.EmailBackend = &apitest.MockEmailbackendImplementation{}
|
|
a.DB = db
|
|
|
|
server, err := controllers.NewServer(&a)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "initializing server")
|
|
}
|
|
|
|
return server, db, nil
|
|
}
|
|
|
|
// setupNewServer creates a new server and returns the server and database.
|
|
// This is useful when a test needs to switch to a new empty server.
|
|
func setupNewServer(t *testing.T) (*httptest.Server, *gorm.DB) {
|
|
server, serverDB, err := setupTestServer(t, serverTime)
|
|
if err != nil {
|
|
t.Fatal(errors.Wrap(err, "setting up new test server"))
|
|
}
|
|
t.Cleanup(func() { server.Close() })
|
|
|
|
return server, serverDB
|
|
}
|
|
|
|
// updateConfigAPIEndpoint updates the config file with the given API endpoint
|
|
func updateConfigAPIEndpoint(t *testing.T, tmpDir string, apiEndpoint string) {
|
|
dnoteDir := filepath.Join(tmpDir, consts.DnoteDirName)
|
|
configPath := filepath.Join(dnoteDir, consts.ConfigFilename)
|
|
configContent := fmt.Sprintf("apiEndpoint: %s\n", apiEndpoint)
|
|
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
|
t.Fatal(errors.Wrap(err, "writing config file"))
|
|
}
|
|
}
|
|
|
|
// switchToEmptyServer closes the current server and creates a new empty server,
|
|
// updating the config file to point to it.
|
|
func switchToEmptyServer(t *testing.T, env *testEnv) {
|
|
// Close old server
|
|
env.Server.Close()
|
|
|
|
// Create new empty server
|
|
env.Server, env.ServerDB = setupNewServer(t)
|
|
|
|
// Update config file to point to new server
|
|
apiEndpoint := fmt.Sprintf("%s/api", env.Server.URL)
|
|
updateConfigAPIEndpoint(t, env.TmpDir, apiEndpoint)
|
|
}
|
|
|
|
// setupUser creates a test user in the server database
|
|
func setupUser(t *testing.T, env testEnv) database.User {
|
|
user := apitest.SetupUserData(env.ServerDB, "alice@example.com", "pass1234")
|
|
|
|
return user
|
|
}
|
|
|
|
// setupUserAndLogin creates a test user and logs them in on the CLI
|
|
func setupUserAndLogin(t *testing.T, env testEnv) database.User {
|
|
user := setupUser(t, env)
|
|
login(t, env.DB, env.ServerDB, user)
|
|
|
|
return user
|
|
}
|
|
|
|
// login logs in the user in CLI
|
|
func login(t *testing.T, db *cliDatabase.DB, serverDB *gorm.DB, user database.User) {
|
|
session := apitest.SetupSession(serverDB, user)
|
|
|
|
cliDatabase.MustExec(t, "inserting session_key", db, "INSERT INTO system (key, value) VALUES (?, ?)", consts.SystemSessionKey, session.Key)
|
|
cliDatabase.MustExec(t, "inserting session_key_expiry", db, "INSERT INTO system (key, value) VALUES (?, ?)", consts.SystemSessionKeyExpiry, session.ExpiresAt.Unix())
|
|
}
|
|
|
|
// apiCreateBook creates a book via the API and returns its UUID
|
|
func apiCreateBook(t *testing.T, env testEnv, user database.User, name, message string) string {
|
|
res := doHTTPReq(t, env, "POST", "/v3/books", fmt.Sprintf(`{"name": "%s"}`, name), message, user)
|
|
|
|
var resp controllers.CreateBookResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
t.Fatal(errors.Wrap(err, "decoding payload for adding book"))
|
|
return ""
|
|
}
|
|
|
|
return resp.Book.UUID
|
|
}
|
|
|
|
// apiPatchBook updates a book via the API
|
|
func apiPatchBook(t *testing.T, env testEnv, user database.User, uuid, payload, message string) {
|
|
doHTTPReq(t, env, "PATCH", fmt.Sprintf("/v3/books/%s", uuid), payload, message, user)
|
|
}
|
|
|
|
// apiDeleteBook deletes a book via the API
|
|
func apiDeleteBook(t *testing.T, env testEnv, user database.User, uuid, message string) {
|
|
doHTTPReq(t, env, "DELETE", fmt.Sprintf("/v3/books/%s", uuid), "", message, user)
|
|
}
|
|
|
|
// apiCreateNote creates a note via the API and returns its UUID
|
|
func apiCreateNote(t *testing.T, env testEnv, user database.User, bookUUID, body, message string) string {
|
|
res := doHTTPReq(t, env, "POST", "/v3/notes", fmt.Sprintf(`{"book_uuid": "%s", "content": "%s"}`, bookUUID, body), message, user)
|
|
|
|
var resp controllers.CreateNoteResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
t.Fatal(errors.Wrap(err, "decoding payload for adding note"))
|
|
return ""
|
|
}
|
|
|
|
return resp.Result.UUID
|
|
}
|
|
|
|
// apiPatchNote updates a note via the API
|
|
func apiPatchNote(t *testing.T, env testEnv, user database.User, noteUUID, payload, message string) {
|
|
doHTTPReq(t, env, "PATCH", fmt.Sprintf("/v3/notes/%s", noteUUID), payload, message, user)
|
|
}
|
|
|
|
// apiDeleteNote deletes a note via the API
|
|
func apiDeleteNote(t *testing.T, env testEnv, user database.User, noteUUID, message string) {
|
|
doHTTPReq(t, env, "DELETE", fmt.Sprintf("/v3/notes/%s", noteUUID), "", message, user)
|
|
}
|
|
|
|
// doHTTPReq performs an authenticated HTTP request and checks for errors
|
|
func doHTTPReq(t *testing.T, env testEnv, method, path, payload, message string, user database.User) *http.Response {
|
|
apiEndpoint := fmt.Sprintf("%s/api", env.Server.URL)
|
|
endpoint := fmt.Sprintf("%s%s", apiEndpoint, path)
|
|
|
|
req, err := http.NewRequest(method, endpoint, strings.NewReader(payload))
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "constructing http request"))
|
|
}
|
|
|
|
res := apitest.HTTPAuthDo(t, env.ServerDB, req, user)
|
|
if res.StatusCode >= 400 {
|
|
bs, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "parsing response body for error"))
|
|
}
|
|
|
|
t.Errorf("%s. HTTP status %d. Message: %s", message, res.StatusCode, string(bs))
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
// setupFunc is a function that sets up test data and returns IDs for assertions
|
|
type setupFunc func(t *testing.T, env testEnv, user database.User) map[string]string
|
|
|
|
// assertFunc is a function that asserts the expected state after sync
|
|
type assertFunc func(t *testing.T, env testEnv, user database.User, ids map[string]string)
|
|
|
|
// testSyncCmd is a test helper that sets up a test environment, runs setup, syncs, and asserts
|
|
func testSyncCmd(t *testing.T, fullSync bool, setup setupFunc, assert assertFunc) {
|
|
env := setupTestEnv(t)
|
|
|
|
user := setupUserAndLogin(t, env)
|
|
ids := setup(t, env, user)
|
|
|
|
if fullSync {
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "sync", "-f")
|
|
} else {
|
|
clitest.RunDnoteCmd(t, env.CmdOpts, cliBinaryName, "sync")
|
|
}
|
|
|
|
assert(t, env, user, ids)
|
|
}
|
|
|
|
// systemState represents the expected state of the sync system
|
|
type systemState struct {
|
|
clientNoteCount int
|
|
clientBookCount int
|
|
clientLastMaxUSN int
|
|
clientLastSyncAt int64
|
|
serverNoteCount int64
|
|
serverBookCount int64
|
|
serverUserMaxUSN int
|
|
}
|
|
|
|
// checkState compares the state of the client and the server with the given system state
|
|
func checkState(t *testing.T, clientDB *cliDatabase.DB, user database.User, serverDB *gorm.DB, expected systemState) {
|
|
var clientBookCount, clientNoteCount int
|
|
cliDatabase.MustScan(t, "counting client notes", clientDB.QueryRow("SELECT count(*) FROM notes"), &clientNoteCount)
|
|
cliDatabase.MustScan(t, "counting client books", clientDB.QueryRow("SELECT count(*) FROM books"), &clientBookCount)
|
|
assert.Equal(t, clientNoteCount, expected.clientNoteCount, "client note count mismatch")
|
|
assert.Equal(t, clientBookCount, expected.clientBookCount, "client book count mismatch")
|
|
|
|
var clientLastMaxUSN int
|
|
var clientLastSyncAt int64
|
|
cliDatabase.MustScan(t, "finding system last_max_usn", clientDB.QueryRow("SELECT value FROM system WHERE key = ?", consts.SystemLastMaxUSN), &clientLastMaxUSN)
|
|
cliDatabase.MustScan(t, "finding system last_sync_at", clientDB.QueryRow("SELECT value FROM system WHERE key = ?", consts.SystemLastSyncAt), &clientLastSyncAt)
|
|
assert.Equal(t, clientLastMaxUSN, expected.clientLastMaxUSN, "client last_max_usn mismatch")
|
|
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")
|
|
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")
|
|
assert.Equal(t, serverUser.MaxUSN, expected.serverUserMaxUSN, "user max_usn mismatch")
|
|
}
|