package api_test import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/docker/docker/api/types" "github.com/shcizo/package-updater/internal/api" "github.com/shcizo/package-updater/internal/discovery" "github.com/shcizo/package-updater/internal/updater" "github.com/stretchr/testify/require" ) type fakeFinder struct { jobs []discovery.Job err error // gotImage records the image argument passed to FindJobs, for // assertions on the exact reference the handler built. gotImage string // swarmShaped, when true, makes FindJobs return a Job whose Image // field carries the received image argument, simulating // discovery.SwarmDiscovery.FindJobs. swarmShaped bool } func (f *fakeFinder) FindJobs(_ context.Context, image string) ([]discovery.Job, error) { f.gotImage = image if f.swarmShaped { return []discovery.Job{{Service: "myapp_web", ServiceID: "svc1", Image: image}}, f.err } return f.jobs, f.err } type fakeSubmitter struct { results []updater.Result } func (f *fakeSubmitter) Submit(_ context.Context, jobs []discovery.Job) []updater.Result { if f.results != nil { return f.results } out := make([]updater.Result, len(jobs)) for i, j := range jobs { out[i] = updater.Result{Job: j, Status: updater.StatusUpdated} } return out } type fakePinger struct{ err error } func (f *fakePinger) Ping(_ context.Context) (types.Ping, error) { return types.Ping{}, f.err } func decode[T any](t *testing.T, body io.Reader) T { t.Helper() var v T require.NoError(t, json.NewDecoder(body).Decode(&v)) return v } func TestUpdate_ValidationError(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"tag":"v1.2.3"}`)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusBadRequest, w.Code) } func TestUpdate_BadJSON(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`not json`)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusBadRequest, w.Code) } func TestUpdate_DiscoveryFailureReturns500(t *testing.T) { finder := &fakeFinder{err: errors.New("daemon unreachable")} h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusInternalServerError, w.Code) } func TestUpdate_ZeroMatchesReturns200(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusOK, w.Code) resp := decode[api.UpdateResponse](t, w.Body) require.Equal(t, 0, resp.Matched) } func TestUpdate_AllSucceeded200(t *testing.T) { finder := &fakeFinder{jobs: []discovery.Job{ {Project: "p", Service: "s", WorkingDir: "/x", ConfigFiles: []string{"/x/c.yml"}}, }} h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "r/x", Tag: "v1"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusOK, w.Code) resp := decode[api.UpdateResponse](t, w.Body) require.Equal(t, 1, resp.Matched) require.Equal(t, "updated", resp.Results[0].Status) require.Equal(t, "/x/c.yml", resp.Results[0].ComposeFile) } func TestUpdate_MixedReturns207(t *testing.T) { jobs := []discovery.Job{ {Project: "p1", Service: "s", ConfigFiles: []string{"/x/c.yml"}}, {Project: "p2", Service: "s", ConfigFiles: []string{"/y/c.yml"}}, } finder := &fakeFinder{jobs: jobs} submitter := &fakeSubmitter{results: []updater.Result{ {Job: jobs[0], Status: updater.StatusUpdated}, {Job: jobs[1], Status: updater.StatusFailed, Error: "boom"}, }} h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusMultiStatus, w.Code) } func TestUpdate_AllFailedReturns500(t *testing.T) { jobs := []discovery.Job{{Project: "p", Service: "s", ConfigFiles: []string{"/x/c.yml"}}} finder := &fakeFinder{jobs: jobs} submitter := &fakeSubmitter{results: []updater.Result{ {Job: jobs[0], Status: updater.StatusFailed, Error: "boom"}, }} h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusInternalServerError, w.Code) } func TestHealthz_OKWhenDockerUp(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() h.Healthz(w, req) require.Equal(t, http.StatusOK, w.Code) } func TestHealthz_503WhenDockerDown(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{err: errors.New("ping fail")}, "v0.0.0", "abc", "now", nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() h.Healthz(w, req) require.Equal(t, http.StatusServiceUnavailable, w.Code) } func TestUpdate_FindJobsCalledWithTaggedImage(t *testing.T) { finder := &fakeFinder{} h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage) } func TestUpdate_FindJobsCalledWithBareImageWhenNoTag(t *testing.T) { finder := &fakeFinder{} h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, "registry.example.com/myapp", finder.gotImage) } func TestUpdate_SwarmModeThreadsTagThroughDiscovery(t *testing.T) { finder := &fakeFinder{swarmShaped: true} h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"}) req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) w := httptest.NewRecorder() h.Update(w, req) require.Equal(t, http.StatusOK, w.Code) // The fake Finder's returned Job.Image (as SwarmDiscovery would build // it) reflects the image argument it received from the handler. // Asserting on the captured argument proves the full request -> // discovery flow carries the tag through to what would reach the // SwarmExecutor. require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage) } func TestVersion(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v1.2.3", "abcdef", "2026-05-22T00:00:00Z", nil) req := httptest.NewRequest(http.MethodGet, "/version", nil) w := httptest.NewRecorder() h.Version(w, req) require.Equal(t, http.StatusOK, w.Code) resp := decode[api.VersionResponse](t, w.Body) require.Equal(t, "v1.2.3", resp.Version) }