# `POST /api/tests/start` — design

**Status**: design draft, awaiting review before implementation
**Date**: 2026-05-23
**Companion**: [answers-rewrite-design-2026-05-23.md](answers-rewrite-design-2026-05-23.md)
**Scope**: single test-start endpoint that replaces today's three mode-specific entry points

---

## Why

Today there are three parallel test-start paths:

| Endpoint | Controller | Service |
|---|---|---|
| `GET /api/tracks/{id}/questions` | `API\TrackController::getQuestions` | `QuestionAssignmentService::getOrCreateTrackTest` |
| `GET /api/kiasu-path/start` | `KiasuController::startKiasuPath` | `KiasuPathService::firstOrCreateKiasuPath` |
| `POST /api/diagnostic/start` | `DiagnosticController::start` | inline (`Test::firstOrCreateDiagnostic` + `getDiagnosticQuestions`) |

Plus diagnostic uses `assessment_sessions` consistently; track and kiasu
use it inconsistently or not at all. Question selection is duplicated
across three services with different fallback chains, different
config-key reads, and different question shapes in the response.

This design replaces them with one orchestrator. The three legacy
controllers stay as route shims during migration.

---

## Goal

One test-start endpoint, mode-aware where it must differ. Every test
gets a consistent (test, session, questions, lives snapshot) bundle.
Selection logic is split into clear strategies — track uses one
selector, diagnostic and kiasu share another (`FieldRoundSelector`).

Companion to the `/api/answers` rewrite — `/api/tests/start` opens the
session; `/api/answers` grades within it.

---

## The endpoint

```http
POST /api/tests/start
Authorization: Bearer <sanctum-token>
{
  "mode": "track" | "diagnostic" | "kiasu",
  "track_id": int        // required when mode = "track"
}
```

### Common response shape

```json
{
  "test_id":            int,
  "session_id":         int,         // assessment_sessions.id (always present)
  "mode":               "track" | "diagnostic" | "kiasu",
  "is_new_test":        bool,        // false if resumed
  "questions":          [...],       // first batch, curated shape (see below)
  "batch_size":         int,         // questions in this batch
  "questions_per_test": int|null,    // total expected for this test (null = open-ended diagnostic)
  "lives":              { ... }      // standard lives snapshot
}
```

---

## The steps

### Step 0 — Pre-gate

- **0.1 Authenticate** via Sanctum. **401** if no user.
- **0.2 Validate payload**: `mode` enum, `track_id` present when `mode = "track"`. **422** on mismatch.
- **0.3 Mode-specific access checks**:
  - `track`: open to all authenticated users. Validate `track_id` is public (`status_id = 3`) and has skills.
  - `kiasu`: premium-only (`user.access_type == 'premium'`). **403** otherwise.
  - `diagnostic`: 30-day cooldown for free users (current `HomeController::checkDiagnosticEligibility` rule). **403** with `code: "DIAGNOSTIC_COOLDOWN"` if in cooldown.
- **0.4 User lock** (light): `User::lockForUpdate()->find(user.id)` for the duration of Steps 1-3. Prevents two simultaneous test-starts creating duplicate active tests for the same `(user, mode)`.

### Step 1 — Find-or-create test + session

- **1.1 Look for active test of this mode** for this user:
  - `track`: `tests.user_id = X AND tests.track_id = Y AND tests.completed = false`
  - `kiasu`: `tests.user_id = X AND tests.test_type_id = 1 AND tests.completed = false`
  - `diagnostic`: `assessment_sessions.user_id = X AND test_type_id = 3 AND status = 'in_progress'`
- **1.2 If found** → return it (`is_new_test = false`), skip Step 2's selection if existing questions still pending. The endpoint always **resumes**; no `force_new` flag.
- **1.3 Else** → create:
  - `tests` row with mode-appropriate `test_type_id` (`track` = 2, `kiasu` = 1, `diagnostic` = 3) and `track_id` when applicable.
  - `test_user` pivot row.
  - **`assessment_sessions` row for all modes** (uniform `session_id` for `/api/answers` to grade against). Source / entrypoint reflects the mode.

### Step 2 — Pick the first batch of questions

Mode-dispatched to a selector. Each selector returns `Collection<Question>`.

