ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/mockups_test.go

package main

import (
	"encoding/json"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
)

func TestMockupArtifactsAndCanonicalFlows(t *testing.T) {
	c, err := loadMockCatalog("..")
	if err != nil {
		t.Fatal(err)
	}
	if len(c.Screens) != 170 {
		t.Fatal(len(c.Screens))
	}
	uc, err := loadUseCases("..")
	if err != nil {
		t.Fatal(err)
	}
	for id, u := range uc.UC {
		g, err := draftMockFlow("..", u, c)
		if err != nil {
			t.Fatal(id, err)
		}
		if err = validateMockGraph(g, c); err != nil {
			t.Fatal(id, err)
		}
	}
	g, err := draftMockFlow("..", uc.UC["W-41"], c)
	if err != nil {
		t.Fatal(err)
	}
	found := false
	for _, e := range g.Edges {
		if e.Label == "ไม่จำเป็น — สมัครด้วยตนเอง" {
			found = true
		}
	}
	if !found {
		t.Fatal("guardian bypass lost")
	}
	if _, err = draftMockSitemap("..", c); err != nil {
		t.Fatal(err)
	}
}
func TestMockupSaveConflictValidationAndRestore(t *testing.T) {
	c, _ := loadMockCatalog("..")
	g, err := draftMockSitemap("..", c)
	if err != nil {
		t.Fatal(err)
	}
	s := &mockStore{dir: t.TempDir()}
	saved, err := s.save("sitemap", g, c)
	if err != nil || saved.Revision != 1 {
		t.Fatal(err)
	}
	if _, err = s.save("sitemap", g, c); err != errMockConflict {
		t.Fatal("stale save accepted", err)
	}
	saved.Nodes[0].Title = "edited"
	saved, err = s.save("sitemap", saved, c)
	if err != nil {
		t.Fatal(err)
	}
	restored, err := s.restore("sitemap", 2, 1, c)
	if err != nil || restored.Revision != 3 || restored.Nodes[0].Title == "edited" {
		t.Fatal("restore failed", err)
	}
	history, err := s.history("sitemap")
	if err != nil || len(history) != 3 {
		t.Fatal("history lost", err)
	}
	bad := restored
	bad.Nodes = append([]MockNode(nil), restored.Nodes...)
	bad.Nodes[0].ScreenID = "missing"
	if validateMockGraph(bad, c) == nil {
		t.Fatal("unknown screen accepted")
	}
	bad = restored
	bad.Edges = append([]DiagramEdge(nil), restored.Edges...)
	bad.Edges[0].Target = "missing"
	if validateMockGraph(bad, c) == nil {
		t.Fatal("dangling edge accepted")
	}
	if _, err = s.load("../../escape", g); err == nil {
		t.Fatal("path escape accepted")
	}
}
func TestMockupHTTP(t *testing.T) {
	store := NewStore(t.TempDir())
	mux := newMux(store, NewHub())
	for _, path := range []string{"/mockup", "/mockup/sitemap", "/mockup/page-flow", "/mockup/page-flow/W-41", "/mockup/screens", "/mockup/screens/A01", "/api/mockups/catalog", "/api/mockups/graphs/sitemap", "/api/mockups/graphs/W-41"} {
		w := httptest.NewRecorder()
		mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
		if w.Code != 200 {
			t.Fatal(path, w.Code, w.Body.String())
		}
	}
	w := httptest.NewRecorder()
	mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/mockups/graphs/sitemap", nil))
	var env mockGraphResponse
	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
		t.Fatal(err)
	}
	b, _ := json.Marshal(env.Graph)
	for _, tc := range []struct {
		origin string
		want   int
	}{{"", 403}, {"http://evil.example", 403}, {"http://example.com", 200}} {
		req := httptest.NewRequest("PUT", "/api/mockups/graphs/sitemap", strings.NewReader(string(b)))
		if tc.origin != "" {
			req.Header.Set("Origin", tc.origin)
		}
		w = httptest.NewRecorder()
		mux.ServeHTTP(w, req)
		if w.Code != tc.want {
			t.Fatal(tc.origin, w.Code, w.Body.String())
		}
	}
	for _, path := range []string{"/mockup/screens/MISSING", "/api/mockups/graphs/MISSING", "/mockup-assets/tracker/data/milestones.json"} {
		w = httptest.NewRecorder()
		mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
		if w.Code != 404 {
			t.Fatal(path, w.Code)
		}
	}
}

