package handler import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) func TestIsReviewingPath(t *testing.T) { cases := []struct { path string wantOK bool wantProj string wantTracking string }{ {"/Project/reviewing/", true, "Project", ""}, {"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001"}, // Case-insensitive on the literal "reviewing" segment. {"/Project/Reviewing/", true, "Project", ""}, {"/Project/REVIEWING/x/", true, "Project", "x"}, // No trailing slash: still classified (caller decides redirect). {"/Project/reviewing", true, "Project", ""}, {"/Project/reviewing/123/", true, "Project", "123"}, // Non-canonical / wrong shape. {"/Project/", false, "", ""}, {"/", false, "", ""}, {"/Project/working/", false, "", ""}, {"/Project/reviewing/x/y/", false, "", ""}, // depth >3 not supported } for _, tc := range cases { gotProj, gotTracking, gotOK := IsReviewingPath(tc.path) if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking { t.Errorf("IsReviewingPath(%q) = (%q,%q,%v), want (%q,%q,%v)", tc.path, gotProj, gotTracking, gotOK, tc.wantProj, tc.wantTracking, tc.wantOK) } } } // Test setup: build a synthetic project tree with two parties, one // pending submittal each. Verify the aggregator returns: // - depth 0: 2 virtual / entries, sorted, both with // URLs under //reviewing/ // - depth 1: received/ + staged/ entries with canonical URLs func TestServeReviewing(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": rwcda\n") // Two parties under archive/, each with a pending submittal. // Acme: submitted but no response staged or issued yet. // Beta: submitted, response staged but not yet issued. pAcmeReceived := filepath.Join(root, "Project", "archive", "Acme", "received", "2025-10-31_001-AB-SUB-0001 (IFR) - Pending acme review") pBetaReceived := filepath.Join(root, "Project", "archive", "Beta", "received", "2025-11-01_002-AB-SUB-0007 (IFR) - Pending beta review") pBetaStaged := filepath.Join(root, "Project", "staging", "2025-11-15_002-AB-SUB-0007 (RSC) - Beta response draft") for _, p := range []string{pAcmeReceived, pBetaReceived, pBetaStaged} { mustMkdir(t, p) } // And a third party (Gamma) where the submittal has BEEN issued — // should NOT appear in the pending list. pGammaReceived := filepath.Join(root, "Project", "archive", "Gamma", "received", "2025-09-01_003-CD-SUB-0099 (IFR) - Already responded") pGammaIssued := filepath.Join(root, "Project", "archive", "Gamma", "issued", "2025-09-15_003-CD-SUB-0099 (RSC) - The response we sent") mustMkdir(t, pGammaReceived) mustMkdir(t, pGammaIssued) zddc.InvalidateCache(root) cfg := config.Config{ Root: root, EmailHeader: "X-Auth-Request-Email", } t.Run("depth-0 lists pending submittals only", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/", nil) req.Header.Set("Accept", "application/json") req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) rec := httptest.NewRecorder() ServeReviewing(cfg, rec, req, "Project", "") if rec.Code != http.StatusOK { t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) } var got []listing.FileInfo if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) } if len(got) != 2 { t.Fatalf("got %d entries, want 2 (Acme + Beta pending; Gamma issued); body=%s", len(got), rec.Body.String()) } // Sorted by tracking number → 001-* before 002-*. if got[0].Name != "001-AB-SUB-0001/" { t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "001-AB-SUB-0001/") } if got[1].Name != "002-AB-SUB-0007/" { t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "002-AB-SUB-0007/") } for i, e := range got { if !e.IsDir || !e.Virtual { t.Errorf("entries[%d] IsDir=%v Virtual=%v, want both true", i, e.IsDir, e.Virtual) } // Per-submittal URL stays under reviewing/ (the user can // drill into the per-submittal received/+staged/ view). if e.URL != "/Project/reviewing/"+got[i].Name[:len(got[i].Name)-1]+"/" { t.Errorf("entries[%d].URL=%q, want under /Project/reviewing/", i, e.URL) } } }) t.Run("depth-1 with staged draft → received/ + staged/", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/002-AB-SUB-0007/", nil) req.Header.Set("Accept", "application/json") req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) rec := httptest.NewRecorder() ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007") if rec.Code != http.StatusOK { t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) } var got []listing.FileInfo if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if len(got) != 2 { t.Fatalf("got %d entries, want 2 (received/ + staged/); body=%s", len(got), rec.Body.String()) } if got[0].Name != "received/" { t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/") } // Canonical URL — outside reviewing/ subtree. if want := "/Project/archive/Beta/received/"; !startsWith(got[0].URL, want) { t.Errorf("received URL=%q, want prefix %q", got[0].URL, want) } if got[1].Name != "staged/" { t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/") } if want := "/Project/staging/"; !startsWith(got[1].URL, want) { t.Errorf("staged URL=%q, want prefix %q", got[1].URL, want) } }) t.Run("depth-1 with no staged draft → received/ only", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/001-AB-SUB-0001/", nil) req.Header.Set("Accept", "application/json") req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) rec := httptest.NewRecorder() ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001") if rec.Code != http.StatusOK { t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) } var got []listing.FileInfo if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if len(got) != 1 || got[0].Name != "received/" { t.Fatalf("got %+v, want [received/] only (no draft)", got) } }) t.Run("depth-1 unknown tracking → 404", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/999-ZZ-SUB-9999/", nil) req.Header.Set("Accept", "application/json") req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) rec := httptest.NewRecorder() ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999") if rec.Code != http.StatusNotFound { t.Errorf("status=%d, want 404", rec.Code) } }) t.Run("missing archive/ entirely → empty depth-0 listing", func(t *testing.T) { // Fresh project state: no archive/ subtree at all. bareRoot := t.TempDir() mustWrite(t, filepath.Join(bareRoot, ".zddc"), "acl:\n permissions:\n \"*\": rwcda\n") mustMkdir(t, filepath.Join(bareRoot, "Fresh")) zddc.InvalidateCache(bareRoot) bareCfg := config.Config{Root: bareRoot, EmailHeader: "X-Auth-Request-Email"} req := httptest.NewRequest(http.MethodGet, "/Fresh/reviewing/", nil) req.Header.Set("Accept", "application/json") req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) rec := httptest.NewRecorder() ServeReviewing(bareCfg, rec, req, "Fresh", "") if rec.Code != http.StatusOK { t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // Empty array, not "null". if body == "null" || body == "null\n" { t.Errorf("body=%q, want []; nil-slice encoded as null", body) } }) } // startsWith — local helper. mustMkdir / mustWrite live in // formhandler_test.go and are reused here. func startsWith(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }