/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors * * This file is part of Dnote. * * Dnote is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Dnote is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Dnote. If not, see . */ package app import ( "fmt" "strings" "testing" "time" "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" ) func TestCreateNote(t *testing.T) { serverTime := time.Date(2017, time.March, 14, 21, 15, 0, 0, time.UTC) ts1 := time.Date(2018, time.November, 12, 10, 11, 0, 0, time.UTC).UnixNano() ts2 := time.Date(2018, time.November, 15, 0, 1, 10, 0, time.UTC).UnixNano() testCases := []struct { userUSN int addedOn *int64 editedOn *int64 expectedUSN int expectedAddedOn int64 expectedEditedOn int64 }{ { userUSN: 8, addedOn: nil, editedOn: nil, expectedUSN: 9, expectedAddedOn: serverTime.UnixNano(), expectedEditedOn: 0, }, { userUSN: 102229, addedOn: &ts1, editedOn: nil, expectedUSN: 102230, expectedAddedOn: ts1, expectedEditedOn: 0, }, { userUSN: 8099, addedOn: &ts1, editedOn: &ts2, expectedUSN: 8100, expectedAddedOn: ts1, expectedEditedOn: ts2, }, } for idx, tc := range testCases { func() { // Create a new clock for each test case to avoid race conditions in parallel tests mockClock := clock.NewMock() mockClock.SetNow(serverTime) db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") testutils.MustExec(t, db.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) fmt.Println(user) anotherUser := testutils.SetupUserData(db, "another@test.com", "password123") testutils.MustExec(t, db.Model(&anotherUser).Update("max_usn", 55), fmt.Sprintf("preparing user max_usn for test case %d", idx)) b1 := database.Book{UserID: user.ID, Label: "js", Deleted: false} testutils.MustExec(t, db.Save(&b1), fmt.Sprintf("preparing b1 for test case %d", idx)) a := NewTest() a.DB = db a.Clock = mockClock if _, err := a.CreateNote(user, b1.UUID, "note content", tc.addedOn, tc.editedOn, ""); err != nil { t.Fatal(errors.Wrapf(err, "creating note for test case %d", idx)) } var bookCount, noteCount int64 var noteRecord database.Note var userRecord database.User testutils.MustExec(t, db.Model(&database.Book{}).Count(&bookCount), fmt.Sprintf("counting book for test case %d", idx)) testutils.MustExec(t, db.Model(&database.Note{}).Count(¬eCount), fmt.Sprintf("counting notes for test case %d", idx)) testutils.MustExec(t, db.First(¬eRecord), fmt.Sprintf("finding note for test case %d", idx)) testutils.MustExec(t, db.Where("id = ?", user.ID).First(&userRecord), fmt.Sprintf("finding user for test case %d", idx)) assert.Equal(t, bookCount, int64(1), "book count mismatch") assert.Equal(t, noteCount, int64(1), "note count mismatch") assert.NotEqual(t, noteRecord.UUID, "", "note UUID should have been generated") assert.Equal(t, noteRecord.UserID, user.ID, "note UserID mismatch") assert.Equal(t, noteRecord.Body, "note content", "note Body mismatch") assert.Equal(t, noteRecord.Deleted, false, "note Deleted mismatch") assert.Equal(t, noteRecord.USN, tc.expectedUSN, "note Label mismatch") assert.Equal(t, noteRecord.AddedOn, tc.expectedAddedOn, "note AddedOn mismatch") assert.Equal(t, noteRecord.EditedOn, tc.expectedEditedOn, "note EditedOn mismatch") assert.Equal(t, userRecord.MaxUSN, tc.expectedUSN, "user max_usn mismatch") // Assert FTS table is updated var ftsBody string testutils.MustExec(t, db.Raw("SELECT body FROM notes_fts WHERE rowid = ?", noteRecord.ID).Scan(&ftsBody), fmt.Sprintf("querying notes_fts for test case %d", idx)) assert.Equal(t, ftsBody, "note content", "FTS body mismatch") var searchCount int64 testutils.MustExec(t, db.Raw("SELECT COUNT(*) FROM notes_fts WHERE notes_fts MATCH ?", "content").Scan(&searchCount), "searching notes_fts") assert.Equal(t, searchCount, int64(1), "Note should still be searchable") }() } } func TestCreateNote_EmptyBody(t *testing.T) { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, db.Save(&b1), "preparing book") a := NewTest() a.DB = db a.Clock = clock.NewMock() // Create note with empty body note, err := a.CreateNote(user, b1.UUID, "", nil, nil, "") if err != nil { t.Fatal(errors.Wrap(err, "creating note with empty body")) } // Assert FTS entry exists with empty body var ftsBody string testutils.MustExec(t, db.Raw("SELECT body FROM notes_fts WHERE rowid = ?", note.ID).Scan(&ftsBody), "querying notes_fts for empty body note") assert.Equal(t, ftsBody, "", "FTS body should be empty for note created with empty body") } func TestUpdateNote(t *testing.T) { testCases := []struct { userUSN int }{ { userUSN: 8, }, { userUSN: 102229, }, { userUSN: 8099, }, } for idx, tc := range testCases { t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") testutils.MustExec(t, db.Model(&user).Update("max_usn", tc.userUSN), "preparing user max_usn for test case") anotherUser := testutils.SetupUserData(db, "another@test.com", "password123") testutils.MustExec(t, db.Model(&anotherUser).Update("max_usn", 55), "preparing user max_usn for test case") b1 := database.Book{UserID: user.ID, Label: "js", Deleted: false} testutils.MustExec(t, db.Save(&b1), "preparing b1 for test case") note := database.Note{UserID: user.ID, Deleted: false, Body: "test content", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e), "preparing note for test case") // Assert FTS table has original content var ftsBodyBefore string testutils.MustExec(t, db.Raw("SELECT body FROM notes_fts WHERE rowid = ?", note.ID).Scan(&ftsBodyBefore), "querying notes_fts before update") assert.Equal(t, ftsBodyBefore, "test content", "FTS body mismatch before update") c := clock.NewMock() content := "updated test content" a := NewTest() a.DB = db a.Clock = c tx := db.Begin() if _, err := a.UpdateNote(tx, user, note, &UpdateNoteParams{ Content: &content, }); err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "updating note")) } tx.Commit() var bookCount, noteCount int64 var noteRecord database.Note var userRecord database.User testutils.MustExec(t, db.Model(&database.Book{}).Count(&bookCount), "counting book for test case") testutils.MustExec(t, db.Model(&database.Note{}).Count(¬eCount), "counting notes for test case") testutils.MustExec(t, db.First(¬eRecord), "finding note for test case") testutils.MustExec(t, db.Where("id = ?", user.ID).First(&userRecord), "finding user for test case") expectedUSN := tc.userUSN + 1 assert.Equal(t, bookCount, int64(1), "book count mismatch") assert.Equal(t, noteCount, int64(1), "note count mismatch") assert.Equal(t, noteRecord.UserID, user.ID, "note UserID mismatch") assert.Equal(t, noteRecord.Body, content, "note Body mismatch") assert.Equal(t, noteRecord.Deleted, false, "note Deleted mismatch") assert.Equal(t, noteRecord.USN, expectedUSN, "note USN mismatch") assert.Equal(t, userRecord.MaxUSN, expectedUSN, "user MaxUSN mismatch") // Assert FTS table is updated with new content var ftsBodyAfter string testutils.MustExec(t, db.Raw("SELECT body FROM notes_fts WHERE rowid = ?", noteRecord.ID).Scan(&ftsBodyAfter), "querying notes_fts after update") assert.Equal(t, ftsBodyAfter, content, "FTS body mismatch after update") var searchCount int64 testutils.MustExec(t, db.Raw("SELECT COUNT(*) FROM notes_fts WHERE notes_fts MATCH ?", "updated").Scan(&searchCount), "searching notes_fts") assert.Equal(t, searchCount, int64(1), "Note should still be searchable") }) } } func TestUpdateNote_SameContent(t *testing.T) { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, db.Save(&b1), "preparing book") note := database.Note{UserID: user.ID, Deleted: false, Body: "test content", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e), "preparing note") a := NewTest() a.DB = db a.Clock = clock.NewMock() // Update note with same content sameContent := "test content" tx := db.Begin() _, err := a.UpdateNote(tx, user, note, &UpdateNoteParams{ Content: &sameContent, }) if err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "updating note with same content")) } tx.Commit() // Assert FTS still has the same content var ftsBody string testutils.MustExec(t, db.Raw("SELECT body FROM notes_fts WHERE rowid = ?", note.ID).Scan(&ftsBody), "querying notes_fts after update") assert.Equal(t, ftsBody, "test content", "FTS body should still be 'test content'") // Assert it's still searchable var searchCount int64 testutils.MustExec(t, db.Raw("SELECT COUNT(*) FROM notes_fts WHERE notes_fts MATCH ?", "test").Scan(&searchCount), "searching notes_fts") assert.Equal(t, searchCount, int64(1), "Note should still be searchable") } func TestDeleteNote(t *testing.T) { testCases := []struct { userUSN int expectedUSN int }{ { userUSN: 3, expectedUSN: 4, }, { userUSN: 9787, expectedUSN: 9788, }, { userUSN: 787, expectedUSN: 788, }, } for idx, tc := range testCases { func() { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") testutils.MustExec(t, db.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) anotherUser := testutils.SetupUserData(db, "another@test.com", "password123") testutils.MustExec(t, db.Model(&anotherUser).Update("max_usn", 55), fmt.Sprintf("preparing user max_usn for test case %d", idx)) b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, db.Save(&b1), fmt.Sprintf("preparing b1 for test case %d", idx)) note := database.Note{UserID: user.ID, Deleted: false, Body: "test content", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e), fmt.Sprintf("preparing note for test case %d", idx)) // Assert FTS table has content before delete var ftsCountBefore int64 testutils.MustExec(t, db.Raw("SELECT COUNT(*) FROM notes_fts WHERE rowid = ?", note.ID).Scan(&ftsCountBefore), fmt.Sprintf("counting notes_fts before delete for test case %d", idx)) assert.Equal(t, ftsCountBefore, int64(1), "FTS should have entry before delete") a := NewTest() a.DB = db tx := db.Begin() ret, err := a.DeleteNote(tx, user, note) if err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "deleting note")) } tx.Commit() var noteCount int64 var noteRecord database.Note var userRecord database.User testutils.MustExec(t, db.Model(&database.Note{}).Count(¬eCount), fmt.Sprintf("counting notes for test case %d", idx)) testutils.MustExec(t, db.First(¬eRecord), fmt.Sprintf("finding note for test case %d", idx)) testutils.MustExec(t, db.Where("id = ?", user.ID).First(&userRecord), fmt.Sprintf("finding user for test case %d", idx)) assert.Equal(t, noteCount, int64(1), "note count mismatch") assert.Equal(t, noteRecord.UserID, user.ID, "note user_id mismatch") assert.Equal(t, noteRecord.Body, "", "note content mismatch") assert.Equal(t, noteRecord.Deleted, true, "note deleted flag mismatch") assert.Equal(t, noteRecord.USN, tc.expectedUSN, "note label mismatch") assert.Equal(t, userRecord.MaxUSN, tc.expectedUSN, "user max_usn mismatch") assert.Equal(t, ret.UserID, user.ID, "note user_id mismatch") assert.Equal(t, ret.Body, "", "note content mismatch") assert.Equal(t, ret.Deleted, true, "note deleted flag mismatch") assert.Equal(t, ret.USN, tc.expectedUSN, "note label mismatch") // Assert FTS body is empty after delete (row still exists but content is cleared) var ftsBody string testutils.MustExec(t, db.Raw("SELECT body FROM notes_fts WHERE rowid = ?", noteRecord.ID).Scan(&ftsBody), fmt.Sprintf("querying notes_fts after delete for test case %d", idx)) assert.Equal(t, ftsBody, "", "FTS body should be empty after delete") }() } } func TestGetNotes_FTSSearch(t *testing.T) { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, db.Save(&b1), "preparing book") // Create notes with different content note1 := database.Note{UserID: user.ID, Deleted: false, Body: "foo bar baz bar", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e1), "preparing note1") note2 := database.Note{UserID: user.ID, Deleted: false, Body: "hello run foo", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e2), "preparing note2") note3 := database.Note{UserID: user.ID, Deleted: false, Body: "running quz succeeded", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e3), "preparing note3") a := NewTest() a.DB = db a.Clock = clock.NewMock() // Search "baz" result, err := a.GetNotes(user.ID, GetNotesParams{ Search: "baz", Page: 1, PerPage: 30, }) if err != nil { t.Fatal(errors.Wrap(err, "getting notes with FTS search")) } assert.Equal(t, result.Total, int64(1), "Should find 1 note with 'baz'") assert.Equal(t, len(result.Notes), 1, "Should return 1 note") for i, note := range result.Notes { assert.Equal(t, strings.Contains(note.Body, "baz"), true, fmt.Sprintf("Note %d should contain highlighted dnote", i)) } // Search for "running" - should return 1 note result, err = a.GetNotes(user.ID, GetNotesParams{ Search: "running", Page: 1, PerPage: 30, }) if err != nil { t.Fatal(errors.Wrap(err, "getting notes with FTS search for review")) } assert.Equal(t, result.Total, int64(2), "Should find 2 note with 'running'") assert.Equal(t, len(result.Notes), 2, "Should return 2 notes") assert.Equal(t, result.Notes[0].Body, "running quz succeeded", "Should return the review note with highlighting") assert.Equal(t, result.Notes[1].Body, "hello run foo", "Should return the review note with highlighting") // Search for non-existent term - should return 0 notes result, err = a.GetNotes(user.ID, GetNotesParams{ Search: "nonexistent", Page: 1, PerPage: 30, }) if err != nil { t.Fatal(errors.Wrap(err, "getting notes with FTS search for nonexistent")) } assert.Equal(t, result.Total, int64(0), "Should find 0 notes with 'nonexistent'") assert.Equal(t, len(result.Notes), 0, "Should return 0 notes") } func TestGetNotes_FTSSearch_Snippet(t *testing.T) { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, db.Save(&b1), "preparing book") // Create a long note to test snippet truncation with "..." // The snippet limit is 50 tokens, so we generate enough words to exceed it longBody := strings.Repeat("filler ", 100) + "the important keyword appears here" longNote := database.Note{UserID: user.ID, Deleted: false, Body: longBody, BookUUID: b1.UUID} testutils.MustExec(t, db.Save(&longNote), "preparing long note") a := NewTest() a.DB = db a.Clock = clock.NewMock() // Search for "keyword" in long note - should return snippet with "..." result, err := a.GetNotes(user.ID, GetNotesParams{ Search: "keyword", Page: 1, PerPage: 30, }) if err != nil { t.Fatal(errors.Wrap(err, "getting notes with FTS search for keyword")) } assert.Equal(t, result.Total, int64(1), "Should find 1 note with 'keyword'") assert.Equal(t, len(result.Notes), 1, "Should return 1 note") // The snippet should contain "..." to indicate truncation and the highlighted keyword assert.Equal(t, strings.Contains(result.Notes[0].Body, "..."), true, "Snippet should contain '...' for truncation") assert.Equal(t, strings.Contains(result.Notes[0].Body, "keyword"), true, "Snippet should contain highlighted keyword") } func TestGetNotes_FTSSearch_ShortWord(t *testing.T) { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, db.Save(&b1), "preparing book") // Create notes with short words note1 := database.Note{UserID: user.ID, Deleted: false, Body: "a b c", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e1), "preparing note1") note2 := database.Note{UserID: user.ID, Deleted: false, Body: "d", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e2), "preparing note2") a := NewTest() a.DB = db a.Clock = clock.NewMock() result, err := a.GetNotes(user.ID, GetNotesParams{ Search: "a", Page: 1, PerPage: 30, }) if err != nil { t.Fatal(errors.Wrap(err, "getting notes with FTS search for 'a'")) } assert.Equal(t, result.Total, int64(1), "Should find 1 note") assert.Equal(t, len(result.Notes), 1, "Should return 1 note") assert.Equal(t, strings.Contains(result.Notes[0].Body, "a"), true, "Should contain highlighted 'a'") } func TestGetNotes_All(t *testing.T) { db := testutils.InitMemoryDB(t) user := testutils.SetupUserData(db, "user@test.com", "password123") b1 := database.Book{UserID: user.ID, Label: "testBook"} testutils.MustExec(t, db.Save(&b1), "preparing book") note1 := database.Note{UserID: user.ID, Deleted: false, Body: "a b c", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e1), "preparing note1") note2 := database.Note{UserID: user.ID, Deleted: false, Body: "d", BookUUID: b1.UUID} testutils.MustExec(t, db.Save(¬e2), "preparing note2") a := NewTest() a.DB = db a.Clock = clock.NewMock() result, err := a.GetNotes(user.ID, GetNotesParams{ Search: "", Page: 1, PerPage: 30, }) if err != nil { t.Fatal(errors.Wrap(err, "getting notes with FTS search for 'a'")) } assert.Equal(t, result.Total, int64(2), "Should not find all notes") assert.Equal(t, len(result.Notes), 2, "Should not find all notes") for _, note := range result.Notes { assert.Equal(t, strings.Contains(note.Body, ""), false, "There should be no keywords") assert.Equal(t, strings.Contains(note.Body, ""), false, "There should be no keywords") } assert.Equal(t, result.Notes[0].Body, "d", "Full content should be returned") assert.Equal(t, result.Notes[1].Body, "a b c", "Full content should be returned") }