- **`TrackSelector(user, track)`** — non-adaptive, all-upfront:
  - Pull `track_questions_per_test` questions from `track.skills` (status_id = 3).
  - Prefer unanswered (questions the user has never seen via `question_user`).
  - Fallback to random from the same pool (allow repeats) if unanswered pool < required.
  - Returns the full set in one batch.
- **`FieldRoundSelector(user, session, mode)`** — shared by diagnostic and kiasu:
  - See "Shared selector" below.
  - Returns up to `field_round_batch_size` questions, one per eligible field.

### Step 3 — Assign questions to user

- Insert `question_user` rows for picked questions (`question_answered = 0`, `correct = 0`, `kudos = 0`, `test_type_id` matching the test).
- Initialize pivot rows for any new `(user, skill)`, `(user, track)`, `(user, field)` combos encountered, with seed values per `QuestionAssignmentService::assignQuestionsToUser` (current behavior — kept).

### Step 4 — Build response

- Format questions in the **curated shape** (see below).
- Attach `lives` snapshot via `LiveService::getLivesInfo($user)`.
- Return.

---

## Shared `FieldRoundSelector` (diagnostic + kiasu)

```text
selectRound(user, session, mode):
    fields = pickEligibleFields(user, session, mode)
    batch = min(field_round_batch_size, fields.count)
    chosen = orderFields(fields, mode).take(batch)
    questions = []
    for field in chosen:
        cursor = currentLevelForField(user, session, field, mode)
        q = pickQuestion(field, cursor, exclude=already_in_session, mode)
        if q: questions.append(q)
    return questions
```

Three strategy plug-ins:

| Plug-in | Diagnostic | Kiasu |
|---|---|---|
| `pickEligibleFields` | fields where `diagnostic_field_progress.completed = false` for this session | all public fields (none "complete" in kiasu's lifetime) |
| `currentLevelForField` | `diagnostic_field_progress.current_level` (boundary-IRT cursor, one-shot move per answer); seed = `AdaptiveLevelService::getStartingMaxile(field)` | `kiasu_field_progress.current_level` (per-field threshold cursor — moves on `no_rights_to_pass` correct streak / `no_wrongs_to_fail` wrong streak in that field); seed = `getStartingMaxile(field)` if no row |
| `pickQuestion` | `WHERE is_diagnostic = 1 AND status_id = 3 AND tracks.field_id = ? AND levels.start_maxile_level = ? AND id NOT IN (...)` | same, but `is_diagnostic = 0` |
| Round-robin when `batch < eligible_fields` | by `diagnostic_field_progress.updated_at ASC` (least-recently-seen first) | by `kiasu_field_progress.updated_at ASC`; NULL first for never-seen |
| Cursor advance trigger | `/api/answers` Step 3 (boundary IRT walk inline in completion logic, today; lifted into service later) | `/api/answers` Step 3.6 (new mode-specific cursor update — see companion doc) |

### New table — `kiasu_field_progress`

Parallel to `diagnostic_field_progress`, but threshold-driven instead of
one-shot. Per `(user, field)` (NOT per session — kiasu's cursor
persists across kiasu test instances).

```
kiasu_field_progress
  user_id        int unsigned   PK
  field_id       int unsigned   PK
  current_level  int            -- maxile cursor (e.g., 100, 200, ...)
  correct_streak int default 0
  wrong_streak   int default 0
  created_at, updated_at
```

Update rules (mirror `Question::processProgressFor`'s skill cascade
but applied to the per-(user, field) cursor):

```text
on each isFinal kiasu answer for field F:
    if correct:
        correct_streak++; wrong_streak = 0
        if correct_streak >= no_rights_to_pass:
            current_level = nextPublicLevelUp(field, current_level)
            correct_streak = 1   # reset (counts the streak-completing answer)
    else:
        wrong_streak++; correct_streak = 0
        if wrong_streak >= no_wrongs_to_fail:
            current_level = nextPublicLevelDown(field, current_level)
            wrong_streak = 1
```

Clamped to the field's min and max public level
(`AdaptiveLevelService::getMinLevelForField` / `getMaxLevelForField`).
Reads the same thresholds as the skill mastery cascade — `Config::passThreshold()`,
`Config::failThreshold()`.

`orderFields` is the round-robin selector. When the batch size matches
or exceeds the eligible-field count (today's default: 5 batch, 5
fields), every field gets one question per round, ordering is moot.

---

## Per-mode breakdown

| Concern | Track | Diagnostic | Kiasu |
|---|---|---|---|
| Required input | `track_id` | none | none |
| Access | open | 30-day cooldown for free users | premium-only |
| `test_type_id` | 2 | 3 | 1 |
| Selector | `TrackSelector` | `FieldRoundSelector` | `FieldRoundSelector` |
| Adaptive? | no | yes (boundary-IRT per field) | yes (per-field maxile cursor) |
| Initial batch size | `track_questions_per_test` (all upfront) | `field_round_batch_size` (default 5) | `field_round_batch_size` (default 5) |
| Total questions in test | fixed (`track_questions_per_test`) | dynamic — ends when all fields cement | fixed (`kiasu_questions_per_test`) |
| Subsequent batches via | client tracks remaining; nothing new from server | `/api/answers` response includes next batch when current batch exhausted | same |
| Session-creation | always | always | always |
| Auto-resume active test | yes | yes | yes |

---

## Configurable parameters (proposed `configs` columns)

| Column | Default | Used by |
|---|---|---|
| `track_questions_per_test` | 10 | `TrackSelector`. Replaces today's ambiguous `questions_per_test` (DB column, default 10) / Laravel `Config::get('questions_per_test')` (default 20). |
| `field_round_batch_size` | 5 | `FieldRoundSelector`. Shared by diagnostic and kiasu. Replaces today's `kiasu_path_questions_per_batch`. |
| `kiasu_questions_per_test` | 10 | Kiasu completion cap. Repurposes the existing `questions_per_test` DB column reading in `KiasuPathService`. |

Today's scattered config (`Config::get('questions_per_test')` from
Laravel config, plus `configs.questions_per_test`, plus
`configs.kiasu_path_questions_per_batch`, plus hardcoded defaults in
each service) collapses to three named columns with explicit per-mode
ownership.

