Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 25bc305fc9 feat: add mobile-friendly HTTP 404 and 500 error pages
Add ErrorNotFound and ErrorInternal handler methods that render styled
error pages using the error.html template, with proper status codes,
responsive layout, SVG icons, and HTMX fragment support. Replace the
plain-text http.NotFound call in Dashboard with the new styled handler.

Closes leeworks-agents/gitea-mobile#131

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:06:10 +00:00
5 changed files with 205 additions and 212 deletions
-211
View File
@@ -377,217 +377,6 @@ func sortTriageQueue(queue []TriageItem) {
}
}
// --- Issue #117: Tests for GetTriageQueue aggregation ---
func TestGetTriageQueue_Integration(t *testing.T) {
// Mock server that returns issues (some assigned, some not) and PRs.
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues") && r.Method == "GET":
// Return mix of assigned and unassigned issues.
issues := []map[string]interface{}{
{
"id": 1, "number": 1, "title": "Unassigned bug",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{{"id": 1, "name": "P1", "color": "ff0000"}},
"html_url": "http://example.com/org1/repo1/issues/1",
},
{
"id": 2, "number": 2, "title": "Assigned issue",
"state": "open",
"assignee": map[string]string{"login": "dev1", "avatar_url": ""},
"assignees": []map[string]string{{"login": "dev1", "avatar_url": ""}},
"labels": []interface{}{},
"html_url": "http://example.com/org1/repo1/issues/2",
},
{
"id": 3, "number": 3, "title": "Unassigned low priority",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{{"id": 2, "name": "P3", "color": "00ff00"}},
"html_url": "http://example.com/org1/repo1/issues/3",
},
}
json.NewEncoder(w).Encode(issues)
case strings.HasSuffix(r.URL.Path, "/pulls") && r.Method == "GET":
prs := []map[string]interface{}{
{
"id": 10, "number": 10, "title": "Open PR needs review",
"state": "open", "body": "please review",
"labels": []map[string]interface{}{{"id": 3, "name": "P2", "color": "ffff00"}},
"html_url": "http://example.com/org1/repo1/pulls/10",
"head": map[string]string{"label": "feature", "ref": "feature"},
"base": map[string]string{"label": "master", "ref": "master"},
},
}
json.NewEncoder(w).Encode(prs)
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
// Should include: 2 unassigned issues + 1 PR = 3 items.
// Assigned issue (#2) should be excluded.
if len(queue) != 3 {
t.Fatalf("expected 3 triage items, got %d", len(queue))
}
// Verify sorting: P1 > P2 > P3.
if queue[0].Title != "Unassigned bug" {
t.Errorf("queue[0] should be P1 'Unassigned bug', got %q", queue[0].Title)
}
if queue[1].Title != "Open PR needs review" {
t.Errorf("queue[1] should be P2 'Open PR needs review', got %q", queue[1].Title)
}
if queue[2].Title != "Unassigned low priority" {
t.Errorf("queue[2] should be P3 'Unassigned low priority', got %q", queue[2].Title)
}
// Verify types.
if queue[0].Type != "issue" {
t.Errorf("queue[0].Type = %q, want 'issue'", queue[0].Type)
}
if queue[1].Type != "pull" {
t.Errorf("queue[1].Type = %q, want 'pull'", queue[1].Type)
}
}
func TestGetTriageQueue_EmptyOrgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{})
if err != nil {
t.Fatalf("GetTriageQueue with empty orgs: %v", err)
}
if len(queue) != 0 {
t.Errorf("expected empty queue for empty orgs, got %d items", len(queue))
}
}
func TestGetTriageQueue_AllAssigned(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues"):
// All issues are assigned.
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1, "number": 1, "title": "Assigned issue",
"state": "open",
"assignee": map[string]string{"login": "dev1"},
"assignees": []map[string]string{{"login": "dev1"}},
"labels": []interface{}{},
"html_url": "http://example.com/org1/repo1/issues/1",
},
})
case strings.HasSuffix(r.URL.Path, "/pulls"):
json.NewEncoder(w).Encode([]interface{}{}) // No PRs.
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
// Only PRs should appear (none here), all issues are assigned.
if len(queue) != 0 {
t.Errorf("expected 0 items (all assigned), got %d", len(queue))
}
}
func TestGetTriageQueue_LabelExtraction(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues"):
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1, "number": 1, "title": "Multi-label issue",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{
{"id": 1, "name": "bug", "color": "d73a4a"},
{"id": 2, "name": "P1", "color": "ff0000"},
{"id": 3, "name": "help wanted", "color": "0e8a16"},
},
"html_url": "http://example.com/org1/repo1/issues/1",
},
})
case strings.HasSuffix(r.URL.Path, "/pulls"):
json.NewEncoder(w).Encode([]interface{}{})
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
if len(queue) != 1 {
t.Fatalf("expected 1 item, got %d", len(queue))
}
if len(queue[0].Labels) != 3 {
t.Errorf("expected 3 labels, got %d: %v", len(queue[0].Labels), queue[0].Labels)
}
}
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
func TestListOrgsAndRepos(t *testing.T) {
+48 -1
View File
@@ -181,11 +181,58 @@ func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string,
}
}
// errorData holds the template data for error pages.
type errorData struct {
Code int
Title string
Message string
}
// ErrorNotFound renders a mobile-friendly 404 error page.
func (h *Handler) ErrorNotFound(w http.ResponseWriter, r *http.Request) {
data := errorData{
Code: http.StatusNotFound,
Title: "Page Not Found",
Message: "The page you are looking for does not exist or has been moved.",
}
h.renderError(w, r, data)
}
// ErrorInternal renders a mobile-friendly 500 error page.
func (h *Handler) ErrorInternal(w http.ResponseWriter, r *http.Request) {
data := errorData{
Code: http.StatusInternalServerError,
Title: "Internal Server Error",
Message: "Something went wrong on our end. Please try again later.",
}
h.renderError(w, r, data)
}
// renderError renders the error template with the given data and status code.
func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) {
tmpl, err := template.ParseFiles("internal/templates/error.html")
if err != nil {
slog.Error("failed to parse error template", "error", err)
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
return
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute error template", "error", err)
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
return
}
w.WriteHeader(data.Code)
renderPage(w, r, data.Title, "", buf.String())
}
// Dashboard handles GET / — the triage queue.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
// Only handle exact root path.
if r.URL.Path != "/" {
http.NotFound(w, r)
h.ErrorNotFound(w, r)
return
}
+81
View File
@@ -183,6 +183,87 @@ func TestAddComment_EmptyBody(t *testing.T) {
}
}
func TestErrorNotFound(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
w := httptest.NewRecorder()
h.ErrorNotFound(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
if body == "" {
t.Error("expected non-empty response body")
}
if !contains(body, "404") {
t.Error("expected body to contain '404'")
}
if !contains(body, "Page Not Found") {
t.Error("expected body to contain 'Page Not Found'")
}
}
func TestErrorInternal(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/error", nil)
w := httptest.NewRecorder()
h.ErrorInternal(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
body := w.Body.String()
if body == "" {
t.Error("expected non-empty response body")
}
if !contains(body, "500") {
t.Error("expected body to contain '500'")
}
if !contains(body, "Internal Server Error") {
t.Error("expected body to contain 'Internal Server Error'")
}
}
func TestDashboard_NonRootPath_Returns404(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/unknown/path", nil)
w := httptest.NewRecorder()
h.Dashboard(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
if !contains(body, "404") {
t.Error("expected body to contain '404' for non-root path")
}
}
func TestErrorNotFound_HTMX(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ErrorNotFound(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
// HTMX response should not contain DOCTYPE.
if contains(body, "<!DOCTYPE") {
t.Error("HTMX response should not contain DOCTYPE")
}
if !contains(body, "Page Not Found") {
t.Error("expected body to contain 'Page Not Found'")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
+23
View File
@@ -0,0 +1,23 @@
{{define "content"}}
<div class="error-page">
<div class="error-icon">
{{if eq .Code 404}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
{{else}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
{{end}}
</div>
<h1 class="error-code">{{.Code}}</h1>
<p class="error-title">{{.Title}}</p>
<p class="error-message">{{.Message}}</p>
<a href="/" class="error-home-link">Go to Dashboard</a>
</div>
{{end}}
+53
View File
@@ -545,3 +545,56 @@ a:active {
--text-link: #0969da;
}
}
/* Error page */
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: var(--spacing-lg);
}
.error-icon {
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.error-code {
font-size: 4rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: var(--spacing-sm);
}
.error-title {
font-size: var(--font-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.error-message {
font-size: var(--font-base);
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
max-width: 300px;
}
.error-home-link {
display: inline-block;
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--accent-blue);
color: #fff;
border-radius: var(--radius);
text-decoration: none;
font-size: var(--font-base);
font-weight: 500;
transition: opacity 0.15s;
}
.error-home-link:active {
opacity: 0.8;
}