func TestMockupConcurrentSavesAndChangedSourcePreserveHistory(t *testing.T) {
	c, _ := loadMockCatalog("..")
	g, err := draftMockSitemap("..", c)
	if err != nil {
		t.Fatal(err)
	}
	s := &mockStore{dir: t.TempDir()}
	var wg sync.WaitGroup
	results := make(chan error, 2)
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); _, err := s.save("sitemap", g, c); results <- err }()
	}
	wg.Wait()
	close(results)
	good, conflicts := 0, 0
	for err := range results {
		if err == nil {
			good++
		} else if err == errMockConflict {
			conflicts++
		} else {
			t.Fatal(err)
		}
	}
	if good != 1 || conflicts != 1 {
		t.Fatal(good, conflicts)
	}
	newDraft := g
	newDraft.SourceHash = strings.Repeat("a", 64)
	loaded, err := s.load("sitemap", newDraft)
	if err != nil || loaded.SourceHash == newDraft.SourceHash || loaded.Revision != 1 {
		t.Fatal("source change erased saved graph", err)
	}
	history, err := s.history("sitemap")
	if err != nil || len(history) != 1 {
		t.Fatal(err)
	}
	path := filepath.Join(s.dir, "mockups", "graphs", "sitemap.json")
	if err = os.WriteFile(path, []byte("broken json"), 0600); err != nil {
		t.Fatal(err)
	}
	if _, err = s.load("sitemap", g); err == nil {
		t.Fatal("corrupt data silently reset")
	}
}

func TestMockupArtifactPathConfinement(t *testing.T) {
	root := t.TempDir()
	outside := t.TempDir()
	if err := os.WriteFile(filepath.Join(outside, "private.svg"), []byte("private"), 0600); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(filepath.Join(outside, "private.svg"), filepath.Join(root, "escape.svg")); err != nil {
		t.Fatal(err)
	}
	for _, path := range []string{"../private.svg", "escape.svg", "/etc/passwd", "nested/../secret.svg"} {
		if _, err := mockFile(root, path); err == nil {
			t.Fatal("allowed path escape", path)
		}
	}
}

func TestFullModuleLabelsAndFlowGroups(t *testing.T) {
	c, err := loadMockCatalog("..")
	if err != nil {
		t.Fatal(err)
	}
	uc, err := loadUseCases("..")
	if err != nil {
		t.Fatal(err)
	}
	for code, m := range uc.Modules {
		if c.ModuleNames[code] != m.Name {
			t.Errorf("missing full label for %s", code)
		}
	}
	for _, s := range c.Screens {
		if s.ModuleName == "" || s.ModuleName == s.Module {
			t.Errorf("abbreviated label for %s", s.ID)
		}
	}
	if moduleLabel(c.ModuleNames, "UNKNOWN") == "UNKNOWN" {
		t.Fatal("unknown module leaked abbreviation")
	}
	mux := newMux(NewStore(t.TempDir()), NewHub())
	w := httptest.NewRecorder()
	mux.ServeHTTP(w, httptest.NewRequest("GET", "/mockup/page-flow", nil))
	if w.Code != 200 {
		t.Fatal(w.Code)
	}
	html := w.Body.String()
	if strings.Count(html, "data-flow-search=") != 162 || !strings.Contains(html, `data-flow-module="ADM"`) || !strings.Contains(html, "รับสมัครและคัดเลือก") || !strings.Contains(html, "data-flow-collapse-all") {
		t.Fatal("module folding or rows missing")
	}
}