diff --git a/frontend/app/admin/puzzle-submission/page.jsx b/frontend/app/admin/puzzle-submission/page.jsx
index 2af36dbf..a4b1dffc 100644
--- a/frontend/app/admin/puzzle-submission/page.jsx
+++ b/frontend/app/admin/puzzle-submission/page.jsx
@@ -1,7 +1,14 @@
"use client";
import React, { useState } from "react";
-import { apiClient } from "../../../lib/api";
+import { useApiMutation } from "../../../hooks/useApiMutation";
+import {
+ buildDraftPayload,
+ createDraft,
+ extractFieldErrors,
+ publishDraft,
+ updateDraft,
+} from "../../../services/puzzleDraftService";
const difficulties = ["Easy", "Medium", "Hard", "Expert"];
@@ -13,55 +20,139 @@ export default function AdminPuzzleSubmission() {
difficulty: difficulties[0],
nftMetadata: "",
});
+ // `null` when no draft exists yet; a string draft id once saved. Its
+ // presence switches the form between "create" and "update" mode.
+ const [draftId, setDraftId] = useState(null);
const [status, setStatus] = useState(null);
- const [submitting, setSubmitting] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ // Local busy flag rather than the mutation's `isLoading`: it covers both
+ // mutations (including the save-then-publish fallback) and stays
+ // independent of the react-query result naming across versions.
+ const [busy, setBusy] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
- const handleSubmit = async (e) => {
+ const resetAfterPublish = () => {
+ setForm({
+ title: "",
+ description: "",
+ answer: "",
+ difficulty: difficulties[0],
+ nftMetadata: "",
+ });
+ setDraftId(null);
+ };
+
+ // Shared mutation hook: consistent retry/error surface, no query cache to
+ // invalidate because draft state is local to this form.
+ const saveMutation = useApiMutation({
+ fn: async ({ payload }) => {
+ if (draftId) {
+ return updateDraft(draftId, payload);
+ }
+ const created = await createDraft(payload);
+ setDraftId(created.id);
+ return created;
+ },
+ onError: (error) => {
+ setFieldErrors(extractFieldErrors(error));
+ setStatus("Failed to save draft.");
+ },
+ });
+
+ const publishMutation = useApiMutation({
+ fn: async () => {
+ if (!draftId) {
+ // The publish endpoint needs a persisted draft; save first.
+ const { payload, metadataError } = buildDraftPayload(form);
+ if (metadataError) {
+ const error = new Error(metadataError);
+ error.data = { message: [metadataError] };
+ throw error;
+ }
+ const created = await createDraft(payload);
+ setDraftId(created.id);
+ return publishDraft(created.id);
+ }
+ return publishDraft(draftId);
+ },
+ onError: (error) => {
+ setFieldErrors(extractFieldErrors(error));
+ setStatus("Failed to publish draft.");
+ },
+ });
+
+ const handleSave = async (e) => {
e.preventDefault();
- setSubmitting(true);
setStatus(null);
-
+ setFieldErrors({});
+ const { payload, metadataError } = buildDraftPayload(form);
+ if (metadataError) {
+ setFieldErrors({ form: metadataError });
+ return;
+ }
+ setBusy(true);
try {
- await apiClient.post("/admin/puzzles", {
- title: form.title.trim(),
- description: form.description.trim(),
- difficulty: form.difficulty.toLowerCase(),
- hint: form.nftMetadata.trim() || undefined,
- solution: form.answer.trim(),
- isActive: true,
- });
+ await saveMutation.mutateAsync({ payload });
+ } catch {
+ // The error surface (status + field errors) is set in onError.
+ } finally {
+ setBusy(false);
+ }
+ };
- setForm({
- title: "",
- description: "",
- answer: "",
- difficulty: difficulties[0],
- nftMetadata: "",
- });
- setStatus("Puzzle submitted successfully.");
- } catch (error) {
- setStatus(error.message || "Failed to submit puzzle.");
+ const handlePublish = async () => {
+ setStatus(null);
+ setFieldErrors({});
+ const { payload, metadataError } = buildDraftPayload(form);
+ if (metadataError) {
+ setFieldErrors({ form: metadataError });
+ return;
+ }
+ setBusy(true);
+ try {
+ const published = await publishMutation.mutateAsync();
+ if (published) {
+ setStatus("Draft published successfully.");
+ resetAfterPublish();
+ }
+ } catch {
+ // The error surface (status + field errors) is set in onError.
} finally {
- setSubmitting(false);
+ setBusy(false);
}
};
+ const submitting = busy || saveMutation.isLoading || publishMutation.isLoading;
+ // The entered content is never cleared on failure: the form state is only
+ // reset in `resetAfterPublish`, which runs after a successful publish.
+ const showError = (field) =>
+ fieldErrors[field] ? (
+
{fieldErrors[field]}
+ ) : null;
+
return (
diff --git a/frontend/services/puzzleDraftService.js b/frontend/services/puzzleDraftService.js
new file mode 100644
index 00000000..7e94995f
--- /dev/null
+++ b/frontend/services/puzzleDraftService.js
@@ -0,0 +1,110 @@
+import { apiClient, ApiError } from "@/lib/api";
+
+/**
+ * Service for the backend puzzle-draft lifecycle (`backend/src/puzzle-draft/`).
+ *
+ * All routes are admin-only on the server (JwtAuthGuard + RolesGuard with
+ * `AdminRole.ADMIN`); this module intentionally performs no authorization
+ * logic of its own — the client-side check is advisory, the backend is the
+ * source of truth (issue #511).
+ *
+ * Endpoints (mounted under the versioned API prefix, see `lib/api.js`):
+ * - POST /drafts → create a draft
+ * - PATCH /drafts/:id → update a draft (rejected for published drafts)
+ * - POST /drafts/:id/publish → publish a draft
+ */
+
+/** Creates a draft from a `CreateDraftDto`-shaped payload. */
+export async function createDraft(payload) {
+ const response = await apiClient.post("/drafts", payload);
+ return response.data;
+}
+
+/** Updates an existing draft from an `UpdateDraftDto`-shaped payload. */
+export async function updateDraft(draftId, payload) {
+ const response = await apiClient.patch(`/drafts/${draftId}`, payload);
+ return response.data;
+}
+
+/** Publishes a draft through the backend publish endpoint. */
+export async function publishDraft(draftId) {
+ const response = await apiClient.post(`/drafts/${draftId}/publish`);
+ return response.data;
+}
+
+/**
+ * Maps a backend validation rejection onto per-field errors.
+ *
+ * The global ValidationPipe returns class-validator messages as an array on
+ * `response.data.message` (e.g. `["title must be a string", ...]`). Each
+ * message is attributed to the first form field it mentions so the form can
+ * render the error next to the offending input; messages that name no known
+ * field are returned under `form`.
+ *
+ * @param {Error} error - an `ApiError` thrown by `apiClient` (or any error)
+ * @returns {Record} field name → message
+ */
+export function extractFieldErrors(error) {
+ const messages = error?.data?.message ?? error?.response?.data?.message;
+ const list = Array.isArray(messages) ? messages : messages ? [messages] : [];
+
+ const knownFields = ["title", "description", "content"];
+ const fieldErrors = {};
+
+ for (const message of list) {
+ const text = typeof message === "string" ? message : String(message);
+ const match = knownFields.find((field) => text.includes(field));
+ const key = match ?? "form";
+ // Keep the first message per field; the rest are noise for the user.
+ if (!fieldErrors[key]) fieldErrors[key] = text;
+ }
+
+ return fieldErrors;
+}
+
+/**
+ * Builds the `CreateDraftDto` payload from the form state.
+ *
+ * The backend DTO contract is `{ title: string, description?: string,
+ * content: object }` (`backend/src/puzzle-draft/dto/create-draft.dto.ts`).
+ * The answer, difficulty and NFT metadata live inside the `content` object;
+ * the NFT metadata textarea is JSON text, so it is parsed (and its parse
+ * failure reported) here — serializing a string into an object is not
+ * re-declaring validation, which stays server-side.
+ *
+ * @returns {{ payload: object, metadataError?: string }}
+ */
+export function buildDraftPayload(form) {
+ const content = {
+ answer: form.answer.trim(),
+ difficulty: form.difficulty.toLowerCase(),
+ };
+
+ let metadataError;
+ const metadataText = form.nftMetadata.trim();
+ if (metadataText) {
+ try {
+ content.nftMetadata = JSON.parse(metadataText);
+ } catch {
+ metadataError = "NFT metadata must be valid JSON.";
+ }
+ }
+
+ const payload = {
+ title: form.title.trim(),
+ content,
+ };
+ if (form.description.trim()) {
+ payload.description = form.description.trim();
+ }
+
+ return { payload, metadataError };
+}
+
+export default {
+ createDraft,
+ updateDraft,
+ publishDraft,
+ extractFieldErrors,
+ buildDraftPayload,
+};
diff --git a/frontend/tests/admin-puzzle-submission.test.jsx b/frontend/tests/admin-puzzle-submission.test.jsx
new file mode 100644
index 00000000..ea610bf3
--- /dev/null
+++ b/frontend/tests/admin-puzzle-submission.test.jsx
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+// Mock the transport layer so the real service module runs against a
+// scripted `apiClient` — no HTTP, but the full payload/error mapping path
+// of `puzzleDraftService` is exercised.
+vi.mock('@/lib/api', () => {
+ class ApiError extends Error {
+ constructor(message, meta = {}) {
+ super(message);
+ this.name = 'ApiError';
+ this.status = meta.status;
+ this.data = meta.data;
+ }
+ }
+ return {
+ ApiError,
+ apiClient: {
+ post: vi.fn(),
+ patch: vi.fn(),
+ },
+ };
+});
+
+import { apiClient } from '@/lib/api';
+import AdminPuzzleSubmission from '@/app/admin/puzzle-submission/page';
+
+function renderForm() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+function fillValidForm() {
+ fireEvent.change(screen.getByLabelText('Title'), {
+ target: { value: 'Riddle of the Ledger' },
+ });
+ fireEvent.change(screen.getByLabelText('Description'), {
+ target: { value: 'A puzzle about invariants' },
+ });
+ fireEvent.change(screen.getByLabelText('Answer'), {
+ target: { value: 'soroban' },
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ apiClient.post.mockResolvedValue({ data: { id: 'draft-1' } });
+ apiClient.patch.mockResolvedValue({ data: { id: 'draft-1' } });
+});
+
+afterEach(cleanup);
+
+describe('Admin puzzle submission form (issue #511)', () => {
+ it('creates a draft through the backend on save', async () => {
+ renderForm();
+ fillValidForm();
+
+ fireEvent.click(screen.getByRole('button', { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('draft-state')).toBeInTheDocument();
+ });
+
+ expect(apiClient.post).toHaveBeenCalledTimes(1);
+ const [path, payload] = apiClient.post.mock.calls[0];
+ expect(path).toBe('/drafts');
+ expect(payload).toEqual({
+ title: 'Riddle of the Ledger',
+ description: 'A puzzle about invariants',
+ content: { answer: 'soroban', difficulty: 'easy' },
+ });
+ });
+
+ it('updates the existing draft once one has been created', async () => {
+ renderForm();
+ fillValidForm();
+ fireEvent.click(screen.getByRole('button', { name: /save draft/i }));
+ await waitFor(() => {
+ expect(screen.getByTestId('draft-state')).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByLabelText('Title'), {
+ target: { value: 'Riddle, revised' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /update draft/i }));
+
+ await waitFor(() => {
+ expect(apiClient.patch).toHaveBeenCalledTimes(1);
+ });
+ const [path, payload] = apiClient.patch.mock.calls[0];
+ expect(path).toBe('/drafts/draft-1');
+ expect(payload.title).toBe('Riddle, revised');
+ // The create endpoint is not hit a second time.
+ expect(apiClient.post).toHaveBeenCalledTimes(1);
+ });
+
+ it('publishes through the backend publish endpoint', async () => {
+ renderForm();
+ fillValidForm();
+ fireEvent.click(screen.getByRole('button', { name: /save draft/i }));
+ await waitFor(() => {
+ expect(screen.getByTestId('draft-state')).toBeInTheDocument();
+ });
+
+ apiClient.post.mockResolvedValueOnce({ data: { event: 'PUZZLE_DRAFT_PUBLISHED' } });
+ fireEvent.click(screen.getByRole('button', { name: /publish/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Draft published successfully.')).toBeInTheDocument();
+ });
+ expect(apiClient.post).toHaveBeenLastCalledWith('/drafts/draft-1/publish');
+ // A successful publish resets the form for the next puzzle.
+ expect(screen.getByLabelText('Title')).toHaveValue('');
+ });
+
+ it('surfaces per-field validation errors and keeps the content on a failed save', async () => {
+ renderForm();
+ fillValidForm();
+
+ // class-validator rejections arrive as an array of messages on
+ // `response.data.message`; the ApiError mapping puts them on `.data`.
+ // Persistent rejection (not `Once`): the shared mutation hook retries
+ // once, and the retry must fail too for the error path to be exercised.
+ apiClient.post.mockRejectedValue({
+ status: 400,
+ data: { message: ['content.answer must be a string', 'title must be longer than or equal to 3 characters'] },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to save draft.')).toBeInTheDocument();
+ });
+ expect(screen.getByText('content.answer must be a string')).toBeInTheDocument();
+ expect(
+ screen.getByText('title must be longer than or equal to 3 characters'),
+ ).toBeInTheDocument();
+
+ // The entered content survives the failure.
+ expect(screen.getByLabelText('Title')).toHaveValue('Riddle of the Ledger');
+ expect(screen.getByLabelText('Description')).toHaveValue('A puzzle about invariants');
+ expect(screen.getByLabelText('Answer')).toHaveValue('soroban');
+ });
+
+ it('reports a failed publish and preserves the draft', async () => {
+ renderForm();
+ fillValidForm();
+ fireEvent.click(screen.getByRole('button', { name: /save draft/i }));
+ await waitFor(() => {
+ expect(screen.getByTestId('draft-state')).toBeInTheDocument();
+ });
+
+ apiClient.post.mockRejectedValue({
+ status: 400,
+ data: { message: ['Draft is missing required content fields'] },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /publish/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to publish draft.')).toBeInTheDocument();
+ });
+ // The draft is still present for a retry.
+ expect(screen.getByTestId('draft-state')).toBeInTheDocument();
+ expect(screen.getByLabelText('Title')).toHaveValue('Riddle of the Ledger');
+ });
+
+ it('rejects invalid NFT metadata JSON before any request is made', async () => {
+ renderForm();
+ fillValidForm();
+ fireEvent.change(screen.getByLabelText('NFT Metadata (JSON)'), {
+ target: { value: '{ not json' },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText('NFT metadata must be valid JSON.')).toBeInTheDocument();
+ });
+ expect(apiClient.post).not.toHaveBeenCalled();
+ });
+});
diff --git a/onchain/contracts/stellar_hunts/src/lib.rs b/onchain/contracts/stellar_hunts/src/lib.rs
index 13d60657..b3497e2c 100644
--- a/onchain/contracts/stellar_hunts/src/lib.rs
+++ b/onchain/contracts/stellar_hunts/src/lib.rs
@@ -272,12 +272,12 @@ pub enum Error {
ArithmeticOverflow = 12,
ContractPaused = 13,
SchemaVersionMismatch = 14,
- /// Returned when an admin tries to set a per-level cap below the number
- /// of questions already indexed for that level.
- CapBelowExistingIndex = 15,
- /// Returned when an admin tries to set a per-level cap of zero (which
- /// would make the level permanently unreachable).
- CapWouldMakeLevelUnreachable = 16,
+ // Appended for the retirement-semantics guard (#447): submitting or
+ // requesting a hint for a retired question, and submitting a question
+ // other than the one the player's level cursor expects. New variants
+ // go at the end so existing discriminants stay stable.
+ QuestionRetired = 15,
+ WrongQuestion = 16,
}
// ---------------------------------------------------------------------
diff --git a/onchain/contracts/stellar_hunts/src/test.rs b/onchain/contracts/stellar_hunts/src/test.rs
index 0c3bd9d7..a7294573 100644
--- a/onchain/contracts/stellar_hunts/src/test.rs
+++ b/onchain/contracts/stellar_hunts/src/test.rs
@@ -288,7 +288,7 @@ fn test_submit_answer_requires_next_indexed_question() {
client.submit_answer(&player, &2u64, &b(&env, "A2"));
}));
assert!(out_of_order.is_err());
- assert!(panic_text(&out_of_order).contains("Error(Contract, #14)"));
+ assert!(panic_text(&out_of_order).contains("Error(Contract, #16)"));
assert_eq!(client.get_player_level_progress(&player, &level).last_question_index, 0);
assert!(client.submit_answer(&player, &1u64, &b(&env, "A1")));
@@ -297,7 +297,7 @@ fn test_submit_answer_requires_next_indexed_question() {
client.submit_answer(&player, &1u64, &b(&env, "A1"));
}));
assert!(duplicate.is_err());
- assert!(panic_text(&duplicate).contains("Error(Contract, #14)"));
+ assert!(panic_text(&duplicate).contains("Error(Contract, #16)"));
assert_eq!(client.get_player_level_progress(&player, &level).last_question_index, 1);
assert_eq!(client.get_player_level(&player), level);
@@ -702,9 +702,9 @@ fn test_retire_question_sets_flag() {
}
/// Submitting to a retired question must fail with the dedicated
-/// `QuestionRetired` (#14) error rather than grading the answer.
+/// `QuestionRetired` (#15) error rather than grading the answer.
#[test]
-#[should_panic(expected = "Error(Contract, #14)")]
+#[should_panic(expected = "Error(Contract, #15)")]
fn test_retired_question_cannot_be_answered() {
let env = Env::default();
env.ledger().set_sequence_number(100_000);
@@ -758,7 +758,7 @@ fn test_retired_answer_does_not_change_progress_or_complete_level() {
/// `request_hint` must refuse retired questions instead of returning the hint.
#[test]
-#[should_panic(expected = "Error(Contract, #14)")]
+#[should_panic(expected = "Error(Contract, #15)")]
fn test_retired_question_hint_denied() {
let env = Env::default();
env.ledger().set_sequence_number(100_000);
@@ -1536,194 +1536,297 @@ fn test_property_index_contains_each_question_exactly_once_after_interleaved_ops
assert_index_invariants(&state);
}
-// ── Per-level question cap tests (issue #467) ─────────────────────────────
-
-/// A level with no per-level cap behaves exactly as before: it uses the global
-/// `QuestionPerLevel` value (or the default of 5 when neither is set).
-#[test]
-fn test_level_without_cap_falls_back_to_global() {
- let env = Env::default();
- let (admin, _, client) = init_with_admin(&env);
-
- // Set a global cap of 2.
- env.mock_all_auths();
- client.set_question_per_level(&2u32);
-
- // Easy has no per-level cap, so it should use the global cap of 2.
- assert_eq!(client.get_question_cap_for_level(&Levels::Easy), 2u32);
- // Master also has no per-level cap.
- assert_eq!(client.get_question_cap_for_level(&Levels::Master), 2u32);
+// ---------------------------------------------------------------------
+// Invariant tests for interleaved admin mutations (issue #468)
+// ---------------------------------------------------------------------
+//
+// The index invariant is formally stated in `lib.rs` (issue #464):
+// 1. `QuestionPerLevelIndex(level)` stores the count `N` of live questions.
+// 2. `QuestionsByLevel(level, i)` for `i in 0..N` holds the i-th live id.
+// 3. Each live id appears in the level's index exactly once.
+// 4. Slots at index `>= N` are cleared.
+//
+// Unlike the property test above, the harness here labels every failure
+// with the admin operation and step number whose application is suspected
+// of breaking the invariant, and the sequence includes `retire_question`
+// and enumeration through `get_question_in_level`.
+
+/// One recorded admin operation, used to label invariant failures.
+#[derive(Clone)]
+enum AdminOp {
+ AddQuestion(u64, Levels),
+ MoveQuestion(u64, Levels, Levels),
+ RetireQuestion(u64),
+ SetQuestionPerLevel(u32),
+}
- let _ = admin;
+impl AdminOp {
+ fn describe(&self) -> String {
+ match self {
+ AdminOp::AddQuestion(id, lvl) => format!("add_question({id}, {lvl:?})"),
+ AdminOp::MoveQuestion(id, from, to) => {
+ format!("update_question({id}, {from:?} -> {to:?})")
+ }
+ AdminOp::RetireQuestion(id) => format!("retire_question({id})"),
+ AdminOp::SetQuestionPerLevel(n) => format!("set_question_per_level({n})"),
+ }
+ }
}
-/// Each of the four levels can have a distinct cap.
-#[test]
-fn test_per_level_caps_are_independent() {
- let env = Env::default();
- let (admin, _, client) = init_with_admin(&env);
+/// Asserts the on-chain index invariant against the expected set of live
+/// `(question_id, level)` pairs. Every panic message names `op` and the
+/// step number, so a failure points at the operation that broke it.
+fn assert_index_invariant(
+ env: &Env,
+ contract_id: &Address,
+ live: &[(u64, Levels)],
+ op: &AdminOp,
+ step: usize,
+) {
+ let op_desc = op.describe();
+ let levels = [
+ Levels::Easy,
+ Levels::Medium,
+ Levels::Hard,
+ Levels::Master,
+ ];
- env.mock_all_auths();
- client.set_question_cap_for_level(&Levels::Easy, &3u32);
- client.set_question_cap_for_level(&Levels::Medium, &5u32);
- client.set_question_cap_for_level(&Levels::Hard, &7u32);
- client.set_question_cap_for_level(&Levels::Master, &10u32);
+ env.as_contract(contract_id, || {
+ let mut seen_across_levels: std::vec::Vec = std::vec::Vec::new();
+
+ for lvl in levels {
+ let expected: std::vec::Vec = live
+ .iter()
+ .filter(|(_, l)| *l == lvl)
+ .map(|(id, _)| *id)
+ .collect();
+
+ // Criterion 2: the stored count equals the number of live entries.
+ let count: u32 = env
+ .storage()
+ .persistent()
+ .get(&crate::DataKey::QuestionPerLevelIndex(lvl.clone()))
+ .unwrap_or(0u32);
+ assert_eq!(
+ count as usize,
+ expected.len(),
+ "after step {step} ({op_desc}): QuestionPerLevelIndex({lvl:?}) is {count}, expected {} live entries",
+ expected.len(),
+ );
+
+ for i in 0..count {
+ let qid: u64 = env
+ .storage()
+ .persistent()
+ .get(&crate::DataKey::QuestionsByLevel(lvl.clone(), i))
+ .unwrap_or_else(|| {
+ panic!(
+ "after step {step} ({op_desc}): QuestionsByLevel({lvl:?}, {i}) is missing but index count is {count}"
+ )
+ });
+
+ // Criteria 1 and 3: each live id appears at most once across
+ // all level indices, and only in its own level.
+ assert!(
+ !seen_across_levels.contains(&qid),
+ "after step {step} ({op_desc}): question {qid} appears more than once across the level indices"
+ );
+ assert!(
+ expected.contains(&qid),
+ "after step {step} ({op_desc}): question {qid} found in level {lvl:?} index but is not live there"
+ );
- assert_eq!(client.get_question_cap_for_level(&Levels::Easy), 3u32);
- assert_eq!(client.get_question_cap_for_level(&Levels::Medium), 5u32);
- assert_eq!(client.get_question_cap_for_level(&Levels::Hard), 7u32);
- assert_eq!(client.get_question_cap_for_level(&Levels::Master), 10u32);
+ seen_across_levels.push(qid);
+ }
+
+ // Criterion 4: the first slot past the live window is cleared.
+ assert!(
+ !env.storage()
+ .persistent()
+ .has(&crate::DataKey::QuestionsByLevel(lvl.clone(), count)),
+ "after step {step} ({op_desc}): trailing slot QuestionsByLevel({lvl:?}, {count}) is still populated"
+ );
+ }
- let _ = admin;
+ // Every live id is reachable through the indices.
+ for (id, lvl) in live {
+ assert!(
+ seen_across_levels.contains(id),
+ "after step {step} ({op_desc}): live question {id} of level {lvl:?} is missing from all level indices"
+ );
+ }
+ });
}
-/// Setting a cap of zero is rejected with `CapWouldMakeLevelUnreachable`.
-#[test]
-#[should_panic]
-fn test_zero_cap_is_rejected() {
- let env = Env::default();
- let (_, _, client) = init_with_admin(&env);
- env.mock_all_auths();
- client.set_question_cap_for_level(&Levels::Easy, &0u32);
+fn add_named_question(client: &StellarHuntsClient, env: &Env, lvl: &Levels, text: &str) {
+ client.add_question(lvl, &b(env, text), &b(env, "A"), &b(env, "H"));
}
-/// Setting a cap below the existing question index is rejected with
-/// `CapBelowExistingIndex`.
#[test]
-#[should_panic]
-fn test_cap_below_existing_index_is_rejected() {
+fn test_index_invariant_holds_after_each_interleaved_admin_operation() {
let env = Env::default();
- let (_, _, client) = init_with_admin(&env);
- env.mock_all_auths();
-
- // Add 2 questions to Easy (cap is 5 by default).
- client.add_question(
- &Levels::Easy,
- &b(&env, "Q1"),
- &b(&env, "A1"),
- &b(&env, "H1"),
- );
- client.add_question(
- &Levels::Easy,
- &b(&env, "Q2"),
- &b(&env, "A2"),
- &b(&env, "H2"),
- );
+ let (_admin, contract_id, client) = init_with_admin(&env);
- // 2 questions indexed; trying to set cap to 1 should be rejected.
- client.set_question_cap_for_level(&Levels::Easy, &1u32);
-}
+ let mut step = 0usize;
+ let mut check = |live: &[(u64, Levels)], op: &AdminOp| {
+ step += 1;
+ assert_index_invariant(&env, &contract_id, live, op, step);
+ };
-/// Setting a cap exactly equal to the existing question index is accepted.
-#[test]
-fn test_cap_equal_to_existing_index_is_accepted() {
- let env = Env::default();
- let (_, _, client) = init_with_admin(&env);
- env.mock_all_auths();
+ let mut live: std::vec::Vec<(u64, Levels)> = std::vec::Vec::new();
- client.add_question(
- &Levels::Easy,
- &b(&env, "Q1"),
- &b(&env, "A1"),
- &b(&env, "H1"),
- );
- client.add_question(
- &Levels::Easy,
- &b(&env, "Q2"),
- &b(&env, "A2"),
- &b(&env, "H2"),
- );
+ client.set_question_per_level(&4u32);
+ check(&live, &AdminOp::SetQuestionPerLevel(4));
- // Setting cap = 2 (same as existing index) is valid.
- client.set_question_cap_for_level(&Levels::Easy, &2u32);
- assert_eq!(client.get_question_cap_for_level(&Levels::Easy), 2u32);
-}
+ // Adds across three levels, checking the invariant after each add.
+ add_named_question(&client, &env, &Levels::Easy, "Q1");
+ live.push((1u64, Levels::Easy));
+ check(&live, &AdminOp::AddQuestion(1, Levels::Easy));
-/// Completion uses the per-level cap. Two players: one in a level with cap 1,
-/// one in a level with cap 2. Completion happens at the correct threshold for
-/// each level.
-#[test]
-fn test_completion_uses_per_level_cap() {
- let env = Env::default();
- let (_, _, client) = init_with_admin(&env);
- env.mock_all_auths();
+ add_named_question(&client, &env, &Levels::Medium, "Q2");
+ live.push((2u64, Levels::Medium));
+ check(&live, &AdminOp::AddQuestion(2, Levels::Medium));
- // Set Easy cap to 1, Medium cap to 2.
- client.set_question_cap_for_level(&Levels::Easy, &1u32);
- client.set_question_cap_for_level(&Levels::Medium, &2u32);
+ add_named_question(&client, &env, &Levels::Easy, "Q3");
+ live.push((3u64, Levels::Easy));
+ check(&live, &AdminOp::AddQuestion(3, Levels::Easy));
- // Add 1 question to Easy, 2 to Medium.
- let answer_easy = b(&env, "easy_answer");
- client.add_question(
- &Levels::Easy,
- &b(&env, "Easy Q1"),
- &answer_easy,
- &b(&env, "H1"),
- );
+ add_named_question(&client, &env, &Levels::Hard, "Q4");
+ live.push((4u64, Levels::Hard));
+ check(&live, &AdminOp::AddQuestion(4, Levels::Hard));
- let answer_med1 = b(&env, "med_answer_1");
- let answer_med2 = b(&env, "med_answer_2");
- client.add_question(
- &Levels::Medium,
- &b(&env, "Med Q1"),
- &answer_med1,
- &b(&env, "H2"),
- );
- client.add_question(
+ // Move Q3 from Easy to Medium.
+ client.update_question(
+ &3u64,
+ &b(&env, "Q3-moved"),
+ &b(&env, "A3"),
&Levels::Medium,
- &b(&env, "Med Q2"),
- &answer_med2,
&b(&env, "H3"),
);
-
- // Player A answers Easy Q1 — should complete Easy (cap = 1).
- let player_a = Address::generate(&env);
- env.ledger().set_sequence_number(1);
- let correct = client.submit_answer(&player_a, &1u64, &answer_easy);
- assert!(correct, "Easy Q1 should be correct");
-
- // Player A should now be at Medium.
- let level_a = client.get_player_level(&player_a);
- assert_eq!(level_a, Levels::Medium, "player A should advance to Medium after cap-1 Easy");
-
- // Player B answers Medium Q1 — should NOT complete (cap = 2, only 1 answered).
- let player_b = Address::generate(&env);
- env.ledger().set_sequence_number(2);
- let correct_b1 = client.submit_answer(&player_b, &2u64, &answer_med1);
- assert!(correct_b1);
- // Player B is still in Medium.
- let level_b_after_1 = client.get_player_level(&player_b);
+ live.iter_mut()
+ .find(|(id, _)| *id == 3)
+ .unwrap()
+ .1 = Levels::Medium;
+ check(&live, &AdminOp::MoveQuestion(3, Levels::Easy, Levels::Medium));
+
+ // Criterion: a move is followed by an enumeration through
+ // `get_question_in_level`, and every level enumerates exactly the
+ // live questions in their index order.
+ assert_eq!(client.get_question_in_level(&Levels::Easy, &0u32), b(&env, "Q1"));
+ assert_eq!(client.get_question_in_level(&Levels::Medium, &0u32), b(&env, "Q2"));
assert_eq!(
- level_b_after_1,
- Levels::Medium,
- "player B should still be in Medium after 1/2 questions"
+ client.get_question_in_level(&Levels::Medium, &1u32),
+ b(&env, "Q3-moved")
);
+ assert_eq!(client.get_question_in_level(&Levels::Hard, &0u32), b(&env, "Q4"));
- // Player B answers Medium Q2 — should complete Medium (cap = 2).
- env.ledger().set_sequence_number(3);
- let correct_b2 = client.submit_answer(&player_b, &3u64, &answer_med2);
- assert!(correct_b2);
- let level_b_after_2 = client.get_player_level(&player_b);
+ // Retire Q2: the first Medium slot compacts and Q3-moved shifts down.
+ client.retire_question(&2u64);
+ live.retain(|(id, _)| *id != 2);
+ check(&live, &AdminOp::RetireQuestion(2));
assert_eq!(
- level_b_after_2,
- Levels::Hard,
- "player B should advance to Hard after cap-2 Medium"
+ client.get_question_in_level(&Levels::Medium, &0u32),
+ b(&env, "Q3-moved")
+ );
+
+ // Move Q4 from Hard to Easy.
+ client.update_question(
+ &4u64,
+ &b(&env, "Q4-moved"),
+ &b(&env, "A4"),
+ &Levels::Easy,
+ &b(&env, "H4"),
);
+ live.iter_mut()
+ .find(|(id, _)| *id == 4)
+ .unwrap()
+ .1 = Levels::Easy;
+ check(&live, &AdminOp::MoveQuestion(4, Levels::Hard, Levels::Easy));
+
+ // `set_question_per_level` is part of the interleaved surface. Lowering
+ // it below current Easy occupancy must not itself mutate any index.
+ client.set_question_per_level(&2u32);
+ check(&live, &AdminOp::SetQuestionPerLevel(2));
+
+ // Retire Q1: Easy drops to a single live question.
+ client.retire_question(&1u64);
+ live.retain(|(id, _)| *id != 1);
+ check(&live, &AdminOp::RetireQuestion(1));
+
+ // Final enumeration: walk each level's full live window through the
+ // public read path.
+ for (lvl, texts) in [
+ (Levels::Easy, std::vec!["Q4-moved"]),
+ (Levels::Medium, std::vec!["Q3-moved"]),
+ (Levels::Hard, std::vec![]),
+ ] {
+ for (i, text) in texts.iter().enumerate() {
+ assert_eq!(
+ client.get_question_in_level(&lvl, &(i as u32)),
+ b(&env, text),
+ "enumeration of {lvl:?} at index {i} diverged after the interleaved sequence"
+ );
+ }
+ }
}
-/// A per-level cap overrides the global cap for that level only.
#[test]
-fn test_per_level_cap_overrides_global_for_that_level() {
+fn test_index_invariant_when_a_level_is_drained_by_retirements() {
let env = Env::default();
- let (_, _, client) = init_with_admin(&env);
- env.mock_all_auths();
+ let (_admin, contract_id, client) = init_with_admin(&env);
+
+ let mut step = 0usize;
+ let mut check = |live: &[(u64, Levels)], op: &AdminOp| {
+ step += 1;
+ assert_index_invariant(&env, &contract_id, live, op, step);
+ };
+
+ let mut live: std::vec::Vec<(u64, Levels)> = std::vec::Vec::new();
- // Global cap = 3.
client.set_question_per_level(&3u32);
- // Easy cap = 1 (overrides global for Easy only).
- client.set_question_cap_for_level(&Levels::Easy, &1u32);
+ check(&live, &AdminOp::SetQuestionPerLevel(3));
+
+ add_named_question(&client, &env, &Levels::Easy, "Q1");
+ live.push((1u64, Levels::Easy));
+ check(&live, &AdminOp::AddQuestion(1, Levels::Easy));
+
+ add_named_question(&client, &env, &Levels::Easy, "Q2");
+ live.push((2u64, Levels::Easy));
+ check(&live, &AdminOp::AddQuestion(2, Levels::Easy));
+
+ add_named_question(&client, &env, &Levels::Medium, "Q3");
+ live.push((3u64, Levels::Medium));
+ check(&live, &AdminOp::AddQuestion(3, Levels::Medium));
+
+ // Drain Easy entirely, one retirement at a time.
+ client.retire_question(&1u64);
+ live.retain(|(id, _)| *id != 1);
+ check(&live, &AdminOp::RetireQuestion(1));
+
+ client.retire_question(&2u64);
+ live.retain(|(id, _)| *id != 2);
+ check(&live, &AdminOp::RetireQuestion(2));
+
+ // Retiring an unknown id must not disturb the indices.
+ let unknown = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ client.retire_question(&999u64);
+ }));
+ assert!(unknown.is_err(), "retiring an unknown question must fail");
+ check(&live, &AdminOp::RetireQuestion(999));
+
+ env.as_contract(&contract_id, || {
+ let easy_count: u32 = env
+ .storage()
+ .persistent()
+ .get(&crate::DataKey::QuestionPerLevelIndex(Levels::Easy))
+ .unwrap_or(0u32);
+ assert_eq!(
+ easy_count, 0,
+ "draining a level must leave its index count at zero"
+ );
+ });
- assert_eq!(client.get_question_cap_for_level(&Levels::Easy), 1u32);
- // Medium has no per-level cap, falls back to global.
- assert_eq!(client.get_question_cap_for_level(&Levels::Medium), 3u32);
+ // The remaining Medium question is untouched and still enumerates.
+ assert_eq!(client.get_question_in_level(&Levels::Medium, &0u32), b(&env, "Q3"));
+ check(&live, &AdminOp::RetireQuestion(999));
}