package server import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "glchat/internal/config" "glchat/internal/database" ) func newTestServer(t *testing.T) (*Server, *database.DB) { t.Helper() ctx := context.Background() db, err := database.Open(ctx, database.Options{ Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true, }) if err != nil { t.Fatalf("open test database: %v", err) } t.Cleanup(func() { if err := db.Close(); err != nil { t.Errorf("close test database: %v", err) } }) cfg := config.Config{ Domain: "gl.mhspx.su", WebRoot: filepath.Join("testdata", "web"), FilesDomain: "files.gl.mhspx.su", InstanceName: "glchat", ListenAddr: "127.0.0.1:0", Version: "v0.1.0-test", Commit: "deadbee", BuildDate: "2026-09-19T00:00:00Z", MaxUploadSize: 26214400, TLSEnabled: true, LogLevel: "error", LogFormat: "json", } logger := slog.New(slog.DiscardHandler) return New(cfg, db, logger), db } func TestHealthz(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/healthz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body["status"] != "ok" { t.Errorf("status field = %v, want ok", body["status"]) } if body["version"] != "v0.1.0-test" { t.Errorf("version field = %v", body["version"]) } if rec.Header().Get("X-Request-Id") == "" { t.Error("X-Request-Id header is missing") } if rec.Header().Get("X-Content-Type-Options") != "nosniff" { t.Error("security headers are missing") } } func TestReadyzReportsDatabase(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body struct { Status string `json:"status"` Checks map[string]string `json:"checks"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body.Status != "ready" { t.Errorf("status = %q, want ready", body.Status) } if body.Checks["database"] != "ok" { t.Errorf("database check = %q, want ok", body.Checks["database"]) } } func TestReadyzFailsWhenDatabaseClosed(t *testing.T) { srv, db := newTestServer(t) if err := db.Close(); err != nil { t.Fatalf("close database: %v", err) } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rec.Code) } } func TestMetaEndpoint(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/meta", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body struct { APIVersion string `json:"api_version"` BaseURL string `json:"base_url"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body.APIVersion != "v1" { t.Errorf("api_version = %q, want v1", body.APIVersion) } if body.BaseURL != "https://gl.mhspx.su" { t.Errorf("base_url = %q", body.BaseURL) } } func TestOpenAPIDocumentIsServed(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/openapi.json", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var doc map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatalf("openapi document is not valid json: %v", err) } if doc["openapi"] != "3.1.0" { t.Errorf("openapi version = %v, want 3.1.0", doc["openapi"]) } if _, ok := doc["paths"].(map[string]any)["/meta"]; !ok { t.Error("openapi document does not describe /meta") } } func TestUnknownAPIRouteUsesErrorEnvelope(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/does-not-exist", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } var body struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode error envelope: %v", err) } if body.Error.Code == "" || body.Error.Message == "" { t.Errorf("error envelope is incomplete: %s", rec.Body.String()) } } func TestServesWebClientWithSPAFallback(t *testing.T) { srv, _ := newTestServer(t) cases := []struct { path string wantStatus int wantSubstr string wantHeaders map[string]string }{ {path: "/", wantStatus: http.StatusOK, wantSubstr: "glchat"}, {path: "/app/guild/1/channel/2", wantStatus: http.StatusOK, wantSubstr: "glchat"}, {path: "/assets/app.js", wantStatus: http.StatusOK, wantSubstr: "console.log"}, } for _, tc := range cases { rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.path, nil)) if rec.Code != tc.wantStatus { t.Errorf("GET %s status = %d, want %d", tc.path, rec.Code, tc.wantStatus) } if !strings.Contains(rec.Body.String(), tc.wantSubstr) { t.Errorf("GET %s body = %q, want it to contain %q", tc.path, rec.Body.String(), tc.wantSubstr) } } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/assets/app.js", nil)) if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") { t.Errorf("assets Cache-Control = %q, want immutable", got) } } func TestServeWebClientReportsMissingBundle(t *testing.T) { ctx := context.Background() db, err := database.Open(ctx, database.Options{ Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true, }) if err != nil { t.Fatalf("open test database: %v", err) } t.Cleanup(func() { _ = db.Close() }) cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"} logger := slog.New(slog.DiscardHandler) srv := New(cfg, db, logger) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)) if rec.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503 when the bundle is absent", rec.Code) } } func TestMethodNotAllowedOnMeta(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/meta", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405", rec.Code) } }