From ccc58b2898132d86c6e333cac1d27d0e71028c09 Mon Sep 17 00:00:00 2001 From: Nicola Zangrandi Date: Fri, 21 Feb 2025 09:35:37 +0100 Subject: [PATCH] refactor(notes): move note functionality into dedicated package --- go.mod | 2 +- main.go | 271 ++-------------------------- notes/model.go | 72 ++++++++ main_test.go => notes/model_test.go | 6 +- notes/routes.go | 123 +++++++++++++ notes/routes_test.go | 232 ++++++++++++++++++++++++ notes/service.go | 108 +++++++++++ notes/service_test.go | 215 ++++++++++++++++++++++ 8 files changed, 766 insertions(+), 263 deletions(-) create mode 100644 notes/model.go rename main_test.go => notes/model_test.go (97%) create mode 100644 notes/routes.go create mode 100644 notes/routes_test.go create mode 100644 notes/service.go create mode 100644 notes/service_test.go diff --git a/go.mod b/go.mod index 1695525..0c07bdf 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module notes +module qn go 1.24 diff --git a/main.go b/main.go index b8770d7..9d3820d 100644 --- a/main.go +++ b/main.go @@ -2,20 +2,18 @@ package main import ( "embed" - "fmt" "log" "mime" "net/http" "path" "path/filepath" - "regexp" "strings" - "time" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" - "github.com/google/uuid" "gorm.io/gorm" + + "qn/notes" ) 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/* 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() { - var err error - db, err = gorm.Open(sqlite.Open("notes.db"), &gorm.Config{}) + // Initialize database + db, err := gorm.Open(sqlite.Open("notes.db"), &gorm.Config{}) if err != nil { log.Fatal(err) } // Auto migrate the schema - if err := db.AutoMigrate(&Note{}); err != nil { + if err := db.AutoMigrate(¬es.Note{}, ¬es.NoteLink{}); err != nil { log.Fatal(err) } + // Initialize services + noteService := notes.NewService(db) + noteHandler := notes.NewHandler(noteService) + // Create Gin router r := gin.Default() // API routes api := r.Group("/api") { - notes := api.Group("/notes") - { - notes.GET("", handleGetNotes) - notes.POST("", handleCreateNote) - notes.GET("/:id", handleGetNote) - notes.PUT("/:id", handleUpdateNote) - notes.DELETE("/:id", handleDeleteNote) - } - - api.POST("/test/reset", handleReset) + noteHandler.RegisterRoutes(api) + // TODO: Add feeds and links routes when implemented } // Serve frontend @@ -188,171 +104,6 @@ func main() { log.Fatal(r.Run(":3000")) } -func handleGetNotes(c *gin.Context) { - var notes []Note - if err := db.Preload("LinksTo").Order("updated_at desc").Find(¬es).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(¬e); 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(¬e).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(¬e).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(¬e, "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(¬e); 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(¬e, "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) { // Don't serve API routes if path.Dir(c.Request.URL.Path) == "/api" { diff --git a/notes/model.go b/notes/model.go new file mode 100644 index 0000000..055fd40 --- /dev/null +++ b/notes/model.go @@ -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 +} \ No newline at end of file diff --git a/main_test.go b/notes/model_test.go similarity index 97% rename from main_test.go rename to notes/model_test.go index 5c68c29..b2cbb1a 100644 --- a/main_test.go +++ b/notes/model_test.go @@ -1,4 +1,4 @@ -package main +package notes import ( "testing" @@ -10,7 +10,9 @@ import ( ) 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 { t.Fatalf("Failed to open test database: %v", err) } diff --git a/notes/routes.go b/notes/routes.go new file mode 100644 index 0000000..71236f9 --- /dev/null +++ b/notes/routes.go @@ -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(¬e); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.service.Create(¬e); 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(¬e); 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) +} \ No newline at end of file diff --git a/notes/routes_test.go b/notes/routes_test.go new file mode 100644 index 0000000..4ab5bc4 --- /dev/null +++ b/notes/routes_test.go @@ -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)) + } +} \ No newline at end of file diff --git a/notes/service.go b/notes/service.go new file mode 100644 index 0000000..5241e15 --- /dev/null +++ b/notes/service.go @@ -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(¬e, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to get note: %w", err) + } + return ¬e, 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(¬es).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(¬e, "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 +} \ No newline at end of file diff --git a/notes/service_test.go b/notes/service_test.go new file mode 100644 index 0000000..0295cc1 --- /dev/null +++ b/notes/service_test.go @@ -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)) + } +} \ No newline at end of file