Feature 04 task 5: Async job runner and GET /api/v1/jobs/{id} (#29)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #29.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
|
||||
// result and error.
|
||||
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
job, err := runner.Get(r.Context(), chi.URLParam(r, "id"))
|
||||
if errors.Is(err, jobs.ErrNotFound) {
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get job", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
type draftPayload struct{}
|
||||
|
||||
type draftResult struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) }
|
||||
|
||||
// startedRunner registers a "draft" kind that publishes {"days":1}, waits
|
||||
// for release and returns {"days":7}, or fails with a *jobs.Failure when
|
||||
// fail is closed first. It starts the runner and stops it when the test ends.
|
||||
func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) {
|
||||
t.Helper()
|
||||
runner := newTestRunner(t)
|
||||
kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) {
|
||||
if err := publish(draftResult{Days: 1}); err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
return draftResult{Days: 7}, nil
|
||||
case <-fail:
|
||||
return draftResult{}, &jobs.Failure{Code: "no_regulars"}
|
||||
case <-ctx.Done():
|
||||
return draftResult{}, ctx.Err()
|
||||
}
|
||||
}, "no_regulars")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
return runner, kind
|
||||
}
|
||||
|
||||
// pollJob GETs the job until want accepts the body, failing after five
|
||||
// seconds.
|
||||
func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want(body) {
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) {
|
||||
t.Errorf("body fields = %v, want id, kind, status, result, error", keys)
|
||||
}
|
||||
return body
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never reached the expected state; last %v", id, body)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func resultDays(body map[string]any) float64 {
|
||||
result, _ := body["result"].(map[string]any)
|
||||
days, _ := result["days"].(float64)
|
||||
return days
|
||||
}
|
||||
|
||||
func TestGetJobShowsProgressThenResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, kind := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobShowsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fail := make(chan struct{})
|
||||
close(fail)
|
||||
runner, kind := startedRunner(t, nil, fail)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" })
|
||||
if body["error"] != "no_regulars" {
|
||||
t.Errorf("error = %v, want the Failure code", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobUnknownIDIsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobRequiresSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -28,13 +29,17 @@ type RouteMounter interface {
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household; assets is the web build
|
||||
// /media; settings serves /api/v1/settings/household; runner serves
|
||||
// /api/v1/jobs/{id}; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -63,6 +68,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -27,17 +28,17 @@ const (
|
||||
|
||||
func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWith(t, version, newTestSettings(t))
|
||||
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) http.Handler {
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,9 +71,8 @@ func TestAuthRoutesMounted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
// newTestStore returns a migrated temp database, closed when the test ends.
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
@@ -87,21 +87,49 @@ func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
return st
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return household.NewSettingsService(st)
|
||||
}
|
||||
|
||||
// newTestRunner returns an unstarted job runner over a migrated temp
|
||||
// database.
|
||||
func newTestRunner(t *testing.T) *jobs.Runner {
|
||||
t.Helper()
|
||||
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestGetHouseholdSettings(t *testing.T) {
|
||||
func TestPatchHouseholdSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
|
||||
var notified []household.Settings
|
||||
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
|
||||
@@ -146,7 +146,7 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
notified := 0
|
||||
settings.Subscribe(func(household.Settings) { notified++ })
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user