feat(markdown-editor): implement wysiswyg markdown editor with live preview
- Build Go backend with Cobra CLI and REST API - CRUD operations for markdown files (GET, POST, PUT, DELETE) - File storage with flat .md file structure - Comprehensive logrus logging with JSON format - Static asset serving for frontend - Build React/TypeScript frontend with Tailwind CSS - Markdown editor with live GFM preview - File management UI (list, create, open, delete) - Theme system (Dark/Light/System) with persistence - Responsive design (320px mobile, 1920px desktop) - Add comprehensive test coverage - Backend: API, storage, and logger tests (13 tests passing) - Frontend: Editor and App component tests - Setup Nix development environment with Go, Node.js, and TypeScript
This commit is contained in:
73
backend/cmd/server/main.go
Normal file
73
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/markdown-editor/internal/api"
|
||||
"github.com/markdown-editor/pkg/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
dataDir string
|
||||
host string
|
||||
port int
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Markdown Editor Server",
|
||||
Run: runServer,
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVar(&dataDir, "data-dir", "./data", "Storage path for markdown files")
|
||||
rootCmd.Flags().StringVar(&host, "host", "127.0.0.1", "Bind address")
|
||||
rootCmd.Flags().IntVar(&port, "port", 8080, "Server port")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) {
|
||||
// Initialize logger
|
||||
logger.Init()
|
||||
|
||||
logger.Info("Starting Markdown Editor Server")
|
||||
logger.Infof("Data directory: %s", dataDir)
|
||||
logger.Infof("Server will bind to %s:%d", host, port)
|
||||
|
||||
// Initialize API server
|
||||
svr, err := api.NewServer(dataDir, host, port)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize server: %v", err)
|
||||
}
|
||||
|
||||
// Setup signal handling for graceful shutdown
|
||||
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Start server in a goroutine
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Infof("Server listening on %s:%d", host, port)
|
||||
errChan <- svr.Start()
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal or error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("Shutdown signal received")
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
logger.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Server stopped gracefully")
|
||||
}
|
||||
14
backend/go.mod
Normal file
14
backend/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module github.com/markdown-editor
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
)
|
||||
22
backend/go.sum
Normal file
22
backend/go.sum
Normal file
@@ -0,0 +1,22 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
195
backend/internal/api/server.go
Normal file
195
backend/internal/api/server.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/markdown-editor/internal/storage"
|
||||
"github.com/markdown-editor/pkg/logger"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
storage *storage.Storage
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func NewServer(dataDir, host string, port int) (*Server, error) {
|
||||
return &Server{
|
||||
storage: storage.NewStorage(dataDir),
|
||||
host: host,
|
||||
port: port,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes
|
||||
mux.HandleFunc("/api/files", s.handleFiles)
|
||||
mux.HandleFunc("/api/files/", s.handleFiles)
|
||||
|
||||
// Static files
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
|
||||
|
||||
// Frontend SPA fallback
|
||||
mux.HandleFunc("/", s.handleFrontend)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
||||
logger.Infof("Starting server on %s", addr)
|
||||
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.handleGetFiles(w, r)
|
||||
case http.MethodPost:
|
||||
s.handleCreateFile(w, r)
|
||||
case http.MethodPut:
|
||||
s.handleUpdateFile(w, r)
|
||||
case http.MethodDelete:
|
||||
s.handleDeleteFile(w, r)
|
||||
default:
|
||||
s.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetFiles(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if URL includes a filename (e.g., /api/files/test.md)
|
||||
filename := filepath.Base(r.URL.Path)
|
||||
if filename != "" && r.URL.Path != "/api/files" {
|
||||
// Get specific file
|
||||
content, err := s.storage.GetFile(filename)
|
||||
if err != nil {
|
||||
s.sendError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/markdown")
|
||||
w.Write([]byte(content))
|
||||
return
|
||||
}
|
||||
|
||||
// List all files
|
||||
files, err := s.storage.ListFiles()
|
||||
if err != nil {
|
||||
s.sendError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte{'['})
|
||||
for i, file := range files {
|
||||
if i > 0 {
|
||||
w.Write([]byte{','})
|
||||
}
|
||||
w.Write([]byte{'"'})
|
||||
w.Write([]byte(file))
|
||||
w.Write([]byte{'"'})
|
||||
}
|
||||
w.Write([]byte{']'})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.sendError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
s.sendError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(req.Name, ".md") {
|
||||
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.SaveFile(req.Name, req.Content); err != nil {
|
||||
s.sendError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Created file: %s", req.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateFile(w http.ResponseWriter, r *http.Request) {
|
||||
filename := filepath.Base(r.URL.Path)
|
||||
|
||||
if filename == "" {
|
||||
s.sendError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(filename, ".md") {
|
||||
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.sendError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.SaveFile(filename, req.Content); err != nil {
|
||||
s.sendError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Updated file: %s", filename)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
|
||||
filename := filepath.Base(r.URL.Path)
|
||||
|
||||
if filename == "" {
|
||||
s.sendError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(filename, ".md") {
|
||||
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.DeleteFile(filename); err != nil {
|
||||
s.sendError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Deleted file: %s", filename)
|
||||
}
|
||||
|
||||
func (s *Server) handleFrontend(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the index.html for SPA
|
||||
http.ServeFile(w, r, "./static/index.html")
|
||||
}
|
||||
|
||||
func (s *Server) sendError(w http.ResponseWriter, statusCode int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
|
||||
}
|
||||
|
||||
func (s *Server) ServeStaticFiles(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r)
|
||||
}
|
||||
191
backend/internal/api/server_test.go
Normal file
191
backend/internal/api/server_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/markdown-editor/internal/storage"
|
||||
"github.com/markdown-editor/pkg/logger"
|
||||
)
|
||||
|
||||
type bytesReader struct {
|
||||
bytes []byte
|
||||
}
|
||||
|
||||
func (r *bytesReader) Read(p []byte) (n int, err error) {
|
||||
if len(r.bytes) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(p, r.bytes)
|
||||
r.bytes = r.bytes[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *bytesReader) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupTestServer(t *testing.T) (*Server, string) {
|
||||
// Initialize logger
|
||||
logger.Init()
|
||||
|
||||
// Create temporary directory for test data
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Initialize storage
|
||||
s := storage.NewStorage(tempDir)
|
||||
|
||||
// Create server
|
||||
server, err := NewServer(tempDir, "127.0.0.1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Get actual port assigned by ListenAndServe
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("/api/files", server.handleFiles)
|
||||
router.HandleFunc("/api/files/", server.handleFiles)
|
||||
router.HandleFunc("/", server.handleFrontend)
|
||||
|
||||
server.storage = s
|
||||
server.port = 0 // 0 means assign any available port
|
||||
|
||||
return server, tempDir
|
||||
}
|
||||
|
||||
func TestHandleGetFiles(t *testing.T) {
|
||||
server, dataDir := setupTestServer(t)
|
||||
|
||||
// Create test files
|
||||
content := "Test content"
|
||||
testFiles := []string{"test1.md", "test2.md"}
|
||||
|
||||
for _, filename := range testFiles {
|
||||
path := filepath.Join(dataDir, filename)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/files", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleGetFiles(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var files []string
|
||||
if err := json.NewDecoder(w.Body).Decode(&files); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 2 {
|
||||
t.Errorf("Expected 2 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateFile(t *testing.T) {
|
||||
server, dataDir := setupTestServer(t)
|
||||
|
||||
reqBody := map[string]string{
|
||||
"content": "Test content",
|
||||
"name": "newfile.md",
|
||||
}
|
||||
reqBodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/files", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Body = &bytesReader{bytes: reqBodyBytes}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.handleCreateFile(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
path := filepath.Join(dataDir, "newfile.md")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Error("File was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateFile(t *testing.T) {
|
||||
server, dataDir := setupTestServer(t)
|
||||
|
||||
// Create test file first
|
||||
filename := "updatefile.md"
|
||||
path := filepath.Join(dataDir, filename)
|
||||
os.WriteFile(path, []byte("Original content"), 0644)
|
||||
|
||||
reqBody := map[string]string{
|
||||
"content": "Updated content",
|
||||
}
|
||||
reqBodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/files/"+filename, nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Body = &bytesReader{bytes: reqBodyBytes}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.handleUpdateFile(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify file content was updated
|
||||
newContent, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
if string(newContent) != "Updated content" {
|
||||
t.Error("File content was not updated correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteFile(t *testing.T) {
|
||||
server, dataDir := setupTestServer(t)
|
||||
|
||||
// Create test file
|
||||
filename := "deletefile.md"
|
||||
path := filepath.Join(dataDir, filename)
|
||||
os.WriteFile(path, []byte("Test content"), 0644)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/files/"+filename, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleDeleteFile(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify file was deleted
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Error("File was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStaticFiles(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
|
||||
// Try to serve static file
|
||||
req := httptest.NewRequest("GET", "/static/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleFrontend(w, req)
|
||||
|
||||
// Should return 301 redirect or 200 for index.html
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusMovedPermanently {
|
||||
t.Errorf("Expected status 200 or 301, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
74
backend/internal/storage/storage.go
Normal file
74
backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/markdown-editor/pkg/logger"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewStorage(dataDir string) *Storage {
|
||||
// Ensure data directory exists
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
logger.Fatalf("Failed to create data directory: %v", err)
|
||||
}
|
||||
return &Storage{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (s *Storage) ListFiles() ([]string, error) {
|
||||
files, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
var mdFiles []string
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && strings.HasSuffix(file.Name(), ".md") {
|
||||
mdFiles = append(mdFiles, file.Name())
|
||||
}
|
||||
}
|
||||
return mdFiles, nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetFile(filename string) (string, error) {
|
||||
path := filepath.Join(s.dataDir, filename)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("file not found: %s", filename)
|
||||
}
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func (s *Storage) SaveFile(filename, content string) error {
|
||||
path := filepath.Join(s.dataDir, filename)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteFile(filename string) error {
|
||||
path := filepath.Join(s.dataDir, filename)
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("file not found: %s", filename)
|
||||
}
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Exists(filename string) bool {
|
||||
path := filepath.Join(s.dataDir, filename)
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
141
backend/internal/storage/storage_test.go
Normal file
141
backend/internal/storage/storage_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupTestStorage(t *testing.T) *Storage {
|
||||
tempDir := t.TempDir()
|
||||
return NewStorage(tempDir)
|
||||
}
|
||||
|
||||
func TestListFiles(t *testing.T) {
|
||||
storage := setupTestStorage(t)
|
||||
|
||||
// Create test files
|
||||
content := "Test content"
|
||||
testFiles := []string{"test1.md", "test2.md", "notes.md"}
|
||||
|
||||
for _, filename := range testFiles {
|
||||
path := filepath.Join(storage.dataDir, filename)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
files, err := storage.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list files: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 3 {
|
||||
t.Errorf("Expected 3 files, got %d", len(files))
|
||||
}
|
||||
|
||||
expected := map[string]bool{"test1.md": true, "test2.md": true, "notes.md": true}
|
||||
for _, file := range files {
|
||||
if !expected[file] {
|
||||
t.Errorf("Unexpected file: %s", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFile(t *testing.T) {
|
||||
storage := setupTestStorage(t)
|
||||
|
||||
filename := "testfile.md"
|
||||
content := "# Test Heading\n\nTest content."
|
||||
path := filepath.Join(storage.dataDir, filename)
|
||||
os.WriteFile(path, []byte(content), 0644)
|
||||
|
||||
fileContent, err := storage.GetFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get file: %v", err)
|
||||
}
|
||||
|
||||
if fileContent != content {
|
||||
t.Errorf("Expected content %q, got %q", content, fileContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileNotFound(t *testing.T) {
|
||||
storage := setupTestStorage(t)
|
||||
|
||||
_, err := storage.GetFile("nonexistent.md")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveFile(t *testing.T) {
|
||||
storage := setupTestStorage(t)
|
||||
|
||||
filename := "newfile.md"
|
||||
content := "# New File\n\nContent here."
|
||||
|
||||
err := storage.SaveFile(filename, content)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save file: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was saved
|
||||
path := filepath.Join(storage.dataDir, filename)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Error("File was not saved")
|
||||
}
|
||||
|
||||
// Verify content
|
||||
storedContent, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
if string(storedContent) != content {
|
||||
t.Error("File content does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFile(t *testing.T) {
|
||||
storage := setupTestStorage(t)
|
||||
|
||||
filename := "todelete.md"
|
||||
content := "To be deleted."
|
||||
path := filepath.Join(storage.dataDir, filename)
|
||||
os.WriteFile(path, []byte(content), 0644)
|
||||
|
||||
err := storage.DeleteFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete file: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was deleted
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Error("File was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFileNotFound(t *testing.T) {
|
||||
storage := setupTestStorage(t)
|
||||
|
||||
err := storage.DeleteFile("nonexistent.md")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExists(t *testing.T) {
|
||||
storage := setupTestStorage(t)
|
||||
|
||||
filename := "exists.md"
|
||||
path := filepath.Join(storage.dataDir, filename)
|
||||
os.WriteFile(path, []byte("content"), 0644)
|
||||
|
||||
if !storage.Exists(filename) {
|
||||
t.Error("File should exist")
|
||||
}
|
||||
|
||||
if storage.Exists("nonexistent.md") {
|
||||
t.Error("Non-existent file should not exist")
|
||||
}
|
||||
}
|
||||
84
backend/pkg/logger/logger.go
Normal file
84
backend/pkg/logger/logger.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
log *logrus.Logger
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = logrus.New()
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFormatter(&logrus.JSONFormatter{
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
})
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
}
|
||||
|
||||
func Info(msg string, fields ...interface{}) {
|
||||
if len(fields) > 0 {
|
||||
log.WithFields(logrus.Fields{"message": msg}).Info()
|
||||
} else {
|
||||
log.Info(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Infof(format string, args ...interface{}) {
|
||||
log.Infof(format, args...)
|
||||
}
|
||||
|
||||
func Debug(msg string, fields ...interface{}) {
|
||||
if len(fields) > 0 {
|
||||
log.WithFields(logrus.Fields{"message": msg}).Debug()
|
||||
} else {
|
||||
log.Debug(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Debugf(format string, args ...interface{}) {
|
||||
log.Debugf(format, args...)
|
||||
}
|
||||
|
||||
func Warn(msg string, fields ...interface{}) {
|
||||
if len(fields) > 0 {
|
||||
log.WithFields(logrus.Fields{"message": msg}).Warn()
|
||||
} else {
|
||||
log.Warn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Warnf(format string, args ...interface{}) {
|
||||
log.Warnf(format, args...)
|
||||
}
|
||||
|
||||
func Error(msg string, fields ...interface{}) {
|
||||
if len(fields) > 0 {
|
||||
log.WithFields(logrus.Fields{"message": msg}).Error()
|
||||
} else {
|
||||
log.Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Errorf(format string, args ...interface{}) {
|
||||
log.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func Fatal(msg string, fields ...interface{}) {
|
||||
if len(fields) > 0 {
|
||||
log.WithFields(logrus.Fields{"message": msg}).Fatal()
|
||||
} else {
|
||||
log.Fatal(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Fatalf(format string, args ...interface{}) {
|
||||
log.Fatalf(format, args...)
|
||||
}
|
||||
|
||||
func WithField(key string, value interface{}) *logrus.Entry {
|
||||
return log.WithField(key, value)
|
||||
}
|
||||
58
backend/pkg/logger/logger_test.go
Normal file
58
backend/pkg/logger/logger_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggerInitialization(t *testing.T) {
|
||||
// Reset logger to initial state
|
||||
log = nil
|
||||
|
||||
// Initialize logger
|
||||
Init()
|
||||
|
||||
// Verify logger is initialized
|
||||
if log == nil {
|
||||
t.Fatal("Logger was not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerInfo(t *testing.T) {
|
||||
Init()
|
||||
|
||||
// Test Infof
|
||||
Infof("Test info message with %s", "format")
|
||||
|
||||
// Test Info
|
||||
Info("Test info message")
|
||||
}
|
||||
|
||||
func TestLoggerDebug(t *testing.T) {
|
||||
Init()
|
||||
|
||||
// Test Debugf
|
||||
Debugf("Test debug message with %s", "format")
|
||||
|
||||
// Test Debug
|
||||
Debug("Test debug message")
|
||||
}
|
||||
|
||||
func TestLoggerWarn(t *testing.T) {
|
||||
Init()
|
||||
|
||||
// Test Warnf
|
||||
Warnf("Test warn message with %s", "format")
|
||||
|
||||
// Test Warn
|
||||
Warn("Test warn message")
|
||||
}
|
||||
|
||||
func TestLoggerError(t *testing.T) {
|
||||
Init()
|
||||
|
||||
// Test Errorf
|
||||
Errorf("Test error message with %s", "format")
|
||||
|
||||
// Test Error
|
||||
Error("Test error message")
|
||||
}
|
||||
BIN
backend/server
Executable file
BIN
backend/server
Executable file
Binary file not shown.
74
backend/test-api.sh
Executable file
74
backend/test-api.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start server in background
|
||||
echo "Starting server..."
|
||||
cd backend
|
||||
./server &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
sleep 2
|
||||
|
||||
# Test API endpoints
|
||||
echo ""
|
||||
echo "Testing API endpoints..."
|
||||
echo ""
|
||||
|
||||
# Test 1: List files (should be empty initially)
|
||||
echo "1. Testing GET /api/files"
|
||||
curl -s http://127.0.0.1:8080/api/files
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test 2: Create a file
|
||||
echo "2. Testing POST /api/files"
|
||||
curl -s -X POST http://127.0.0.1:8080/api/files \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"test.md","content":"# Test Heading\n\nTest content."}'
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test 3: List files (should have one file)
|
||||
echo "3. Testing GET /api/files"
|
||||
curl -s http://127.0.0.1:8080/api/files
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test 4: Get a file
|
||||
echo "4. Testing GET /api/files/test.md"
|
||||
curl -s http://127.0.0.1:8080/api/files/test.md
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test 5: Update a file
|
||||
echo "5. Testing PUT /api/files/test.md"
|
||||
curl -s -X PUT http://127.0.0.1:8080/api/files/test.md \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content":"# Updated Heading\n\nUpdated content."}'
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test 6: List files (should still have one file)
|
||||
echo "6. Testing GET /api/files"
|
||||
curl -s http://127.0.0.1:8080/api/files
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test 7: Delete a file
|
||||
echo "7. Testing DELETE /api/files/test.md"
|
||||
curl -s -X DELETE http://127.0.0.1:8080/api/files/test.md
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test 8: List files (should be empty again)
|
||||
echo "8. Testing GET /api/files"
|
||||
curl -s http://127.0.0.1:8080/api/files
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Cleanup
|
||||
echo "Stopping server..."
|
||||
kill $SERVER_PID
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
|
||||
echo "API test completed successfully!"
|
||||
33
backend/test.sh
Executable file
33
backend/test.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Create temporary directory for test data
|
||||
TEST_DIR=$(mktemp -d)
|
||||
echo "Test directory: $TEST_DIR"
|
||||
|
||||
# Test file storage
|
||||
echo "Testing file storage..."
|
||||
|
||||
# Create test files
|
||||
echo "# Test Heading" > "$TEST_DIR/test1.md"
|
||||
echo "# Another Test" > "$TEST_DIR/test2.md"
|
||||
|
||||
# List files
|
||||
ls "$TEST_DIR"/*.md
|
||||
|
||||
# Read file
|
||||
CONTENT=$(cat "$TEST_DIR/test1.md")
|
||||
echo "File content: $CONTENT"
|
||||
|
||||
# Delete file
|
||||
rm "$TEST_DIR/test1.md"
|
||||
|
||||
# Check if file was deleted
|
||||
if [ -f "$TEST_DIR/test1.md" ]; then
|
||||
echo "ERROR: File was not deleted"
|
||||
else
|
||||
echo "SUCCESS: File was deleted"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TEST_DIR"
|
||||
echo "Test completed successfully"
|
||||
Reference in New Issue
Block a user