refactor(notes): move note functionality into dedicated package

This commit is contained in:
Nicola Zangrandi 2025-02-21 09:35:37 +01:00
parent ec4f412267
commit ccc58b2898
Signed by: wasp
GPG key ID: 43C1470D890F23ED
8 changed files with 766 additions and 263 deletions

2
go.mod
View file

@ -1,4 +1,4 @@
module notes module qn
go 1.24 go 1.24

271
main.go
View file

@ -2,20 +2,18 @@ package main
import ( import (
"embed" "embed"
"fmt"
"log" "log"
"mime" "mime"
"net/http" "net/http"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"qn/notes"
) )
func serveStaticFile(c *gin.Context, prefix string) error { func serveStaticFile(c *gin.Context, prefix string) error {
@ -73,112 +71,30 @@ func serveStaticFile(c *gin.Context, prefix string) error {
//go:embed frontend/build/* frontend/static/* //go:embed frontend/build/* frontend/static/*
var frontend embed.FS var frontend embed.FS
type Note struct {
ID string `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"not null"`
Content string `json:"content" gorm:"not null"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Link relationships
LinksTo []*Note `json:"linksTo,omitempty" gorm:"many2many:note_links;joinForeignKey:source_note_id;joinReferences:target_note_id"`
LinkedBy []*Note `json:"linkedBy,omitempty" gorm:"many2many:note_links;joinForeignKey:target_note_id;joinReferences:source_note_id"`
}
// Link represents a connection between two notes
type NoteLink struct {
SourceNoteID string `gorm:"primaryKey"`
TargetNoteID string `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"autoCreateTime"`
}
// ExtractLinks finds all [[note-title]] style links in the content
func (n *Note) ExtractLinks(content string) []string {
re := regexp.MustCompile(`\[\[(.*?)\]\]`)
matches := re.FindAllStringSubmatch(content, -1)
titles := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) > 1 {
titles = append(titles, match[1])
}
}
return titles
}
// UpdateLinks updates the note's links based on its content
func (n *Note) UpdateLinks(db *gorm.DB) error {
// Start a transaction
tx := db.Begin()
if tx.Error != nil {
return fmt.Errorf("failed to start transaction: %w", tx.Error)
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Delete existing links
if err := tx.Where("source_note_id = ?", n.ID).Delete(&NoteLink{}).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to delete existing links: %w", err)
}
// Extract and create new links
titles := n.ExtractLinks(n.Content)
for _, title := range titles {
var target Note
if err := tx.Where("title = ?", title).First(&target).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Skip non-existent notes
continue
}
tx.Rollback()
return fmt.Errorf("failed to find target note %q: %w", title, err)
}
link := NoteLink{
SourceNoteID: n.ID,
TargetNoteID: target.ID,
}
if err := tx.Create(&link).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to create link to %q: %w", title, err)
}
}
return tx.Commit().Error
}
var db *gorm.DB
func main() { func main() {
var err error // Initialize database
db, err = gorm.Open(sqlite.Open("notes.db"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("notes.db"), &gorm.Config{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Auto migrate the schema // Auto migrate the schema
if err := db.AutoMigrate(&Note{}); err != nil { if err := db.AutoMigrate(&notes.Note{}, &notes.NoteLink{}); err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Initialize services
noteService := notes.NewService(db)
noteHandler := notes.NewHandler(noteService)
// Create Gin router // Create Gin router
r := gin.Default() r := gin.Default()
// API routes // API routes
api := r.Group("/api") api := r.Group("/api")
{ {
notes := api.Group("/notes") noteHandler.RegisterRoutes(api)
{ // TODO: Add feeds and links routes when implemented
notes.GET("", handleGetNotes)
notes.POST("", handleCreateNote)
notes.GET("/:id", handleGetNote)
notes.PUT("/:id", handleUpdateNote)
notes.DELETE("/:id", handleDeleteNote)
}
api.POST("/test/reset", handleReset)
} }
// Serve frontend // Serve frontend
@ -188,171 +104,6 @@ func main() {
log.Fatal(r.Run(":3000")) log.Fatal(r.Run(":3000"))
} }
func handleGetNotes(c *gin.Context) {
var notes []Note
if err := db.Preload("LinksTo").Order("updated_at desc").Find(&notes).Error; err != nil {
log.Printf("ERROR: Failed to query notes: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, notes)
}
func handleCreateNote(c *gin.Context) {
var note Note
if err := c.ShouldBindJSON(&note); err != nil {
log.Printf("ERROR: Failed to decode note: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate a new UUID for the note
note.ID = uuid.New().String()
// Start a transaction
tx := db.Begin()
if tx.Error != nil {
log.Printf("ERROR: Failed to start transaction: %v", tx.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": tx.Error.Error()})
return
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Create the note
if err := tx.Create(&note).Error; err != nil {
tx.Rollback()
log.Printf("ERROR: Failed to create note: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update links
if err := note.UpdateLinks(tx); err != nil {
tx.Rollback()
log.Printf("ERROR: Failed to update note links: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := tx.Commit().Error; err != nil {
log.Printf("ERROR: Failed to commit transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Load the note with its relationships
if err := db.Preload("LinksTo").First(&note).Error; err != nil {
log.Printf("ERROR: Failed to load note relationships: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, note)
}
func handleGetNote(c *gin.Context) {
id := c.Param("id")
var note Note
if err := db.Preload("LinksTo").Preload("LinkedBy").First(&note, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.Status(http.StatusNotFound)
return
}
log.Printf("ERROR: Failed to get note %s: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, note)
}
func handleUpdateNote(c *gin.Context) {
id := c.Param("id")
var note Note
if err := c.ShouldBindJSON(&note); err != nil {
log.Printf("ERROR: Failed to decode note update: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Start a transaction
tx := db.Begin()
if tx.Error != nil {
log.Printf("ERROR: Failed to start transaction: %v", tx.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": tx.Error.Error()})
return
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Update the note
if err := tx.Model(&Note{}).Where("id = ?", id).Updates(map[string]interface{}{
"title": note.Title,
"content": note.Content,
"updated_at": note.UpdatedAt,
}).Error; err != nil {
tx.Rollback()
log.Printf("ERROR: Failed to update note %s: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Load the updated note for link processing
if err := tx.First(&note, "id = ?", id).Error; err != nil {
tx.Rollback()
log.Printf("ERROR: Failed to load note %s: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update links
if err := note.UpdateLinks(tx); err != nil {
tx.Rollback()
log.Printf("ERROR: Failed to update note links: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := tx.Commit().Error; err != nil {
log.Printf("ERROR: Failed to commit transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func handleDeleteNote(c *gin.Context) {
id := c.Param("id")
if err := db.Delete(&Note{}, "id = ?", id).Error; err != nil {
log.Printf("ERROR: Failed to delete note %s: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleReset(c *gin.Context) {
if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&Note{}).Error; err != nil {
log.Printf("ERROR: Failed to reset database: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func handleFrontend(c *gin.Context) { func handleFrontend(c *gin.Context) {
// Don't serve API routes // Don't serve API routes
if path.Dir(c.Request.URL.Path) == "/api" { if path.Dir(c.Request.URL.Path) == "/api" {

72
notes/model.go Normal file
View file

@ -0,0 +1,72 @@
package notes
import (
"fmt"
"regexp"
"time"
"gorm.io/gorm"
)
// Note represents a note in the system
type Note struct {
ID string `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"not null"`
Content string `json:"content" gorm:"not null"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Link relationships
LinksTo []*Note `json:"linksTo,omitempty" gorm:"many2many:note_links;joinForeignKey:source_note_id;joinReferences:target_note_id"`
LinkedBy []*Note `json:"linkedBy,omitempty" gorm:"many2many:note_links;joinForeignKey:target_note_id;joinReferences:source_note_id"`
}
// NoteLink represents a connection between two notes
type NoteLink struct {
SourceNoteID string `gorm:"primaryKey"`
TargetNoteID string `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"autoCreateTime"`
}
// ExtractLinks finds all [[note-title]] style links in the content
func (n *Note) ExtractLinks(content string) []string {
re := regexp.MustCompile(`\[\[(.*?)\]\]`)
matches := re.FindAllStringSubmatch(content, -1)
titles := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) > 1 {
titles = append(titles, match[1])
}
}
return titles
}
// UpdateLinks updates the note's links based on its content
func (n *Note) UpdateLinks(db *gorm.DB) error {
// Delete existing links
if err := db.Where("source_note_id = ?", n.ID).Delete(&NoteLink{}).Error; err != nil {
return fmt.Errorf("failed to delete existing links: %w", err)
}
// Extract and create new links
titles := n.ExtractLinks(n.Content)
for _, title := range titles {
var target Note
if err := db.Where("title = ?", title).First(&target).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Skip non-existent notes
continue
}
return fmt.Errorf("failed to find target note %q: %w", title, err)
}
link := NoteLink{
SourceNoteID: n.ID,
TargetNoteID: target.ID,
}
if err := db.Create(&link).Error; err != nil {
return fmt.Errorf("failed to create link to %q: %w", title, err)
}
}
return nil
}

