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:
SansanandClaude Sonnet 5 committed 2026-10-03 12:16:59 +02:00
1 parent 384c4a7217
commit 9972537b63
97 files changed
+11808 -334

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
branches: [main, develop]
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# cmd/gui (Fyne) uses cgo and links against the system's OpenGL/X11
# libraries, so building or testing ./... needs these present even
# though the GUI itself never opens a window in CI.
- name: Install system dependencies for the Fyne GUI build
run: |
sudo apt-get update
sudo apt-get install -y libgl1-mesa-dev xorg-dev
- name: gofmt
run: |
fmt_out="$(gofmt -l .)"
if [ -n "$fmt_out" ]; then
echo "The following files are not gofmt'd:"
echo "$fmt_out"
exit 1
fi
- name: go vet
run: go vet ./...
- name: go build
run: go build ./...
- name: go test
run: go test ./...
- name: go mod tidy (no diff expected)
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
sqlc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: sqlc-dev/setup-sqlc@v4
with:
sqlc-version: "1.31.1"
- name: sqlc diff (generated code must match the schema/queries)
run: sqlc diff
web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm test
+168
View File
@@ -0,0 +1,168 @@
name: Release
# A pushed tag like v1.2.3 builds every client and attaches the result to a
# Gitea release of the same name. Tag a commit that has already passed CI
# on main; this workflow doesn't re-run those checks.
on:
push:
tags: ["v*.*.*"]
jobs:
# cmd/api and cmd/tui are pure Go (no cgo), so they cross-compile cleanly
# from one Linux runner for every OS/arch below.
build-go:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build api and tui
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
mkdir -p dist
ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
go build -o "dist/completed-api${ext}" ./cmd/api
go build -o "dist/completed-tui${ext}" ./cmd/tui
- name: Archive
run: |
cd dist
base="completed_${{ matrix.goos }}_${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
zip "../${base}.zip" completed-api.exe completed-tui.exe
else
tar czf "../${base}.tar.gz" completed-api completed-tui
fi
- uses: actions/upload-artifact@v4
with:
name: completed_${{ matrix.goos }}_${{ matrix.goarch }}
path: completed_${{ matrix.goos }}_${{ matrix.goarch }}.*
retention-days: 7
# cmd/gui (Fyne) uses cgo and links the system's native GL/windowing
# libraries, so - unlike build-go above - it can't be cross-compiled for
# other operating systems from this one Linux runner. This job only
# produces a linux/amd64 build. A macOS or Windows build of the GUI would
# need this workflow extended with a job running on a matching
# self-hosted runner (there isn't one registered for this repo today).
build-gui-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1-mesa-dev xorg-dev
- name: Build gui
run: |
mkdir -p dist
go build -o dist/completed-gui ./cmd/gui
- name: Archive
run: tar czf completed-gui_linux_amd64.tar.gz -C dist completed-gui
- uses: actions/upload-artifact@v4
with:
name: completed-gui_linux_amd64
path: completed-gui_linux_amd64.tar.gz
retention-days: 7
build-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build
working-directory: web
run: |
npm ci
npm run build
- name: Archive
run: tar czf completed-web_dist.tar.gz -C web/dist/web .
- uses: actions/upload-artifact@v4
with:
name: completed-web_dist
path: completed-web_dist.tar.gz
retention-days: 7
release:
needs: [build-go, build-gui-linux, build-web]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create the Gitea release and upload each artifact
env:
# Gitea Actions auto-provides this for the compatibility GITHUB_*
# context; it needs the repo's Settings > Actions permissions set
# to allow writing contents. If your instance doesn't populate it,
# replace with a secret holding a personal/repo access token that
# has repo write access instead.
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
sudo apt-get update && sudo apt-get install -y jq
release_json=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases")
release_id=$(echo "$release_json" | jq -r '.id')
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "Failed to create release: $release_json" >&2
exit 1
fi
for f in artifacts/*; do
name=$(basename "$f")
echo "Uploading $name"
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${f}" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${release_id}/assets?name=${name}"
done
-39
View File
@@ -1,39 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: go vet
run: go vet ./...
- name: go test
run: go test ./...
- name: go mod tidy (no diff expected)
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
sqlc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: sqlc-dev/setup-sqlc@v4
with:
sqlc-version: "1.31.1"
- name: sqlc diff (generated code must match the schema/queries)
run: sqlc diff
+22 -4
View File
@@ -12,7 +12,7 @@ build-api:
# Run the API server locally
run-api:
go run ./cmd/api/main.go
go run ./cmd/api
# Start the API and Database using Docker Compose
docker-up:
@@ -42,17 +42,35 @@ build-tui:
# Run the Terminal UI
run-tui:
go run ./cmd/tui/main.go
go run ./cmd/tui
# --- GUI ---
# Build the GUI (Raylib)
# Build the GUI (Fyne)
build-gui:
go build -o bin/gui ./cmd/gui
# Run the GUI
run-gui:
go run ./cmd/gui/main.go
go run ./cmd/gui
# --- Web ---
# Install web dependencies
web-install:
cd web && npm ci
# Run the web app's dev server
web-dev:
cd web && npm start
# Build the web app for production (output: web/dist/web)
web-build:
cd web && npm run build
# Run the web app's tests
web-test:
cd web && npm test
# --- Database ---
+40 -7
View File
@@ -5,8 +5,12 @@ your score for it, and whether (and when) you finished it.
## Status
Each account has its own private collection. The API is usable; there's no
GUI yet beyond two read-only clients (see below).
Each account has its own private game list, but all accounts share one
platform list (only an admin can add/rename/delete a platform - the first
account ever registered becomes an admin automatically). The API is usable.
There are three small-base clients: a terminal UI, a desktop GUI (Fyne),
and a web app (Angular) — each can log in and show your games, with more
to come.
## Running it
@@ -28,7 +32,8 @@ Browse `http://localhost:8080/docs` for the generated API docs.
## Using the API
```sh
# Register (also logs you in)
# Register (also logs you in; the first account ever registered becomes
# an admin, which is required to create platforms - see Status above)
curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"a long password","name":"You"}'
# -> {"token": "...", "user": {...}}
@@ -44,19 +49,47 @@ curl localhost:8080/api/games -H "Authorization: Bearer $TOKEN"
A browser-based client can skip the `Authorization` header: register/login
also set an httpOnly `session` cookie.
## Other clients
## Clients
- `just run-tui` — a read-only terminal viewer of `/games` (no login yet)
- `just run-gui` — a read-only raylib desktop viewer of `/games` (no login
yet; planned to move to Fyne)
- `just run-tui` — a terminal UI (Bubble Tea). Log in, see your games in a
scrollable table, `r` to refresh.
- `just run-gui` — a desktop GUI (Fyne). Log in, see your games in a table,
refresh.
- `just web-dev` — a web app (Angular + Material) at `http://localhost:4200`,
proxying nothing itself; it talks directly to the API at `localhost:8080`
(CORS is permissive enough for local dev as-is). Log in, see your games.
All three point at `http://localhost:8080` by default. The TUI and GUI read
`COMPLETED_API_URL` to use a different API; the web app's URL is a build-time
constant in `web/src/app/app.config.ts`.
Each client is a small base - listing is there, but adding/editing a game
or managing platforms isn't yet.
## Development
```sh
just test # go test ./...
just sqlc # regenerate internal/db after changing db/query.sql or a migration
just openapi # regenerate api/openapi.json after changing a route
just web-test # web app unit tests (Vitest)
just web-build # production build of the web app
```
Schema changes go in `db/migrations` as a new `<timestamp>_<name>.up.sql` /
`.down.sql` pair; `sqlc.yaml` reads the schema from that directory directly,
so there's no separate schema file to keep in sync.
The TUI and GUI share `internal/client`, a typed Go HTTP client for the API.
The web app's API client (`web/src/app/api/`) is generated from
`api/openapi.json` by `ng-openapi-gen` (`cd web && npx ng-openapi-gen`) and
shouldn't be hand-edited.
## CI/CD
`.gitea/workflows/ci.yml` builds and tests everything (Go + web) on every
push to `main` or `develop`. `.gitea/workflows/release.yml` builds all three
clients and attaches them to a Gitea release when a `vX.Y.Z` tag is pushed;
see that file's comments for its one real limitation (the desktop GUI is
only built for linux/amd64, since it needs cgo and this repo has no
registered macOS/Windows runner to cross-build it on).
+1 -1
View File
@@ -1 +1 @@
{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated
{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated
+9
View File
@@ -131,6 +131,15 @@ func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// The web client (e.g. the Angular dev server on localhost:4200) calls
// this API from a different origin. It authenticates with a bearer
// token, not cookies, so this doesn't need AllowCredentials - which
// means a wildcard origin is safe here. Tighten AllowOrigins to the
// real deployed origin(s) if this ever serves more than local dev.
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization},
}))
humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0"))
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"completed/internal/client"
"completed/internal/model"
)
// guiApp holds the window and the single client all screens share. It's a
// small base: log in, see your games in a table, refresh. Adding/editing
// games is for a later pass.
type guiApp struct {
client *client.Client
window fyne.Window
}
func newGUIApp(c *client.Client, w fyne.Window) *guiApp {
return &guiApp{client: c, window: w}
}
// showLogin builds and displays the login form. It's the window's initial
// content.
func (a *guiApp) showLogin() {
email := widget.NewEntry()
email.SetPlaceHolder("email")
password := widget.NewPasswordEntry()
password.SetPlaceHolder("password")
status := widget.NewLabel("")
form := widget.NewForm(
widget.NewFormItem("Email", email),
widget.NewFormItem("Password", password),
)
form.SubmitText = "Log in"
form.OnSubmit = func() {
if err := validateLogin(email.Text, password.Text); err != nil {
status.SetText(err.Error())
return
}
status.SetText("Logging in...")
a.login(email.Text, password.Text, status)
}
a.window.SetContent(container.NewVBox(
widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
form,
status,
))
}
// login runs the login request off the UI goroutine, then either shows the
// games screen or reports the error back on the login form - both via
// fyne.Do, since Fyne widgets may only be touched from the UI goroutine.
func (a *guiApp) login(email, password string, status *widget.Label) {
go func() {
_, err := a.client.Login(context.Background(), email, password)
if err != nil {
fyne.Do(func() { status.SetText(err.Error()) })
return
}
fyne.Do(a.showGames)
}()
}
// showGames displays the games table and fetches the current list.
func (a *guiApp) showGames() {
var games []model.Game
table := widget.NewTable(
func() (int, int) { return gameTableSize(games) },
func() fyne.CanvasObject { return widget.NewLabel("") },
func(id widget.TableCellID, obj fyne.CanvasObject) {
obj.(*widget.Label).SetText(gameCellText(games, id.Row, id.Col))
},
)
for i := range gameColumns {
table.SetColumnWidth(i, 120)
}
status := widget.NewLabel("Loading...")
refresh := widget.NewButton("Refresh", func() {
a.fetchGames(table, &games, status)
})
a.window.SetContent(container.NewBorder(
container.NewVBox(
widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
container.NewHBox(refresh, status),
),
nil, nil, nil,
table,
))
a.fetchGames(table, &games, status)
}
func (a *guiApp) fetchGames(table *widget.Table, games *[]model.Game, status *widget.Label) {
go func() {
result, err := a.client.ListGames(context.Background())
fyne.Do(func() {
if err != nil {
status.SetText("Error: " + err.Error())
dialog.ShowError(err, a.window)
return
}
*games = result
status.SetText("")
table.Refresh()
})
}()
}
+12 -135
View File
@@ -1,145 +1,22 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"os"
rl "github.com/gen2brain/raylib-go/raylib"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"completed/internal/client"
)
type Game struct {
ID int64 `json:"id"`
Name string `json:"name"`
PlatformID int64 `json:"platform_id"`
Score int32 `json:"score"`
ReleaseYear string `json:"release_year"`
Finished string `json:"finished,omitempty"`
}
type GamesResponse struct {
Body []Game `json:"body"`
}
func main() {
rl.InitWindow(800, 600, "My Game Collection - Raylib")
defer rl.CloseWindow()
fyneApp := app.New()
window := fyneApp.NewWindow("Completed")
window.Resize(fyne.NewSize(700, 500))
rl.SetTargetFPS(60)
c := client.New(os.Getenv("COMPLETED_API_URL"))
a := newGUIApp(c, window)
a.showLogin()
loading := false
var err error
var games []Game
// Channel to receive data from background goroutine
dataChan := make(chan []Game)
errChan := make(chan error)
func() {
loading = true
go fetchGames(dataChan, errChan)
}()
for !rl.WindowShouldClose() {
rl.BeginDrawing()
rl.ClearBackground(rl.RayWhite)
// Header
rl.DrawRectangle(0, 0, 800, 60, rl.NewColor(100, 100, 255, 255))
rl.DrawText("My Game Collection", 20, 15, 30, rl.White)
startY := 80
if loading {
rl.DrawText("Loading games...", 20, int32(startY), 20, rl.Gray)
// Simple spinner animation
if int(rl.GetTime()*4)%2 == 0 {
rl.DrawText(".", 180, int32(startY), 20, rl.Gray)
}
} else if err != nil {
rl.DrawText(fmt.Sprintf("Error: %v", err), 20, int32(startY), 20, rl.Red)
rl.DrawText("Press SPACE to retry", 20, int32(startY)+30, 20, rl.DarkGray)
} else {
if len(games) == 0 {
rl.DrawText("No games found in database.", 20, int32(startY), 20, rl.DarkGray)
} else {
for i, g := range games {
yPos := int32(startY + (i * 40))
// Row background (alternating)
if i%2 == 0 {
rl.DrawRectangle(20, yPos-5, 760, 35, rl.NewColor(240, 240, 240, 255))
}
// Game Name
rl.DrawText(g.Name, 30, yPos, 20, rl.Black)
// Year
rl.DrawText(g.ReleaseYear, 350, yPos, 20, rl.DarkGray)
// Score
scoreColor := rl.Orange
if g.Score >= 90 {
scoreColor = rl.Green
} else if g.Score < 70 {
scoreColor = rl.Red
}
rl.DrawText(fmt.Sprintf("%d", g.Score), 500, yPos, 20, scoreColor)
// Finished Status
if g.Finished != "" {
rl.DrawText("Finished", 600, yPos, 20, rl.NewColor(0, 200, 0, 255))
}
}
}
}
// Footer / Help
rl.DrawText("Press SPACE to refresh", 20, 570, 10, rl.Gray)
rl.EndDrawing()
// Inputs
if rl.IsKeyPressed(rl.KeySpace) && !loading {
loading = true
err = nil
games = nil
go fetchGames(dataChan, errChan)
}
// Check channels (non-blocking)
select {
case data := <-dataChan:
loading = false
games = data
case e := <-errChan:
loading = false
err = e
default:
}
}
}
func fetchGames(dataChan chan<- []Game, errChan chan<- error) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("http://localhost:8080/games")
if err != nil {
errChan <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errChan <- fmt.Errorf("status code: %d", resp.StatusCode)
return
}
var data GamesResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
errChan <- err
return
}
dataChan <- data.Body
window.ShowAndRun()
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"fmt"
"completed/internal/model"
)
// gameColumns are the games table's column headers, in order. Kept next to
// gameCellText so the two can't drift apart.
var gameColumns = []string{"Name", "Platform", "Score", "Released", "Finished"}
// gameTableSize returns the (rows, cols) Fyne's widget.Table needs from its
// Length function: one row per game, plus a header row.
func gameTableSize(games []model.Game) (rows, cols int) {
return len(games) + 1, len(gameColumns)
}
// gameCellText returns the text for row/col in the games table. row 0 is
// the header; row i (i>0) is games[i-1]. It has no Fyne dependency, so it's
// tested directly without needing any widget or display.
func gameCellText(games []model.Game, row, col int) string {
if row == 0 {
return gameColumns[col]
}
g := games[row-1]
switch col {
case 0:
return g.Name
case 1:
return g.PlatformName
case 2:
return fmt.Sprintf("%d", g.Score)
case 3:
return g.ReleaseDate
case 4:
if g.Finished != "" {
return "✓"
}
return ""
default:
return ""
}
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"testing"
"completed/internal/model"
)
func TestGameTableSize(t *testing.T) {
games := []model.Game{{Name: "Doom"}, {Name: "Half-Life"}}
rows, cols := gameTableSize(games)
if rows != 3 {
t.Errorf("rows = %d, want 3 (header + 2 games)", rows)
}
if cols != len(gameColumns) {
t.Errorf("cols = %d, want %d", cols, len(gameColumns))
}
}
func TestGameTableSizeEmpty(t *testing.T) {
rows, _ := gameTableSize(nil)
if rows != 1 {
t.Errorf("rows = %d, want 1 (header only)", rows)
}
}
func TestGameCellTextHeader(t *testing.T) {
for col, want := range gameColumns {
if got := gameCellText(nil, 0, col); got != want {
t.Errorf("gameCellText(nil, 0, %d) = %q, want %q", col, got, want)
}
}
}
func TestGameCellTextDataRows(t *testing.T) {
games := []model.Game{
{Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
{Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"},
}
tests := []struct {
row, col int
want string
}{
{1, 0, "Doom"},
{1, 1, "PC"},
{1, 2, "95"},
{1, 3, "1993-12-10"},
{1, 4, ""},
{2, 0, "Half-Life"},
{2, 4, "✓"},
}
for _, tt := range tests {
if got := gameCellText(games, tt.row, tt.col); got != tt.want {
t.Errorf("gameCellText(games, %d, %d) = %q, want %q", tt.row, tt.col, got, tt.want)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
package main
import "errors"
// validateLogin checks the login form before it's sent to the server, so a
// blank field shows an immediate, specific error instead of an HTTP
// round trip followed by a generic one.
func validateLogin(email, password string) error {
if email == "" {
return errors.New("email is required")
}
if password == "" {
return errors.New("password is required")
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package main
import "testing"
func TestValidateLogin(t *testing.T) {
tests := []struct {
name, email, password string
wantErr bool
}{
{"valid", "a@example.com", "secret", false},
{"empty email", "", "secret", true},
{"empty password", "a@example.com", "", true},
{"both empty", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateLogin(tt.email, tt.password)
if (err != nil) != tt.wantErr {
t.Errorf("validateLogin(%q, %q) error = %v, wantErr %v", tt.email, tt.password, err, tt.wantErr)
}
})
}
}
+6 -136
View File
@@ -1,150 +1,20 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"completed/internal/client"
)
// Styles
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
itemStyle = lipgloss.NewStyle().
PaddingLeft(2)
scoreStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFD700")) // Gold color
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555")).
Bold(true)
)
type Game struct {
ID int64 `json:"id"`
Name string `json:"name"`
PlatformID int64 `json:"platform_id"`
Score int32 `json:"score"`
ReleaseYear string `json:"release_year"`
Finished string `json:"finished,omitempty"`
}
type GamesResponse struct {
Body []Game `json:"body"`
}
type Model struct {
spinner spinner.Model
loading bool
err error
games []Game
quitting bool
}
func InitialModel() Model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return Model{spinner: s, loading: true}
}
type loadedMsg []Game
type errMsg error
func fetchGames() tea.Msg {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("http://localhost:8080/games")
if err != nil {
return errMsg(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errMsg(fmt.Errorf("status code: %d", resp.StatusCode))
}
var data GamesResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errMsg(err)
}
return loadedMsg(data.Body)
}
func (m Model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, fetchGames)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
m.quitting = true
return m, tea.Quit
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case loadedMsg:
m.loading = false
m.games = msg
return m, nil
case errMsg:
m.loading = false
m.err = msg
return m, nil
}
return m, nil
}
func (m Model) View() string {
if m.quitting {
return "Bye!\n"
}
s := titleStyle.Render("My Game Collection") + "\n\n"
if m.loading {
s += fmt.Sprintf("%s Loading games...", m.spinner.View())
} else if m.err != nil {
s += errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
s += "\nMake sure the API is running on localhost:8080"
} else {
if len(m.games) == 0 {
s += "No games found."
} else {
for _, g := range m.games {
score := scoreStyle.Render(fmt.Sprintf("★ %d", g.Score))
line := fmt.Sprintf("• %s (%s) - %s", g.Name, g.ReleaseYear, score)
if g.Finished != "" {
line += " [✓ Finished]"
}
s += itemStyle.Render(line) + "\n"
}
}
}
s += strings.Repeat("\n", 2) + lipgloss.NewStyle().Foreground(lipgloss.Color("#555")).Render("Press 'q' to quit")
return s
}
func main() {
p := tea.NewProgram(InitialModel())
c := client.New(os.Getenv("COMPLETED_API_URL"))
p := tea.NewProgram(NewModel(c))
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
fmt.Fprintf(os.Stderr, "Alas, there's been an error: %v\n", err)
os.Exit(1)
}
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"completed/internal/client"
"completed/internal/model"
)
// screen is which part of the app is currently showing. The model only
// ever does one thing at a time, so a single enum (rather than a flag per
// feature) keeps Update's branching simple.
type screen int
const (
screenLogin screen = iota
screenLoading
screenGames
)
// focusField is which of the two login inputs has keyboard focus.
type focusField int
const (
focusEmail focusField = iota
focusPassword
)
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555")).
Bold(true)
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555"))
focusedInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4"))
)
// Model is the TUI's whole state. It's a small base: log in, see your
// games in a scrollable table, refresh. Adding/editing games is for a
// later pass; the point here is a working, tested skeleton the rest of
// the TUI can grow from.
type Model struct {
client *client.Client
screen screen
err error
// Login screen.
emailInput textinput.Model
passwordInput textinput.Model
focus focusField
// Loading screen (used for both the initial post-login fetch and a
// manual refresh).
spinner spinner.Model
// Games screen.
table table.Model
user model.User
}
// NewModel builds the initial Model, showing the login screen.
func NewModel(c *client.Client) Model {
email := textinput.New()
email.Placeholder = "email"
email.Focus()
email.CharLimit = 254
password := textinput.New()
password.Placeholder = "password"
password.EchoMode = textinput.EchoPassword
password.CharLimit = 254
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return Model{
client: c,
screen: screenLogin,
emailInput: email,
passwordInput: password,
spinner: s,
table: newGamesTable(nil),
}
}
func newGamesTable(games []model.Game) table.Model {
columns := []table.Column{
{Title: "Name", Width: 30},
{Title: "Platform", Width: 16},
{Title: "Score", Width: 7},
{Title: "Released", Width: 12},
{Title: "Finished", Width: 10},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(gamesToRows(games)),
table.WithFocused(true),
table.WithHeight(15),
)
return t
}
func gamesToRows(games []model.Game) []table.Row {
rows := make([]table.Row, len(games))
for i, g := range games {
finished := ""
if g.Finished != "" {
finished = "✓"
}
rows[i] = table.Row{g.Name, g.PlatformName, fmt.Sprintf("%d", g.Score), g.ReleaseDate, finished}
}
return rows
}
// --- Messages ---
type loginSucceededMsg struct{ user model.User }
type loginFailedMsg struct{ err error }
type gamesLoadedMsg struct{ games []model.Game }
type gamesLoadFailedMsg struct{ err error }
// --- Commands ---
func doLogin(c *client.Client, email, password string) tea.Cmd {
return func() tea.Msg {
resp, err := c.Login(context.Background(), email, password)
if err != nil {
return loginFailedMsg{err: err}
}
return loginSucceededMsg{user: resp.User}
}
}
func doListGames(c *client.Client) tea.Cmd {
return func() tea.Msg {
games, err := c.ListGames(context.Background())
if err != nil {
return gamesLoadFailedMsg{err: err}
}
return gamesLoadedMsg{games: games}
}
}
// --- bubbletea.Model ---
func (m Model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, textinput.Blink)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.updateKey(msg)
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case loginSucceededMsg:
m.user = msg.user
m.err = nil
m.screen = screenLoading
return m, doListGames(m.client)
case loginFailedMsg:
m.err = msg.err
return m, nil
case gamesLoadedMsg:
m.table = newGamesTable(msg.games)
m.err = nil
m.screen = screenGames
return m, nil
case gamesLoadFailedMsg:
m.err = msg.err
m.screen = screenGames
return m, nil
}
return m, nil
}
func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch m.screen {
case screenLogin:
return m.updateLoginKey(msg)
case screenGames:
if msg.String() == "q" {
return m, tea.Quit
}
if msg.String() == "r" {
m.screen = screenLoading
m.err = nil
return m, doListGames(m.client)
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
return m, nil
}
func (m Model) updateLoginKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "tab", "shift+tab", "down", "up":
if m.focus == focusEmail {
m.focus = focusPassword
m.emailInput.Blur()
m.passwordInput.Focus()
} else {
m.focus = focusEmail
m.passwordInput.Blur()
m.emailInput.Focus()
}
return m, nil
case "enter":
m.err = nil
m.screen = screenLoading
return m, doLogin(m.client, m.emailInput.Value(), m.passwordInput.Value())
}
var cmd tea.Cmd
if m.focus == focusEmail {
m.emailInput, cmd = m.emailInput.Update(msg)
} else {
m.passwordInput, cmd = m.passwordInput.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
switch m.screen {
case screenLogin:
return m.viewLogin()
case screenLoading:
return fmt.Sprintf("%s Loading...\n", m.spinner.View())
case screenGames:
return m.viewGames()
}
return ""
}
func (m Model) viewLogin() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Completed") + "\n\n")
emailLabel, passwordLabel := "Email", "Password"
if m.focus == focusEmail {
emailLabel = focusedInputStyle.Render(emailLabel)
} else {
passwordLabel = focusedInputStyle.Render(passwordLabel)
}
b.WriteString(emailLabel + "\n" + m.emailInput.View() + "\n\n")
b.WriteString(passwordLabel + "\n" + m.passwordInput.View() + "\n\n")
if m.err != nil {
b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n")
}
b.WriteString(helpStyle.Render("tab: switch field · enter: log in · ctrl+c: quit"))
return b.String()
}
func (m Model) viewGames() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Completed") + " " + m.user.Name + "\n\n")
if m.err != nil {
b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n")
}
b.WriteString(m.table.View() + "\n\n")
b.WriteString(helpStyle.Render("r: refresh · q: quit"))
return b.String()
}
+179
View File
@@ -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)
}
}
}
+29 -4
View File
@@ -3,11 +3,11 @@ module completed
go 1.25.4
require (
fyne.io/fyne/v2 v2.8.1
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/danielgtaylor/huma/v2 v2.34.1
github.com/gen2brain/raylib-go/raylib v0.55.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
@@ -17,35 +17,60 @@ require (
)
require (
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/ebitengine/purego v0.7.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
+64 -8
View File
@@ -1,9 +1,23 @@
fyne.io/fyne/v2 v2.8.1 h1:EztGuE2W3Qhd0cWVmU+h5rkzNezUD1To6UqsoLQYUIM=
fyne.io/fyne/v2 v2.8.1/go.mod h1:kpeuFrClm0fiAgJYr2soTfwKMT5rzNcSKzmgGjxvHOY=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -16,13 +30,16 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ=
github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -38,24 +55,46 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8=
github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -66,6 +105,10 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -84,8 +127,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -98,6 +141,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -107,11 +154,16 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -123,6 +175,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
@@ -137,6 +191,8 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+4
View File
@@ -65,6 +65,7 @@ func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (stri
func registerAuthRoutes(api huma.API, queries db.Querier) {
huma.Register(api, huma.Operation{
OperationID: "register",
Tags: []string{"Auth"},
Method: http.MethodPost,
Path: "/api/auth/register",
Summary: "Register a new account",
@@ -100,6 +101,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
huma.Register(api, huma.Operation{
OperationID: "login",
Tags: []string{"Auth"},
Method: http.MethodPost,
Path: "/api/auth/login",
Summary: "Log in to an existing account",
@@ -133,6 +135,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
logoutOp := huma.Operation{
OperationID: "logout",
Tags: []string{"Auth"},
Method: http.MethodPost,
Path: "/api/auth/logout",
Summary: "Log out of the current session",
@@ -149,6 +152,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
meOp := huma.Operation{
OperationID: "get-me",
Tags: []string{"Auth"},
Method: http.MethodGet,
Path: "/api/auth/me",
Summary: "Get the current user",
+11
View File
@@ -17,6 +17,7 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
// Root/Hello Route
huma.Register(api, huma.Operation{
OperationID: "get-hello",
Tags: []string{"Misc"},
Method: http.MethodGet,
Path: "/hello",
Summary: "Say Hello",
@@ -52,6 +53,7 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
func registerPlatformRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{
OperationID: "list-platforms",
Tags: []string{"Platforms"},
Method: http.MethodGet,
Path: "/api/platforms",
Summary: "List Platforms",
@@ -71,6 +73,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
getOp := huma.Operation{
OperationID: "get-platform",
Tags: []string{"Platforms"},
Method: http.MethodGet,
Path: "/api/platforms/{id}",
Summary: "Get Platform",
@@ -88,6 +91,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
createOp := huma.Operation{
OperationID: "create-platform",
Tags: []string{"Platforms"},
Method: http.MethodPost,
Path: "/api/platforms",
Summary: "Create Platform",
@@ -103,6 +107,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
updateOp := huma.Operation{
OperationID: "update-platform",
Tags: []string{"Platforms"},
Method: http.MethodPut,
Path: "/api/platforms/{id}",
Summary: "Update Platform",
@@ -124,6 +129,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
deleteOp := huma.Operation{
OperationID: "delete-platform",
Tags: []string{"Platforms"},
Method: http.MethodDelete,
Path: "/api/platforms/{id}",
Summary: "Delete Platform",
@@ -185,6 +191,7 @@ func parseDate(s string) (pgtype.Date, error) {
func registerGameRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{
OperationID: "list-games",
Tags: []string{"Games"},
Method: http.MethodGet,
Path: "/api/games",
Summary: "List Games",
@@ -212,6 +219,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
getOp := huma.Operation{
OperationID: "get-game",
Tags: []string{"Games"},
Method: http.MethodGet,
Path: "/api/games/{id}",
Summary: "Get Game",
@@ -237,6 +245,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
createOp := huma.Operation{
OperationID: "create-game",
Tags: []string{"Games"},
Method: http.MethodPost,
Path: "/api/games",
Summary: "Create Game",
@@ -280,6 +289,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
updateOp := huma.Operation{
OperationID: "update-game",
Tags: []string{"Games"},
Method: http.MethodPut,
Path: "/api/games/{id}",
Summary: "Update Game",
@@ -327,6 +337,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
deleteOp := huma.Operation{
OperationID: "delete-game",
Tags: []string{"Games"},
Method: http.MethodDelete,
Path: "/api/games/{id}",
Summary: "Delete Game",
+218
View File
@@ -0,0 +1,218 @@
// Package client is a thin, typed HTTP client for the Completed API. It's
// the shared base for the TUI and the Fyne GUI: both hold a *Client and
// call its methods instead of each hand-rolling net/http calls, so request
// building, auth, and error handling live in one tested place.
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"completed/internal/model"
)
// DefaultBaseURL is used when no other base URL is configured. It matches
// cmd/api's own default port.
const DefaultBaseURL = "http://localhost:8080"
// Client talks to the Completed API. The zero value is not usable; create
// one with New. A Client is safe for concurrent use once Token is no
// longer being written concurrently with reads - in practice, callers set
// it only right after Login/Register, before starting concurrent calls.
type Client struct {
BaseURL string
Token string
httpClient *http.Client
}
// New returns a Client pointed at baseURL. An empty baseURL is replaced
// with DefaultBaseURL.
func New(baseURL string) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return &Client{
BaseURL: baseURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// APIError is returned for any non-2xx response. It carries the status
// code so callers can distinguish, say, a 401 (show "wrong password") from
// a 500 (show "something went wrong").
type APIError struct {
Status int
Title string
Detail string
}
func (e *APIError) Error() string {
if e.Detail != "" {
return e.Detail
}
if e.Title != "" {
return e.Title
}
return fmt.Sprintf("request failed with status %d", e.Status)
}
// huma's error body shape; see internal/api's ErrorModel (from the huma
// package) in api/openapi.json.
type errorBody struct {
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail"`
}
// do sends an HTTP request and decodes a JSON response into out (if out is
// non-nil). It attaches the bearer token, if set, and turns any non-2xx
// response into an *APIError.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 300 {
var eb errorBody
_ = json.Unmarshal(data, &eb) // best-effort; fall back to status alone
return &APIError{Status: resp.StatusCode, Title: eb.Title, Detail: eb.Detail}
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// --- Auth ---
func (c *Client) Register(ctx context.Context, email, password, name string) (model.AuthResponse, error) {
var resp model.AuthResponse
err := c.do(ctx, http.MethodPost, "/api/auth/register", model.RegisterInput{
Email: email, Password: password, Name: name,
}, &resp)
if err == nil {
c.Token = resp.Token
}
return resp, err
}
func (c *Client) Login(ctx context.Context, email, password string) (model.AuthResponse, error) {
var resp model.AuthResponse
err := c.do(ctx, http.MethodPost, "/api/auth/login", model.LoginInput{
Email: email, Password: password,
}, &resp)
if err == nil {
c.Token = resp.Token
}
return resp, err
}
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Token = ""
return err
}
func (c *Client) Me(ctx context.Context) (model.User, error) {
var u model.User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
return u, err
}
// --- Platforms ---
func (c *Client) ListPlatforms(ctx context.Context) ([]model.Platform, error) {
var platforms []model.Platform
err := c.do(ctx, http.MethodGet, "/api/platforms", nil, &platforms)
return platforms, err
}
func (c *Client) GetPlatform(ctx context.Context, id string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodGet, "/api/platforms/"+id, nil, &p)
return p, err
}
// CreatePlatform requires the current user to be an admin; see
// internal/api/middleware.go's addAdminAuth.
func (c *Client) CreatePlatform(ctx context.Context, name string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodPost, "/api/platforms", model.PlatformInput{Name: name}, &p)
return p, err
}
func (c *Client) UpdatePlatform(ctx context.Context, id, name string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodPut, "/api/platforms/"+id, model.PlatformInput{Name: name}, &p)
return p, err
}
func (c *Client) DeletePlatform(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/platforms/"+id, nil, nil)
}
// --- Games ---
func (c *Client) ListGames(ctx context.Context) ([]model.Game, error) {
var games []model.Game
err := c.do(ctx, http.MethodGet, "/api/games", nil, &games)
return games, err
}
func (c *Client) GetGame(ctx context.Context, id string) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodGet, "/api/games/"+id, nil, &g)
return g, err
}
func (c *Client) CreateGame(ctx context.Context, in model.GameInput) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodPost, "/api/games", in, &g)
return g, err
}
func (c *Client) UpdateGame(ctx context.Context, id string, in model.GameInput) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodPut, "/api/games/"+id, in, &g)
return g, err
}
func (c *Client) DeleteGame(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/games/"+id, nil, nil)
}
+186
View File
@@ -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)
}
}
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# Web
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.2.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+71
View File
@@ -0,0 +1,71 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"web": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/material-theme.scss", "src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "web:build:production"
},
"development": {
"buildTarget": "web:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "node_modules/ng-openapi-gen/ng-openapi-gen-schema.json",
"input": "../api/openapi.json",
"output": "src/app/api",
"ignoreUnusedModels": false,
"services": true
}
+7131
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.19.1",
"dependencies": {
"@angular/animations": "^22.2.1",
"@angular/cdk": "^22.2.1",
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/material": "^22.2.1",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.2.1",
"@angular/cli": "^22.2.1",
"@angular/compiler-cli": "^22.2.0",
"jsdom": "^30.0.0",
"ng-openapi-gen": "^1.1.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^5.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+27
View File
@@ -0,0 +1,27 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { Injectable } from '@angular/core';
/**
* Provides the ApiConfiguration configuration object with a given root URL.
*/
export function provideApiConfiguration(rootUrl: string) {
var config = new ApiConfiguration();
config.rootUrl = rootUrl;
return {
provide: ApiConfiguration,
useValue: config
};
}
/**
* Global configuration
*/
@Injectable({
providedIn: 'root',
})
export class ApiConfiguration {
rootUrl: string = '';
}
+64
View File
@@ -0,0 +1,64 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { Injectable } from '@angular/core';
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable, firstValueFrom } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { ApiConfiguration } from './api-configuration';
import { StrictHttpResponse } from './strict-http-response';
export type ApiFnOptional<P, R> = (http: HttpClient, rootUrl: string, params?: P, context?: HttpContext) => Observable<StrictHttpResponse<R>>;
export type ApiFnRequired<P, R> = (http: HttpClient, rootUrl: string, params: P, context?: HttpContext) => Observable<StrictHttpResponse<R>>;
/**
* Helper service to call API functions directly
*/
@Injectable({ providedIn: 'root' })
export class Api {
constructor(
private config: ApiConfiguration,
private http: HttpClient
) {
}
private _rootUrl?: string;
/**
* Returns the root url for API operations. If not set directly here,
* will fallback to `ApiConfiguration.rootUrl`.
*/
get rootUrl(): string {
return this._rootUrl || this.config.rootUrl;
}
/**
* Sets the root URL for API operations
*/
set rootUrl(rootUrl: string) {
this._rootUrl = rootUrl;
}
/**
* Executes an API call, returning the response body only
*/
invoke<P, R>(fn: ApiFnRequired<P, R>, params: P, context?: HttpContext): Promise<R>;
invoke<P, R>(fn: ApiFnOptional<P, R>, params?: P, context?: HttpContext): Promise<R>;
async invoke<P, R>(fn: ApiFnRequired<P, R> | ApiFnOptional<P, R>, params: P, context?: HttpContext): Promise<R> {
const resp = this.invoke$Response(fn, params, context);
return (await resp).body;
}
/**
* Executes an API call, returning the entire response
*/
invoke$Response<P, R>(fn: ApiFnRequired<P, R>, params: P, context?: HttpContext): Promise<StrictHttpResponse<R>>;
invoke$Response<P, R>(fn: ApiFnOptional<P, R>, params?: P, context?: HttpContext): Promise<StrictHttpResponse<R>>;
invoke$Response<P, R>(fn: ApiFnRequired<P, R> | ApiFnOptional<P, R>, params: P, context?: HttpContext): Promise<StrictHttpResponse<R>> {
const obs = fn(this.http, this.rootUrl, params, context)
.pipe(
filter(r => r instanceof HttpResponse),
map(r => r as StrictHttpResponse<R>));
return firstValueFrom(obs);
}
}
+35
View File
@@ -0,0 +1,35 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApiConfiguration } from './api-configuration';
/**
* Base class for services
*/
@Injectable()
export class BaseService {
constructor(
protected config: ApiConfiguration,
protected http: HttpClient
) {
}
private _rootUrl?: string;
/**
* Returns the root url for all operations in this service. If not set directly in this
* service, will fallback to `ApiConfiguration.rootUrl`.
*/
get rootUrl(): string {
return this._rootUrl || this.config.rootUrl;
}
/**
* Sets the root URL for API operations in this service.
*/
set rootUrl(rootUrl: string) {
this._rootUrl = rootUrl;
}
}
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { User } from '../../models/user';
export interface GetMe$Params {
}
export function getMe(http: HttpClient, rootUrl: string, params?: GetMe$Params, context?: HttpContext): Observable<StrictHttpResponse<User>> {
const rb = new RequestBuilder(rootUrl, getMe.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<User>;
})
);
}
getMe.PATH = '/api/auth/me';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { AuthResponse } from '../../models/auth-response';
import { LoginInput } from '../../models/login-input';
export interface Login$Params {
body: LoginInput
}
export function login(http: HttpClient, rootUrl: string, params: Login$Params, context?: HttpContext): Observable<StrictHttpResponse<AuthResponse>> {
const rb = new RequestBuilder(rootUrl, login.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<AuthResponse>;
})
);
}
login.PATH = '/api/auth/login';
+29
View File
@@ -0,0 +1,29 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
export interface Logout$Params {
}
export function logout(http: HttpClient, rootUrl: string, params?: Logout$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
const rb = new RequestBuilder(rootUrl, logout.PATH, 'post');
if (params) {
}
return http.request(
rb.build({ responseType: 'text', accept: '*/*', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
})
);
}
logout.PATH = '/api/auth/logout';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { AuthResponse } from '../../models/auth-response';
import { RegisterInput } from '../../models/register-input';
export interface Register$Params {
body: RegisterInput
}
export function register(http: HttpClient, rootUrl: string, params: Register$Params, context?: HttpContext): Observable<StrictHttpResponse<AuthResponse>> {
const rb = new RequestBuilder(rootUrl, register.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<AuthResponse>;
})
);
}
register.PATH = '/api/auth/register';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
import { GameInput } from '../../models/game-input';
export interface CreateGame$Params {
body: GameInput
}
export function createGame(http: HttpClient, rootUrl: string, params: CreateGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
const rb = new RequestBuilder(rootUrl, createGame.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Game>;
})
);
}
createGame.PATH = '/api/games';
+31
View File
@@ -0,0 +1,31 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
export interface DeleteGame$Params {
id: string;
}
export function deleteGame(http: HttpClient, rootUrl: string, params: DeleteGame$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
const rb = new RequestBuilder(rootUrl, deleteGame.PATH, 'delete');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'text', accept: '*/*', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
})
);
}
deleteGame.PATH = '/api/games/{id}';
+32
View File
@@ -0,0 +1,32 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
export interface GetGame$Params {
id: string;
}
export function getGame(http: HttpClient, rootUrl: string, params: GetGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
const rb = new RequestBuilder(rootUrl, getGame.PATH, 'get');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Game>;
})
);
}
getGame.PATH = '/api/games/{id}';
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
export interface ListGames$Params {
}
export function listGames(http: HttpClient, rootUrl: string, params?: ListGames$Params, context?: HttpContext): Observable<StrictHttpResponse<(Array<Game> | null)>> {
const rb = new RequestBuilder(rootUrl, listGames.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<(Array<Game> | null)>;
})
);
}
listGames.PATH = '/api/games';
+35
View File
@@ -0,0 +1,35 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
import { GameInput } from '../../models/game-input';
export interface UpdateGame$Params {
id: string;
body: GameInput
}
export function updateGame(http: HttpClient, rootUrl: string, params: UpdateGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
const rb = new RequestBuilder(rootUrl, updateGame.PATH, 'put');
if (params) {
rb.path('id', params.id, {});
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Game>;
})
);
}
updateGame.PATH = '/api/games/{id}';
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { GetHelloResponse } from '../../models/get-hello-response';
export interface GetHello$Params {
}
export function getHello(http: HttpClient, rootUrl: string, params?: GetHello$Params, context?: HttpContext): Observable<StrictHttpResponse<GetHelloResponse>> {
const rb = new RequestBuilder(rootUrl, getHello.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<GetHelloResponse>;
})
);
}
getHello.PATH = '/hello';
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
import { PlatformInput } from '../../models/platform-input';
export interface CreatePlatform$Params {
body: PlatformInput
}
export function createPlatform(http: HttpClient, rootUrl: string, params: CreatePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
const rb = new RequestBuilder(rootUrl, createPlatform.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Platform>;
})
);
}
createPlatform.PATH = '/api/platforms';
@@ -0,0 +1,31 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
export interface DeletePlatform$Params {
id: string;
}
export function deletePlatform(http: HttpClient, rootUrl: string, params: DeletePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
const rb = new RequestBuilder(rootUrl, deletePlatform.PATH, 'delete');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'text', accept: '*/*', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
})
);
}
deletePlatform.PATH = '/api/platforms/{id}';
@@ -0,0 +1,32 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
export interface GetPlatform$Params {
id: string;
}
export function getPlatform(http: HttpClient, rootUrl: string, params: GetPlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
const rb = new RequestBuilder(rootUrl, getPlatform.PATH, 'get');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Platform>;
})
);
}
getPlatform.PATH = '/api/platforms/{id}';
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
export interface ListPlatforms$Params {
}
export function listPlatforms(http: HttpClient, rootUrl: string, params?: ListPlatforms$Params, context?: HttpContext): Observable<StrictHttpResponse<(Array<Platform> | null)>> {
const rb = new RequestBuilder(rootUrl, listPlatforms.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<(Array<Platform> | null)>;
})
);
}
listPlatforms.PATH = '/api/platforms';
@@ -0,0 +1,35 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
import { PlatformInput } from '../../models/platform-input';
export interface UpdatePlatform$Params {
id: string;
body: PlatformInput
}
export function updatePlatform(http: HttpClient, rootUrl: string, params: UpdatePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
const rb = new RequestBuilder(rootUrl, updatePlatform.PATH, 'put');
if (params) {
rb.path('id', params.id, {});
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Platform>;
})
);
}
updatePlatform.PATH = '/api/platforms/{id}';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export type { Login$Params as Login$Params } from './fn/auth/login';
export { login as login } from './fn/auth/login';
export type { Logout$Params as Logout$Params } from './fn/auth/logout';
export { logout as logout } from './fn/auth/logout';
export type { GetMe$Params as GetMe$Params } from './fn/auth/get-me';
export { getMe as getMe } from './fn/auth/get-me';
export type { Register$Params as Register$Params } from './fn/auth/register';
export { register as register } from './fn/auth/register';
export type { ListGames$Params as ListGames$Params } from './fn/games/list-games';
export { listGames as listGames } from './fn/games/list-games';
export type { CreateGame$Params as CreateGame$Params } from './fn/games/create-game';
export { createGame as createGame } from './fn/games/create-game';
export type { GetGame$Params as GetGame$Params } from './fn/games/get-game';
export { getGame as getGame } from './fn/games/get-game';
export type { UpdateGame$Params as UpdateGame$Params } from './fn/games/update-game';
export { updateGame as updateGame } from './fn/games/update-game';
export type { DeleteGame$Params as DeleteGame$Params } from './fn/games/delete-game';
export { deleteGame as deleteGame } from './fn/games/delete-game';
export type { ListPlatforms$Params as ListPlatforms$Params } from './fn/platforms/list-platforms';
export { listPlatforms as listPlatforms } from './fn/platforms/list-platforms';
export type { CreatePlatform$Params as CreatePlatform$Params } from './fn/platforms/create-platform';
export { createPlatform as createPlatform } from './fn/platforms/create-platform';
export type { GetPlatform$Params as GetPlatform$Params } from './fn/platforms/get-platform';
export { getPlatform as getPlatform } from './fn/platforms/get-platform';
export type { UpdatePlatform$Params as UpdatePlatform$Params } from './fn/platforms/update-platform';
export { updatePlatform as updatePlatform } from './fn/platforms/update-platform';
export type { DeletePlatform$Params as DeletePlatform$Params } from './fn/platforms/delete-platform';
export { deletePlatform as deletePlatform } from './fn/platforms/delete-platform';
export type { GetHello$Params as GetHello$Params } from './fn/misc/get-hello';
export { getHello as getHello } from './fn/misc/get-hello';
+14
View File
@@ -0,0 +1,14 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export type { AuthResponse } from './models/auth-response';
export type { ErrorDetail } from './models/error-detail';
export type { ErrorModel } from './models/error-model';
export type { Game } from './models/game';
export type { GameInput } from './models/game-input';
export type { GetHelloResponse } from './models/get-hello-response';
export type { LoginInput } from './models/login-input';
export type { Platform } from './models/platform';
export type { PlatformInput } from './models/platform-input';
export type { RegisterInput } from './models/register-input';
export type { User } from './models/user';
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { User } from '../models/user';
export interface AuthResponse {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
token: string;
user: User;
}
+20
View File
@@ -0,0 +1,20 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface ErrorDetail {
/**
* Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'
*/
location?: string;
/**
* Error message text
*/
message?: string;
/**
* The value at the given location
*/
value?: any;
}
+41
View File
@@ -0,0 +1,41 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { ErrorDetail } from '../models/error-detail';
export interface ErrorModel {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* A human-readable explanation specific to this occurrence of the problem.
*/
detail?: string;
/**
* Optional list of individual error details
*/
errors?: (Array<ErrorDetail> | null);
/**
* A URI reference that identifies the specific occurrence of the problem.
*/
instance?: string;
/**
* HTTP status code
*/
status?: number;
/**
* A short, human-readable summary of the problem type. This value should not change between occurrences of the error.
*/
title?: string;
/**
* A URI reference to human-readable documentation for the error.
*/
type?: string;
}
+23
View File
@@ -0,0 +1,23 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface GameInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* YYYY-MM-DD (optional)
*/
finished?: string;
name: string;
platform_id: string;
/**
* YYYY-MM-DD
*/
release_date: string;
score: number;
}
+25
View File
@@ -0,0 +1,25 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface Game {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* YYYY-MM-DD (optional)
*/
finished?: string;
id: string;
name: string;
platform_id: string;
platform_name: string;
/**
* YYYY-MM-DD
*/
release_date: string;
score: number;
}
@@ -0,0 +1,11 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface GetHelloResponse {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
message: string;
}
+12
View File
@@ -0,0 +1,12 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface LoginInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
email: string;
password: string;
}
+15
View File
@@ -0,0 +1,15 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface PlatformInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* Name of the platform
*/
name: string;
}
+12
View File
@@ -0,0 +1,12 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface Platform {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
id: string;
name: string;
}
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface RegisterInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
email: string;
name: string;
password: string;
}
+15
View File
@@ -0,0 +1,15 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface User {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
bio?: string;
email: string;
id: string;
is_admin: boolean;
name: string;
}
+369
View File
@@ -0,0 +1,369 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpRequest, HttpParameterCodec, HttpParams, HttpHeaders, HttpContext } from '@angular/common/http';
/**
* Custom parameter codec to correctly handle the plus sign in parameter
* values. See https://github.com/angular/angular/issues/18261
*/
class ParameterCodec implements HttpParameterCodec {
encodeKey(key: string): string {
return encodeURIComponent(key);
}
encodeValue(value: string): string {
return encodeURIComponent(value);
}
decodeKey(key: string): string {
return decodeURIComponent(key);
}
decodeValue(value: string): string {
return decodeURIComponent(value);
}
}
const ParameterCodecInstance = new ParameterCodec();
/**
* Defines the options for appending a parameter
*/
interface ParameterOptions {
style?: string;
explode?: boolean;
}
/**
* Base class for a parameter
*/
abstract class Parameter {
constructor(public name: string, public value: any, public options: ParameterOptions, defaultStyle: string, defaultExplode: boolean) {
this.options = options || {};
if (this.options.style === null || this.options.style === undefined) {
this.options.style = defaultStyle;
}
if (this.options.explode === null || this.options.explode === undefined) {
this.options.explode = defaultExplode;
}
}
serializeValue(value: any, separator = ','): string {
if (value === null || value === undefined) {
return '';
} else if (value instanceof Array) {
return value.map(v => this.serializeValue(v).split(separator).join(encodeURIComponent(separator))).join(separator);
} else if (typeof value === 'object') {
const array: string[] = [];
for (const key of Object.keys(value)) {
let propVal = value[key];
if (propVal !== null && propVal !== undefined) {
propVal = this.serializeValue(propVal).split(separator).join(encodeURIComponent(separator));
if (this.options.explode) {
array.push(`${key}=${propVal}`);
} else {
array.push(key);
array.push(propVal);
}
}
}
return array.join(separator);
} else {
return String(value);
}
}
}
/**
* A parameter in the operation path
*/
class PathParameter extends Parameter {
constructor(name: string, value: any, options: ParameterOptions) {
super(name, value, options, 'simple', false);
}
append(path: string): string {
let value = this.value;
if (value === null || value === undefined) {
value = '';
}
let prefix = this.options.style === 'label' ? '.' : '';
let separator = this.options.explode ? prefix === '' ? ',' : prefix : ',';
let alreadySerialized = false;
if (this.options.style === 'matrix') {
// The parameter name is just used as prefix, except in some cases...
prefix = `;${this.name}=`;
if (this.options.explode && typeof value === 'object') {
prefix = ';';
if (value instanceof Array) {
// For arrays we have to repeat the name for each element
value = value.map(v => `${this.name}=${this.serializeValue(v, ';')}`);
value = value.join(';');
alreadySerialized = true;
} else {
// For objects we have to put each the key / value pairs
value = this.serializeValue(value, ';');
alreadySerialized = true
}
}
}
value = prefix + (alreadySerialized ? value : this.serializeValue(value, separator));
// Replace both the plain variable and the corresponding variant taking in the prefix and explode into account
path = path.replace(`{${this.name}}`, value);
path = path.replace(`{${prefix}${this.name}${this.options.explode ? '*' : ''}}`, value);
return path;
}
// @ts-ignore
serializeValue(value: any, separator = ','): string {
var result = typeof value === 'string' ? encodeURIComponent(value) : super.serializeValue(value, separator);
result = result.replace(/%3D/g, '=');
result = result.replace(/%3B/g, ';');
result = result.replace(/%2C/g, ',');
return result;
}
}
/**
* A parameter in the query
*/
class QueryParameter extends Parameter {
constructor(name: string, value: any, options: ParameterOptions) {
super(name, value, options, 'form', true);
}
append(params: HttpParams): HttpParams {
if (this.value instanceof Array) {
// Array serialization
if (this.options.explode) {
for (const v of this.value) {
params = params.append(this.name, this.serializeValue(v));
}
} else {
const separator = this.options.style === 'spaceDelimited'
? ' ' : this.options.style === 'pipeDelimited'
? '|' : ',';
return params.append(this.name, this.serializeValue(this.value, separator));
}
} else if (this.value !== null && typeof this.value === 'object') {
// Object serialization
if (this.options.style === 'deepObject') {
// Append a parameter for each key, in the form `name[key]`
for (const key of Object.keys(this.value)) {
const propVal = this.value[key];
if (propVal !== null && propVal !== undefined) {
params = params.append(`${this.name}[${key}]`, this.serializeValue(propVal));
}
}
} else if (this.options.explode) {
// Append a parameter for each key without using the parameter name
for (const key of Object.keys(this.value)) {
const propVal = this.value[key];
if (propVal !== null && propVal !== undefined) {
params = params.append(key, this.serializeValue(propVal));
}
}
} else {
// Append a single parameter whose values are a comma-separated list of key,value,key,value...
const array: any[] = [];
for (const key of Object.keys(this.value)) {
const propVal = this.value[key];
if (propVal !== null && propVal !== undefined) {
array.push(key);
array.push(propVal);
}
}
params = params.append(this.name, this.serializeValue(array));
}
} else if (this.value !== null && this.value !== undefined) {
// Plain value
params = params.append(this.name, this.serializeValue(this.value));
}
return params;
}
}
/**
* A parameter in the HTTP request header
*/
class HeaderParameter extends Parameter {
constructor(name: string, value: any, options: ParameterOptions) {
super(name, value, options, 'simple', false);
}
append(headers: HttpHeaders): HttpHeaders {
if (this.value !== null && this.value !== undefined) {
if (this.value instanceof Array) {
for (const v of this.value) {
headers = headers.append(this.name, this.serializeValue(v));
}
} else {
headers = headers.append(this.name, this.serializeValue(this.value));
}
}
return headers;
}
}
/**
* Helper to build http requests from parameters
*/
export class RequestBuilder {
private _path = new Map<string, PathParameter>();
private _query = new Map<string, QueryParameter>();
private _header = new Map<string, HeaderParameter>();
_bodyContent: any | null;
_bodyContentType?: string;
constructor(
public rootUrl: string,
public operationPath: string,
public method: string) {
}
/**
* Sets a path parameter
*/
path(name: string, value: any, options?: ParameterOptions): void {
this._path.set(name, new PathParameter(name, value, options || {}));
}
/**
* Sets a query parameter
*/
query(name: string, value: any, options?: ParameterOptions): void {
this._query.set(name, new QueryParameter(name, value, options || {}));
}
/**
* Sets a header parameter
*/
header(name: string, value: any, options?: ParameterOptions): void {
this._header.set(name, new HeaderParameter(name, value, options || {}));
}
/**
* Sets the body content, along with the content type
*/
body(value: any, contentType = 'application/json'): void {
if (value instanceof Blob) {
this._bodyContentType = value.type;
} else {
this._bodyContentType = contentType;
}
if (this._bodyContentType === 'application/x-www-form-urlencoded' && value !== null && typeof value === 'object') {
// Handle URL-encoded data
const pairs: Array<[string, string]> = [];
for (const key of Object.keys(value)) {
let val = value[key];
if (!(val instanceof Array)) {
val = [val];
}
for (const v of val) {
const formValue = this.formDataValue(v);
if (formValue !== null) {
pairs.push([key, formValue]);
}
}
}
this._bodyContent = pairs.map(p => `${encodeURIComponent(p[0])}=${encodeURIComponent(p[1])}`).join('&');
} else if (this._bodyContentType === 'multipart/form-data') {
// Handle multipart form data
const formData = new FormData();
if (value !== null && value !== undefined) {
for (const key of Object.keys(value)) {
const val = value[key];
if (val instanceof Array) {
for (const v of val) {
const toAppend = this.formDataValue(v);
if (toAppend !== null) {
formData.append(key, toAppend);
}
}
} else {
const toAppend = this.formDataValue(val);
if (toAppend !== null) {
formData.set(key, toAppend);
}
}
}
}
this._bodyContent = formData;
} else {
// The body is the plain content
this._bodyContent = value;
}
}
private formDataValue(value: any): any {
if (value === null || value === undefined) {
return null;
}
if (value instanceof Blob) {
return value;
}
if (typeof value === 'object') {
return new Blob([JSON.stringify(value)], {type: 'application/json'})
}
return String(value);
}
/**
* Builds the request with the current set parameters
*/
build<T = any>(options?: {
/** Which content types to accept */
accept?: string;
/** The expected response type */
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
/** Whether to report progress on uploads / downloads */
reportProgress?: boolean;
/** Allow passing HttpContext for HttpClient */
context?: HttpContext;
}): HttpRequest<T> {
options = options || {};
// Path parameters
let path = this.operationPath;
for (const pathParam of this._path.values()) {
path = pathParam.append(path);
}
const url = this.rootUrl + path;
// Query parameters
let httpParams = new HttpParams({
encoder: ParameterCodecInstance
});
for (const queryParam of this._query.values()) {
httpParams = queryParam.append(httpParams);
}
// Header parameters
let httpHeaders = new HttpHeaders();
if (options.accept) {
httpHeaders = httpHeaders.append('Accept', options.accept);
}
for (const headerParam of this._header.values()) {
httpHeaders = headerParam.append(httpHeaders);
}
// Request content headers
if (this._bodyContentType && !(this._bodyContent instanceof FormData)) {
httpHeaders = httpHeaders.set('Content-Type', this._bodyContentType);
}
// Perform the request
return new HttpRequest<T>(this.method.toUpperCase(), url, this._bodyContent, {
params: httpParams,
headers: httpHeaders,
responseType: options.responseType,
reportProgress: options.reportProgress,
context: options.context
});
}
}
+7
View File
@@ -0,0 +1,7 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export { AuthService } from './services/auth.service';
export { GamesService } from './services/games.service';
export { PlatformsService } from './services/platforms.service';
export { MiscService } from './services/misc.service';
+161
View File
@@ -0,0 +1,161 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { AuthResponse } from '../models/auth-response';
import { getMe } from '../fn/auth/get-me';
import { GetMe$Params } from '../fn/auth/get-me';
import { login } from '../fn/auth/login';
import { Login$Params } from '../fn/auth/login';
import { logout } from '../fn/auth/logout';
import { Logout$Params } from '../fn/auth/logout';
import { register } from '../fn/auth/register';
import { Register$Params } from '../fn/auth/register';
import { User } from '../models/user';
@Injectable({ providedIn: 'root' })
export class AuthService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `login()` */
static readonly LoginPath = '/api/auth/login';
/**
* Log in to an existing account.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `login()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
login$Response(params: Login$Params, context?: HttpContext): Promise<StrictHttpResponse<AuthResponse>> {
const obs = login(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Log in to an existing account.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `login$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
login(params: Login$Params, context?: HttpContext): Promise<AuthResponse> {
const resp = this.login$Response(params, context);
return resp.then((r: StrictHttpResponse<AuthResponse>): AuthResponse => r.body);
}
/** Path part for operation `logout()` */
static readonly LogoutPath = '/api/auth/logout';
/**
* Log out of the current session.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `logout()` instead.
*
* This method doesn't expect any request body.
*/
logout$Response(params?: Logout$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
const obs = logout(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Log out of the current session.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `logout$Response()` instead.
*
* This method doesn't expect any request body.
*/
logout(params?: Logout$Params, context?: HttpContext): Promise<void> {
const resp = this.logout$Response(params, context);
return resp.then((r: StrictHttpResponse<void>): void => r.body);
}
/** Path part for operation `getMe()` */
static readonly GetMePath = '/api/auth/me';
/**
* Get the current user.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getMe()` instead.
*
* This method doesn't expect any request body.
*/
getMe$Response(params?: GetMe$Params, context?: HttpContext): Promise<StrictHttpResponse<User>> {
const obs = getMe(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Get the current user.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getMe$Response()` instead.
*
* This method doesn't expect any request body.
*/
getMe(params?: GetMe$Params, context?: HttpContext): Promise<User> {
const resp = this.getMe$Response(params, context);
return resp.then((r: StrictHttpResponse<User>): User => r.body);
}
/** Path part for operation `register()` */
static readonly RegisterPath = '/api/auth/register';
/**
* Register a new account.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `register()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
register$Response(params: Register$Params, context?: HttpContext): Promise<StrictHttpResponse<AuthResponse>> {
const obs = register(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Register a new account.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `register$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
register(params: Register$Params, context?: HttpContext): Promise<AuthResponse> {
const resp = this.register$Response(params, context);
return resp.then((r: StrictHttpResponse<AuthResponse>): AuthResponse => r.body);
}
}
+195
View File
@@ -0,0 +1,195 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { createGame } from '../fn/games/create-game';
import { CreateGame$Params } from '../fn/games/create-game';
import { deleteGame } from '../fn/games/delete-game';
import { DeleteGame$Params } from '../fn/games/delete-game';
import { Game } from '../models/game';
import { getGame } from '../fn/games/get-game';
import { GetGame$Params } from '../fn/games/get-game';
import { listGames } from '../fn/games/list-games';
import { ListGames$Params } from '../fn/games/list-games';
import { updateGame } from '../fn/games/update-game';
import { UpdateGame$Params } from '../fn/games/update-game';
@Injectable({ providedIn: 'root' })
export class GamesService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `listGames()` */
static readonly ListGamesPath = '/api/games';
/**
* List Games.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `listGames()` instead.
*
* This method doesn't expect any request body.
*/
listGames$Response(params?: ListGames$Params, context?: HttpContext): Promise<StrictHttpResponse<(Array<Game> | null)>> {
const obs = listGames(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* List Games.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `listGames$Response()` instead.
*
* This method doesn't expect any request body.
*/
listGames(params?: ListGames$Params, context?: HttpContext): Promise<(Array<Game> | null)> {
const resp = this.listGames$Response(params, context);
return resp.then((r: StrictHttpResponse<(Array<Game> | null)>): (Array<Game> | null) => r.body);
}
/** Path part for operation `createGame()` */
static readonly CreateGamePath = '/api/games';
/**
* Create Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `createGame()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createGame$Response(params: CreateGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
const obs = createGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Create Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `createGame$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createGame(params: CreateGame$Params, context?: HttpContext): Promise<Game> {
const resp = this.createGame$Response(params, context);
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
}
/** Path part for operation `getGame()` */
static readonly GetGamePath = '/api/games/{id}';
/**
* Get Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getGame()` instead.
*
* This method doesn't expect any request body.
*/
getGame$Response(params: GetGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
const obs = getGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Get Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getGame$Response()` instead.
*
* This method doesn't expect any request body.
*/
getGame(params: GetGame$Params, context?: HttpContext): Promise<Game> {
const resp = this.getGame$Response(params, context);
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
}
/** Path part for operation `updateGame()` */
static readonly UpdateGamePath = '/api/games/{id}';
/**
* Update Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `updateGame()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updateGame$Response(params: UpdateGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
const obs = updateGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Update Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `updateGame$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updateGame(params: UpdateGame$Params, context?: HttpContext): Promise<Game> {
const resp = this.updateGame$Response(params, context);
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
}
/** Path part for operation `deleteGame()` */
static readonly DeleteGamePath = '/api/games/{id}';
/**
* Delete Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `deleteGame()` instead.
*
* This method doesn't expect any request body.
*/
deleteGame$Response(params: DeleteGame$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
const obs = deleteGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Delete Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `deleteGame$Response()` instead.
*
* This method doesn't expect any request body.
*/
deleteGame(params: DeleteGame$Params, context?: HttpContext): Promise<void> {
const resp = this.deleteGame$Response(params, context);
return resp.then((r: StrictHttpResponse<void>): void => r.body);
}
}
+55
View File
@@ -0,0 +1,55 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { GetHelloResponse } from '../models/get-hello-response';
import { getHello } from '../fn/misc/get-hello';
import { GetHello$Params } from '../fn/misc/get-hello';
@Injectable({ providedIn: 'root' })
export class MiscService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `getHello()` */
static readonly GetHelloPath = '/hello';
/**
* Say Hello.
*
* Returns a friendly hello message.
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getHello()` instead.
*
* This method doesn't expect any request body.
*/
getHello$Response(params?: GetHello$Params, context?: HttpContext): Promise<StrictHttpResponse<GetHelloResponse>> {
const obs = getHello(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Say Hello.
*
* Returns a friendly hello message.
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getHello$Response()` instead.
*
* This method doesn't expect any request body.
*/
getHello(params?: GetHello$Params, context?: HttpContext): Promise<GetHelloResponse> {
const resp = this.getHello$Response(params, context);
return resp.then((r: StrictHttpResponse<GetHelloResponse>): GetHelloResponse => r.body);
}
}
@@ -0,0 +1,195 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { createPlatform } from '../fn/platforms/create-platform';
import { CreatePlatform$Params } from '../fn/platforms/create-platform';
import { deletePlatform } from '../fn/platforms/delete-platform';
import { DeletePlatform$Params } from '../fn/platforms/delete-platform';
import { getPlatform } from '../fn/platforms/get-platform';
import { GetPlatform$Params } from '../fn/platforms/get-platform';
import { listPlatforms } from '../fn/platforms/list-platforms';
import { ListPlatforms$Params } from '../fn/platforms/list-platforms';
import { Platform } from '../models/platform';
import { updatePlatform } from '../fn/platforms/update-platform';
import { UpdatePlatform$Params } from '../fn/platforms/update-platform';
@Injectable({ providedIn: 'root' })
export class PlatformsService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `listPlatforms()` */
static readonly ListPlatformsPath = '/api/platforms';
/**
* List Platforms.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `listPlatforms()` instead.
*
* This method doesn't expect any request body.
*/
listPlatforms$Response(params?: ListPlatforms$Params, context?: HttpContext): Promise<StrictHttpResponse<(Array<Platform> | null)>> {
const obs = listPlatforms(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* List Platforms.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `listPlatforms$Response()` instead.
*
* This method doesn't expect any request body.
*/
listPlatforms(params?: ListPlatforms$Params, context?: HttpContext): Promise<(Array<Platform> | null)> {
const resp = this.listPlatforms$Response(params, context);
return resp.then((r: StrictHttpResponse<(Array<Platform> | null)>): (Array<Platform> | null) => r.body);
}
/** Path part for operation `createPlatform()` */
static readonly CreatePlatformPath = '/api/platforms';
/**
* Create Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `createPlatform()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createPlatform$Response(params: CreatePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
const obs = createPlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Create Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `createPlatform$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createPlatform(params: CreatePlatform$Params, context?: HttpContext): Promise<Platform> {
const resp = this.createPlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
}
/** Path part for operation `getPlatform()` */
static readonly GetPlatformPath = '/api/platforms/{id}';
/**
* Get Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getPlatform()` instead.
*
* This method doesn't expect any request body.
*/
getPlatform$Response(params: GetPlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
const obs = getPlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Get Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getPlatform$Response()` instead.
*
* This method doesn't expect any request body.
*/
getPlatform(params: GetPlatform$Params, context?: HttpContext): Promise<Platform> {
const resp = this.getPlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
}
/** Path part for operation `updatePlatform()` */
static readonly UpdatePlatformPath = '/api/platforms/{id}';
/**
* Update Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `updatePlatform()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updatePlatform$Response(params: UpdatePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
const obs = updatePlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Update Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `updatePlatform$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updatePlatform(params: UpdatePlatform$Params, context?: HttpContext): Promise<Platform> {
const resp = this.updatePlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
}
/** Path part for operation `deletePlatform()` */
static readonly DeletePlatformPath = '/api/platforms/{id}';
/**
* Delete Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `deletePlatform()` instead.
*
* This method doesn't expect any request body.
*/
deletePlatform$Response(params: DeletePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
const obs = deletePlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Delete Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `deletePlatform$Response()` instead.
*
* This method doesn't expect any request body.
*/
deletePlatform(params: DeletePlatform$Params, context?: HttpContext): Promise<void> {
const resp = this.deletePlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<void>): void => r.body);
}
}
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpResponse } from '@angular/common/http';
/**
* Constrains the http response to not have the body defined as `T | null`, but `T` only.
*/
export type StrictHttpResponse<T> = HttpResponse<T> & {
readonly body: T;
}
+24
View File
@@ -0,0 +1,24 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth-interceptor';
import { provideApiConfiguration } from './api/api-configuration';
// The API's base URL. There's no server-side config to read it from at
// runtime in this small base (a plain SPA build), so it's a build-time
// constant; swap to Angular's fileReplacements if a later build needs a
// different URL per environment.
const API_BASE_URL = 'http://localhost:8080';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideAnimationsAsync(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideApiConfiguration(API_BASE_URL),
],
};
+16
View File
@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth-guard';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'games' },
{
path: 'login',
loadComponent: () => import('./features/login/login').then((m) => m.Login),
},
{
path: 'games',
loadComponent: () => import('./features/games/games').then((m) => m.Games),
canActivate: [authGuard],
},
];
+18
View File
@@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
expect(fixture.componentInstance).toBeTruthy();
});
});
+9
View File
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
imports: [RouterOutlet],
selector: 'app-root',
template: '<router-outlet />',
})
export class App {}
+43
View File
@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, UrlTree } from '@angular/router';
import { provideRouter } from '@angular/router';
import { authGuard } from './auth-guard';
import { Session } from './session';
import { provideApiConfiguration } from '../api/api-configuration';
describe('authGuard', () => {
let session: Session;
let router: Router;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
provideRouter([]),
],
});
session = TestBed.inject(Session);
router = TestBed.inject(Router);
});
function runGuard() {
return TestBed.runInInjectionContext(() => authGuard({} as any, {} as any));
}
it('allows navigation when authenticated', () => {
session.token.set('tok-abc');
expect(runGuard()).toBe(true);
});
it('redirects to /login when not authenticated', () => {
const result = runGuard();
expect(result).not.toBe(true);
expect((result as UrlTree).toString()).toBe(router.createUrlTree(['/login']).toString());
});
});
+12
View File
@@ -0,0 +1,12 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Session } from './session';
/** Redirects to /login unless a session token is present. */
export const authGuard: CanActivateFn = () => {
if (inject(Session).isAuthenticated()) {
return true;
}
return inject(Router).createUrlTree(['/login']);
};
+45
View File
@@ -0,0 +1,45 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { authInterceptor } from './auth-interceptor';
import { Session } from './session';
import { provideApiConfiguration } from '../api/api-configuration';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let session: Session;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
session = TestBed.inject(Session);
});
afterEach(() => httpMock.verify());
it('attaches the bearer token when a session is present', () => {
session.token.set('tok-abc');
http.get('/whatever').subscribe();
const req = httpMock.expectOne('/whatever');
expect(req.request.headers.get('Authorization')).toBe('Bearer tok-abc');
});
it('sends no Authorization header when logged out', () => {
http.get('/whatever').subscribe();
const req = httpMock.expectOne('/whatever');
expect(req.request.headers.has('Authorization')).toBe(false);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Session } from './session';
/** Attaches the stored session token to every outgoing API request. */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(Session).token();
if (!token) {
return next(req);
}
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
+90
View File
@@ -0,0 +1,90 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { Session } from './session';
import { provideApiConfiguration } from '../api/api-configuration';
describe('Session', () => {
let service: Session;
let httpMock: HttpTestingController;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideApiConfiguration('http://api.test')],
});
service = TestBed.inject(Session);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('starts logged out with no stored token', () => {
expect(service.isAuthenticated()).toBe(false);
expect(service.user()).toBeNull();
});
it('restores the token from a previous session', () => {
localStorage.setItem('completed.token', 'tok-123');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideApiConfiguration('http://api.test')],
});
const restored = TestBed.inject(Session);
expect(restored.isAuthenticated()).toBe(true);
});
it('login stores the token and user, and persists to localStorage', async () => {
const promise = service.login('alice@example.com', 'secret');
const req = httpMock.expectOne('http://api.test/api/auth/login');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ email: 'alice@example.com', password: 'secret' });
req.flush({ token: 'tok-abc', user: { id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false } });
await promise;
expect(service.token()).toBe('tok-abc');
expect(service.user()?.name).toBe('Alice');
expect(service.isAdmin()).toBe(false);
expect(localStorage.getItem('completed.token')).toBe('tok-abc');
});
it('login failure leaves the session logged out', async () => {
const promise = service.login('alice@example.com', 'wrong');
const req = httpMock.expectOne('http://api.test/api/auth/login');
req.flush({ title: 'Unauthorized', status: 401, detail: 'invalid email or password' }, { status: 401, statusText: 'Unauthorized' });
await expect(promise).rejects.toBeTruthy();
expect(service.isAuthenticated()).toBe(false);
});
it('logout clears the session even if the request fails', async () => {
service.token.set('tok-abc');
service.user.set({ id: 'u1', email: 'a@example.com', name: 'Alice', is_admin: false });
const promise = service.logout();
const req = httpMock.expectOne('http://api.test/api/auth/logout');
req.flush(null, { status: 500, statusText: 'Internal Server Error' });
await expect(promise).rejects.toBeTruthy();
expect(service.isAuthenticated()).toBe(false);
expect(service.user()).toBeNull();
expect(localStorage.getItem('completed.token')).toBeNull();
});
it('restore() clears an invalid stored token instead of leaving a broken session', async () => {
service.token.set('tok-stale');
const promise = service.restore();
const req = httpMock.expectOne('http://api.test/api/auth/me');
req.flush({ title: 'Unauthorized', status: 401, detail: 'session expired' }, { status: 401, statusText: 'Unauthorized' });
await promise;
expect(service.isAuthenticated()).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { Service, computed, inject, signal } from '@angular/core';
import { AuthService } from '../api/services/auth.service';
import { User } from '../api/models/user';
/** Where the token is kept so a page refresh doesn't log the user out. */
const STORAGE_KEY = 'completed.token';
/**
* Holds the current login state (token + user) and is the one place that
* talks to the generated AuthService. Components read `user()` /
* `isAdmin()` and call `login`/`register`/`logout`; nothing else touches
* localStorage or the auth endpoints directly.
*/
@Service()
export class Session {
private readonly auth = inject(AuthService);
readonly user = signal<User | null>(null);
readonly token = signal<string | null>(this.readStoredToken());
readonly isAuthenticated = computed(() => this.token() !== null);
readonly isAdmin = computed(() => this.user()?.is_admin ?? false);
// A stored token with no user yet (e.g. after a page refresh) is
// resolved lazily by whoever needs `user()` first - in this small base,
// the games page calls `restore()` on init.
private readStoredToken(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
// localStorage can throw in some privacy modes; fall back to
// "logged out" rather than crashing the app.
return null;
}
}
private storeToken(token: string | null): void {
try {
if (token) {
localStorage.setItem(STORAGE_KEY, token);
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
// Same as above: a storage failure shouldn't break login/logout.
}
}
/** Re-fetches the current user for a token restored from storage. */
async restore(): Promise<void> {
if (!this.token() || this.user()) {
return;
}
try {
const user = await this.auth.getMe();
this.user.set(user);
} catch {
// The stored token is no longer valid (expired/revoked); drop it so
// the app shows the login screen instead of a broken "logged in".
this.clear();
}
}
async login(email: string, password: string): Promise<void> {
const resp = await this.auth.login({ body: { email, password } });
this.token.set(resp.token);
this.user.set(resp.user);
this.storeToken(resp.token);
}
async register(email: string, password: string, name: string): Promise<void> {
const resp = await this.auth.register({ body: { email, password, name } });
this.token.set(resp.token);
this.user.set(resp.user);
this.storeToken(resp.token);
}
async logout(): Promise<void> {
try {
await this.auth.logout();
} finally {
this.clear();
}
}
private clear(): void {
this.token.set(null);
this.user.set(null);
this.storeToken(null);
}
}
+26
View File
@@ -0,0 +1,26 @@
.spacer {
flex: 1 1 auto;
}
.user-name {
margin-right: 1rem;
opacity: 0.8;
}
.content {
padding: 1.5rem;
}
.games-table {
width: 100%;
}
.centered {
display: flex;
justify-content: center;
padding-top: 3rem;
}
.error {
color: var(--mat-sys-error, #b3261e);
}
+51
View File
@@ -0,0 +1,51 @@
<mat-toolbar color="primary">
<span>Completed</span>
<span class="spacer"></span>
@if (session.user()) {
<span class="user-name">{{ session.user()!.name }}</span>
}
<button mat-icon-button (click)="refresh()" aria-label="Refresh" [disabled]="loading()">
<mat-icon>refresh</mat-icon>
</button>
<button mat-button (click)="logout()">Log out</button>
</mat-toolbar>
<div class="content">
@if (loading()) {
<div class="centered"><mat-spinner /></div>
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else if (games().length === 0) {
<p>No games yet.</p>
} @else {
<table mat-table [dataSource]="games()" class="games-table">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let g">{{ g.name }}</td>
</ng-container>
<ng-container matColumnDef="platform_name">
<th mat-header-cell *matHeaderCellDef>Platform</th>
<td mat-cell *matCellDef="let g">{{ g.platform_name }}</td>
</ng-container>
<ng-container matColumnDef="score">
<th mat-header-cell *matHeaderCellDef>Score</th>
<td mat-cell *matCellDef="let g">{{ g.score }}</td>
</ng-container>
<ng-container matColumnDef="release_date">
<th mat-header-cell *matHeaderCellDef>Released</th>
<td mat-cell *matCellDef="let g">{{ g.release_date }}</td>
</ng-container>
<ng-container matColumnDef="finished">
<th mat-header-cell *matHeaderCellDef>Finished</th>
<td mat-cell *matCellDef="let g">{{ g.finished ? '✓' : '' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
}
</div>
+109
View File
@@ -0,0 +1,109 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, provideRouter } from '@angular/router';
import { Games } from './games';
import { Session } from '../../core/session';
import { provideApiConfiguration } from '../../api/api-configuration';
describe('Games', () => {
let component: Games;
let fixture: ComponentFixture<Games>;
let httpMock: HttpTestingController;
let session: Session;
let router: Router;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Games],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
provideRouter([]),
],
}).compileComponents();
fixture = TestBed.createComponent(Games);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
session = TestBed.inject(Session);
router = TestBed.inject(Router);
});
afterEach(() => httpMock.verify());
// Setting both token and user means `session.restore()` (called by
// ngOnInit) finds a user already present and skips its own HTTP call,
// so each test only has to deal with the request it's actually testing.
function logIn(): void {
session.token.set('tok-abc');
session.user.set({ id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false });
}
// ngOnInit awaits session.restore() before calling refresh(), and
// restore() returns early (no HTTP call) once a user is already set -
// but that early return is still through an async function, so it still
// takes one microtask tick before ngOnInit resumes far enough to
// actually dispatch the /api/games request.
async function flushMicrotask(): Promise<void> {
await Promise.resolve();
}
it('loads games on init', async () => {
logIn();
const initPromise = component.ngOnInit();
await flushMicrotask();
const req = httpMock.expectOne('http://api.test/api/games');
req.flush([{ id: 'g1', name: 'Doom', platform_id: 'p1', platform_name: 'PC', score: 95, release_date: '1993-12-10' }]);
await initPromise;
expect(component.games()).toHaveLength(1);
expect(component.games()[0].name).toBe('Doom');
expect(component.error()).toBeNull();
expect(component.loading()).toBe(false);
});
it('shows an error when the request fails', async () => {
logIn();
const initPromise = component.ngOnInit();
await flushMicrotask();
const req = httpMock.expectOne('http://api.test/api/games');
req.flush({ title: 'Internal Server Error', status: 500 }, { status: 500, statusText: 'Internal Server Error' });
await initPromise;
expect(component.error()).toContain('Could not load games');
expect(component.games()).toHaveLength(0);
});
it('refresh() re-fetches the list', async () => {
logIn();
const initPromise = component.ngOnInit();
await flushMicrotask();
httpMock.expectOne('http://api.test/api/games').flush([]);
await initPromise;
const refreshPromise = component.refresh();
const req = httpMock.expectOne('http://api.test/api/games');
req.flush([{ id: 'g1', name: 'Half-Life', platform_id: 'p1', platform_name: 'PC', score: 96, release_date: '1998-11-19' }]);
await refreshPromise;
expect(component.games()).toHaveLength(1);
});
it('logout() clears the session and navigates to /login', async () => {
logIn();
const navigateSpy = vi.spyOn(router, 'navigateByUrl');
const logoutPromise = component.logout();
httpMock.expectOne('http://api.test/api/auth/logout').flush(null);
await logoutPromise;
expect(session.isAuthenticated()).toBe(false);
expect(navigateSpy).toHaveBeenCalledWith('/login');
});
});
+58
View File
@@ -0,0 +1,58 @@
import { Component, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { GamesService } from '../../api/services/games.service';
import { Game } from '../../api/models/game';
import { Session } from '../../core/session';
@Component({
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTableModule,
MatToolbarModule,
],
selector: 'app-games',
styleUrl: './games.css',
templateUrl: './games.html',
})
export class Games implements OnInit {
readonly columns = ['name', 'platform_name', 'score', 'release_date', 'finished'];
readonly games = signal<Game[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor(
readonly session: Session,
private readonly gamesService: GamesService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
await this.session.restore();
await this.refresh();
}
async refresh(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
this.games.set((await this.gamesService.listGames()) ?? []);
} catch {
this.error.set('Could not load games. Check your connection and try again.');
} finally {
this.loading.set(false);
}
}
async logout(): Promise<void> {
await this.session.logout();
this.router.navigateByUrl('/login');
}
}
+18
View File
@@ -0,0 +1,18 @@
.login-page {
display: flex;
justify-content: center;
padding-top: 10vh;
}
.login-card {
width: 100%;
max-width: 360px;
}
.full-width {
width: 100%;
}
.error {
color: var(--mat-sys-error, #b3261e);
}
+32
View File
@@ -0,0 +1,32 @@
<div class="login-page">
<mat-card class="login-card">
<mat-card-header>
<mat-card-title>Completed</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Password</mat-label>
<input matInput type="password" formControlName="password" autocomplete="current-password" />
</mat-form-field>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<button mat-flat-button color="primary" type="submit" [disabled]="form.invalid || submitting()">
@if (submitting()) {
<mat-spinner diameter="20" />
} @else {
Log in
}
</button>
</form>
</mat-card-content>
</mat-card>
</div>
+84
View File
@@ -0,0 +1,84 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, provideRouter } from '@angular/router';
import { Login } from './login';
import { provideApiConfiguration } from '../../api/api-configuration';
describe('Login', () => {
let component: Login;
let fixture: ComponentFixture<Login>;
let httpMock: HttpTestingController;
let router: Router;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Login],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
provideRouter([]),
],
}).compileComponents();
fixture = TestBed.createComponent(Login);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
await fixture.whenStable();
});
afterEach(() => httpMock.verify());
it('is invalid with empty fields', () => {
expect(component.form.invalid).toBe(true);
});
it('is valid with a well-formed email and non-empty password', () => {
component.form.setValue({ email: 'alice@example.com', password: 'secret' });
expect(component.form.valid).toBe(true);
});
it('rejects a malformed email', () => {
component.form.setValue({ email: 'not-an-email', password: 'secret' });
expect(component.form.valid).toBe(false);
});
it('on success, logs in and navigates to /games', async () => {
const navigateSpy = vi.spyOn(router, 'navigateByUrl');
component.form.setValue({ email: 'alice@example.com', password: 'secret' });
const submitPromise = component.submit();
const req = httpMock.expectOne('http://api.test/api/auth/login');
req.flush({ token: 'tok-abc', user: { id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false } });
await submitPromise;
expect(navigateSpy).toHaveBeenCalledWith('/games');
expect(component.error()).toBeNull();
});
it('on failure, shows an error and does not navigate', async () => {
const navigateSpy = vi.spyOn(router, 'navigateByUrl');
component.form.setValue({ email: 'alice@example.com', password: 'wrong' });
const submitPromise = component.submit();
const req = httpMock.expectOne('http://api.test/api/auth/login');
req.flush(
{ title: 'Unauthorized', status: 401, detail: 'invalid email or password' },
{ status: 401, statusText: 'Unauthorized' },
);
await submitPromise;
expect(navigateSpy).not.toHaveBeenCalled();
expect(component.error()).toBe('invalid email or password');
});
it('does nothing when the form is invalid', async () => {
component.form.setValue({ email: '', password: '' });
await component.submit();
httpMock.expectNone('http://api.test/api/auth/login');
});
});
+68
View File
@@ -0,0 +1,68 @@
import { Component, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Session } from '../../core/session';
@Component({
imports: [
ReactiveFormsModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
selector: 'app-login',
styleUrl: './login.css',
templateUrl: './login.html',
})
export class Login {
readonly form = new FormBuilder().group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
readonly submitting = signal(false);
readonly error = signal<string | null>(null);
constructor(
private readonly session: Session,
private readonly router: Router,
) {}
async submit(): Promise<void> {
if (this.form.invalid || this.submitting()) {
return;
}
const { email, password } = this.form.getRawValue();
this.submitting.set(true);
this.error.set(null);
try {
await this.session.login(email!, password!);
this.router.navigateByUrl('/games');
} catch (err) {
this.error.set(errorMessage(err));
} finally {
this.submitting.set(false);
}
}
}
/** Pulls a server-provided message out of the generated client's error
* shape, falling back to a generic one for network failures etc. */
function errorMessage(err: unknown): string {
if (err && typeof err === 'object' && 'error' in err) {
const body = (err as { error?: unknown }).error;
if (body && typeof body === 'object' && 'detail' in body && typeof (body as any).detail === 'string') {
return (body as any).detail;
}
}
return 'Could not log in. Check your connection and try again.';
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Completed</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined"
rel="stylesheet"
/>
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+38
View File
@@ -0,0 +1,38 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
)
);
}
body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}
+1
View File
@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}