Field count itself is **not** a runtime config — it's content (`fields.status_id`).
The batch size is the knob; the eligible-field count is data.

---

## Curated question shape

**Decision**: ship curated shape, additive. Return both raw and curated
fields for a release window so the Flutter app can migrate at its own
pace. Drop the raw fields once Flutter adopts.

### Curated payload (target shape)

```json
{
  "id":                 5094,
  "type":               "mcq",        // "mcq" | "fib"
  "field_id":           37,
  "field_name":         "Number & Algebra",
  "skill_id":           237,
  "difficulty_id":      3,
  "level_maxile_start": 600,
  "level_maxile_end":   700,
  "question":           "Jenny has $b...",
  "question_image":     null,
  "options": [                        // for MCQ only
    { "id": 0, "text": "$5 + b", "image_url": null },
    { "id": 1, "text": "$b/5",   "image_url": null },
    { "id": 2, "text": "$b * 5", "image_url": null },
    { "id": 3, "text": "$b - 5", "image_url": null }
  ],
  "fields_count": null                // for FIB only — count of input slots (null for MCQ)
}
```

Notes:
- **`correct_answer` is never in this payload.** Server-side grading at `/api/answers` is now the only authoritative path; the client doesn't need it to render.
- **Internal columns** (`qa_status`, `qa_reviewer_id`, `source`, `original_question_id`, `published_at`, `gamecodes_id`, etc) are not exposed.
- **`options[]` for MCQ** flattens `answer0..answer3` + `answerN_image` into one array. `null`/empty options are omitted.
- **`fields_count` for FIB** tells the FE how many input slots to render (count of non-null `answer0..answer3`).

### Migration plan

1. **Phase A — additive**: response includes both `question.raw` (the current full Eloquent model wrap) and `question.curated` (the new shape). Document `raw` as deprecated.
2. **Phase B — Flutter adopts** the curated shape, drops `raw` reads.
3. **Phase C — drop `raw`** from the response when the Flutter version-gate confirms adoption.

If schedule pressure forces it, hard cutover is possible — but then the Flutter ship must precede the BE ship.

---

## Cross-cutting contracts

| Concern | Contract |
|---|---|
| Transactions | Step 1 + Step 3 in one DB transaction (test/session + question_user inserts). Step 2 selection is read-only. |
| User locking | `User::lockForUpdate` taken in Step 0.4, held through Step 3. Prevents duplicate active tests on rapid double-tap. |
| Auto-resume | All three modes resume if an active test exists. No `force_new` flag — to start fresh, the client calls `/api/diagnostic/abandon/{id}` (existing) or equivalent for track/kiasu (new — would need an `abandon` route per mode if desired). |
| Failure | Step 0 fail → 4xx, no state. Step 1 fail → 5xx, partial state possible if test row written and session not; transaction protects this. Step 2 fail → 500, no questions returned (client retries). Step 3 fail → rollback, test/session removed. |
| Idempotency | Not required for `tests/start` — auto-resume serves the same purpose (a retry returns the same test row). |

