a2bea8601a
Phase 1 of the in-app update flow — server side. Endpoints serve the bundled Android APK + sidecar version file from /app/client/. Returns 404 gracefully when files aren't present, so dev environments and pre-CI-wiring images degrade cleanly to "no update available." - internal/api/client_assets.go: handleClientVersion + handleClientAPK. Both unauthenticated (matches /healthz) so install flow doesn't depend on a live session. APK served with proper application/vnd.android.package-archive Content-Type + http.ServeContent so Range requests work for resumable downloads on flaky networks. - Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR env var overrides for dev. - Dockerfile creates /app/client/ + commented COPY hooks for the CI sequencing phase. - Tests cover all four states: missing apk, apk-but-no-version, both present (200 with correct shape), apk stream (200 with correct Content-Type + body bytes). Phases 2 (Flutter client provider + banner + install intent + Android manifest changes) and 3 (CI sequencing to bake the APK into the image) land in follow-up commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
3.7 KiB
Go
116 lines
3.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// withClientAPKDir points the handlers at a fresh temp dir for each
|
|
// test and restores the env var on cleanup. Returns the dir.
|
|
func withClientAPKDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
prev, hadPrev := os.LookupEnv("MINSTREL_CLIENT_APK_DIR")
|
|
os.Setenv("MINSTREL_CLIENT_APK_DIR", dir)
|
|
t.Cleanup(func() {
|
|
if hadPrev {
|
|
os.Setenv("MINSTREL_CLIENT_APK_DIR", prev)
|
|
} else {
|
|
os.Unsetenv("MINSTREL_CLIENT_APK_DIR")
|
|
}
|
|
})
|
|
return dir
|
|
}
|
|
|
|
func TestClientVersion_404WhenNoAPK(t *testing.T) {
|
|
withClientAPKDir(t)
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("want 404, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestClientVersion_404WhenAPKButNoVersion(t *testing.T) {
|
|
dir := withClientAPKDir(t)
|
|
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), []byte("fake apk"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("want 404, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestClientVersion_200WithBothFiles(t *testing.T) {
|
|
dir := withClientAPKDir(t)
|
|
body := []byte("fake apk content")
|
|
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, clientVersionFile), []byte("v2026.05.10\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp clientVersionResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Version != "v2026.05.10" {
|
|
t.Errorf("version: want trimmed v2026.05.10, got %q", resp.Version)
|
|
}
|
|
if resp.APKURL != "/api/client/apk" {
|
|
t.Errorf("apk_url: want /api/client/apk, got %q", resp.APKURL)
|
|
}
|
|
if resp.SizeBytes != int64(len(body)) {
|
|
t.Errorf("size_bytes: want %d, got %d", len(body), resp.SizeBytes)
|
|
}
|
|
}
|
|
|
|
func TestClientAPK_404WhenMissing(t *testing.T) {
|
|
withClientAPKDir(t)
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientAPK(rr, httptest.NewRequest(http.MethodGet, "/api/client/apk", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("want 404, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestClientAPK_StreamsWithCorrectContentType(t *testing.T) {
|
|
dir := withClientAPKDir(t)
|
|
body := []byte("PK\x03\x04 fake apk bytes")
|
|
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientAPK(rr, httptest.NewRequest(http.MethodGet, "/api/client/apk", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d", rr.Code)
|
|
}
|
|
if got := rr.Header().Get("Content-Type"); got != "application/vnd.android.package-archive" {
|
|
t.Errorf("Content-Type: want application/vnd.android.package-archive, got %q", got)
|
|
}
|
|
if rr.Body.Len() != len(body) {
|
|
t.Errorf("body length: want %d, got %d", len(body), rr.Body.Len())
|
|
}
|
|
if got := rr.Body.Bytes(); string(got) != string(body) {
|
|
t.Errorf("body bytes mismatch")
|
|
}
|
|
}
|