Files
creator-hub/cmd/control-plane/hub_test.go
T

673 lines
24 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.ipao.vip/rogee/creator-hub/internal/hub"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
)
// memoryStore 是 hubStore 的内存桩,记录写入以便断言编排副作用。
type memoryStore struct {
mu sync.Mutex
gateways map[string]hub.Gateway
images map[string]hub.Image
envs map[string]hub.Env
deleted []string
upgraded map[string]string
upgradeErr error
gatewayFn func(name string) (hub.Gateway, error)
}
func newMemoryStore() *memoryStore {
return &memoryStore{
gateways: map[string]hub.Gateway{},
images: map[string]hub.Image{},
envs: map[string]hub.Env{},
upgraded: map[string]string{},
}
}
func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) {
return hub.Gateway{}, nil
}
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) { return nil, nil }
func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.gatewayFn != nil {
return s.gatewayFn(name)
}
gateway, ok := s.gateways[name]
if !ok {
return hub.Gateway{}, hub.ErrNotFound
}
return gateway, nil
}
func (s *memoryStore) DeleteGateway(context.Context, string) error { return nil }
func (s *memoryStore) CreateImage(_ context.Context, image hub.Image) error {
s.mu.Lock()
defer s.mu.Unlock()
s.images[image.Version] = image
return nil
}
func (s *memoryStore) UpdateImage(_ context.Context, image hub.Image) error {
s.mu.Lock()
defer s.mu.Unlock()
s.images[image.Version] = image
return nil
}
func (s *memoryStore) ListImages(context.Context, bool) ([]hub.Image, error) {
s.mu.Lock()
defer s.mu.Unlock()
images := make([]hub.Image, 0, len(s.images))
for _, image := range s.images {
images = append(images, image)
}
return images, nil
}
func (s *memoryStore) DeleteImage(context.Context, string) error { return nil }
func (s *memoryStore) ImageRef(_ context.Context, version string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
image, ok := s.images[version]
if !ok || !image.Enabled {
return "", hub.ErrNotFound
}
return image.ImageRef, nil
}
func (s *memoryStore) CreateEnv(_ context.Context, env hub.Env) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.envs[env.Alias]; exists {
return hub.ErrConflict
}
if image, exists := s.images[env.ImageVersion]; !exists || !image.Enabled {
return hub.ErrNotFound
}
s.envs[env.Alias] = env
return nil
}
func (s *memoryStore) ListEnvs(context.Context) ([]hub.Env, error) {
s.mu.Lock()
defer s.mu.Unlock()
envs := make([]hub.Env, 0, len(s.envs))
for _, env := range s.envs {
envs = append(envs, env)
}
return envs, nil
}
func (s *memoryStore) GetEnv(_ context.Context, alias string) (hub.Env, error) {
s.mu.Lock()
defer s.mu.Unlock()
env, ok := s.envs[alias]
if !ok {
return hub.Env{}, hub.ErrNotFound
}
return env, nil
}
func (s *memoryStore) UpgradeEnv(_ context.Context, alias, version string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.upgradeErr != nil {
return s.upgradeErr
}
if _, ok := s.envs[alias]; !ok {
return hub.ErrNotFound
}
if image, exists := s.images[version]; !exists || !image.Enabled {
return hub.ErrNotFound
}
s.upgraded[alias] = version
env := s.envs[alias]
env.ImageVersion = version
s.envs[alias] = env
return nil
}
func (s *memoryStore) DeleteEnv(_ context.Context, alias string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.deleted = append(s.deleted, alias)
delete(s.envs, alias)
return nil
}
type recordedRequest struct {
method string
path string
body map[string]any
}
// fakeGateway 模拟 docker-gateway:按路由表应答并记录请求。
type fakeGateway struct {
mu sync.Mutex
createOnce sync.Once
token string
requests []recordedRequest
containers []containerStatus
failCreate int // 前 N 次 create 返回失败
createStarted chan struct{}
releaseCreate <-chan struct{}
}
func (g *fakeGateway) handler(t *testing.T) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer "+g.token {
response.WriteHeader(http.StatusUnauthorized)
_, _ = response.Write([]byte(`{"error":"gateway token rejected"}`))
return
}
var body map[string]any
if request.Body != nil {
raw, _ := io.ReadAll(request.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &body)
}
}
g.mu.Lock()
g.requests = append(g.requests, recordedRequest{method: request.Method, path: request.URL.Path, body: body})
g.mu.Unlock()
switch {
case request.Method == http.MethodPost && request.URL.Path == "/v1/browsers":
g.mu.Lock()
if g.failCreate > 0 {
g.failCreate--
g.mu.Unlock()
response.WriteHeader(http.StatusConflict)
_, _ = response.Write([]byte(`{"error":"alias already in use"}`))
return
}
g.mu.Unlock()
if g.createStarted != nil {
g.createOnce.Do(func() { close(g.createStarted) })
}
if g.releaseCreate != nil {
<-g.releaseCreate
}
response.WriteHeader(http.StatusCreated)
_, _ = response.Write([]byte(`{"id":"container-id","alias":"account-a"}`))
case request.Method == http.MethodGet && request.URL.Path == "/v1/browsers":
_ = json.NewEncoder(response).Encode(g.containers)
case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/v1/browsers/"):
response.WriteHeader(http.StatusNoContent)
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"),
request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/stop"):
response.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected gateway request %s %s", request.Method, request.URL.Path)
}
})
}
func (g *fakeGateway) recorded() []recordedRequest {
g.mu.Lock()
defer g.mu.Unlock()
return append([]recordedRequest{}, g.requests...)
}
func newTestApp(t *testing.T, store *memoryStore, gateway *fakeGateway) *fiber.App {
t.Helper()
server := httptest.NewServer(gateway.handler(t))
t.Cleanup(server.Close)
if store.gateways == nil {
store.gateways = map[string]hub.Gateway{}
}
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
app := fiber.New()
registerHub(app, store)
return app
}
func do(app *fiber.App, method, path, body string) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
adaptor.FiberApp(app).ServeHTTP(response, httptest.NewRequest(method, path, reader))
return response
}
const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1","image_version":"148.0.7778.215",` +
`"fingerprint":{"seed":2024,"platform":"windows","timezone":"Asia/Shanghai"}}`
func TestCreateBrowserOrchestratesGateway(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser@sha256:abc", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
}
if _, kept := store.envs["account-a"]; !kept {
t.Fatal("env must be persisted after successful gateway create")
}
requests := gateway.recorded()
if len(requests) != 1 || requests[0].method != http.MethodPost || requests[0].path != "/v1/browsers" {
t.Fatalf("unexpected gateway calls: %#v", requests)
}
payload := requests[0].body
if payload["alias"] != "account-a" || payload["name"] != "店铺一号" ||
payload["image"] != "registry.example/browser@sha256:abc" ||
payload["volume"] != "creatorhub-profile-account-a" {
t.Fatalf("platform must fully specify the gateway payload: %#v", payload)
}
cmd := payload["cmd"].([]any)
if len(cmd) != 4 || cmd[0] != "--fingerprint=2024" || cmd[1] != "--fingerprint-platform=windows" ||
cmd[2] != "--timezone=Asia/Shanghai" || cmd[3] != "about:blank" {
t.Fatalf("cmd must carry fingerprint args plus start url: %#v", cmd)
}
}
func TestCreateBrowserRollsBackWhenGatewayRejects(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusConflict {
t.Fatalf("expected gateway conflict to pass through as 409, got %d: %s", response.Code, response.Body.String())
}
if len(store.deleted) != 1 || store.deleted[0] != "account-a" {
t.Fatalf("env must be rolled back after gateway rejection, deleted=%v", store.deleted)
}
if _, kept := store.envs["account-a"]; kept {
t.Fatal("rolled-back env must not remain in the store")
}
}
func TestCreateBrowserReconcilesDisconnectedGateway(t *testing.T) {
for _, test := range []struct {
name string
reconcile bool
wantStatus int
}{
{name: "completed create", reconcile: true, wantStatus: http.StatusCreated},
{name: "unknown result", wantStatus: http.StatusBadGateway},
} {
t.Run(test.name, func(t *testing.T) {
var created bool
var mu sync.Mutex
gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer unit-test-gateway-token" {
response.WriteHeader(http.StatusUnauthorized)
return
}
if request.Method == http.MethodGet && test.reconcile {
mu.Lock()
exists := created
mu.Unlock()
if exists {
_ = json.NewEncoder(response).Encode([]containerStatus{{ID: "container-id", Alias: "account-a"}})
return
}
_ = json.NewEncoder(response).Encode([]containerStatus{})
return
}
if request.Method == http.MethodPost {
mu.Lock()
created = true
mu.Unlock()
}
hijacker, ok := response.(http.Hijacker)
if !ok {
t.Error("test server does not support hijacking")
return
}
connection, _, err := hijacker.Hijack()
if err != nil {
t.Errorf("hijack gateway response: %v", err)
return
}
_ = connection.Close()
}))
defer gatewayServer.Close()
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"}
app := fiber.New()
registerHub(app, store)
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != test.wantStatus {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if _, err := store.GetEnv(context.Background(), "account-a"); err != nil {
t.Fatalf("unknown gateway result must retain the environment for reconciliation: %v", err)
}
if len(store.deleted) != 0 {
t.Fatalf("unknown result must not roll back the environment: %v", store.deleted)
}
})
}
}
func TestCreateBrowserReconcilesGatewayBadGateway(t *testing.T) {
for _, test := range []struct {
name string
missingReads int
}{
{name: "Docker create disconnect returned 502"},
{name: "container becomes visible after query window", missingReads: 2},
} {
t.Run(test.name, func(t *testing.T) {
var mu sync.Mutex
listCalls := 0
gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer unit-test-gateway-token" {
response.WriteHeader(http.StatusUnauthorized)
return
}
if request.Method == http.MethodPost {
response.WriteHeader(http.StatusBadGateway)
_, _ = response.Write([]byte(`{"error":"create container: context deadline exceeded"}`))
return
}
mu.Lock()
listCalls++
missing := listCalls <= test.missingReads
mu.Unlock()
if missing {
_ = json.NewEncoder(response).Encode([]containerStatus{})
return
}
_ = json.NewEncoder(response).Encode([]containerStatus{{ID: "container-id", Alias: "account-a"}})
}))
defer gatewayServer.Close()
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"}
app := fiber.New()
registerHub(app, store)
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusCreated {
t.Fatalf("expected reconciled create, status=%d body=%s", response.Code, response.Body.String())
}
if _, err := store.GetEnv(context.Background(), "account-a"); err != nil || len(store.deleted) != 0 {
t.Fatalf("reconciled create must retain DB state: err=%v deleted=%v", err, store.deleted)
}
mu.Lock()
gotCalls := listCalls
mu.Unlock()
if gotCalls != test.missingReads+1 {
t.Fatalf("expected %d reconciliation reads, got %d", test.missingReads+1, gotCalls)
}
})
}
}
func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers",
`{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148.0.7778.215","fingerprint":{"seed":0}}`)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String())
}
if len(gateway.recorded()) != 0 || len(store.envs) != 0 {
t.Fatal("invalid input must not reach the gateway or the store")
}
}
func TestListBrowsersMergesLiveGatewayState(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148"}
store.envs["account-b"] = hub.Env{Alias: "account-b", Name: "店铺二号", Gateway: "gw-1", ImageVersion: "148"}
gateway := &fakeGateway{
token: "unit-test-gateway-token",
containers: []containerStatus{
{ID: "id-1", Alias: "account-a", State: "running", Status: "Up", Endpoint: "http://creatorhub-browser-account-a:9222"},
},
}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
var views []envView
if err := json.NewDecoder(response.Body).Decode(&views); err != nil || len(views) != 2 {
t.Fatalf("expected two env views, err=%v body=%s", err, response.Body.String())
}
byAlias := map[string]envView{}
for _, view := range views {
byAlias[view.Alias] = view
}
if byAlias["account-a"].State != "running" || byAlias["account-a"].ContainerID != "id-1" {
t.Fatalf("running container state must be merged: %#v", byAlias["account-a"])
}
if byAlias["account-b"].State != "missing" {
t.Fatalf("env without container must report missing: %#v", byAlias["account-b"])
}
}
func TestUpgradeBrowserRecreatesWithSameVolumeAndParams(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{
Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148",
Fingerprint: hub.Fingerprint{Seed: 2024, Timezone: "Asia/Shanghai"},
}
_ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`)
if response.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
}
requests := gateway.recorded()
if len(requests) != 2 || requests[0].method != http.MethodDelete || requests[0].path != "/v1/browsers/account-a" ||
requests[1].method != http.MethodPost || requests[1].path != "/v1/browsers" {
t.Fatalf("upgrade must delete then recreate: %#v", requests)
}
payload := requests[1].body
if payload["image"] != "registry.example/browser:144" || payload["volume"] != "creatorhub-profile-account-a" {
t.Fatalf("upgrade must reuse the profile volume and switch image: %#v", payload)
}
cmd := payload["cmd"].([]any)
if cmd[0] != "--fingerprint=2024" || cmd[len(cmd)-1] != "about:blank" {
t.Fatalf("upgrade must reuse stored fingerprint params: %#v", cmd)
}
if store.upgraded["account-a"] != "144.0.7559.132" || store.envs["account-a"].ImageVersion != "144.0.7559.132" {
t.Fatal("image version must be persisted after successful upgrade")
}
}
func TestUpgradeBrowserRemovesUncommittedContainer(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}}
store.upgradeErr = hub.ErrNotFound
_ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`)
if response.Code != http.StatusNotFound {
t.Fatalf("expected persistence failure, got %d: %s", response.Code, response.Body.String())
}
requests := gateway.recorded()
if len(requests) != 3 || requests[2].method != http.MethodDelete || requests[2].path != "/v1/browsers/account-a" {
t.Fatalf("uncommitted upgraded container must be removed: %#v", requests)
}
env, err := store.GetEnv(context.Background(), "account-a")
if err != nil || env.ImageVersion != "148" {
t.Fatalf("failed upgrade must preserve the stored version: %#v %v", env, err)
}
}
func TestImageDisableWaitsForUpgradeCommit(t *testing.T) {
releaseCreate := make(chan struct{})
gateway := &fakeGateway{
token: "unit-test-gateway-token",
createStarted: make(chan struct{}),
releaseCreate: releaseCreate,
}
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}}
_ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true})
gatewayServer := httptest.NewServer(gateway.handler(t))
defer gatewayServer.Close()
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: gateway.token}
disableArrived := make(chan struct{})
var disableOnce sync.Once
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
if c.Method() == http.MethodPut {
disableOnce.Do(func() { close(disableArrived) })
}
return c.Next()
})
registerHub(app, store)
server := httptest.NewServer(adaptor.FiberApp(app))
defer server.Close()
type result struct {
status int
err error
}
upgradeDone := make(chan result, 1)
go func() {
response, err := server.Client().Post(server.URL+"/api/browsers/account-a/upgrade", "application/json", strings.NewReader(`{"version":"144.0.7559.132"}`))
if err != nil {
upgradeDone <- result{err: err}
return
}
defer response.Body.Close()
upgradeDone <- result{status: response.StatusCode}
}()
select {
case <-gateway.createStarted:
case <-time.After(time.Second):
t.Fatal("upgrade did not reach gateway create")
}
disableDone := make(chan result, 1)
go func() {
request, err := http.NewRequest(http.MethodPut, server.URL+"/api/browser-images/144.0.7559.132", strings.NewReader(
`{"image_ref":"registry.example/browser:144","enabled":false}`))
if err != nil {
disableDone <- result{err: err}
return
}
request.Header.Set("Content-Type", "application/json")
response, err := server.Client().Do(request)
if err != nil {
disableDone <- result{err: err}
return
}
defer response.Body.Close()
disableDone <- result{status: response.StatusCode}
}()
<-disableArrived
select {
case result := <-disableDone:
t.Fatalf("disable completed before upgrade commit: %#v", result)
case <-time.After(50 * time.Millisecond):
}
close(releaseCreate)
if result := <-upgradeDone; result.err != nil || result.status != http.StatusNoContent {
t.Fatalf("upgrade failed: %#v", result)
}
if result := <-disableDone; result.err != nil || result.status != http.StatusNoContent {
t.Fatalf("disable failed: %#v", result)
}
env, err := store.GetEnv(context.Background(), "account-a")
if err != nil || env.ImageVersion != "144.0.7559.132" {
t.Fatalf("upgrade must commit before disable: %#v %v", env, err)
}
if _, err := store.ImageRef(context.Background(), "144.0.7559.132"); !errors.Is(err, hub.ErrNotFound) {
t.Fatalf("disable must apply after upgrade: %v", err)
}
}
func TestBrowserActionRoutesStartStopAndRejectsUnknown(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent {
t.Fatalf("expected 204 for start, got %d: %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/browsers/account-a/pause", ""); response.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for unknown action, got %d", response.Code)
}
}
func TestDeleteBrowserKeepsEnvAndContainerInSync(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
if response := do(app, http.MethodDelete, "/api/browsers/account-a", ""); response.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
}
_, kept := store.envs["account-a"]
if len(store.deleted) != 1 || kept {
t.Fatalf("env must be removed with the container, deleted=%v", store.deleted)
}
}
func TestGatewayAndImageCRUDRoutes(t *testing.T) {
store := newMemoryStore()
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
if response := do(app, http.MethodPost, "/api/browser-images",
`{"version":"148.0.7778.215","image_ref":"registry.example/browser:148","note":"main","enabled":true}`); response.Code != http.StatusCreated {
t.Fatalf("expected 201 for image create, got %d: %s", response.Code, response.Body.String())
}
if image, ok := store.images["148.0.7778.215"]; !ok || image.ImageRef != "registry.example/browser:148" || !image.Enabled {
t.Fatalf("image must be stored: %#v", store.images)
}
if response := do(app, http.MethodPost, "/api/browser-images",
`{"version":"148.0.7778.215","image_ref":"registry.example/browser:148"}`); response.Code != http.StatusCreated {
t.Fatalf("enabled must default to true, got %d", response.Code)
}
}
func TestCreateImageReturnsJSONOverHTTP(t *testing.T) {
store := newMemoryStore()
app := fiber.New()
registerHub(app, store)
server := httptest.NewServer(adaptor.FiberApp(app))
defer server.Close()
response, err := server.Client().Post(server.URL+"/api/browser-images", "application/json", strings.NewReader(
`{"version":"148.0.7778.215","image_ref":"registry.example/browser:148","note":"main","enabled":true}`))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var image hub.Image
if err := json.NewDecoder(response.Body).Decode(&image); err != nil {
t.Fatalf("201 response must be JSON: %v", err)
}
if response.StatusCode != http.StatusCreated || !strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") ||
image.Version != "148.0.7778.215" || image.ImageRef != "registry.example/browser:148" || !image.Enabled {
t.Fatalf("unexpected create response: status=%d content-type=%q image=%#v", response.StatusCode, response.Header.Get("Content-Type"), image)
}
}