diff --git a/core/files/doc.go b/core/files/doc.go deleted file mode 100644 index a4727ba..0000000 --- a/core/files/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package files provides utilities for working with files and directories. -package files diff --git a/core/files/file_handler.go b/core/files/file_handler.go deleted file mode 100644 index 3ba059b..0000000 --- a/core/files/file_handler.go +++ /dev/null @@ -1,19 +0,0 @@ -package files - -import "os" - -func WriteToFile(path string, data []byte) (filePath string, err error) { - if checkIfFileExists(path) { - return path, nil - } - err = os.WriteFile(path, data, 0o644) - if err != nil { - return "", err - } - return path, nil -} - -func checkIfFileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} diff --git a/core/handlers/create_note_handler.go b/core/handlers/create_note_handler.go index 1fe121d..4225430 100644 --- a/core/handlers/create_note_handler.go +++ b/core/handlers/create_note_handler.go @@ -2,11 +2,10 @@ package handlers import ( "context" - "database/sql" "encoding/json" - "os" "github.com/KristianJBorgwarth/dendrite.daemon/core/frontmatter" + "github.com/KristianJBorgwarth/dendrite.daemon/core/template" "github.com/KristianJBorgwarth/dendrite.daemon/persistence/repositories" ) @@ -25,51 +24,48 @@ func NewCreateNoteHandler(uow *repositories.UnitOfWork) *CreateNoteHandler { return &CreateNoteHandler{uow: uow} } -func (h CreateNoteHandler) Handle(ctx context.Context, raw json.RawMessage) (any, error) { +func (h *CreateNoteHandler) Handle(ctx context.Context, raw json.RawMessage) (any, error) { var cmd createNoteCommand - if err := json.Unmarshal(raw, &cmd); err != nil { return nil, err } - data, err := h.getTemplate(cmd.TemplatePath) + data, err := template.GenerateTemplate(cmd.TemplatePath) if err != nil { return nil, err } + if data == nil { + data = []byte("---\ntitle: " + cmd.Title + "\ntags: []\n---\n") + } + tags, err := frontmatter.ParseTags(data) if err != nil { return nil, err } - err = h.uow.Execute(ctx, func(tx *sql.Tx) error { - tagRepo := repositories.NewTagRepository(tx) - noteRepo := repositories.NewNoteRepository(tx) - - if err = tagRepo.Upsert(ctx, tags); err != nil { - return err - } - - if err = noteRepo.Upsert(ctx, cmd.Title, cmd.Path, frontmatter.Slugify(cmd.Title)); err != nil { - return err - } - - return nil - }) + tx, err := h.uow.Begin() if err != nil { return nil, err } + defer h.uow.Rollback() + + tagRepo := repositories.NewTagRepository(tx) + noteRepo := repositories.NewNoteRepository(tx) + + if err = tagRepo.Upsert(ctx, tags); err != nil { + return nil, err + } + + if err = noteRepo.Upsert(ctx, cmd.Title, cmd.Path, frontmatter.Slugify(cmd.Title)); err != nil { + return nil, err + } + + h.uow.FileStore.Stage(cmd.Path, data) + + if err = h.uow.Commit(); err != nil { + return nil, err + } return cmd.Path, nil } - -func (h *CreateNoteHandler) getTemplate(templatePath string) ([]byte, error) { - if templatePath == "" { - return nil, nil - } - data, err := os.ReadFile(templatePath) - if err != nil { - return nil, err - } - return data, nil -} diff --git a/persistence/repositories/note_repository.go b/persistence/repositories/note_repository.go index 0c858b7..e9d9444 100644 --- a/persistence/repositories/note_repository.go +++ b/persistence/repositories/note_repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "database/sql" ) type NoteRepository interface { @@ -9,21 +10,21 @@ type NoteRepository interface { } type noteRepository struct { - dbCtx DBContext + Transaction *sql.Tx } -func NewNoteRepository(dbContext DBContext) NoteRepository { - return ¬eRepository{dbCtx: dbContext} +func NewNoteRepository(tx *sql.Tx) NoteRepository { + return ¬eRepository{Transaction: tx} } func (r *noteRepository) Upsert(ctx context.Context, title string, path string, slug string) error { query := ` - INSERT INTO notes (title, path, slug) - VALUES ($1, $2, $3) + INSERT INTO notes (title, path, slug, created_at, updated_at) + VALUES (?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT (slug) DO UPDATE SET title = EXCLUDED.title, path = EXCLUDED.path; ` - _, err := r.dbCtx.ExecContext(ctx, query, title, path, slug) + _, err := r.Transaction.ExecContext(ctx, query, title, path, slug) return err } diff --git a/persistence/repositories/tag_repository.go b/persistence/repositories/tag_repository.go index 3f8b950..a49257e 100644 --- a/persistence/repositories/tag_repository.go +++ b/persistence/repositories/tag_repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "database/sql" "strings" ) @@ -10,11 +11,11 @@ type TagRepository interface { } type tagRepository struct { - dbContext DBContext + Transaction *sql.Tx } -func NewTagRepository(dbContext DBContext) TagRepository { - return &tagRepository{dbContext: dbContext} +func NewTagRepository(tx *sql.Tx) TagRepository { + return &tagRepository{Transaction: tx} } func (r *tagRepository) Upsert(ctx context.Context, names []string) error { @@ -30,18 +31,10 @@ func (r *tagRepository) Upsert(ctx context.Context, names []string) error { args = append(args, name) } - query := "WITH input(name) AS (VALUES " + - strings.Join(placeholders, ",") + - ") INSERT INTO tags(name) " + - "SELECT name FROM input " + - "ON CONFLICT(name) DO NOTHING;" + query := "INSERT OR IGNORE INTO tags(name) VALUES " + strings.Join(placeholders, ",") - _, err := r.dbContext.ExecContext(ctx, query, args) - if err != nil { - return err - } - - return nil + _, err := r.Transaction.ExecContext(ctx, query, args...) + return err } func (r *tagRepository) UpsertNoteTags(noteID int64, tagIDs []int64) error { @@ -64,7 +57,7 @@ func (r *tagRepository) UpsertNoteTags(noteID int64, tagIDs []int64) error { "ON CONFLICT(note_id, tag_id) DO NOTHING" + "SELECT note_id, tag_id FROM input;" - _, err := r.dbContext.ExecContext(context.Background(), query, args) + _, err := r.Transaction.ExecContext(context.Background(), query, args...) if err != nil { return err } diff --git a/persistence/repositories/uow.go b/persistence/repositories/uow.go index 4213763..49db413 100644 --- a/persistence/repositories/uow.go +++ b/persistence/repositories/uow.go @@ -1,40 +1,49 @@ package repositories import ( - "context" "database/sql" + + "github.com/KristianJBorgwarth/dendrite.daemon/persistence/store" ) -type DBContext interface { - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row -} - type UnitOfWork struct { - db *sql.DB + db *sql.DB + Transaction *sql.Tx + FileStore *store.FileStore } func NewUnitOfWork(db *sql.DB) *UnitOfWork { - return &UnitOfWork{db: db} + return &UnitOfWork{db: db, FileStore: store.NewFileStore()} } -func (u *UnitOfWork) Execute(ctx context.Context, fn func(tx *sql.Tx) error) (err error) { - transcation, err := u.db.BeginTx(ctx, nil) +func (u *UnitOfWork) Begin() (tx *sql.Tx, err error) { + tx, err = u.db.Begin() if err != nil { - return err + return nil, err } - - defer func() { - if err != nil { - _ = transcation.Rollback() - } - }() - - if err = fn(transcation); err != nil { - return err - } - - err = transcation.Commit() - return err + u.Transaction = tx + return tx, nil +} + +func (u *UnitOfWork) Commit() error { + if err := u.FileStore.Flush(); err != nil { + u.Transaction.Rollback() + u.Transaction = nil + return err + } + if err := u.Transaction.Commit(); err != nil { + u.FileStore.Rollback() + u.Transaction = nil + return err + } + u.Transaction = nil + return nil +} + +func (u *UnitOfWork) Rollback() { + if u.Transaction == nil { + return + } + u.Transaction.Rollback() + u.FileStore.Rollback() } diff --git a/persistence/store/doc.go b/persistence/store/doc.go new file mode 100644 index 0000000..a60b605 --- /dev/null +++ b/persistence/store/doc.go @@ -0,0 +1,2 @@ +// Package store provides access to any in memory stores related to files and index +package store diff --git a/persistence/store/file_store.go b/persistence/store/file_store.go new file mode 100644 index 0000000..6229823 --- /dev/null +++ b/persistence/store/file_store.go @@ -0,0 +1,50 @@ +package store + +import "os" + +type FileStore struct { + staged []stagedFile + committed []string +} + +type stagedFile struct { + path string + data []byte +} + +func NewFileStore() *FileStore { + return &FileStore{} +} + +func (fs *FileStore) Stage(path string, data []byte) { + fs.staged = append(fs.staged, stagedFile{path: path, data: data}) +} + +func (fs *FileStore) Flush() error { + for _, file := range fs.staged { + if fs.fileExists(file.path) { + continue + } + if err := os.WriteFile(file.path, file.data, 0o644); err != nil { + return err + } + fs.committed = append(fs.committed, file.path) + } + fs.staged = nil + return nil +} + +func (fs *FileStore) Rollback() error { + for _, path := range fs.committed { + if err := os.Remove(path); err != nil { + return err + } + } + fs.committed = nil + return nil +} + +func (fs *FileStore) fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/test/test_integration/create_note_handler_test.go b/test/test_integration/create_note_handler_test.go index e210a42..093539b 100644 --- a/test/test_integration/create_note_handler_test.go +++ b/test/test_integration/create_note_handler_test.go @@ -2,42 +2,137 @@ package integration_test import ( "encoding/json" + "os" + "path/filepath" "testing" "github.com/KristianJBorgwarth/dendrite.daemon/core/handlers" "github.com/KristianJBorgwarth/dendrite.daemon/persistence/repositories" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestCreateNoteHandlerOnSucess(t *testing.T) { - // Arrange +func newCreateNoteHandler() *handlers.CreateNoteHandler { uow := repositories.NewUnitOfWork(Fixture.DB) + return handlers.NewCreateNoteHandler(uow) +} - handler := handlers.NewCreateNoteHandler(uow) - - requestParams := struct { - Title string `json:"title"` - Content string `json:"content"` - }{ - Title: "Test Note", - Content: "This is a test note.", - } - - requestParamsBytes, err := json.Marshal(requestParams) - if err != nil { - t.Fatalf("failed to marshal request params: %v", err) - } - - request := CreateTestRequest("createNote", 1, requestParamsBytes) +func TestCreateNoteHandler_NoTemplate_CreatesNoteFileAndReturnsPath(t *testing.T) { + // Arrange + handler := newCreateNoteHandler() + notePath := filepath.Join(t.TempDir(), "my-note.md") + params, _ := json.Marshal(map[string]any{ + "title": "My Note", + "path": notePath, + }) // Act - response, err := handler.Handle(Fixture.TestContext, request.Params) + result, err := handler.Handle(Fixture.TestContext, params) // Assert - if err != nil { - t.Fatalf("handler returned an error: %v", err) - } + require.NoError(t, err) + assert.Equal(t, notePath, result) - if response != nil { - t.Fatalf("expected response to be nil, got: %v", response) - } + var count int + require.NoError(t, Fixture.DB.QueryRow(`SELECT COUNT(*) FROM notes WHERE slug = ?`, "my-note").Scan(&count)) + assert.Equal(t, 1, count) + + _, statErr := os.Stat(notePath) + assert.NoError(t, statErr) +} + +func TestCreateNoteHandler_WithTemplate_CreatesNoteFileWithTagsAndReturnsPath(t *testing.T) { + // Arrange + handler := newCreateNoteHandler() + dir := t.TempDir() + + templatePath := filepath.Join(dir, "template.md") + require.NoError(t, os.WriteFile(templatePath, []byte("---\ntitle: Template\ntags: [go, testing]\n---\n"), 0o644)) + + notePath := filepath.Join(dir, "templated-note.md") + params, _ := json.Marshal(map[string]any{ + "title": "Templated Note", + "path": notePath, + "templatePath": templatePath, + }) + + // Act + result, err := handler.Handle(Fixture.TestContext, params) + + // Assert + require.NoError(t, err) + assert.Equal(t, notePath, result) + + var noteCount int + require.NoError(t, Fixture.DB.QueryRow(`SELECT COUNT(*) FROM notes WHERE slug = ?`, "templated-note").Scan(¬eCount)) + assert.Equal(t, 1, noteCount) + + rows, err := Fixture.DB.Query(`SELECT name FROM tags WHERE name IN ('go', 'testing')`) + require.NoError(t, err) + defer rows.Close() + + var tags []string + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + tags = append(tags, name) + } + assert.Len(t, tags, 2) + + _, statErr := os.Stat(notePath) + assert.NoError(t, statErr) +} + +func TestCreateNoteHandler_DuplicateSlug_Upserts(t *testing.T) { + // Arrange + dir := t.TempDir() + + path1 := filepath.Join(dir, "dup-note.md") + params1, _ := json.Marshal(map[string]any{"title": "Dup Note", "path": path1}) + _, err := newCreateNoteHandler().Handle(Fixture.TestContext, params1) + require.NoError(t, err) + + path2 := filepath.Join(dir, "dup-note-moved.md") + params2, _ := json.Marshal(map[string]any{"title": "Dup Note", "path": path2}) + + // Act + _, err = newCreateNoteHandler().Handle(Fixture.TestContext, params2) + + // Assert + require.NoError(t, err) + + var count int + require.NoError(t, Fixture.DB.QueryRow(`SELECT COUNT(*) FROM notes WHERE slug = ?`, "dup-note").Scan(&count)) + assert.Equal(t, 1, count) + + var path string + require.NoError(t, Fixture.DB.QueryRow(`SELECT path FROM notes WHERE slug = ?`, "dup-note").Scan(&path)) + assert.Equal(t, path2, path) +} + +func TestCreateNoteHandler_InvalidJSON_ReturnsError(t *testing.T) { + // Arrange + handler := newCreateNoteHandler() + + // Act + _, err := handler.Handle(Fixture.TestContext, json.RawMessage(`{invalid json}`)) + + // Assert + assert.Error(t, err) +} + +func TestCreateNoteHandler_NonExistentTemplatePath_ReturnsError(t *testing.T) { + // Arrange + handler := newCreateNoteHandler() + params, _ := json.Marshal(map[string]any{ + "title": "Ghost Note", + "path": filepath.Join(t.TempDir(), "ghost.md"), + "templatePath": "/non/existent/template.md", + }) + + // Act + _, err := handler.Handle(Fixture.TestContext, params) + + // Assert + assert.Error(t, err) }