diff --git a/migrations/0010_add_surveys.sql b/migrations/0010_add_surveys.sql new file mode 100644 index 0000000..8c9a485 --- /dev/null +++ b/migrations/0010_add_surveys.sql @@ -0,0 +1,64 @@ +-- Migration 0010: Add survey system +-- +-- Survey content (title/description/questions/options) is treated as +-- public-facing data (not PII), so it is stored in plaintext to support +-- server-side search and CSV export, unlike encrypted activity fields. + +CREATE TABLE IF NOT EXISTS surveys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS survey_questions ( + id TEXT PRIMARY KEY, + survey_id TEXT NOT NULL, + question_text TEXT NOT NULL, + question_type TEXT NOT NULL CHECK ( + question_type IN ('multiple_choice', 'checkbox', 'text', 'true_false', 'scale') + ), + required INTEGER NOT NULL DEFAULT 0, + display_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (survey_id) REFERENCES surveys(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS survey_options ( + id TEXT PRIMARY KEY, + question_id TEXT NOT NULL, + option_text TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (question_id) REFERENCES survey_questions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS survey_responses ( + id TEXT PRIMARY KEY, + survey_id TEXT NOT NULL, + user_id TEXT, + submitted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (survey_id, user_id), + FOREIGN KEY (survey_id) REFERENCES surveys(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS survey_answers ( + id TEXT PRIMARY KEY, + response_id TEXT NOT NULL, + question_id TEXT NOT NULL, + answer_text TEXT, + FOREIGN KEY (response_id) REFERENCES survey_responses(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES survey_questions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_surveys_user ON surveys(user_id); +CREATE INDEX IF NOT EXISTS idx_surveys_public ON surveys(is_public); +CREATE INDEX IF NOT EXISTS idx_survey_questions_survey ON survey_questions(survey_id); +CREATE INDEX IF NOT EXISTS idx_survey_options_question ON survey_options(question_id); +CREATE INDEX IF NOT EXISTS idx_survey_responses_survey ON survey_responses(survey_id); +CREATE INDEX IF NOT EXISTS idx_survey_responses_user ON survey_responses(user_id); +CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id); +CREATE INDEX IF NOT EXISTS idx_survey_answers_question ON survey_answers(question_id); diff --git a/public/partials/navbar.html b/public/partials/navbar.html index acaf410..292681f 100644 --- a/public/partials/navbar.html +++ b/public/partials/navbar.html @@ -66,7 +66,7 @@ Learning Requests - + Surveys @@ -278,6 +278,9 @@ Courses + + Surveys + diff --git a/public/survey-create.html b/public/survey-create.html new file mode 100644 index 0000000..1171afa --- /dev/null +++ b/public/survey-create.html @@ -0,0 +1,480 @@ + + + + + + Create Survey - Alpha One Labs + + + + + + + + + + + + + +
+
+ +

Create a Survey

+

Build a survey with multiple question types. Your draft is saved automatically.

+
+
+ +
+ + + +
+
+ + +
+
+ + +
+
+
+

Public Survey

+

Public surveys appear in the directory and can be taken by anyone.

+
+ +
+
+ +
+
+

Questions

+ 0 / 50 +
+ +
+ +
+ + No questions yet. Add your first question below. +
+ + +
+ +
+ + +
+
+ + +
+ + + + + + + diff --git a/public/survey-results.html b/public/survey-results.html new file mode 100644 index 0000000..6ac61ed --- /dev/null +++ b/public/survey-results.html @@ -0,0 +1,362 @@ + + + + + + Survey Results - Alpha One Labs + + + + + + + + + + + + + + +
+
+ +
+
+

Loading…

+

Survey Analytics

+
+
+ + Take Survey + + +
+
+
+
+ +
+ +
+
+
+
+
+
+ + + + +
+ + +
+ + + + + diff --git a/public/survey.html b/public/survey.html new file mode 100644 index 0000000..68c2d94 --- /dev/null +++ b/public/survey.html @@ -0,0 +1,324 @@ + + + + + + Survey - Alpha One Labs + + + + + + + + + + + + + +
+
+ +

Loading…

+

+
+
+ +
+ +
+
+
+
+
+ + + + + + + + +
+ + +
+ + + + + diff --git a/public/surveys.html b/public/surveys.html new file mode 100644 index 0000000..a97241e --- /dev/null +++ b/public/surveys.html @@ -0,0 +1,276 @@ + + + + + + All Surveys - Alpha One Labs + + + + + + + + + + + + + +
+
+

+ + All Surveys +

+ +
+ +
+
+
+
+
+ + + +
+ +
+ + + + +
+ + +
+ + + + + + + \ No newline at end of file diff --git a/src/api/surveys.py b/src/api/surveys.py new file mode 100644 index 0000000..8babb94 --- /dev/null +++ b/src/api/surveys.py @@ -0,0 +1,115 @@ +""" +Survey system — HTTP handlers. + +Thin request/response layer over ``surveys.py``: parses auth headers, query +params and JSON bodies, then delegates to the data-access functions and +wraps the result in the project's standard response envelope. +""" + +import surveys as core + + +def _auth_user(req, env): + return core.verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + + +async def api_list_surveys(req, env): + user = _auth_user(req, env) + params = core.get_query_params(req) + search = (params.get("q") or [""])[0].strip() + limit = core.query_int(params, "limit", 20, 1, 50) + offset = core.query_int(params, "offset", 0, 0, 10_000) + + surveys = await core.list_surveys(env, search, limit, offset, user["id"] if user else None) + return core.json_resp({"surveys": surveys, "limit": limit, "offset": offset}) + + +async def api_create_survey(req, env): + user = _auth_user(req, env) + if not user: + return core.err("Authentication required", 401) + + body, bad_resp = await core.parse_json_object(req) + if bad_resp: + return bad_resp + + cleaned, error = core.validate_survey_payload(body) + if error: + return core.err(error) + + survey_id = await core.create_survey(env, user["id"], cleaned) + return core.ok({"id": survey_id, "title": cleaned["title"]}, "Survey created") + + +async def api_get_survey(survey_id, req, env): + user = _auth_user(req, env) + survey = await core.get_survey(env, survey_id, user["id"] if user else None) + if not survey: + return core.err("Survey not found", 404) + return core.json_resp({"survey": survey}) + + +async def api_submit_survey_response(survey_id, req, env): + user = _auth_user(req, env) + + body, bad_resp = await core.parse_json_object(req) + if bad_resp: + return bad_resp + + answers = body.get("answers") + if not isinstance(answers, dict): + return core.err("answers must be an object mapping question_id to a value") + + response_id, error = await core.submit_response( + env, survey_id, user["id"] if user else None, answers + ) + if error: + if error == "already_submitted": + return core.err( + "You have already submitted a response to this survey", + 400, + code="already_submitted", + ) + status = 404 if error == "Survey not found" else 400 + return core.err(error, status) + + return core.ok({"response_id": response_id}, "Response submitted") + + +async def api_get_survey_results(survey_id, req, env): + user = _auth_user(req, env) + results, error = await core.get_results(env, survey_id, user["id"] if user else None) + if error: + return core.err(error, 404) + return core.json_resp({"results": results}) + + +async def api_delete_survey(survey_id, req, env): + user = _auth_user(req, env) + if not user: + return core.err("Authentication required", 401) + + success, error = await core.delete_survey(env, survey_id, user["id"]) + if not success: + status = 404 if error == "Survey not found" else 403 + return core.err(error, status) + + return core.ok(None, "Survey deleted") + + +async def api_export_survey(survey_id, req, env): + user = _auth_user(req, env) + csv_text, error = await core.export_csv(env, survey_id, user["id"] if user else None) + if error: + return core.err(error, 404) + + from workers import Response + return Response( + csv_text, + status=200, + headers={ + "Content-Type": "text/csv", + "Content-Disposition": f'attachment; filename="survey-{survey_id}-export.csv"', + **core.CORS_HEADERS, + }, + ) \ No newline at end of file diff --git a/src/surveys.py b/src/surveys.py new file mode 100644 index 0000000..e54a688 --- /dev/null +++ b/src/surveys.py @@ -0,0 +1,654 @@ +""" +Survey system — core helpers and D1 data-access layer. + +This module is intentionally self-contained (it does not import from +``worker``) so that ``worker.py`` can import it without creating a circular +dependency. The handful of low-level helpers duplicated here (``new_id``, +``verify_token``, the response envelope helpers) are exact copies of the +ones in ``worker.py`` — they are pure, stateless functions so the +duplication carries no risk of behavioural drift. + +D1 does not enforce ``PRAGMA foreign_keys = ON`` by default, so cascading +deletes are performed explicitly in ``delete_survey`` rather than relying on +the ``ON DELETE CASCADE`` clauses declared in the migration. +""" + +import base64 +import csv +import hashlib +import hmac as _hmac +import io +import json +import os +from urllib.parse import parse_qs, urlparse + +from workers import Response + +QUESTION_TYPES = {"multiple_choice", "checkbox", "text", "true_false", "scale"} +OPTION_QUESTION_TYPES = {"multiple_choice", "checkbox"} +MAX_QUESTIONS = 50 +MAX_TITLE_LEN = 200 + +# Separator used to pack multiple selected checkbox options into a single +# ``survey_answers.answer_text`` column. +CHECKBOX_SEP = "␟" + +# Characters that Excel/Sheets treat as formula triggers when a cell value +# starts with them (CSV/formula injection, CWE-1236). +_CSV_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r", "\n") + + +# --------------------------------------------------------------------------- +# Shared low-level helpers (duplicated from worker.py — see module docstring) +# --------------------------------------------------------------------------- + +def new_id() -> str: + """Generate a random UUID v4 using os.urandom.""" + b = bytearray(os.urandom(16)) + b[6] = (b[6] & 0x0F) | 0x40 + b[8] = (b[8] & 0x3F) | 0x80 + h = b.hex() + return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}" + + +def verify_token(raw: str, secret: str): + """Return decoded payload dict or None if invalid/missing.""" + if not raw: + return None + try: + token = raw.removeprefix("Bearer ").strip() + dot = token.rfind(".") + if dot == -1: + return None + p, sig = token[:dot], token[dot + 1:] + exp = _hmac.new( + secret.encode("utf-8"), p.encode("utf-8"), hashlib.sha256 + ).hexdigest() + if not _hmac.compare_digest(sig, exp): + return None + padding = (4 - len(p) % 4) % 4 + return json.loads(base64.b64decode(p + "=" * padding).decode("utf-8")) + except Exception: + return None + + +# Public name so other modules (e.g. src/api/surveys.py) can build custom +# Response objects with the correct CORS headers without reaching into a +# leading-underscore "private" constant. +CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +} +# Kept as an alias for backwards compatibility with any existing internal +# references within this module. +_CORS = CORS_HEADERS + + +def json_resp(data, status: int = 200): + return Response( + json.dumps(data), + status=status, + headers={"Content-Type": "application/json", **CORS_HEADERS}, + ) + + +def ok(data=None, msg: str = "OK"): + body = {"success": True, "message": msg} + if data is not None: + body["data"] = data + return json_resp(body, 200) + + +def err(msg: str, status: int = 400, code: str | None = None): + body = {"error": msg} + if code: + body["code"] = code + return json_resp(body, status) + + +async def parse_json_object(req): + """Parse request JSON and ensure payload is an object/dict.""" + try: + text = await req.text() + body = json.loads(text) + except Exception: + return None, err("Invalid JSON body") + + if not isinstance(body, dict): + return None, err("JSON body must be an object", 400) + + return body, None + + +def query_int(params: dict, key: str, default: int, min_val: int, max_val: int) -> int: + raw = (params.get(key) or [None])[0] + if raw is None: + return default + try: + return max(min_val, min(int(raw), max_val)) + except (ValueError, TypeError): + return default + + +def get_query_params(req) -> dict: + return parse_qs(urlparse(req.url).query) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def validate_survey_payload(body: dict): + """Validate a create-survey request body. + + Returns ``(cleaned, None)`` on success or ``(None, error_message)``. + ``cleaned`` is ``{"title", "description", "is_public", "questions"}`` + where each question is + ``{"text", "type", "required", "options": [str, ...]}``. + """ + title = (body.get("title") or "").strip() + if not title: + return None, "title is required" + if len(title) > MAX_TITLE_LEN: + return None, f"title must be {MAX_TITLE_LEN} characters or fewer" + + description = (body.get("description") or "").strip() + is_public = bool(body.get("is_public", True)) + + questions = body.get("questions") + if not isinstance(questions, list) or len(questions) == 0: + return None, "at least 1 question is required" + if len(questions) > MAX_QUESTIONS: + return None, f"a survey may have at most {MAX_QUESTIONS} questions" + + cleaned_questions = [] + for i, q in enumerate(questions): + if not isinstance(q, dict): + return None, f"question {i + 1} is malformed" + + text = (q.get("text") or "").strip() + if not text: + return None, f"question {i + 1} is missing text" + + qtype = (q.get("type") or "").strip() + if qtype not in QUESTION_TYPES: + return None, f"question {i + 1} has an invalid type" + + required = bool(q.get("required", False)) + + options = [] + if qtype in OPTION_QUESTION_TYPES: + raw_options = q.get("options") + if not isinstance(raw_options, list): + raw_options = [] + options = [str(o).strip() for o in raw_options if str(o).strip()] + if len(options) < 2: + return None, f"question {i + 1} needs at least 2 options" + + cleaned_questions.append({ + "text": text, + "type": qtype, + "required": required, + "options": options, + }) + + return { + "title": title, + "description": description, + "is_public": is_public, + "questions": cleaned_questions, + }, None + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + +async def create_survey(env, user_id: str, cleaned: dict) -> str: + survey_id = new_id() + await env.DB.prepare( + "INSERT INTO surveys (id, user_id, title, description, is_public) " + "VALUES (?, ?, ?, ?, ?)" + ).bind( + survey_id, user_id, cleaned["title"], cleaned["description"], + 1 if cleaned["is_public"] else 0, + ).run() + + for order, q in enumerate(cleaned["questions"]): + question_id = new_id() + await env.DB.prepare( + "INSERT INTO survey_questions " + "(id, survey_id, question_text, question_type, required, display_order) " + "VALUES (?, ?, ?, ?, ?, ?)" + ).bind( + question_id, survey_id, q["text"], q["type"], + 1 if q["required"] else 0, order, + ).run() + + for opt_order, opt_text in enumerate(q["options"]): + await env.DB.prepare( + "INSERT INTO survey_options (id, question_id, option_text, display_order) " + "VALUES (?, ?, ?, ?)" + ).bind(new_id(), question_id, opt_text, opt_order).run() + + return survey_id + + +# --------------------------------------------------------------------------- +# List / detail +# --------------------------------------------------------------------------- + +async def list_surveys(env, search: str, limit: int, offset: int, viewer_id): + where = ["(s.is_public = 1"] + params = [] + if viewer_id: + where[0] += " OR s.user_id = ?" + params.append(viewer_id) + where[0] += ")" + + if search: + where.append("(s.title LIKE ? OR s.description LIKE ?)") + like = f"%{search}%" + params.extend([like, like]) + + where_sql = " AND ".join(where) + params.extend([limit, offset]) + + res = await env.DB.prepare( + "SELECT s.id, s.title, s.description, s.is_public, s.user_id, s.created_at, " + "u.username AS creator_username, " + "(SELECT COUNT(*) FROM survey_questions WHERE survey_id = s.id) AS question_count, " + "(SELECT COUNT(*) FROM survey_responses WHERE survey_id = s.id) AS participant_count " + "FROM surveys s JOIN users u ON s.user_id = u.id " + f"WHERE {where_sql} " + "ORDER BY s.created_at DESC LIMIT ? OFFSET ?" + ).bind(*params).all() + + surveys = [] + for r in res.results or []: + surveys.append({ + "id": r.id, + "title": r.title, + "description": r.description, + "is_public": bool(r.is_public), + "question_count": r.question_count, + "participant_count": r.participant_count, + "creator_username": r.creator_username, + "created_at": r.created_at, + "is_owner": bool(viewer_id and r.user_id == viewer_id), + }) + return surveys + + +async def get_survey(env, survey_id: str, viewer_id): + survey = await env.DB.prepare( + "SELECT s.id, s.title, s.description, s.is_public, s.user_id, s.created_at, " + "s.updated_at, u.username AS creator_username " + "FROM surveys s JOIN users u ON s.user_id = u.id WHERE s.id = ?" + ).bind(survey_id).first() + + if not survey: + return None + + is_owner = bool(viewer_id and survey.user_id == viewer_id) + if not survey.is_public and not is_owner: + return None + + q_res = await env.DB.prepare( + "SELECT id, question_text, question_type, required, display_order " + "FROM survey_questions WHERE survey_id = ? ORDER BY display_order" + ).bind(survey_id).all() + + o_res = await env.DB.prepare( + "SELECT o.id, o.question_id, o.option_text, o.display_order " + "FROM survey_options o JOIN survey_questions q ON o.question_id = q.id " + "WHERE q.survey_id = ? ORDER BY o.display_order" + ).bind(survey_id).all() + + options_by_question = {} + for o in o_res.results or []: + options_by_question.setdefault(o.question_id, []).append({ + "id": o.id, + "text": o.option_text, + }) + + questions = [] + for q in q_res.results or []: + questions.append({ + "id": q.id, + "text": q.question_text, + "type": q.question_type, + "required": bool(q.required), + "options": options_by_question.get(q.id, []), + }) + + return { + "id": survey.id, + "title": survey.title, + "description": survey.description, + "is_public": bool(survey.is_public), + "creator_username": survey.creator_username, + "created_at": survey.created_at, + "updated_at": survey.updated_at, + "is_owner": is_owner, + "questions": questions, + } + + +# --------------------------------------------------------------------------- +# Submit response +# --------------------------------------------------------------------------- + +async def submit_response(env, survey_id: str, user_id, answers: dict): + """Validate and store a survey response. + + ``answers`` maps ``question_id -> value`` where ``value`` is a string for + text/true_false/scale questions, or a list of strings for checkbox + questions. + + Returns ``(response_id, None)`` on success or ``(None, error_message)``. + For a duplicate submission, ``error_message`` is the stable machine + code ``"already_submitted"`` rather than free-form text, so callers can + branch on it instead of pattern-matching a human-readable sentence. + """ + survey = await env.DB.prepare( + "SELECT id, is_public, user_id FROM surveys WHERE id = ?" + ).bind(survey_id).first() + if not survey: + return None, "Survey not found" + + q_res = await env.DB.prepare( + "SELECT id, question_type, required FROM survey_questions " + "WHERE survey_id = ? ORDER BY display_order" + ).bind(survey_id).all() + questions = q_res.results or [] + if not questions: + return None, "Survey has no questions" + + o_res = await env.DB.prepare( + "SELECT o.question_id, o.option_text FROM survey_options o " + "JOIN survey_questions q ON o.question_id = q.id WHERE q.survey_id = ?" + ).bind(survey_id).all() + valid_options = {} + for o in o_res.results or []: + valid_options.setdefault(o.question_id, set()).add(o.option_text) + + if user_id: + existing = await env.DB.prepare( + "SELECT id FROM survey_responses WHERE survey_id = ? AND user_id = ?" + ).bind(survey_id, user_id).first() + if existing: + return None, "already_submitted" + + cleaned_answers = [] # list of (question_id, answer_text) + for q in questions: + raw = answers.get(q.id) + + if q.question_type == "checkbox": + values = raw if isinstance(raw, list) else ([] if raw is None else [raw]) + values = [str(v).strip() for v in values if str(v).strip()] + if q.required and not values: + return None, "All required questions must be answered" + allowed = valid_options.get(q.id, set()) + for v in values: + if v not in allowed: + return None, "Invalid option selected" + if values: + cleaned_answers.append((q.id, CHECKBOX_SEP.join(values))) + continue + + value = "" if raw is None else str(raw).strip() + if q.required and not value: + return None, "All required questions must be answered" + if not value: + continue + + if q.question_type == "multiple_choice": + if value not in valid_options.get(q.id, set()): + return None, "Invalid option selected" + elif q.question_type == "true_false": + if value not in ("true", "false"): + return None, "Invalid true/false value" + elif q.question_type == "scale": + try: + n = int(value) + except ValueError: + return None, "Invalid scale value" + if n < 1 or n > 5: + return None, "Scale value must be between 1 and 5" + value = str(n) + + cleaned_answers.append((q.id, value)) + + response_id = new_id() + await env.DB.prepare( + "INSERT INTO survey_responses (id, survey_id, user_id) VALUES (?, ?, ?)" + ).bind(response_id, survey_id, user_id).run() + + for question_id, answer_text in cleaned_answers: + await env.DB.prepare( + "INSERT INTO survey_answers (id, response_id, question_id, answer_text) " + "VALUES (?, ?, ?, ?)" + ).bind(new_id(), response_id, question_id, answer_text).run() + + return response_id, None + + +# --------------------------------------------------------------------------- +# Results / analytics +# --------------------------------------------------------------------------- + +async def get_results(env, survey_id: str, viewer_id): + survey = await env.DB.prepare( + "SELECT id, title, is_public, user_id FROM surveys WHERE id = ?" + ).bind(survey_id).first() + if not survey: + return None, "Survey not found" + if not survey.is_public and not (viewer_id and survey.user_id == viewer_id): + return None, "Survey not found" + + q_res = await env.DB.prepare( + "SELECT id, question_text, question_type, required, display_order " + "FROM survey_questions WHERE survey_id = ? ORDER BY display_order" + ).bind(survey_id).all() + questions = q_res.results or [] + + o_res = await env.DB.prepare( + "SELECT o.question_id, o.option_text, o.display_order FROM survey_options o " + "JOIN survey_questions q ON o.question_id = q.id " + "WHERE q.survey_id = ? ORDER BY o.display_order" + ).bind(survey_id).all() + options_by_question = {} + for o in o_res.results or []: + options_by_question.setdefault(o.question_id, []).append(o.option_text) + + r_res = await env.DB.prepare( + "SELECT id FROM survey_responses WHERE survey_id = ?" + ).bind(survey_id).all() + response_ids = [r.id for r in (r_res.results or [])] + participant_count = len(response_ids) + + a_res = await env.DB.prepare( + "SELECT a.response_id, a.question_id, a.answer_text FROM survey_answers a " + "JOIN survey_responses r ON a.response_id = r.id WHERE r.survey_id = ?" + ).bind(survey_id).all() + answers = a_res.results or [] + + answers_by_question = {} + answered_by_response = {} + for a in answers: + answers_by_question.setdefault(a.question_id, []).append(a) + answered_by_response.setdefault(a.response_id, set()).add(a.question_id) + + required_ids = {q.id for q in questions if q.required} + fully_completed = 0 + total_answer_ratio = 0.0 + for rid in response_ids: + answered = answered_by_response.get(rid, set()) + if required_ids.issubset(answered): + fully_completed += 1 + if questions: + total_answer_ratio += len(answered) / len(questions) + + completion_rate = round((fully_completed / participant_count) * 100, 1) if participant_count else 0.0 + avg_answer_ratio = (total_answer_ratio / participant_count) if participant_count else 0.0 + engagement_score = round(completion_rate * 0.7 + avg_answer_ratio * 100 * 0.3, 1) + + question_results = [] + for q in questions: + q_answers = answers_by_question.get(q.id, []) + entry = { + "id": q.id, + "text": q.question_text, + "type": q.question_type, + "required": bool(q.required), + "response_count": len(q_answers), + } + + if q.question_type in ("multiple_choice", "checkbox"): + option_list = options_by_question.get(q.id, []) + counts = {opt: 0 for opt in option_list} + total_selections = 0 + for a in q_answers: + values = a.answer_text.split(CHECKBOX_SEP) if q.question_type == "checkbox" else [a.answer_text] + for v in values: + if v in counts: + counts[v] += 1 + total_selections += 1 + percentages = { + opt: (round(counts[opt] / total_selections * 100, 1) if total_selections else 0.0) + for opt in option_list + } + entry["options"] = [ + {"text": opt, "count": counts[opt], "percentage": percentages[opt]} + for opt in option_list + ] + + elif q.question_type == "true_false": + yes = sum(1 for a in q_answers if a.answer_text == "true") + no = sum(1 for a in q_answers if a.answer_text == "false") + entry["yes_count"] = yes + entry["no_count"] = no + + elif q.question_type == "scale": + values = [int(a.answer_text) for a in q_answers if a.answer_text.isdigit()] + distribution = {str(n): values.count(n) for n in range(1, 6)} + entry["average"] = round(sum(values) / len(values), 2) if values else 0.0 + entry["min"] = min(values) if values else 0 + entry["max"] = max(values) if values else 0 + entry["distribution"] = distribution + + elif q.question_type == "text": + entry["responses"] = [a.answer_text for a in q_answers] + + question_results.append(entry) + + return { + "survey_id": survey.id, + "title": survey.title, + "participant_count": participant_count, + "completion_rate": completion_rate, + "engagement_score": engagement_score, + "question_count": len(questions), + "questions": question_results, + }, None + + +# --------------------------------------------------------------------------- +# Delete (explicit cascade — D1 does not enforce FK pragmas by default) +# --------------------------------------------------------------------------- + +async def delete_survey(env, survey_id: str, user_id: str): + survey = await env.DB.prepare( + "SELECT user_id FROM surveys WHERE id = ?" + ).bind(survey_id).first() + if not survey: + return False, "Survey not found" + if survey.user_id != user_id: + return False, "Only the survey creator can delete this survey" + + # Run the full cascade as a single atomic batch so a mid-cascade failure + # can't leave orphaned rows behind. + statements = [ + env.DB.prepare( + "DELETE FROM survey_answers WHERE question_id IN " + "(SELECT id FROM survey_questions WHERE survey_id = ?)" + ).bind(survey_id), + env.DB.prepare( + "DELETE FROM survey_responses WHERE survey_id = ?" + ).bind(survey_id), + env.DB.prepare( + "DELETE FROM survey_options WHERE question_id IN " + "(SELECT id FROM survey_questions WHERE survey_id = ?)" + ).bind(survey_id), + env.DB.prepare( + "DELETE FROM survey_questions WHERE survey_id = ?" + ).bind(survey_id), + env.DB.prepare( + "DELETE FROM surveys WHERE id = ?" + ).bind(survey_id), + ] + await env.DB.batch(statements) + + return True, None + + +# --------------------------------------------------------------------------- +# Export CSV +# --------------------------------------------------------------------------- + +def _csv_safe(value) -> str: + """Neutralize CSV/formula injection (CWE-1236). + + Prefixes values that start with a formula-trigger character with a + single quote so spreadsheet apps (Excel/Sheets) render them as literal + text instead of executing them as a formula. + """ + s = "" if value is None else str(value) + if s.startswith(_CSV_FORMULA_PREFIXES): + return "'" + s + return s + + +async def export_csv(env, survey_id: str, viewer_id): + survey = await env.DB.prepare( + "SELECT id, title, is_public, user_id FROM surveys WHERE id = ?" + ).bind(survey_id).first() + if not survey: + return None, "Survey not found" + if not survey.is_public and not (viewer_id and survey.user_id == viewer_id): + return None, "Survey not found" + + q_res = await env.DB.prepare( + "SELECT id, question_text FROM survey_questions " + "WHERE survey_id = ? ORDER BY display_order" + ).bind(survey_id).all() + questions = q_res.results or [] + + r_res = await env.DB.prepare( + "SELECT r.id, r.user_id, r.submitted_at, u.username FROM survey_responses r " + "LEFT JOIN users u ON r.user_id = u.id WHERE r.survey_id = ? ORDER BY r.submitted_at" + ).bind(survey_id).all() + responses = r_res.results or [] + + a_res = await env.DB.prepare( + "SELECT a.response_id, a.question_id, a.answer_text FROM survey_answers a " + "JOIN survey_responses r ON a.response_id = r.id WHERE r.survey_id = ?" + ).bind(survey_id).all() + + answers_by_response = {} + for a in (a_res.results or []): + answers_by_response.setdefault(a.response_id, {})[a.question_id] = a.answer_text.replace(CHECKBOX_SEP, "; ") + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["Response ID", "Participant", "Submitted At"] + [q.question_text for q in questions]) + for r in responses: + row_answers = answers_by_response.get(r.id, {}) + writer.writerow( + [r.id, _csv_safe(r.username or "Anonymous"), r.submitted_at] + + [_csv_safe(row_answers.get(q.id, "")) for q in questions] + ) + + return buf.getvalue(), None \ No newline at end of file diff --git a/src/worker.py b/src/worker.py index f274a07..68979a9 100644 --- a/src/worker.py +++ b/src/worker.py @@ -14,6 +14,13 @@ POST /api/sessions – add a session to activity [host] GET /api/tags – list all tags POST /api/activity-tags – add tags to an activity [host] + GET /api/surveys – list surveys (?q=&limit=&offset=) + POST /api/surveys – create survey [auth] + GET /api/surveys/:id – survey detail + DELETE /api/surveys/:id – delete survey [owner] + POST /api/surveys/:id/responses – submit a response + GET /api/surveys/:id/results – survey analytics + GET /api/surveys/:id/export – export responses as CSV Security model * ALL user PII (username, email, display name, role) is encrypted with @@ -39,12 +46,29 @@ import json import os import re +import sys import traceback from types import SimpleNamespace from typing import Any, Dict, Optional from urllib.parse import urlparse, parse_qs, urlencode +# Allow importing sibling modules (surveys.py, api/surveys.py) — the +# Worker entrypoint is loaded by absolute path, so its own directory is not +# automatically on sys.path. +_SRC_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SRC_DIR not in sys.path: + sys.path.insert(0, _SRC_DIR) + from workers import Response, DurableObject +from api.surveys import ( + api_list_surveys, + api_create_survey, + api_get_survey, + api_submit_survey_response, + api_get_survey_results, + api_delete_survey, + api_export_survey, +) import js from pyodide.ffi import to_js @@ -2708,6 +2732,30 @@ async def _dispatch(request, env): if path == "/api/notification-preferences" and method == "PATCH": return await api_patch_notification_preferences(request, env) + # Surveys + if path == "/api/surveys" and method == "GET": + return await api_list_surveys(request, env) + if path == "/api/surveys" and method == "POST": + return await api_create_survey(request, env) + + m_survey_results = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)/results", path) + if m_survey_results and method == "GET": + return await api_get_survey_results(m_survey_results.group(1), request, env) + + m_survey_export = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)/export", path) + if m_survey_export and method == "GET": + return await api_export_survey(m_survey_export.group(1), request, env) + + m_survey_responses = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)/responses", path) + if m_survey_responses and method == "POST": + return await api_submit_survey_response(m_survey_responses.group(1), request, env) + + m_survey = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)", path) + if m_survey and method == "GET": + return await api_get_survey(m_survey.group(1), request, env) + if m_survey and method == "DELETE": + return await api_delete_survey(m_survey.group(1), request, env) + return err("API endpoint not found", 404) return await serve_static(path, env) diff --git a/tests/test_api_surveys.py b/tests/test_api_surveys.py new file mode 100644 index 0000000..a25e256 --- /dev/null +++ b/tests/test_api_surveys.py @@ -0,0 +1,631 @@ +""" +Tests for the survey system API handlers (src/api/surveys.py, src/surveys.py): + * api_list_surveys() + * api_create_survey() + * api_get_survey() + * api_submit_survey_response() + * api_get_survey_results() + * api_delete_survey() + * api_export_survey() +""" + +import json + +from tests.helpers import load_worker, MockRequest, MockRow, MockDB, make_env, make_stmt, json_request + +worker = load_worker() + +JWT = "test-jwt-secret" + + +def _parse(resp): + return json.loads(resp.body) + + +def _token(uid="user-1", username="alice", role="user"): + return worker.create_token(uid, username, role, JWT) + + +def _auth_header(token): + return {"Authorization": f"Bearer {token}"} + + +def _get_req(path, token=None): + headers = _auth_header(token) if token else {} + return MockRequest(method="GET", url=f"http://localhost{path}", headers=headers) + + +def _delete_req(path, token=None): + headers = _auth_header(token) if token else {} + return MockRequest(method="DELETE", url=f"http://localhost{path}", headers=headers) + + +def _basic_survey_payload(qtype="text"): + return { + "title": "Customer Feedback", + "description": "Tell us what you think", + "is_public": True, + "questions": [ + {"text": "How was your experience?", "type": qtype, "required": True, + **({"options": ["Good", "Bad"]} if qtype in ("multiple_choice", "checkbox") else {})}, + ], + } + + +# --------------------------------------------------------------------------- +# Local helper: MockDB variant that supports D1's batch() API. +# +# ``delete_survey()`` in src/surveys.py issues its cascading deletes via +# ``env.DB.batch([...])`` (a real method on Cloudflare's D1 binding) so the +# whole cascade commits atomically. The shared ``MockDB`` in tests/helpers.py +# intentionally only stubs the primitives the rest of the suite relies on +# (``prepare``/``bind``/``run``/``first``/``all``), so rather than widen that +# shared double for every test in the project, we extend it locally here, +# scoped to just the survey-deletion test that needs it. +# --------------------------------------------------------------------------- + +class BatchMockDB(MockDB): + async def batch(self, statements): + results = [] + for stmt in statements: + results.append(await stmt.run()) + return results + + +# --------------------------------------------------------------------------- +# api_create_survey() +# --------------------------------------------------------------------------- + +class TestApiCreateSurvey: + def _req(self, payload, token=None): + headers = _auth_header(token) if token else {} + return json_request("/api/surveys", payload, headers=headers) + + async def test_no_auth_returns_401(self): + env = make_env() + r = await worker.api_create_survey(self._req(_basic_survey_payload()), env) + assert r.status == 401 + + async def test_missing_title_returns_400(self): + token = _token() + body = _basic_survey_payload() + body["title"] = " " + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + assert "title" in _parse(r)["error"] + + async def test_title_too_long_returns_400(self): + token = _token() + body = _basic_survey_payload() + body["title"] = "x" * 201 + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + + async def test_no_questions_returns_400(self): + token = _token() + body = _basic_survey_payload() + body["questions"] = [] + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + assert "question" in _parse(r)["error"] + + async def test_too_many_questions_returns_400(self): + token = _token() + body = _basic_survey_payload() + body["questions"] = [{"text": f"Q{i}", "type": "text"} for i in range(51)] + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + + async def test_invalid_question_type_returns_400(self): + token = _token() + body = _basic_survey_payload() + body["questions"][0]["type"] = "essay" + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + + async def test_missing_question_text_returns_400(self): + token = _token() + body = _basic_survey_payload() + body["questions"][0]["text"] = "" + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + + async def test_multiple_choice_without_enough_options_returns_400(self): + token = _token() + body = _basic_survey_payload(qtype="multiple_choice") + body["questions"][0]["options"] = ["Only One"] + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + assert "options" in _parse(r)["error"] + + async def test_checkbox_without_options_returns_400(self): + token = _token() + body = _basic_survey_payload(qtype="checkbox") + body["questions"][0]["options"] = [] + env = make_env() + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 400 + + async def test_invalid_json_body_returns_400(self): + token = _token() + req = MockRequest(method="POST", url="http://localhost/api/surveys", + headers={**_auth_header(token), "Content-Type": "application/json"}, + body="not json") + env = make_env() + r = await worker.api_create_survey(req, env) + assert r.status == 400 + + async def test_successful_creation_text_question(self): + token = _token() + env = make_env(db=MockDB()) + r = await worker.api_create_survey(self._req(_basic_survey_payload(), token), env) + assert r.status == 200 + data = _parse(r) + assert data["success"] is True + assert data["data"]["title"] == "Customer Feedback" + + async def test_successful_creation_with_options(self): + token = _token() + env = make_env(db=MockDB()) + r = await worker.api_create_survey( + self._req(_basic_survey_payload(qtype="multiple_choice"), token), env + ) + assert r.status == 200 + + async def test_is_public_defaults_true_when_omitted(self): + token = _token() + body = _basic_survey_payload() + del body["is_public"] + env = make_env(db=MockDB()) + r = await worker.api_create_survey(self._req(body, token), env) + assert r.status == 200 + + +# --------------------------------------------------------------------------- +# api_list_surveys() +# --------------------------------------------------------------------------- + +class TestApiListSurveys: + def _row(self, sid="s1", title="Feedback", user_id="user-1", is_public=1): + return MockRow( + id=sid, title=title, description="desc", is_public=is_public, user_id=user_id, + created_at="2024-01-01T00:00:00", creator_username="alice", + question_count=3, participant_count=10, + ) + + async def test_returns_surveys_list(self): + env = make_env(db=MockDB([make_stmt(all_results=[self._row()])])) + r = await worker.api_list_surveys(_get_req("/api/surveys"), env) + assert r.status == 200 + data = _parse(r) + assert len(data["surveys"]) == 1 + assert data["surveys"][0]["title"] == "Feedback" + + async def test_empty_list(self): + env = make_env(db=MockDB([make_stmt(all_results=[])])) + r = await worker.api_list_surveys(_get_req("/api/surveys"), env) + assert _parse(r)["surveys"] == [] + + async def test_is_owner_true_for_creator(self): + token = _token(uid="user-1") + env = make_env(db=MockDB([make_stmt(all_results=[self._row(user_id="user-1")])])) + r = await worker.api_list_surveys(_get_req("/api/surveys", token), env) + assert _parse(r)["surveys"][0]["is_owner"] is True + + async def test_is_owner_false_for_anonymous(self): + env = make_env(db=MockDB([make_stmt(all_results=[self._row(user_id="user-1")])])) + r = await worker.api_list_surveys(_get_req("/api/surveys"), env) + assert _parse(r)["surveys"][0]["is_owner"] is False + + async def test_search_query_param_accepted(self): + env = make_env(db=MockDB([make_stmt(all_results=[])])) + r = await worker.api_list_surveys(_get_req("/api/surveys?q=feedback"), env) + assert r.status == 200 + + async def test_pagination_params_clamped(self): + env = make_env(db=MockDB([make_stmt(all_results=[])])) + r = await worker.api_list_surveys(_get_req("/api/surveys?limit=500&offset=-5"), env) + data = _parse(r) + assert data["limit"] == 50 + + +# --------------------------------------------------------------------------- +# api_get_survey() +# --------------------------------------------------------------------------- + +class TestApiGetSurvey: + def _survey_row(self, sid="s1", user_id="user-1", is_public=1): + return MockRow( + id=sid, title="Feedback", description="desc", is_public=is_public, + user_id=user_id, created_at="2024-01-01", updated_at="2024-01-01", + creator_username="alice", + ) + + def _question_row(self, qid="q1"): + return MockRow(id=qid, question_text="How was it?", question_type="multiple_choice", + required=1, display_order=0) + + def _option_row(self, qid="q1"): + return MockRow(id="o1", question_id=qid, option_text="Good", display_order=0) + + async def test_not_found_returns_404(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_get_survey("missing", _get_req("/api/surveys/missing"), env) + assert r.status == 404 + + async def test_public_survey_visible_to_anonymous(self): + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[self._question_row()]), + make_stmt(all_results=[self._option_row()]), + ])) + r = await worker.api_get_survey("s1", _get_req("/api/surveys/s1"), env) + assert r.status == 200 + data = _parse(r)["survey"] + assert data["title"] == "Feedback" + assert len(data["questions"]) == 1 + assert len(data["questions"][0]["options"]) == 1 + + async def test_private_survey_hidden_from_non_owner(self): + token = _token(uid="someone-else") + env = make_env(db=MockDB([make_stmt(first=self._survey_row(is_public=0))])) + r = await worker.api_get_survey("s1", _get_req("/api/surveys/s1", token), env) + assert r.status == 404 + + async def test_private_survey_visible_to_owner(self): + token = _token(uid="user-1") + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row(is_public=0)), + make_stmt(all_results=[self._question_row()]), + make_stmt(all_results=[]), + ])) + r = await worker.api_get_survey("s1", _get_req("/api/surveys/s1", token), env) + assert r.status == 200 + assert _parse(r)["survey"]["is_owner"] is True + + +# --------------------------------------------------------------------------- +# api_submit_survey_response() +# --------------------------------------------------------------------------- + +class TestApiSubmitSurveyResponse: + def _req(self, survey_id, answers, token=None): + headers = _auth_header(token) if token else {} + return json_request(f"/api/surveys/{survey_id}/responses", {"answers": answers}, headers=headers) + + def _survey_row(self, sid="s1", is_public=1, user_id="user-1"): + return MockRow(id=sid, is_public=is_public, user_id=user_id) + + def _text_question(self): + return MockRow(id="q1", question_type="text", required=True) + + def _mc_question(self): + return MockRow(id="q1", question_type="multiple_choice", required=True) + + def _option_row(self, text="Good"): + return MockRow(question_id="q1", option_text=text) + + async def test_survey_not_found_returns_404(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_submit_survey_response("missing", self._req("missing", {"q1": "hi"}), env) + assert r.status == 404 + + async def test_answers_must_be_object(self): + env = make_env() + req = json_request("/api/surveys/s1/responses", {"answers": "not-an-object"}) + r = await worker.api_submit_survey_response("s1", req, env) + assert r.status == 400 + + async def test_no_questions_returns_400(self): + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[]), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {}), env) + assert r.status == 400 + + async def test_required_question_missing_returns_400(self): + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[self._text_question()]), + make_stmt(all_results=[]), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {}), env) + assert r.status == 400 + assert "required" in _parse(r)["error"].lower() + + async def test_invalid_option_returns_400(self): + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[self._mc_question()]), + make_stmt(all_results=[self._option_row("Good")]), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Not An Option"}), env) + assert r.status == 400 + + async def test_successful_anonymous_submission(self): + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[self._text_question()]), + make_stmt(all_results=[]), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Great!"}), env) + assert r.status == 200 + assert _parse(r)["data"]["response_id"] + + async def test_successful_authenticated_submission(self): + token = _token(uid="user-9") + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[self._text_question()]), + make_stmt(all_results=[]), + make_stmt(first=None), # no existing response + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Great!"}, token), env) + assert r.status == 200 + + async def test_duplicate_submission_blocked(self): + token = _token(uid="user-9") + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[self._text_question()]), + make_stmt(all_results=[]), + make_stmt(first=MockRow(id="existing-response")), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Again"}, token), env) + assert r.status == 400 + assert "already" in _parse(r)["error"].lower() + + async def test_checkbox_multiple_selections_accepted(self): + q = MockRow(id="q1", question_type="checkbox", required=False) + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=[self._option_row("Good"), self._option_row("Bad")]), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": ["Good", "Bad"]}), env) + assert r.status == 200 + + async def test_scale_out_of_range_returns_400(self): + q = MockRow(id="q1", question_type="scale", required=True) + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=[]), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "9"}), env) + assert r.status == 400 + + async def test_true_false_invalid_value_returns_400(self): + q = MockRow(id="q1", question_type="true_false", required=True) + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=[]), + ])) + r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "maybe"}), env) + assert r.status == 400 + + +# --------------------------------------------------------------------------- +# api_get_survey_results() +# --------------------------------------------------------------------------- + +class TestApiGetSurveyResults: + def _survey_row(self, sid="s1", is_public=1, user_id="user-1"): + return MockRow(id=sid, title="Feedback", is_public=is_public, user_id=user_id) + + async def test_not_found_returns_404(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_get_survey_results("missing", _get_req("/api/surveys/missing/results"), env) + assert r.status == 404 + + async def test_private_survey_hidden_from_non_owner(self): + token = _token(uid="someone-else") + env = make_env(db=MockDB([make_stmt(first=self._survey_row(is_public=0))])) + r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results", token), env) + assert r.status == 404 + + async def test_multiple_choice_counts_and_percentages(self): + q = MockRow(id="q1", question_text="Pick", question_type="multiple_choice", + required=1, display_order=0) + opts = [MockRow(question_id="q1", option_text="A", display_order=0), + MockRow(question_id="q1", option_text="B", display_order=1)] + responses = [MockRow(id="r1"), MockRow(id="r2")] + answers = [MockRow(response_id="r1", question_id="q1", answer_text="A"), + MockRow(response_id="r2", question_id="q1", answer_text="B")] + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=opts), + make_stmt(all_results=responses), + make_stmt(all_results=answers), + ])) + r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env) + assert r.status == 200 + results = _parse(r)["results"] + assert results["participant_count"] == 2 + assert results["completion_rate"] == 100.0 + opt_a = next(o for o in results["questions"][0]["options"] if o["text"] == "A") + assert opt_a["count"] == 1 + assert opt_a["percentage"] == 50.0 + + async def test_true_false_yes_no_totals(self): + q = MockRow(id="q1", question_text="Like it?", question_type="true_false", + required=0, display_order=0) + responses = [MockRow(id="r1"), MockRow(id="r2"), MockRow(id="r3")] + answers = [ + MockRow(response_id="r1", question_id="q1", answer_text="true"), + MockRow(response_id="r2", question_id="q1", answer_text="true"), + MockRow(response_id="r3", question_id="q1", answer_text="false"), + ] + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=[]), + make_stmt(all_results=responses), + make_stmt(all_results=answers), + ])) + r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env) + q_result = _parse(r)["results"]["questions"][0] + assert q_result["yes_count"] == 2 + assert q_result["no_count"] == 1 + + async def test_scale_avg_min_max_distribution(self): + q = MockRow(id="q1", question_text="Rate us", question_type="scale", + required=0, display_order=0) + responses = [MockRow(id="r1"), MockRow(id="r2"), MockRow(id="r3")] + answers = [ + MockRow(response_id="r1", question_id="q1", answer_text="3"), + MockRow(response_id="r2", question_id="q1", answer_text="5"), + MockRow(response_id="r3", question_id="q1", answer_text="1"), + ] + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=[]), + make_stmt(all_results=responses), + make_stmt(all_results=answers), + ])) + r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env) + q_result = _parse(r)["results"]["questions"][0] + assert q_result["average"] == 3.0 + assert q_result["min"] == 1 + assert q_result["max"] == 5 + assert q_result["distribution"]["1"] == 1 + assert q_result["distribution"]["5"] == 1 + + async def test_text_responses_listed(self): + q = MockRow(id="q1", question_text="Comments?", question_type="text", + required=0, display_order=0) + responses = [MockRow(id="r1"), MockRow(id="r2")] + answers = [ + MockRow(response_id="r1", question_id="q1", answer_text="Great course!"), + MockRow(response_id="r2", question_id="q1", answer_text="Could be better"), + ] + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=[]), + make_stmt(all_results=responses), + make_stmt(all_results=answers), + ])) + r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env) + q_result = _parse(r)["results"]["questions"][0] + assert "Great course!" in q_result["responses"] + assert len(q_result["responses"]) == 2 + + async def test_no_participants_yields_zero_stats(self): + q = MockRow(id="q1", question_text="Comments?", question_type="text", + required=0, display_order=0) + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=[]), + make_stmt(all_results=[]), + make_stmt(all_results=[]), + ])) + r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env) + results = _parse(r)["results"] + assert results["participant_count"] == 0 + assert results["completion_rate"] == 0.0 + + +# --------------------------------------------------------------------------- +# api_delete_survey() +# --------------------------------------------------------------------------- + +class TestApiDeleteSurvey: + async def test_no_auth_returns_401(self): + env = make_env() + r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1"), env) + assert r.status == 401 + + async def test_not_found_returns_404(self): + token = _token(uid="user-1") + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1", token), env) + assert r.status == 404 + + async def test_non_owner_returns_403(self): + token = _token(uid="someone-else") + env = make_env(db=MockDB([make_stmt(first=MockRow(user_id="user-1"))])) + r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1", token), env) + assert r.status == 403 + + async def test_owner_can_delete(self): + token = _token(uid="user-1") + # delete_survey() runs its cascade via env.DB.batch([...]); use the + # local batch-aware MockDB subclass so that call has something to hit. + env = make_env(db=BatchMockDB([make_stmt(first=MockRow(user_id="user-1"))])) + r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1", token), env) + assert r.status == 200 + assert _parse(r)["success"] is True + + +# --------------------------------------------------------------------------- +# api_export_survey() +# --------------------------------------------------------------------------- + +class TestApiExportSurvey: + def _survey_row(self, is_public=1, user_id="user-1"): + return MockRow(id="s1", title="Feedback", is_public=is_public, user_id=user_id) + + async def test_not_found_returns_404(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_export_survey("missing", _get_req("/api/surveys/missing/export"), env) + assert r.status == 404 + + async def test_private_survey_hidden_from_non_owner(self): + token = _token(uid="someone-else") + env = make_env(db=MockDB([make_stmt(first=self._survey_row(is_public=0))])) + r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export", token), env) + assert r.status == 404 + + async def test_returns_csv_content_type(self): + q = MockRow(id="q1", question_text="Comments?") + responses = [MockRow(id="r1", user_id="user-9", submitted_at="2024-01-01", username="bob")] + answers = [MockRow(response_id="r1", question_id="q1", answer_text="Nice!")] + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=responses), + make_stmt(all_results=answers), + ])) + r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export"), env) + assert r.status == 200 + assert r.headers["Content-Type"] == "text/csv" + + async def test_csv_contains_header_and_rows(self): + q = MockRow(id="q1", question_text="Comments?") + responses = [MockRow(id="r1", user_id="user-9", submitted_at="2024-01-01", username="bob")] + answers = [MockRow(response_id="r1", question_id="q1", answer_text="Nice!")] + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=responses), + make_stmt(all_results=answers), + ])) + r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export"), env) + assert "Comments?" in r.body + assert "bob" in r.body + assert "Nice!" in r.body + + async def test_anonymous_response_labeled_in_csv(self): + q = MockRow(id="q1", question_text="Comments?") + responses = [MockRow(id="r1", user_id=None, submitted_at="2024-01-01", username=None)] + env = make_env(db=MockDB([ + make_stmt(first=self._survey_row()), + make_stmt(all_results=[q]), + make_stmt(all_results=responses), + make_stmt(all_results=[]), + ])) + r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export"), env) + assert "Anonymous" in r.body \ No newline at end of file