---

## What changes vs today

| Today | New |
|---|---|
| Three controllers each implementing test-start | One `POST /api/tests/start` orchestrator; old endpoints become 5-line shims |
| `QuestionAssignmentService::getOrCreateTrackTest` (200+ lines, track-specific) | `TrackSelector` (selection only, ~50 lines) + shared session/pivot init |
| `KiasuPathService` walks levels from `floor(maxile/100)*100`, ignores field distribution | Kiasu uses `FieldRoundSelector` (one Q per field per round, same shape as diagnostic) |
| `DiagnosticController::getDiagnosticQuestions` inline question selection | Lifted into `FieldRoundSelector` |
| `assessment_sessions` only consistently used by diagnostic | Every test gets an `assessment_sessions` row; uniform `session_id` for `/api/answers` |
| Question shape varies (track wraps raw model; diagnostic returns near-raw with relations) | Single curated shape across all modes; `correct_answer` never leaked |
| Three config knobs (`questions_per_test` DB column, Laravel config of same name, `kiasu_path_questions_per_batch`) | Three named columns with explicit per-mode ownership |
| Selection logic for diagnostic + kiasu duplicated | Shared `FieldRoundSelector` with three plug-in strategies |

---

## Implementation phases (suggested)

Each phase independently shippable:

1. **Phase 0 — extract `FieldRoundSelector`** from `DiagnosticController` inline code. Diagnostic uses it. Behavior unchanged. (Refactor for testability.)
2. **Phase 1 — wire kiasu to `FieldRoundSelector`**. Per-field cursor sourced from `field_user.field_maxile`. Kiasu's level-walk code retired. Behavior change for kiasu — needs product sign-off.
3. **Phase 2 — extract `TrackSelector`** from `QuestionAssignmentService`. Track behavior unchanged.
4. **Phase 3 — introduce `POST /api/tests/start`** as the unified entry. Old endpoints stay live as shims that call it.
5. **Phase 4 — curated question shape, additive**. Both old and new fields in responses.
6. **Phase 5 — Flutter adopts** curated shape; old fields dropped from response in a follow-up.
7. **Phase 6 — every test gets `assessment_sessions`** consistently (not just diagnostic). Done as Phase 3's session-init covers it but legacy tests may need a backfill if `/api/answers` is to grade against them retroactively.

---

## Out of scope (named so they don't surface as bugs later)

- **`force_new` / abandon**: today only diagnostic has an abandon endpoint (`POST /api/diagnostic/abandon/{id}`). Adding parallel abandon for track + kiasu is a follow-up; not in this design.
- **Test-start telemetry / analytics**: events emitted on test creation (which mode, which track for track-mode, etc.) — useful for product but not part of the rewrite.
- **Question shape per-difficulty variations** (e.g., calculator-allowed hints, image-heavy questions): out of scope; existing `calculator` column not surfaced in curated shape yet.
- **Multi-skill track questions** (a question that belongs to multiple skills): today the model assumes one skill per question (`questions.skill_id`); selectors assume this. Multi-skill is a content schema change, not this rewrite.

---

## Decisions captured

All locked from review:

1. **Track**: all-at-once, non-adaptive. ✓
2. **Kiasu** uses `FieldRoundSelector` (same code as diagnostic, different cursor source). ✓
3. **Auto-resume** for all modes; no `force_new` flag in v1. ✓
4. **Curated question shape** with additive rollout. ✓
5. **Every test gets `assessment_sessions`** (uniform `session_id` for `/api/answers`). ✓
6. **`field_round_batch_size`** is the configurable knob; field count is content. ✓

---

## Ready for implementation

Once this doc and the [`/api/answers` doc](answers-rewrite-design-2026-05-23.md) are signed off, the implementation plan can be drafted with file-level diffs. Suggested order: Phase 0 (extract `FieldRoundSelector`) lands first since it's pure refactor; Phase 1 (kiasu behavior change) needs product confirmation before shipping.
