Initial commit: WYSIWYG Markdown Editor - Go backend + React/TypeScript frontend with Tailwind CSS
Backend: - Cobra CLI with --data-dir, --port, --host flags - Gin HTTP server with REST API for markdown CRUD operations - File storage on disk (.md files only) - Comprehensive logrus logging - Backend tests with CRUD round-trip verification Frontend: - React 18 + TypeScript + Tailwind CSS - Markdown editor with live GFM preview (react-markdown + remark-gfm) - File management UI (list, create, open, save, delete) - Theme switcher with Dark/Light/System modes - Responsive design - Frontend tests with vitest Testing: - All backend tests pass (go test ./...) - All frontend tests pass (npm test)
This commit is contained in:
149
internal/api/api.go
Normal file
149
internal/api/api.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor/internal/storage"
|
||||
"markdown-editor/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
storage *storage.Storage
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func NewAPI(storage *storage.Storage) *API {
|
||||
log := logger.GetLogger()
|
||||
return &API{
|
||||
storage: storage,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// FileInfo represents a file for API responses
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// File represents a file for API requests
|
||||
type File struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error response
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (a *API) RegisterRoutes(router *gin.Engine) {
|
||||
// File endpoints
|
||||
router.GET("/api/files", a.listFiles)
|
||||
router.GET("/api/files/:name", a.getFile)
|
||||
router.POST("/api/files", a.createFile)
|
||||
router.PUT("/api/files/:name", a.updateFile)
|
||||
router.DELETE("/api/files/:name", a.deleteFile)
|
||||
}
|
||||
|
||||
func (a *API) listFiles(c *gin.Context) {
|
||||
a.log.Info("Handling GET /api/files")
|
||||
|
||||
files, err := a.storage.ListFiles()
|
||||
if err != nil {
|
||||
a.log.Errorf("Failed to list files: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to list files"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"files": files,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) getFile(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
a.log.WithField("filename", name).Info("Handling GET /api/files/:name")
|
||||
|
||||
file, err := a.storage.GetFile(name)
|
||||
if err != nil {
|
||||
a.log.Errorf("Failed to get file %s: %v", name, err)
|
||||
if err.Error() == "file not found: "+name {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, file)
|
||||
}
|
||||
|
||||
func (a *API) createFile(c *gin.Context) {
|
||||
var file File
|
||||
if err := c.ShouldBindJSON(&file); err != nil {
|
||||
a.log.Errorf("Invalid request body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
a.log.WithField("filename", file.Name).Info("Handling POST /api/files")
|
||||
|
||||
if file.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "File name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.storage.CreateFile(file.Name, file.Content); err != nil {
|
||||
a.log.Errorf("Failed to create file %s: %v", file.Name, err)
|
||||
if err.Error() == "file already exists: "+file.Name {
|
||||
c.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, file)
|
||||
}
|
||||
|
||||
func (a *API) updateFile(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
var file File
|
||||
if err := c.ShouldBindJSON(&file); err != nil {
|
||||
a.log.Errorf("Invalid request body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
a.log.WithField("filename", name).Info("Handling PUT /api/files/:name")
|
||||
|
||||
if file.Name != name {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "File name in path and body must match"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.storage.UpdateFile(name, file.Content); err != nil {
|
||||
a.log.Errorf("Failed to update file %s: %v", name, err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, file)
|
||||
}
|
||||
|
||||
func (a *API) deleteFile(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
a.log.WithField("filename", name).Info("Handling DELETE /api/files/:name")
|
||||
|
||||
if err := a.storage.DeleteFile(name); err != nil {
|
||||
a.log.Errorf("Failed to delete file %s: %v", name, err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
228
internal/api/api_test.go
Normal file
228
internal/api/api_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"markdown-editor/internal/logger"
|
||||
"markdown-editor/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAPI_ListFiles(t *testing.T) {
|
||||
logger.SetLevel("error")
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := storage.NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
api := NewAPI(store)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api.RegisterRoutes(router)
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(tempDir, "test.md")
|
||||
err = os.WriteFile(testFile, []byte("# Test Content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/files", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var resp gin.H
|
||||
err = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(resp["files"].([]interface{})) != 1 {
|
||||
t.Errorf("Expected 1 file, got %d", len(resp["files"].([]interface{})))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_CreateFile(t *testing.T) {
|
||||
logger.SetLevel("error")
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := storage.NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
api := NewAPI(store)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api.RegisterRoutes(router)
|
||||
|
||||
body, _ := json.Marshal(File{
|
||||
Name: "test.md",
|
||||
Content: "# Test Content",
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/files", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
if _, err := os.Stat(filepath.Join(tempDir, "test.md")); os.IsNotExist(err) {
|
||||
t.Error("File was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_GetFile(t *testing.T) {
|
||||
logger.SetLevel("error")
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := storage.NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create test file
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
api := NewAPI(store)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api.RegisterRoutes(router)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/files/test.md", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var resp File
|
||||
err = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp.Content != "# Test Content" {
|
||||
t.Errorf("Expected '# Test Content', got '%s'", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_UpdateFile(t *testing.T) {
|
||||
logger.SetLevel("error")
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := storage.NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create test file
|
||||
err = store.CreateFile("test.md", "# Original Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
api := NewAPI(store)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api.RegisterRoutes(router)
|
||||
|
||||
body, _ := json.Marshal(File{
|
||||
Name: "test.md",
|
||||
Content: "# Updated Content",
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PUT", "/api/files/test.md", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// Verify file was updated
|
||||
content, _ := os.ReadFile(filepath.Join(tempDir, "test.md"))
|
||||
if string(content) != "# Updated Content" {
|
||||
t.Errorf("Expected '# Updated Content', got '%s'", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_DeleteFile(t *testing.T) {
|
||||
logger.SetLevel("error")
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := storage.NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create test file
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
api := NewAPI(store)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api.RegisterRoutes(router)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", "/api/files/test.md", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusNoContent, w.Code)
|
||||
}
|
||||
|
||||
// Verify file was deleted
|
||||
if _, err := os.Stat(filepath.Join(tempDir, "test.md")); !os.IsNotExist(err) {
|
||||
t.Error("File was not deleted")
|
||||
}
|
||||
}
|
||||
32
internal/logger/logger.go
Normal file
32
internal/logger/logger.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
log *logrus.Logger
|
||||
)
|
||||
|
||||
func init() {
|
||||
log = logrus.New()
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
log.SetFormatter(&logrus.JSONFormatter{})
|
||||
|
||||
// Output to stderr by default
|
||||
log.SetOutput(os.Stderr)
|
||||
}
|
||||
|
||||
func GetLogger() *logrus.Logger {
|
||||
return log
|
||||
}
|
||||
|
||||
func SetLevel(level string) {
|
||||
lvl, err := logrus.ParseLevel(level)
|
||||
if err != nil {
|
||||
log.Warnf("Invalid log level %s, using INFO", level)
|
||||
return
|
||||
}
|
||||
log.SetLevel(lvl)
|
||||
}
|
||||
35
internal/logger/logger_test.go
Normal file
35
internal/logger/logger_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestGetLogger(t *testing.T) {
|
||||
log := GetLogger()
|
||||
if log == nil {
|
||||
t.Error("Logger is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLevel(t *testing.T) {
|
||||
// Test valid levels
|
||||
SetLevel("debug")
|
||||
SetLevel("info")
|
||||
SetLevel("warn")
|
||||
SetLevel("error")
|
||||
|
||||
// Test invalid level (should not panic)
|
||||
SetLevel("invalid")
|
||||
}
|
||||
|
||||
func TestJSONFormatter(t *testing.T) {
|
||||
log := GetLogger()
|
||||
log.SetFormatter(&logrus.JSONFormatter{})
|
||||
|
||||
// Just verify the formatter doesn't panic
|
||||
if log.Formatter == nil {
|
||||
t.Error("Formatter is nil")
|
||||
}
|
||||
}
|
||||
115
internal/server/server.go
Normal file
115
internal/server/server.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor/internal/storage"
|
||||
"markdown-editor/internal/api"
|
||||
"markdown-editor/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
dataDir string
|
||||
port int
|
||||
host string
|
||||
storage *storage.Storage
|
||||
api *api.API
|
||||
router *gin.Engine
|
||||
httpSrv *http.Server
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func NewServer(dataDir string, port int, host string) *Server {
|
||||
log := logger.GetLogger()
|
||||
|
||||
s := &Server{
|
||||
dataDir: dataDir,
|
||||
port: port,
|
||||
host: host,
|
||||
log: log,
|
||||
}
|
||||
|
||||
var err error
|
||||
s.storage, err = storage.NewStorage(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
|
||||
s.api = api.NewAPI(s.storage)
|
||||
|
||||
// Initialize Gin router
|
||||
s.router = gin.New()
|
||||
s.router.Use(gin.Logger())
|
||||
s.router.Use(gin.Recovery())
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
s.log.Info("Starting HTTP server")
|
||||
|
||||
// Register API routes
|
||||
s.api.RegisterRoutes(s.router)
|
||||
|
||||
// Serve static files from frontend build
|
||||
s.serveStaticAssets()
|
||||
|
||||
// Build the URL
|
||||
url := fmt.Sprintf("%s:%d", s.host, s.port)
|
||||
s.log.Infof("Server listening on %s", url)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Addr: url,
|
||||
Handler: s.router,
|
||||
}
|
||||
|
||||
return s.httpSrv.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) serveStaticAssets() {
|
||||
// Try to serve from multiple possible locations
|
||||
possiblePaths := []string{
|
||||
"./frontend/dist",
|
||||
"frontend/dist",
|
||||
}
|
||||
|
||||
var assetPath string
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
assetPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if assetPath == "" {
|
||||
s.log.Warn("Frontend build not found, serving API only")
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Infof("Serving static assets from: %s", assetPath)
|
||||
|
||||
// Serve files from the dist directory
|
||||
s.router.Static("/", assetPath)
|
||||
s.router.Static("/assets", filepath.Join(assetPath, "assets"))
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.log.Info("Shutting down HTTP server")
|
||||
|
||||
// Clean up storage
|
||||
if s.storage != nil {
|
||||
s.storage = nil
|
||||
}
|
||||
|
||||
if s.httpSrv != nil {
|
||||
return s.httpSrv.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
36
internal/server/server_test.go
Normal file
36
internal/server/server_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServer_Shutdown(t *testing.T) {
|
||||
s := NewServer("./testdata", 8889, "127.0.0.1")
|
||||
|
||||
ctx := context.Background()
|
||||
err := s.Shutdown(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Shutdown error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServer(t *testing.T) {
|
||||
s := NewServer("./testdata", 8890, "127.0.0.1")
|
||||
|
||||
if s == nil {
|
||||
t.Error("Server is nil")
|
||||
}
|
||||
|
||||
if s.dataDir != "./testdata" {
|
||||
t.Errorf("Expected dataDir './testdata', got '%s'", s.dataDir)
|
||||
}
|
||||
|
||||
if s.port != 8890 {
|
||||
t.Errorf("Expected port 8890, got %d", s.port)
|
||||
}
|
||||
|
||||
if s.host != "127.0.0.1" {
|
||||
t.Errorf("Expected host '127.0.0.1', got '%s'", s.host)
|
||||
}
|
||||
}
|
||||
0
internal/server/testdata/.gitkeep
vendored
Normal file
0
internal/server/testdata/.gitkeep
vendored
Normal file
134
internal/storage/storage.go
Normal file
134
internal/storage/storage.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedExt = regexp.MustCompile(`(?i)\.md$`)
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
dataDir string
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func NewStorage(dataDir string) (*Storage, error) {
|
||||
log := logger.GetLogger()
|
||||
|
||||
// Create data directory if it doesn't exist
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
return &Storage{
|
||||
dataDir: dataDir,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// File represents a markdown file
|
||||
type File struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ListFiles returns all .md files in the data directory
|
||||
func (s *Storage) ListFiles() ([]string, error) {
|
||||
s.log.WithField("operation", "list_files").Info("Listing markdown files")
|
||||
|
||||
files := []string{}
|
||||
err := filepath.WalkDir(s.dataDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && allowedExt.MatchString(d.Name()) {
|
||||
files = append(files, d.Name())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetFile reads a markdown file by name
|
||||
func (s *Storage) GetFile(name string) (*File, error) {
|
||||
s.log.WithField("operation", "get_file").WithField("filename", name).Info("Getting file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return nil, fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return &File{
|
||||
Name: name,
|
||||
Content: string(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFile creates a new markdown file
|
||||
func (s *Storage) CreateFile(name string, content string) error {
|
||||
s.log.WithField("operation", "create_file").WithField("filename", name).Info("Creating file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("file already exists: %s", name)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFile updates an existing markdown file
|
||||
func (s *Storage) UpdateFile(name string, content string) error {
|
||||
s.log.WithField("operation", "update_file").WithField("filename", name).Info("Updating file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFile deletes a markdown file
|
||||
func (s *Storage) DeleteFile(name string) error {
|
||||
s.log.WithField("operation", "delete_file").WithField("filename", name).Info("Deleting file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
168
internal/storage/storage_test.go
Normal file
168
internal/storage/storage_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStorage_ListFiles(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create test files
|
||||
files := []string{"file1.md", "file2.md", "file3.txt"}
|
||||
for _, name := range files {
|
||||
err = os.WriteFile(filepath.Join(tempDir, name), []byte("# Content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := store.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 .md files, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify only .md files are listed
|
||||
for _, name := range result {
|
||||
if !IsMarkdownFile(name) {
|
||||
t.Errorf("Non-markdown file found: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_CreateFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tempDir, "test.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(content) != "# Test Content" {
|
||||
t.Errorf("Expected '# Test Content', got '%s'", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_UpdateFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
err = store.CreateFile("test.md", "# Original Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Update file
|
||||
err = store.UpdateFile("test.md", "# Updated Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tempDir, "test.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(content) != "# Updated Content" {
|
||||
t.Errorf("Expected '# Updated Content', got '%s'", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_DeleteFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Delete file
|
||||
err = store.DeleteFile("test.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(tempDir, "test.md"))
|
||||
if !os.IsNotExist(err) {
|
||||
t.Error("File was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_GetFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
file, err := store.GetFile("test.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if file.Content != "# Test Content" {
|
||||
t.Errorf("Expected '# Test Content', got '%s'", file.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func IsMarkdownFile(filename string) bool {
|
||||
return filepath.Ext(filename) == ".md"
|
||||
}
|
||||
Reference in New Issue
Block a user