Add authenticated POST /api/v1/jobs to enqueue registered job kinds
CI / ci (pull_request) Successful in 1m29s
CI / ci (push) Successful in 1m28s

Addresses feature-review finding F1 on Feature 04 PR #30: a job can now
be enqueued over the API and its progressive result read back.

- jobs: Runner.Enqueue(kind, payload) rejects an unregistered kind with
  ErrUnknownKind and a payload that does not decode into the kind's
  payload type with ErrInvalidPayload, storing no job. Kind.Enqueue
  shares the same insert path.
- server: POST /api/v1/jobs, inside the session- and CSRF-guarded /api/v1
  group, takes {kind, payload?} and returns 202 {id}; unknown kind,
  bad payload or malformed body return 400 with the standard error shape.
- Tests drive POST -> GET partial result (handler held on a channel) ->
  GET succeeded through the production router, plus 400/401/403 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:29:49 -04:00
co-authored by Claude Opus 5.5
parent e050736178
commit 57591e21cd
5 changed files with 289 additions and 14 deletions
+110
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
@@ -151,3 +152,112 @@ func TestGetJobRequiresSession(t *testing.T) {
t.Errorf("status = %d, want 401", rec.Code)
}
}
// TestPostJobThenGetProgressAndResult drives the whole flow over the
// router: POST enqueues a progressive "draft" job, GET reads its partial
// result while the handler is held mid-run, then its final result.
func TestPostJobThenGetProgressAndResult(t *testing.T) {
t.Parallel()
release := make(chan struct{})
runner, _ := startedRunner(t, release, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
}
id, _ := body["id"].(string)
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
t.Fatalf("body = %v, want only a job id", body)
}
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 done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
}
}
func TestPostJobWithoutPayloadEnqueues(t *testing.T) {
t.Parallel()
release := make(chan struct{})
close(release)
runner, _ := startedRunner(t, release, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft"}`)))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
}
id, _ := body["id"].(string)
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
if resultDays(done) != 7 {
t.Errorf("finished body = %v, want result {\"days\":7}", done)
}
}
func TestPostJobRefusesBadRequests(t *testing.T) {
t.Parallel()
runner, _ := startedRunner(t, nil, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
for _, tc := range []struct {
name, body, code, message string
}{
{"unknown kind", `{"kind":"no_such_kind"}`, "unknown_kind", "unknown job kind"},
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and an optional payload"},
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and an optional payload"},
{"unknown field", `{"kind":"draft","extra":1}`, "invalid_request", "request body must be a JSON object with kind and an optional payload"},
{"trailing data", `{"kind":"draft"} {}`, "invalid_request", "request body must be a single JSON object"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
t.Errorf("body = %+v, want %+v", body, want)
}
})
}
}
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
const body = `{"kind":"noop"}`
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
}
for _, header := range []string{"", "wrong-token"} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
req.AddCookie(validSessionCookie)
if header != "" {
req.Header.Set(CSRFHeader, header)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
}
}
}