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,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"completed/internal/client"
|
||||
"completed/internal/model"
|
||||
)
|
||||
|
||||
// key builds the tea.KeyMsg Update expects for a named key, matching what
|
||||
// bubbletea itself constructs from terminal input.
|
||||
func key(s string) tea.KeyMsg {
|
||||
switch s {
|
||||
case "enter":
|
||||
return tea.KeyMsg{Type: tea.KeyEnter}
|
||||
case "tab":
|
||||
return tea.KeyMsg{Type: tea.KeyTab}
|
||||
case "ctrl+c":
|
||||
return tea.KeyMsg{Type: tea.KeyCtrlC}
|
||||
default:
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
||||
}
|
||||
}
|
||||
|
||||
func update(t *testing.T, m Model, msg tea.Msg) Model {
|
||||
t.Helper()
|
||||
newModel, _ := m.Update(msg)
|
||||
m, ok := newModel.(Model)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T, want Model", newModel)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTypingFillsFocusedField(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
|
||||
m = update(t, m, key("a"))
|
||||
m = update(t, m, key("b"))
|
||||
if got := m.emailInput.Value(); got != "ab" {
|
||||
t.Errorf("emailInput.Value() = %q, want %q", got, "ab")
|
||||
}
|
||||
if got := m.passwordInput.Value(); got != "" {
|
||||
t.Errorf("passwordInput.Value() = %q, want empty while email is focused", got)
|
||||
}
|
||||
|
||||
m = update(t, m, key("tab"))
|
||||
m = update(t, m, key("c"))
|
||||
if got := m.passwordInput.Value(); got != "c" {
|
||||
t.Errorf("passwordInput.Value() = %q, want %q after tab switches focus", got, "c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterOnLoginStartsLoading(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
m.emailInput.SetValue("a@example.com")
|
||||
m.passwordInput.SetValue("secret")
|
||||
|
||||
newModel, cmd := m.Update(key("enter"))
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.screen != screenLoading {
|
||||
t.Errorf("screen = %v, want screenLoading", m.screen)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command to perform the login request, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSucceededTransitionsToLoadingGames(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
m.screen = screenLoading
|
||||
|
||||
newModel, cmd := m.Update(loginSucceededMsg{user: model.User{Name: "Alice"}})
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.screen != screenLoading {
|
||||
t.Errorf("screen = %v, want to stay screenLoading while games load", m.screen)
|
||||
}
|
||||
if m.user.Name != "Alice" {
|
||||
t.Errorf("user.Name = %q, want Alice", m.user.Name)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command to fetch games, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailedStaysOnLoginWithError(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
m.screen = screenLoading
|
||||
wantErr := errors.New("invalid email or password")
|
||||
|
||||
m = update(t, m, loginFailedMsg{err: wantErr})
|
||||
|
||||
if m.err != wantErr {
|
||||
t.Errorf("err = %v, want %v", m.err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamesLoadedPopulatesTableAndSwitchesScreen(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
m.screen = screenLoading
|
||||
games := []model.Game{
|
||||
{ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
|
||||
{ID: "g2", Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"},
|
||||
}
|
||||
|
||||
m = update(t, m, gamesLoadedMsg{games: games})
|
||||
|
||||
if m.screen != screenGames {
|
||||
t.Errorf("screen = %v, want screenGames", m.screen)
|
||||
}
|
||||
rows := m.table.Rows()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("table has %d rows, want 2", len(rows))
|
||||
}
|
||||
if rows[0][0] != "Doom" || rows[0][4] != "" {
|
||||
t.Errorf("row 0 = %v, want Doom unfinished", rows[0])
|
||||
}
|
||||
if rows[1][0] != "Half-Life" || rows[1][4] != "✓" {
|
||||
t.Errorf("row 1 = %v, want Half-Life finished", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamesLoadFailedShowsErrorOnGamesScreen(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
m.screen = screenLoading
|
||||
wantErr := errors.New("network error")
|
||||
|
||||
m = update(t, m, gamesLoadFailedMsg{err: wantErr})
|
||||
|
||||
if m.screen != screenGames {
|
||||
t.Errorf("screen = %v, want screenGames (so 'r' can retry)", m.screen)
|
||||
}
|
||||
if m.err != wantErr {
|
||||
t.Errorf("err = %v, want %v", m.err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshOnGamesScreenReloads(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
m.screen = screenGames
|
||||
m.table = newGamesTable([]model.Game{{ID: "g1", Name: "Doom"}})
|
||||
|
||||
newModel, cmd := m.Update(key("r"))
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.screen != screenLoading {
|
||||
t.Errorf("screen = %v, want screenLoading", m.screen)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command to refetch games, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitOnGamesScreen(t *testing.T) {
|
||||
m := NewModel(client.New(""))
|
||||
m.screen = screenGames
|
||||
|
||||
_, cmd := m.Update(key("q"))
|
||||
if cmd == nil {
|
||||
t.Fatal("expected tea.Quit, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlCQuitsFromAnyScreen(t *testing.T) {
|
||||
for _, s := range []screen{screenLogin, screenLoading, screenGames} {
|
||||
m := NewModel(client.New(""))
|
||||
m.screen = s
|
||||
|
||||
_, cmd := m.Update(key("ctrl+c"))
|
||||
if cmd == nil {
|
||||
t.Errorf("screen %v: expected tea.Quit on ctrl+c, got nil", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user