feat: implement WYSIWYG markdown editor

Add full-stack markdown editor with Go backend and React frontend.

Backend:
- Cobra CLI with --data-dir, --port, --host flags
- REST API for markdown file CRUD operations
- File storage with flat directory structure
- logrus logging for all operations
- Static file serving for frontend
- Comprehensive tests for CRUD and static assets

Frontend:
- React + TypeScript + Vite + Tailwind CSS
- Live markdown preview with marked (GFM)
- File management: list, create, open, save, delete
- Theme system: Dark/Light/System with persistence
- Responsive design (320px to 1920px)
- Component tests for Editor, Preview, Sidebar

Build:
- Makefile for build, test, and run automation
- Single command testing (make test)

Closes SPEC.md requirements
This commit is contained in:
2026-02-06 10:01:09 -05:00
parent 6b66cee21d
commit 773b9db4b2
44 changed files with 7692 additions and 0 deletions

View File

@@ -0,0 +1,255 @@
package server_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/sirupsen/logrus"
"markdown-editor-backend/server"
)
func TestServerCRUD(t *testing.T) {
// Create temp directory for test data
tempDir, err := os.MkdirTemp("", "markdown-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create static directory
staticDir := filepath.Join(tempDir, "static")
if err := os.MkdirAll(staticDir, 0755); err != nil {
t.Fatalf("Failed to create static dir: %v", err)
}
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte("<html></html>"), 0644); err != nil {
t.Fatalf("Failed to create index.html: %v", err)
}
// Create logger
log := logrus.New()
log.SetLevel(logrus.WarnLevel)
// Create server with dynamic port
srv := server.New(tempDir, "127.0.0.1", 0, log)
srv.SetStaticPath(staticDir)
// Start server asynchronously
addr, err := srv.StartAsync()
if err != nil {
t.Fatalf("Failed to start server: %v", err)
}
defer srv.Stop()
// Wait for server to be ready
time.Sleep(100 * time.Millisecond)
baseURL := "http://" + addr
client := &http.Client{Timeout: 5 * time.Second}
// Test health endpoint
t.Run("HealthCheck", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/health")
if err != nil {
t.Fatalf("Health check failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["status"] != "ok" {
t.Errorf("Expected status 'ok', got '%s'", result["status"])
}
})
// Test Create
t.Run("CreateFile", func(t *testing.T) {
body := map[string]string{
"name": "test-file",
"content": "# Hello World\n\nThis is a test file.",
}
jsonBody, _ := json.Marshal(body)
resp, err := client.Post(baseURL+"/api/files", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
t.Fatalf("Create file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("Expected status 201, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["name"] != "test-file" {
t.Errorf("Expected name 'test-file', got '%s'", result["name"])
}
})
// Test List
t.Run("ListFiles", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/files")
if err != nil {
t.Fatalf("List files failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result []string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
found := false
for _, name := range result {
if name == "test-file" {
found = true
break
}
}
if !found {
t.Errorf("Expected 'test-file' in list, got %v", result)
}
})
// Test Get
t.Run("GetFile", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/files/test-file")
if err != nil {
t.Fatalf("Get file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["name"] != "test-file" {
t.Errorf("Expected name 'test-file', got '%s'", result["name"])
}
if result["content"] != "# Hello World\n\nThis is a test file." {
t.Errorf("Expected specific content, got '%s'", result["content"])
}
})
// Test Update
t.Run("UpdateFile", func(t *testing.T) {
body := map[string]string{
"content": "# Updated Content\n\nThis file has been updated.",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, baseURL+"/api/files/test-file", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Update file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
// Verify the update
resp2, _ := client.Get(baseURL + "/api/files/test-file")
defer resp2.Body.Close()
var result map[string]string
json.NewDecoder(resp2.Body).Decode(&result)
if result["content"] != "# Updated Content\n\nThis file has been updated." {
t.Errorf("Expected updated content, got '%s'", result["content"])
}
})
// Test Delete
t.Run("DeleteFile", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/files/test-file", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Delete file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("Expected status 204, got %d", resp.StatusCode)
}
// Verify deletion
resp2, _ := client.Get(baseURL + "/api/files/test-file")
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 404 for deleted file, got %d", resp2.StatusCode)
}
})
// Test 404
t.Run("GetNonExistentFile", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/files/nonexistent")
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["error"] != "file not found" {
t.Errorf("Expected error 'file not found', got '%s'", result["error"])
}
})
// Test static file serving
t.Run("StaticFiles", func(t *testing.T) {
resp, err := client.Get(baseURL + "/")
if err != nil {
t.Fatalf("Get static file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "<html></html>" {
t.Errorf("Expected '<html></html>', got '%s'", string(body))
}
})
}