Add a small, tested base for all three clients, and Gitea CI/release workflows
internal/client (new): a typed Go HTTP client for the API - login, register, logout, me, and full CRUD for games/platforms - shared by the TUI and the Fyne GUI so request-building, auth and error handling (an APIError with the HTTP status) live in one tested place instead of being duplicated. 8 tests against an httptest server covering success, 401, 403 and that the token is stored/cleared at the right times. cmd/tui (rewrite): drops the old unauthenticated read-only viewer. Now a login screen (bubbles/textinput) -> a scrollable games table (bubbles/table) with 'r' to refresh, built as an explicit state machine (screenLogin/screenLoading/screenGames) so Update's transitions are testable without a real terminal. 9 tests driving Update() directly. cmd/gui (rewrite): drops raylib for Fyne. Same shape as the TUI - a login form, then a games table, with networking on a goroutine and fyne.Do for the UI updates. The table-cell and login-validation logic are plain functions with no Fyne/display dependency, so they're tested directly (7 tests) rather than needing Fyne's headless test driver. internal/api: tagged every operation (Auth/Platforms/Games/Misc) so the generated OpenAPI spec groups into named services instead of one bucket; api/openapi.json regenerated. Added CORS middleware (wildcard origin, no credentials needed since clients use a bearer token, not the cookie) - needed for the new web client's browser requests to the API's a different origin. web/ (new): an Angular 22 + Material app, API client generated from api/openapi.json via ng-openapi-gen (services: true for per-resource classes - AuthService/GamesService/PlatformsService - instead of one generic Api facade). A Session service holds the token/user as signals, persists the token to localStorage, and is the only thing that calls the generated AuthService; an HttpInterceptorFn attaches the bearer token to every request; a CanActivateFn guard protects /games. Login and Games are standalone, lazy-loaded route components. 21 tests (Vitest, Angular's new default - no browser/Karma needed) covering the service, interceptor, guard and both components, run via `npm test` exactly as CI will call it. Justfile: fixed run-tui/run-gui, which used `go run .../main.go` - broken now that both packages span multiple files, since that only compiles the one named file. Added web-install/web-dev/web-build/web-test. .gitea/workflows/ci.yml (moved from .github, which Gitea doesn't read): on push to main/develop - gofmt, go vet, go build, go test, go mod tidy diff (with the Fyne build's system deps installed), sqlc diff, and the web app's build+test. .gitea/workflows/release.yml (new): on a vX.Y.Z tag, cross-compiles api+tui for linux/darwin/windows (amd64+arm64, pure Go, no cgo), builds the Fyne GUI for linux/amd64 only (cgo + its OpenGL/X11 link means it can't cross-compile from one Linux runner the way api/tui do, and this repo has no registered macOS/Windows runner to build it on natively - documented in the workflow's comments), builds the web app, and creates a Gitea release with all of it attached via the Gitea API. Written to Gitea's documented GitHub-Actions-compatible syntax but not executable from here - there's no Gitea server in this environment to run it against, unlike everything else in this commit, which was. Verified: every Go package builds/vets/tests clean; sqlc diff and go mod tidy -diff are clean; the Angular app builds for production and its test suite passes via `npm test`/`npm run build` (the exact commands CI runs), including after a clean `npm ci` from the committed lockfile; the new CORS config was checked with real preflight and GET requests against a running server, not just read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
384c4a7217
commit
9972537b63
97 files changed
+11810
-336
No files matched your search
@@ -0,0 +1,186 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"completed/internal/model"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
return New(srv.URL), srv
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Fatalf("encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessStoresToken(t *testing.T) {
|
||||
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/auth/login" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var in model.LoginInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if in.Email != "alice@example.com" || in.Password != "secret" {
|
||||
t.Fatalf("unexpected credentials: %+v", in)
|
||||
}
|
||||
writeJSON(t, w, http.StatusOK, model.AuthResponse{
|
||||
Token: "tok-123",
|
||||
User: model.User{ID: "u1", Email: in.Email, Name: "Alice"},
|
||||
})
|
||||
})
|
||||
|
||||
resp, err := c.Login(t.Context(), "alice@example.com", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if resp.Token != "tok-123" {
|
||||
t.Errorf("Token = %q, want tok-123", resp.Token)
|
||||
}
|
||||
if c.Token != "tok-123" {
|
||||
t.Errorf("Client.Token = %q, want tok-123 (Login must store it)", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailureDoesNotStoreToken(t *testing.T) {
|
||||
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, http.StatusUnauthorized, map[string]any{
|
||||
"title": "Unauthorized", "status": 401, "detail": "invalid email or password",
|
||||
})
|
||||
})
|
||||
c.Token = "" // sanity
|
||||
|
||||
_, err := c.Login(t.Context(), "alice@example.com", "wrong")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnauthorized {
|
||||
t.Errorf("Status = %d, want 401", apiErr.Status)
|
||||
}
|
||||
if apiErr.Error() != "invalid email or password" {
|
||||
t.Errorf("Error() = %q, want the detail message", apiErr.Error())
|
||||
}
|
||||
if c.Token != "" {
|
||||
t.Errorf("Token = %q, want empty after a failed login", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGamesSendsBearerToken(t *testing.T) {
|
||||
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer tok-abc" {
|
||||
t.Errorf("Authorization header = %q, want %q", got, "Bearer tok-abc")
|
||||
}
|
||||
writeJSON(t, w, http.StatusOK, []model.Game{
|
||||
{ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
|
||||
})
|
||||
})
|
||||
c.Token = "tok-abc"
|
||||
|
||||
games, err := c.ListGames(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("ListGames: %v", err)
|
||||
}
|
||||
if len(games) != 1 || games[0].Name != "Doom" {
|
||||
t.Errorf("games = %+v, want one game named Doom", games)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGamesUnauthorized(t *testing.T) {
|
||||
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, http.StatusUnauthorized, map[string]any{
|
||||
"title": "Unauthorized", "status": 401, "detail": "authentication required",
|
||||
})
|
||||
})
|
||||
// No token set.
|
||||
|
||||
_, err := c.ListGames(t.Context())
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnauthorized {
|
||||
t.Errorf("Status = %d, want 401", apiErr.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGameSendsBodyAndParsesResponse(t *testing.T) {
|
||||
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/games" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var in model.GameInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if in.Name != "Half-Life" || in.Score != 96 {
|
||||
t.Fatalf("unexpected body: %+v", in)
|
||||
}
|
||||
writeJSON(t, w, http.StatusOK, model.Game{
|
||||
ID: "g2", Name: in.Name, PlatformID: in.PlatformID, Score: in.Score, ReleaseDate: in.ReleaseDate,
|
||||
})
|
||||
})
|
||||
|
||||
g, err := c.CreateGame(t.Context(), model.GameInput{
|
||||
Name: "Half-Life", PlatformID: "p1", Score: 96, ReleaseDate: "1998-11-19",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGame: %v", err)
|
||||
}
|
||||
if g.ID != "g2" {
|
||||
t.Errorf("ID = %q, want g2", g.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePlatformForbidden(t *testing.T) {
|
||||
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, http.StatusForbidden, map[string]any{
|
||||
"title": "Forbidden", "status": 403, "detail": "admin access required",
|
||||
})
|
||||
})
|
||||
c.Token = "tok-non-admin"
|
||||
|
||||
_, err := c.CreatePlatform(t.Context(), "PC")
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusForbidden {
|
||||
t.Errorf("Status = %d, want 403", apiErr.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsTokenEvenOnError(t *testing.T) {
|
||||
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
c.Token = "tok-abc"
|
||||
|
||||
_ = c.Logout(t.Context())
|
||||
if c.Token != "" {
|
||||
t.Errorf("Token = %q, want empty after Logout regardless of the response", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBaseURL(t *testing.T) {
|
||||
c := New("")
|
||||
if c.BaseURL != DefaultBaseURL {
|
||||
t.Errorf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user