The server now registers the framework's built-in noop kind through
registerJobKinds before the runner starts, so production can run a job; a
noop job succeeds with {"ok":true}. Each feature's kinds are registered in
the same function.
Handlers report failure only with a *jobs.Failure code their kind declared
at registration. The runner stores and returns only that code, or
internal_error, interrupted or unknown_kind, and logs only the job id, kind,
code and a fixed reason: handler error text is never stored, returned or
logged. Results implement jobs.Public, whose PublicJSON names exactly the
data that is stored and returned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
4.6 KiB
Go
154 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|