View file

@ -1,4 +1,4 @@
package main package notes
import ( import (
"testing" "testing"
@ -10,7 +10,9 @@ import (
) )
func setupTestDB(t *testing.T) *gorm.DB { func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil { if err != nil {
t.Fatalf("Failed to open test database: %v", err) t.Fatalf("Failed to open test database: %v", err)
} }

123
notes/routes.go Normal file
View file

@ -0,0 +1,123 @@
package notes
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Handler handles HTTP requests for notes
type Handler struct {
service *Service
}
// NewHandler creates a new notes handler
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// RegisterRoutes registers the note routes with the given router group
func (h *Handler) RegisterRoutes(group *gin.RouterGroup) {
notes := group.Group("/notes")
{
notes.GET("", h.handleGetNotes)
notes.POST("", h.handleCreateNote)
notes.GET("/:id", h.handleGetNote)
notes.PUT("/:id", h.handleUpdateNote)
notes.DELETE("/:id", h.handleDeleteNote)
}
// Test endpoint
group.POST("/test/reset", h.handleReset)
}
func (h *Handler) handleGetNotes(c *gin.Context) {
notes, err := h.service.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, notes)
}
func (h *Handler) handleCreateNote(c *gin.Context) {
var note Note
if err := c.ShouldBindJSON(&note); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Create(&note); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, note)
}
func (h *Handler) handleGetNote(c *gin.Context) {
id := c.Param("id")
note, err := h.service.Get(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if note == nil {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusOK, note)
}
func (h *Handler) handleUpdateNote(c *gin.Context) {
id := c.Param("id")
var note Note
if err := c.ShouldBindJSON(&note); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{
"title": note.Title,
"content": note.Content,
"updated_at": note.UpdatedAt,
}
if err := h.service.Update(id, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get the updated note
updated, err := h.service.Get(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if updated == nil {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusOK, updated)
}
func (h *Handler) handleDeleteNote(c *gin.Context) {
id := c.Param("id")
if err := h.service.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *Handler) handleReset(c *gin.Context) {
if err := h.service.Reset(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}

232
notes/routes_test.go Normal file
View file

@ -0,0 +1,232 @@
package notes
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func setupTestRouter(t *testing.T) (*gin.Engine, *Service) {
gin.SetMode(gin.TestMode)
router := gin.New()
db := setupTestDB(t)
service := NewService(db)
handler := NewHandler(service)
handler.RegisterRoutes(router.Group("/api"))
return router, service
}
func TestHandler_CreateNote(t *testing.T) {
router, _ := setupTestRouter(t)
// Test creating a note
note := map[string]interface{}{
"title": "Test Note",
"content": "Test content with [[Another Note]]",
}
body, _ := json.Marshal(note)
req := httptest.NewRequest("POST", "/api/notes", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if response["title"] != note["title"] {
t.Errorf("Expected title %q, got %q", note["title"], response["title"])
}
}
func TestHandler_GetNote(t *testing.T) {
router, service := setupTestRouter(t)
// Create a test note
note := &Note{
Title: "Test Note",
Content: "Test content",
}
if err := service.Create(note); err != nil {
t.Fatalf("Failed to create test note: %v", err)
}
// Test getting the note
req := httptest.NewRequest("GET", "/api/notes/"+note.ID, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if response["title"] != note.Title {
t.Errorf("Expected title %q, got %q", note.Title, response["title"])
}
// Test getting non-existent note
req = httptest.NewRequest("GET", "/api/notes/non-existent", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
func TestHandler_ListNotes(t *testing.T) {
router, service := setupTestRouter(t)
// Create some test notes
notes := []*Note{
{Title: "First Note", Content: "First content"},
{Title: "Second Note", Content: "Second content"},
}
for _, note := range notes {
if err := service.Create(note); err != nil {
t.Fatalf("Failed to create test note: %v", err)
}
}
// Test listing notes
req := httptest.NewRequest("GET", "/api/notes", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var response []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if len(response) != len(notes) {
t.Errorf("Expected %d notes, got %d", len(notes), len(response))
}
}
func TestHandler_UpdateNote(t *testing.T) {
router, service := setupTestRouter(t)
// Create a test note
note := &Note{
Title: "Original Title",
Content: "Original content",
}
if err := service.Create(note); err != nil {
t.Fatalf("Failed to create test note: %v", err)
}
// Test updating the note
update := map[string]interface{}{
"title": "Updated Title",
"content": "Updated content with [[Link]]",
}
body, _ := json.Marshal(update)
req := httptest.NewRequest("PUT", "/api/notes/"+note.ID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if response["title"] != update["title"] {
t.Errorf("Expected title %q, got %q", update["title"], response["title"])
}
}
func TestHandler_DeleteNote(t *testing.T) {
router, service := setupTestRouter(t)
// Create a test note
note := &Note{
Title: "Test Note",
Content: "Test content",
}
if err := service.Create(note); err != nil {
t.Fatalf("Failed to create test note: %v", err)
}
// Test deleting the note
req := httptest.NewRequest("DELETE", "/api/notes/"+note.ID, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
// Verify note is deleted
req = httptest.NewRequest("GET", "/api/notes/"+note.ID, nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
func TestHandler_ResetNotes(t *testing.T) {
router, service := setupTestRouter(t)
// Create some test notes
notes := []*Note{
{Title: "First Note", Content: "First content"},
{Title: "Second Note", Content: "Second content"},
}
for _, note := range notes {
if err := service.Create(note); err != nil {
t.Fatalf("Failed to create test note: %v", err)
}
}
// Test resetting notes
req := httptest.NewRequest("POST", "/api/test/reset", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
// Verify all notes are deleted
req = httptest.NewRequest("GET", "/api/notes", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var response []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if len(response) != 0 {
t.Errorf("Expected empty notes list, got %d notes", len(response))
}
}

108
notes/service.go Normal file
View file

@ -0,0 +1,108 @@
package notes
import (
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Service handles note operations
type Service struct {
db *gorm.DB
}
// NewService creates a new note service
func NewService(db *gorm.DB) *Service {
return &Service{db: db}
}
// Create creates a new note
func (s *Service) Create(note *Note) error {
note.ID = uuid.New().String()
err := s.db.Transaction(func(tx *gorm.DB) error {
// Create the note
if err := tx.Create(note).Error; err != nil {
return fmt.Errorf("failed to create note: %w", err)
}
// Update links
if err := note.UpdateLinks(tx); err != nil {
return fmt.Errorf("failed to update note links: %w", err)
}
return nil
})
if err != nil {
return err
}
// Load the note with its relationships
if err := s.db.Preload("LinksTo").First(note).Error; err != nil {
return fmt.Errorf("failed to load note relationships: %w", err)
}
return nil
}
// Get retrieves a note by ID
func (s *Service) Get(id string) (*Note, error) {
var note Note
if err := s.db.Preload("LinksTo").Preload("LinkedBy").First(&note, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, fmt.Errorf("failed to get note: %w", err)
}
return &note, nil
}
// List retrieves all notes
func (s *Service) List() ([]Note, error) {
var notes []Note
if err := s.db.Preload("LinksTo").Order("updated_at desc").Find(&notes).Error; err != nil {
return nil, fmt.Errorf("failed to list notes: %w", err)
}
return notes, nil
}
// Update updates a note
func (s *Service) Update(id string, updates map[string]interface{}) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// Update the note
if err := tx.Model(&Note{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return fmt.Errorf("failed to update note: %w", err)
}
// Load the updated note for link processing
var note Note
if err := tx.First(&note, "id = ?", id).Error; err != nil {
return fmt.Errorf("failed to load note: %w", err)
}
// Update links
if err := note.UpdateLinks(tx); err != nil {
return fmt.Errorf("failed to update note links: %w", err)
}
return nil
})
}
// Delete deletes a note
func (s *Service) Delete(id string) error {
if err := s.db.Delete(&Note{}, "id = ?", id).Error; err != nil {
return fmt.Errorf("failed to delete note: %w", err)
}
return nil
}
// Reset deletes all notes (for testing)
func (s *Service) Reset() error {
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&Note{}).Error; err != nil {
return fmt.Errorf("failed to reset notes: %w", err)
}
return nil
}

215
notes/service_test.go Normal file
View file

@ -0,0 +1,215 @@
package notes
import (
"testing"
"time"
)
func TestService_Create(t *testing.T) {
db := setupTestDB(t)
service := NewService(db)
note := &Note{
Title: "Test Note",
Content: "Content with [[Another Note]]",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Test creating a note
if err := service.Create(note); err != nil {
t.Fatalf("Failed to create note: %v", err)
}
if note.ID == "" {
t.Error("Note ID was not set")
}
// Test that no links were created (target note doesn't exist)
if len(note.LinksTo) != 0 {
t.Errorf("Expected 0 links, got %d", len(note.LinksTo))
}
// Create target note and update original note
another := &Note{
Title: "Another Note",
Content: "Some content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := service.Create(another); err != nil {
t.Fatalf("Failed to create another note: %v", err)
}
// Update original note to create the link
if err := service.Update(note.ID, map[string]interface{}{
"content": note.Content,
}); err != nil {
t.Fatalf("Failed to update note: %v", err)
}
// Get the note and verify the link was created
updated, err := service.Get(note.ID)
if err != nil {
t.Fatalf("Failed to get note: %v", err)
}
if len(updated.LinksTo) != 1 {
t.Errorf("Expected 1 link, got %d", len(updated.LinksTo))
}
}
func TestService_Get(t *testing.T) {
db := setupTestDB(t)
service := NewService(db)
// Test getting non-existent note
note, err := service.Get("non-existent")
if err != nil {
t.Fatalf("Expected nil error for non-existent note, got %v", err)
}
if note != nil {
t.Error("Expected nil note for non-existent ID")
}
// Create a note and test getting it
created := &Note{
Title: "Test Note",
Content: "Test content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := service.Create(created); err != nil {
t.Fatalf("Failed to create note: %v", err)
}
note, err = service.Get(created.ID)
if err != nil {
t.Fatalf("Failed to get note: %v", err)
}
if note == nil {
t.Fatal("Expected note, got nil")
}
if note.Title != created.Title {
t.Errorf("Expected title %q, got %q", created.Title, note.Title)
}
}
func TestService_List(t *testing.T) {
db := setupTestDB(t)
service := NewService(db)
// Test empty list
notes, err := service.List()
if err != nil {
t.Fatalf("Failed to list notes: %v", err)
}
if len(notes) != 0 {
t.Errorf("Expected empty list, got %d notes", len(notes))
}
// Create some notes
note1 := &Note{
Title: "First Note",
Content: "First content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
note2 := &Note{
Title: "Second Note",
Content: "Second content",
CreatedAt: time.Now(),
UpdatedAt: time.Now().Add(time.Hour), // Later update time
}
if err := service.Create(note1); err != nil {
t.Fatalf("Failed to create note1: %v", err)
}
if err := service.Create(note2); err != nil {
t.Fatalf("Failed to create note2: %v", err)
}
// Test listing notes
notes, err = service.List()
if err != nil {
t.Fatalf("Failed to list notes: %v", err)
}
if len(notes) != 2 {
t.Errorf("Expected 2 notes, got %d", len(notes))
}
// Verify order (most recently updated first)
if notes[0].ID != note2.ID {
t.Error("Notes not ordered by updated_at desc")
}
}
func TestService_Delete(t *testing.T) {
db := setupTestDB(t)
service := NewService(db)
// Create a note
note := &Note{
Title: "Test Note",
Content: "Test content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := service.Create(note); err != nil {
t.Fatalf("Failed to create note: %v", err)
}
// Delete the note
if err := service.Delete(note.ID); err != nil {
t.Fatalf("Failed to delete note: %v", err)
}
// Verify note is deleted
found, err := service.Get(note.ID)
if err != nil {
t.Fatalf("Failed to check deleted note: %v", err)
}
if found != nil {
t.Error("Note still exists after deletion")
}
}
func TestService_Reset(t *testing.T) {
db := setupTestDB(t)
service := NewService(db)
// Create some notes
note1 := &Note{
Title: "First Note",
Content: "First content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
note2 := &Note{
Title: "Second Note",
Content: "Second content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := service.Create(note1); err != nil {
t.Fatalf("Failed to create note1: %v", err)
}
if err := service.Create(note2); err != nil {
t.Fatalf("Failed to create note2: %v", err)
}
// Reset the database
if err := service.Reset(); err != nil {
t.Fatalf("Failed to reset database: %v", err)
}
// Verify all notes are deleted
notes, err := service.List()
if err != nil {
t.Fatalf("Failed to list notes after reset: %v", err)
}
if len(notes) != 0 {
t.Errorf("Expected empty database after reset, got %d notes", len(notes))
}
}