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 } func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) { 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 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) }