forked from jamesp/sasa-membership
632e66e21d
- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
- Add admin UI for managing profile questions and member profile answers
- Add volunteer level/profile data support across backend schemas, models, API, and migration
- Update dashboard/profile UI, super admin menu, membership service types, and related styling
- Add privacy policy, terms of service, cookie notice, and footer links
- Add frontend Vitest coverage for profile question logic
- Add backend pytest coverage for profile answer normalization and validation
- Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
- Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
- Protect feature flag reload behind super-admin access
- Restrict admin-triggered password resets so admins can only reset member accounts
- Replace email template HTML preview rendering with escaped text preview
- Update docs for feature flag reload access, password reset scope, and email template preview safety
-- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
114 lines
3.5 KiB
Python
114 lines
3.5 KiB
Python
from datetime import date, datetime
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.v1.users import (
|
|
_deserialize_answer_value,
|
|
_normalize_answer_value,
|
|
_normalize_volunteer_level,
|
|
_parse_options,
|
|
_serialize_options,
|
|
)
|
|
from app.models.models import ProfileQuestion
|
|
from app.schemas import QuestionOption
|
|
|
|
|
|
def make_question(input_type: str, options_json: str | None = None) -> ProfileQuestion:
|
|
return ProfileQuestion(
|
|
key=f"{input_type}_question",
|
|
label=f"{input_type.title()} Question",
|
|
input_type=input_type,
|
|
options_json=options_json,
|
|
)
|
|
|
|
|
|
def test_option_parsing_and_serialization_filters_invalid_items() -> None:
|
|
assert _parse_options('[{"label":" Yes ","value":" yes "}, {"label":"","value":"no"}, "bad"]') == [
|
|
{"label": "Yes", "value": "yes"}
|
|
]
|
|
assert _parse_options("not-json") == []
|
|
|
|
serialized = _serialize_options([QuestionOption(label="Private Pilot", value="ppl")])
|
|
|
|
assert _parse_options(serialized) == [{"label": "Private Pilot", "value": "ppl"}]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
(True, "true"),
|
|
("yes", "true"),
|
|
("0", "false"),
|
|
(False, "false"),
|
|
],
|
|
)
|
|
def test_boolean_answers_are_normalized(value: object, expected: str) -> None:
|
|
assert _normalize_answer_value(make_question("boolean"), value) == expected
|
|
|
|
|
|
def test_invalid_boolean_answer_raises_400() -> None:
|
|
with pytest.raises(HTTPException) as exc:
|
|
_normalize_answer_value(make_question("boolean"), "maybe")
|
|
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
(3, "3"),
|
|
("3.50", "3.5"),
|
|
(date(2026, 5, 4), "2026-05-04"),
|
|
(datetime(2026, 5, 4, 12, 30), "2026-05-04"),
|
|
],
|
|
)
|
|
def test_number_and_date_answers_are_normalized(value: object, expected: str) -> None:
|
|
input_type = "date" if isinstance(value, (date, datetime)) else "number"
|
|
|
|
assert _normalize_answer_value(make_question(input_type), value) == expected
|
|
|
|
|
|
def test_select_answers_must_match_configured_options() -> None:
|
|
question = make_question("select", '[{"label":"Private Pilot","value":"ppl"}]')
|
|
|
|
assert _normalize_answer_value(question, "ppl") == "ppl"
|
|
with pytest.raises(HTTPException) as exc:
|
|
_normalize_answer_value(question, "cpl")
|
|
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def test_empty_answers_clear_existing_values() -> None:
|
|
assert _normalize_answer_value(make_question("text"), "") is None
|
|
assert _normalize_answer_value(make_question("text"), None) is None
|
|
|
|
|
|
def test_answer_deserialization_restores_frontend_types() -> None:
|
|
assert _deserialize_answer_value(make_question("boolean"), "true") is True
|
|
assert _deserialize_answer_value(make_question("boolean"), "false") is False
|
|
assert _deserialize_answer_value(make_question("number"), "10") == 10
|
|
assert _deserialize_answer_value(make_question("number"), "10.5") == 10.5
|
|
assert _deserialize_answer_value(make_question("text"), "SASA") == "SASA"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
("yes", "yes"),
|
|
("true", "yes"),
|
|
("0", "no"),
|
|
("", None),
|
|
(None, None),
|
|
],
|
|
)
|
|
def test_volunteer_level_accepts_boolean_like_values(value: str | None, expected: str | None) -> None:
|
|
assert _normalize_volunteer_level(value) == expected
|
|
|
|
|
|
def test_invalid_volunteer_level_raises_400() -> None:
|
|
with pytest.raises(HTTPException) as exc:
|
|
_normalize_volunteer_level("sometimes")
|
|
|
|
assert exc.value.status_code == 400
|