From b56c08c195f6efb7a072c8ab82df7abb8f5d8077 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 13 Apr 2026 22:36:10 +0200 Subject: [PATCH 1/4] feat: add simple tests to codebase --- .circleci/config.yml | 2 +- discord/tests.py | 0 docs/testing.md | 226 ++++++++++++++++++++ hardware/tests.py | 3 - meals/tests.py | 0 pytest.ini | 7 + requirements.txt | 6 + setup.cfg | 6 + tests/__init__.py | 1 + tests/conftest.py | 83 +++++++ tests/factories.py | 150 +++++++++++++ baggage/tests.py => tests/flows/__init__.py | 0 tests/flows/test_hacker.py | 105 +++++++++ tests/flows/test_mentor.py | 72 +++++++ tests/flows/test_sponsor.py | 50 +++++ tests/flows/test_volunteer.py | 70 ++++++ 16 files changed, 777 insertions(+), 4 deletions(-) delete mode 100644 discord/tests.py create mode 100644 docs/testing.md delete mode 100644 hardware/tests.py delete mode 100644 meals/tests.py create mode 100644 pytest.ini create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/factories.py rename baggage/tests.py => tests/flows/__init__.py (100%) create mode 100644 tests/flows/test_hacker.py create mode 100644 tests/flows/test_mentor.py create mode 100644 tests/flows/test_sponsor.py create mode 100644 tests/flows/test_volunteer.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 005a77356..0000a180f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,7 @@ jobs: name: Running tests command: | . env/bin/activate - python manage.py test + pytest --cov - run: name: Linting code command: | diff --git a/discord/tests.py b/discord/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..470dd9cf8 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,226 @@ +# Testing + +This project uses [pytest](https://pytest.org) with [pytest-django](https://pytest-django.readthedocs.io) and [factory-boy](https://factoryboy.readthedocs.io) for automated testing. Tests live in `tests/` and cover the four main application flows: hacker, volunteer, mentor, and sponsor. + +--- + +## Running the tests + +```bash +# Run all tests +pytest + +# With coverage report +pytest --cov + +# Run a single file +pytest tests/flows/test_hacker.py + +# Run a single test +pytest tests/flows/test_hacker.py::test_hacker_can_submit_application -v +``` + +Coverage is configured in `setup.cfg`. The report will fail if coverage across `applications`, `organizers`, and `user` drops below 60%. + +--- + +## Structure + +``` +tests/ +├── conftest.py # Shared fixtures (users, authenticated clients) +├── factories.py # factory-boy factories for creating test data +└── flows/ + ├── test_hacker.py # 8 tests covering the hacker application flow + ├── test_volunteer.py # 5 tests covering the volunteer application flow + ├── test_mentor.py # 5 tests covering the mentor application flow + └── test_sponsor.py # 3 tests covering the sponsor application flow +``` + +--- + +## How it works + +### Fixtures (`conftest.py`) + +`conftest.py` defines shared pytest fixtures available to every test file. + +`**use_locmem_email_backend` (autouse)** — runs automatically for every test. It overrides two Django settings that would otherwise break tests: + +- `EMAIL_BACKEND`: swaps SendGrid for Django's in-memory backend so views that send confirmation emails don't fail. +- `STATICFILES_STORAGE`: swaps whitenoise's manifest storage (which requires `collectstatic` to have been run) for a simple one that works without it. + +**User fixtures** — each creates a database user of the right type: + +```python +hacker_user # type=USR_HACKER +organizer_user # type=USR_ORGANIZER +volunteer_user # type=USR_VOLUNTEER +mentor_user # type=USR_MENTOR +sponsor_user # type=USR_SPONSOR +director_user # type=USR_ORGANIZER + is_director=True +``` + +**Client fixtures** — each returns `(client, user)` where the client is already logged in as that user: + +```python +hacker_client, organizer_client, volunteer_client, +mentor_client, sponsor_client, director_client +``` + +Use the tuple unpacking pattern in tests: + +```python +def test_something(hacker_client): + client, user = hacker_client + response = client.get(reverse("dashboard")) +``` + +### Factories (`factories.py`) + +Factories create realistic model instances without hitting external services. They use `factory.Sequence` for unique fields and `factory.Faker` for realistic fake data. + +**Important:** `UserFactory._create()` calls `user.set_password()` before saving. This is required because view mixins (`IsHackerMixin`, `DashboardMixin`, etc.) call `has_usable_password()` and redirect to the password-change page if it returns `False`. Django's default `create()` does not call `set_password()`, so the override is necessary. + + +| Factory | Model | Default status | +| ----------------------------- | ---------------------- | ------------------------------------ | +| `UserFactory` | `User` | — | +| `OrganizerUserFactory` | `User` | type=USR_ORGANIZER | +| `DirectorUserFactory` | `User` | type=USR_ORGANIZER, is_director=True | +| `HackerApplicationFactory` | `HackerApplication` | APP_PENDING | +| `VolunteerApplicationFactory` | `VolunteerApplication` | APP_PENDING | +| `MentorApplicationFactory` | `MentorApplication` | APP_PENDING | +| `SponsorApplicationFactory` | `SponsorApplication` | APP_CONFIRMED | + + +Override any field when creating an instance: + +```python +app = HackerApplicationFactory(user=user, status=APP_INVITED) +``` + +### Tests (`flows/`) + +Each test file covers one applicant type. Tests use `@pytest.mark.django_db` to get database access per test. The pattern is: + +1. Set up data (via fixtures or factories) +2. Make an HTTP request via `client.get()` or `client.post()` +3. Assert the response status code and the resulting database state + +--- + +## Key points to know + +### `origin` must match `cities.json` + +The `origin` field on application forms is validated against a list of cities. It must be in the format `"City, Province, Country"`: + +```python +"origin": "Barcelona, Barcelona, Spain" # correct +"origin": "Barcelona" # fails validation +``` + +### Cancel requires `APP_INVITED`, not `APP_PENDING` + +`BaseApplication.can_be_cancelled()` only returns `True` for `APP_INVITED`, `APP_CONFIRMED`, and `APP_LAST_REMINDER`. Testing cancellation with a PENDING application will fail silently (the view will redirect but the status won't change): + +```python +app = HackerApplicationFactory(user=user, status=APP_INVITED) # correct +app = HackerApplicationFactory(user=user, status=APP_PENDING) # can't be cancelled +``` + +### `ConfirmApplication` is GET-only + +The confirm view (`/application//confirm/`) uses `client.get()`, not `client.post()`. Confirming a PENDING application raises a `ValidationError` inside the model, which the view catches and converts to a 404. + +### Organizer vote uses integer PK, not UUID + +`ReviewApplicationView.post()` looks up the application with `HackerApplication.objects.get(pk=request.POST.get("app_id"))`. Pass the integer primary key as a string: + +```python +data={"app_id": str(app.pk), ...} # correct +data={"app_id": str(app.uuid), ...} # wrong — lookup will fail +``` + +### Mentor and sponsor lists require `is_director=True` + +`HaveMentorPermissionMixin` and `HaveSponsorPermissionMixin` require either a specific permission or `is_director=True`. A plain `OrganizerUserFactory` user will get a 302 redirect. Use `director_client`: + +```python +def test_organizer_can_view_mentor_list(director_client): # correct +def test_organizer_can_view_mentor_list(organizer_client): # 302, not 200 +``` + +### Sponsor submission uses a token URL, not the dashboard + +Sponsors apply via a unique invite URL (`/sponsor///`), not by logging in. The token comes from the `user.models.Token` model (not Django's password reset). Test it by constructing the URL directly: + +```python +token_obj = Token.objects.create(user=sponsor_user) +uid = urlsafe_base64_encode(force_bytes(sponsor_user.pk)) +url = f"/sponsor/{uid}/{token_obj.uuid_str()}/" +client.post(url, data=VALID_SPONSOR_FORM) +``` + +The view renders `sponsor_submitted.html` on success (status 200), not a redirect. + +--- + +## Adding a new test + +### Adding a test to an existing file + +Open the relevant file in `tests/flows/` and add a function: + +```python +@pytest.mark.django_db +def test_hacker_cannot_edit_after_review(hacker_client): + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("application")) + # invited hackers should not see the edit form + assert response.status_code == 302 +``` + +Use `@pytest.mark.django_db` on every test that touches the database. Use the fixtures from `conftest.py` as parameters — pytest injects them automatically. + +### Adding a test for a new applicant type + +1. Add a `UserFactory` subclass in `tests/factories.py` with the correct `type` value. +2. Add an `ApplicationFactory` subclass with all required fields (run the form in a browser or read the model to find required fields). +3. Add user and client fixtures to `tests/conftest.py` following the existing pattern. +4. Create `tests/flows/test_.py` and write your tests. + +### Adding a factory for a new model + +```python +class MyModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = MyModel + + # Use factory.Sequence for fields that must be unique + name = factory.Sequence(lambda n: f"Name {n}") + + # Use factory.Faker for realistic fake data + description = factory.Faker("text", max_nb_chars=200) + + # Use factory.SubFactory to link related models + user = factory.SubFactory(UserFactory) + + # Hard-code constants where variation isn't needed + status = APP_PENDING +``` + +--- + +## CI + +Tests run automatically on CircleCI on every push. The CI config is at `.circleci/config.yml`. It runs: + +```bash +pytest --cov # runs tests and generates coverage +flake8 # lints the codebase +``` + +Both must pass for a build to go green. \ No newline at end of file diff --git a/hardware/tests.py b/hardware/tests.py deleted file mode 100644 index 2cef6ba97..000000000 --- a/hardware/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# TODO -# from django.test import TestCase -# Create your tests here. diff --git a/meals/tests.py b/meals/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..88e7640ea --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +DJANGO_SETTINGS_MODULE = app.settings +testpaths = tests +python_files = test_*.py +filterwarnings = + ignore::django.utils.deprecation.RemovedInDjango40Warning + ignore:Use '__' to separate path components:DeprecationWarning diff --git a/requirements.txt b/requirements.txt index 43d673b1b..d946bec2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,3 +58,9 @@ whitenoise==5.3.0 xlrd==1.2.0 xlwt==1.3.0 slack-sdk==3.15.2 +pytest==7.4.3 +pytest-django==4.7.0 +factory-boy==3.3.0 +faker==20.1.0 +coverage==7.3.2 +pytest-cov==4.1.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..000f3e702 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[coverage:run] +source = applications,organizers,user +omit = */migrations/*, */tests/* + +[coverage:report] +fail_under = 60 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e878bfc80 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Don't delete this file, pytest needs it to find the source of tests hehe \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..1aad7e3ec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,83 @@ +import pytest + +from tests.factories import ( + DirectorUserFactory, + MentorUserFactory, + OrganizerUserFactory, + SponsorUserFactory, + UserFactory, + VolunteerUserFactory, +) + + +@pytest.fixture(autouse=True) +def use_locmem_email_backend(settings): + """Override email backend so confirm views don't attempt to hit SendGrid.""" + settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + settings.STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + + +@pytest.fixture +def hacker_user(db): + return UserFactory() + + +@pytest.fixture +def organizer_user(db): + return OrganizerUserFactory() + + +@pytest.fixture +def volunteer_user(db): + return VolunteerUserFactory() + + +@pytest.fixture +def mentor_user(db): + return MentorUserFactory() + + +@pytest.fixture +def sponsor_user(db): + return SponsorUserFactory() + + +@pytest.fixture +def hacker_client(client, hacker_user): + client.force_login(hacker_user) + return client, hacker_user + + +@pytest.fixture +def organizer_client(client, organizer_user): + client.force_login(organizer_user) + return client, organizer_user + + +@pytest.fixture +def volunteer_client(client, volunteer_user): + client.force_login(volunteer_user) + return client, volunteer_user + + +@pytest.fixture +def mentor_client(client, mentor_user): + client.force_login(mentor_user) + return client, mentor_user + + +@pytest.fixture +def sponsor_client(client, sponsor_user): + client.force_login(sponsor_user) + return client, sponsor_user + + +@pytest.fixture +def director_user(db): + return DirectorUserFactory() + + +@pytest.fixture +def director_client(client, director_user): + client.force_login(director_user) + return client, director_user diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 000000000..0249621b6 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,150 @@ +import factory +from django.contrib.auth import get_user_model + +from applications.models import APP_CONFIRMED, APP_PENDING +from applications.models.hacker import HackerApplication +from applications.models.mentor import MentorApplication +from applications.models.sponsor import SponsorApplication +from applications.models.volunteer import VolunteerApplication +from user.models import ( + USR_HACKER, + USR_MENTOR, + USR_ORGANIZER, + USR_SPONSOR, + USR_VOLUNTEER, +) + +User = get_user_model() + + +class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = User + + email = factory.Sequence(lambda n: f"hacker{n}@example.com") + name = factory.Faker("name") + type = USR_HACKER + email_verified = True + is_active = True + + @classmethod + def _create(cls, model_class, *args, **kwargs): + # set_password() is required — views check has_usable_password() + user = model_class(*args, **kwargs) + user.set_password("testpass123") + user.save() + return user + + +class OrganizerUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"organizer{n}@example.com") + type = USR_ORGANIZER + + +class DirectorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"director{n}@example.com") + type = USR_ORGANIZER + is_director = True + + +class VolunteerUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"volunteer{n}@example.com") + type = USR_VOLUNTEER + + +class MentorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"mentor{n}@example.com") + type = USR_MENTOR + + +class SponsorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"sponsor{n}@example.com") + type = USR_SPONSOR + + +class HackerApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = HackerApplication + + user = factory.SubFactory(UserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + description = factory.Faker("text", max_nb_chars=200) + university = factory.Faker("company") + degree = "Computer Science" + kind_studies = "BACHELOR" + graduation_year = 2026 + tshirt_size = "M" + diet = "None" + phone_number = "+34600000000" + gender = "NA" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + online = False + + +class VolunteerApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = VolunteerApplication + + user = factory.SubFactory(VolunteerUserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + gender = "NA" + tshirt_size = "M" + diet = "None" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + studies_and_course = "Computer Science" + quality = "Teamwork" + weakness = "Perfectionism" + cool_skill = "Python" + volunteer_motivation = "I want to help hackers." + attendance = "1" + languages = "English" + night_shifts = "No" + first_time_volunteer = True + hear_about_us = "Posters" + + +class MentorApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = MentorApplication + + user = factory.SubFactory(MentorUserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + gender = "NA" + tshirt_size = "M" + diet = "None" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + english_level = 3 + attendance = "1" + online = False + fluent = "Python, JavaScript" + experience = "5 years of software development" + why_mentor = "I want to share my knowledge with students." + participated = "HackUPC 2023" + study_work = True + degree = "Computer Science" + graduation_year = 2026 + first_time_mentor = True + + +class SponsorApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = SponsorApplication + + user = factory.SubFactory(SponsorUserFactory) + status = APP_CONFIRMED # sponsors default to CONFIRMED, not PENDING + name = factory.Sequence(lambda n: f"Sponsor Corp {n}") + email = factory.Faker("email") + phone_number = "+34600000000" + tshirt_size = "M" + diet = "None" + position = "Engineer" + attendance = "1" diff --git a/baggage/tests.py b/tests/flows/__init__.py similarity index 100% rename from baggage/tests.py rename to tests/flows/__init__.py diff --git a/tests/flows/test_hacker.py b/tests/flows/test_hacker.py new file mode 100644 index 000000000..987ed21d4 --- /dev/null +++ b/tests/flows/test_hacker.py @@ -0,0 +1,105 @@ +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.hacker import HackerApplication +from organizers.models import Vote +from tests.factories import HackerApplicationFactory + +VALID_HACKER_FORM = { + "phone_number": "+34600000000", + "kind_studies": "BACHELOR", + "under_age": "False", + "terms_and_conditions": True, + "diet": "None", + "tshirt_size": "M", + "origin": "Barcelona, Barcelona, Spain", + "description": "I want to build things at a hackathon.", + "graduation_year": "2026", + "gender": "NA", + "first_timer": True, + "lennyface": "( ͡° ͜ʖ ͡°)", + "online": False, + "university": "Universitat Politècnica de Catalunya", + "degree": "Computer Science", + "discover": "3", +} + + +@pytest.mark.django_db +def test_unauthenticated_redirected_from_dashboard(client): + response = client.get(reverse("dashboard")) + assert response.status_code == 302 + assert "/user/login/" in response["Location"] + + +@pytest.mark.django_db +def test_hacker_can_view_dashboard(hacker_client): + client, user = hacker_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_hacker_can_submit_application(hacker_client): + client, user = hacker_client + resume = SimpleUploadedFile("cv.pdf", b"pdf content", content_type="application/pdf") + data = {**VALID_HACKER_FORM, "resume": resume} + response = client.post(reverse("dashboard"), data=data) + assert response.status_code == 302 + assert HackerApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_hacker_cannot_submit_duplicate(hacker_client): + client, user = hacker_client + HackerApplicationFactory(user=user) + resume = SimpleUploadedFile("cv.pdf", b"pdf content", content_type="application/pdf") + data = {**VALID_HACKER_FORM, "resume": resume} + client.post(reverse("dashboard"), data=data) + # OneToOneField constraint means there is always exactly one application per user + assert HackerApplication.objects.filter(user=user).count() == 1 + + +@pytest.mark.django_db +def test_hacker_can_cancel_invited(hacker_client): + # APP_PENDING cannot be cancelled — can_be_cancelled() requires INVITED/CONFIRMED/LAST_REMINDER + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_hacker_can_confirm(hacker_client): + # ConfirmApplication is GET-only + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_pending_hacker_cannot_confirm(hacker_client): + # confirm() raises ValidationError for PENDING status → view raises Http404 + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_PENDING) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_organizer_can_vote_on_application(organizer_client, db): + client, organizer = organizer_client + app = HackerApplicationFactory() + response = client.post( + reverse("review_detail", kwargs={"id": app.uuid_str}), + data={"app_id": str(app.pk), "tech_rat": "3", "pers_rat": "4"}, + ) + assert response.status_code == 302 + assert Vote.objects.filter(application=app, user=organizer).count() == 1 diff --git a/tests/flows/test_mentor.py b/tests/flows/test_mentor.py new file mode 100644 index 000000000..34b246bdb --- /dev/null +++ b/tests/flows/test_mentor.py @@ -0,0 +1,72 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.mentor import MentorApplication +from tests.factories import MentorApplicationFactory + +VALID_MENTOR_FORM = { + "gender": "NA", + "under_age": "False", + "study_work": "True", + "english_level": "3", + "attendance": ["1"], + "tshirt_size": "M", + "diet": "None", + "origin": "Barcelona, Barcelona, Spain", + "linkedin": "https://www.linkedin.com/in/testmentor", + "fluent": "Python, JavaScript", + "experience": "5 years of software development.", + "why_mentor": "I want to share my knowledge with students.", + "participated": "HackUPC 2023", + "terms_and_conditions": True, + "degree": "Computer Science", + "graduation_year": "2026", + "first_timer": True, + "lennyface": "( ͡° ͜ʖ ͡°)", + "online": False, +} + + +@pytest.mark.django_db +def test_mentor_can_view_dashboard(mentor_client): + client, user = mentor_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_mentor_can_submit_application(mentor_client): + client, user = mentor_client + response = client.post(reverse("dashboard"), data=VALID_MENTOR_FORM) + if response.status_code != 302: + print(response.context['form'].errors) + assert response.status_code == 302 + assert MentorApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_mentor_can_cancel_invited(mentor_client): + client, user = mentor_client + app = MentorApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_mentor_can_confirm(mentor_client): + client, user = mentor_client + app = MentorApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_organizer_can_view_mentor_list(director_client): + client, _ = director_client + response = client.get(reverse("mentor_list")) + assert response.status_code == 200 diff --git a/tests/flows/test_sponsor.py b/tests/flows/test_sponsor.py new file mode 100644 index 000000000..6f3c8c396 --- /dev/null +++ b/tests/flows/test_sponsor.py @@ -0,0 +1,50 @@ +import pytest +from django.test import Client +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from applications.models.sponsor import SponsorApplication +from user.models import Token +from tests.factories import SponsorUserFactory + +VALID_SPONSOR_FORM = { + "name": "Jane Doe", + "email": "jane.doe@techcorp.com", + "attendance": ["1"], + "diet": "None", + "tshirt_size": "M", + "phone_number": "+34600000000", + "position": "Software Engineer", + "terms_and_conditions": True, +} + + +@pytest.mark.django_db +def test_sponsor_can_view_dashboard(sponsor_client): + client, user = sponsor_client + response = client.get(reverse("sponsor_dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_sponsor_can_submit_application(db): + sponsor_user = SponsorUserFactory() + token_obj = Token.objects.create(user=sponsor_user) + uid = urlsafe_base64_encode(force_bytes(sponsor_user.pk)) + token = token_obj.uuid_str() + url = f"/sponsor/{uid}/{token}/" + client = Client() + response = client.post(url, data=VALID_SPONSOR_FORM) + if response.status_code != 200: + print(response.context['form'].errors) + # View renders sponsor_submitted.html on success (200, not 302) + assert response.status_code == 200 + assert SponsorApplication.objects.count() == 1 + + +@pytest.mark.django_db +def test_organizer_can_view_sponsor_list(director_client): + client, _ = director_client + response = client.get(reverse("sponsor_list")) + assert response.status_code == 200 diff --git a/tests/flows/test_volunteer.py b/tests/flows/test_volunteer.py new file mode 100644 index 000000000..864926df7 --- /dev/null +++ b/tests/flows/test_volunteer.py @@ -0,0 +1,70 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.volunteer import VolunteerApplication +from tests.factories import VolunteerApplicationFactory + +VALID_VOLUNTEER_FORM = { + "gender": "NA", + "under_age": "False", + "studies_and_course": "Computer Science", + "night_shifts": "No", + "first_time_volunteer": "True", + "diet": "None", + "tshirt_size": "M", + "origin": "Barcelona, Barcelona, Spain", + "hear_about_us": "Posters", + "terms_and_conditions": True, + "attendance": ["1"], + "languages": ["English"], + "quality": "Team player", + "weakness": "Perfectionist", + "cool_skill": "Python", + "volunteer_motivation": "I want to help hackers succeed.", + "graduation_year": "2026", +} + + +@pytest.mark.django_db +def test_volunteer_can_view_dashboard(volunteer_client): + client, user = volunteer_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_volunteer_can_submit_application(volunteer_client): + client, user = volunteer_client + response = client.post(reverse("dashboard"), data=VALID_VOLUNTEER_FORM) + if response.status_code != 302: + print(response.context['form'].errors) + assert response.status_code == 302 + assert VolunteerApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_volunteer_can_cancel_invited(volunteer_client): + client, user = volunteer_client + app = VolunteerApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_volunteer_can_confirm(volunteer_client): + client, user = volunteer_client + app = VolunteerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_organizer_can_view_volunteer_list(organizer_client): + client, _ = organizer_client + response = client.get(reverse("volunteer_list")) + assert response.status_code == 200 From 30cf286b73dacfe115555e3bf1fe4aa1078fb453 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 10:49:34 +0200 Subject: [PATCH 2/4] test: cover auth flows to satisfy coverage gate The 60% coverage gate was already failing on the adding-tests base branch (55.56%). Adds flow tests for signup, login, logout, password reset, email activation, and verification views, lifting total coverage to 60.77%. Co-Authored-By: Claude Fable 5 --- tests/flows/test_auth.py | 175 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/flows/test_auth.py diff --git a/tests/flows/test_auth.py b/tests/flows/test_auth.py new file mode 100644 index 000000000..7bbc49c40 --- /dev/null +++ b/tests/flows/test_auth.py @@ -0,0 +1,175 @@ +import pytest +from django.contrib.auth import get_user_model +from django.core import mail +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from tests.factories import UserFactory +from user.tokens import account_activation_token, password_reset_token + +User = get_user_model() + +VALID_SIGNUP_FORM = { + "name": "Gerard Madrid", + "email": "newuser@example.com", + "password": "S3curePass!x", + "password2": "S3curePass!x", + "terms_and_conditions": True, +} + + +@pytest.mark.django_db +def test_signup_creates_user_and_logs_in(client): + response = client.post(reverse("account_signup"), data=VALID_SIGNUP_FORM) + + assert response.status_code == 302 + assert User.objects.filter(email="newuser@example.com").count() == 1 + assert response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_signup_rejects_duplicate_email(client): + UserFactory(email="newuser@example.com") + + response = client.post(reverse("account_signup"), data=VALID_SIGNUP_FORM) + + assert response.status_code == 200 + assert User.objects.filter(email="newuser@example.com").count() == 1 + + +@pytest.mark.django_db +def test_signup_rejects_mismatched_passwords(client): + response = client.post(reverse("account_signup"), data={**VALID_SIGNUP_FORM, "password2": "Different1!"}) + + assert response.status_code == 200 + assert User.objects.filter(email="newuser@example.com").count() == 0 + + +@pytest.mark.django_db +def test_login_with_valid_credentials(client): + UserFactory(email="hacker@example.com") + + response = client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "testpass123"}) + + assert response.status_code == 302 + assert response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_login_with_wrong_password_shows_error(client): + UserFactory(email="hacker@example.com") + + response = client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "wrongpass1!"}) + + assert response.status_code == 200 + assert b"Incorrect username or password" in response.content + + +@pytest.mark.django_db +def test_login_succeeds_after_failed_attempt(client): + UserFactory(email="hacker@example.com") + client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "wrongpass1!"}) + + response = client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "testpass123"}) + + assert response.status_code == 302 + assert response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_logout_deauthenticates(hacker_client): + client, user = hacker_client + + response = client.get(reverse("account_logout")) + + assert response.status_code == 302 + assert not response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_password_reset_sends_email(client): + UserFactory(email="hacker@example.com") + + response = client.post(reverse("password_reset"), data={"email": "hacker@example.com"}) + + assert response.status_code == 302 + assert len(mail.outbox) == 1 + + +@pytest.mark.django_db +def test_password_reset_rejects_unknown_email(client): + response = client.post(reverse("password_reset"), data={"email": "nobody@example.com"}) + + assert response.status_code == 200 + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +def test_password_reset_confirm_sets_new_password(client): + user = UserFactory(email="hacker@example.com") + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = password_reset_token.make_token(user) + + response = client.post( + reverse("password_reset_confirm", kwargs={"uid": uid, "token": token}), + data={"new_password1": "Fr3shPass!x", "new_password2": "Fr3shPass!x"}, + ) + + user.refresh_from_db() + assert response.status_code == 302 + assert user.check_password("Fr3shPass!x") + + +@pytest.mark.django_db +def test_password_reset_confirm_rejects_invalid_token(client): + user = UserFactory(email="hacker@example.com") + uid = urlsafe_base64_encode(force_bytes(user.pk)) + + response = client.get(reverse("password_reset_confirm", kwargs={"uid": uid, "token": "123-abc"})) + + assert response.status_code == 200 + assert response.context["validlink"] is False + + +@pytest.mark.django_db +def test_activate_verifies_email(client): + user = UserFactory(email="hacker@example.com", email_verified=False) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = account_activation_token.make_token(user) + + response = client.get(reverse("activate", kwargs={"uid": uid, "token": token})) + + user.refresh_from_db() + assert response.status_code == 302 + assert user.email_verified + + +@pytest.mark.django_db +def test_activate_with_unknown_user_redirects(client): + uid = urlsafe_base64_encode(force_bytes(99999)) + + response = client.get(reverse("activate", kwargs={"uid": uid, "token": "123-abc"})) + + assert response.status_code == 302 + + +@pytest.mark.django_db +def test_send_email_verification_for_unverified_user(client): + user = UserFactory(email="hacker@example.com", email_verified=False) + client.force_login(user) + mail.outbox.clear() + + response = client.get(reverse("send_email_verification")) + + assert response.status_code == 302 + assert len(mail.outbox) == 1 + + +@pytest.mark.django_db +def test_verify_email_required_redirects_verified_user(hacker_client): + client, user = hacker_client + + response = client.get(reverse("verify_email_required")) + + assert response.status_code == 302 From bd7a6d6edb510e1b1dd6f79f6d85751ddfd8bc5d Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 11:01:13 +0200 Subject: [PATCH 3/4] lint: add missing newline at end of tests/__init__.py Co-Authored-By: Claude Fable 5 --- tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index e878bfc80..613fe8b63 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# Don't delete this file, pytest needs it to find the source of tests hehe \ No newline at end of file +# Don't delete this file, pytest needs it to find the source of tests hehe From b51c0d638328ffd42d39bccd6d3d0e0bbe2cf0e8 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 11:12:23 +0200 Subject: [PATCH 4/4] test: cover organizer review and list flows Raises coverage from 60.6% to 68.3%: review voting (show next pending, skip, comment, mark dubious), director actions (invite, confirm, waitlist, batch invite, waitlist-all), all organizer list views with permission checks, and user profile. Co-Authored-By: Claude Fable 5 --- tests/flows/test_organizer_lists.py | 174 +++++++++++++++++++++++++++ tests/flows/test_organizer_review.py | 164 +++++++++++++++++++++++++ tests/flows/test_profile.py | 22 ++++ 3 files changed, 360 insertions(+) create mode 100644 tests/flows/test_organizer_lists.py create mode 100644 tests/flows/test_organizer_review.py create mode 100644 tests/flows/test_profile.py diff --git a/tests/flows/test_organizer_lists.py b/tests/flows/test_organizer_lists.py new file mode 100644 index 000000000..05384e917 --- /dev/null +++ b/tests/flows/test_organizer_lists.py @@ -0,0 +1,174 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_BLACKLISTED, APP_DUBIOUS, APP_INVITED, APP_PENDING, APP_REJECTED +from applications.models.hacker import HackerApplication +from tests.factories import ( + HackerApplicationFactory, + MentorApplicationFactory, + SponsorApplicationFactory, + VolunteerApplicationFactory, +) + + +@pytest.fixture +def director_client(client, director_user): + client.force_login(director_user) + return client, director_user + + +@pytest.mark.django_db +def test_organizer_can_view_application_list(organizer_client): + client, organizer = organizer_client + app = HackerApplicationFactory() + + response = client.get(reverse("app_list")) + + assert response.status_code == 200 + assert app.user.email in response.context["emails"] + + +@pytest.mark.django_db +def test_hacker_cannot_view_application_list(hacker_client): + client, hacker = hacker_client + + response = client.get(reverse("app_list")) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_organizer_can_view_volunteer_list(organizer_client): + client, organizer = organizer_client + VolunteerApplicationFactory() + + response = client.get(reverse("volunteer_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_invite_list(director_client): + client, director = director_client + HackerApplicationFactory(status=APP_PENDING) + HackerApplicationFactory(status=APP_INVITED) + + response = client.get(reverse("invite_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_organizer_cannot_view_invite_list(organizer_client): + client, organizer = organizer_client + + response = client.get(reverse("invite_list")) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_director_can_batch_invite(director_client): + client, director = director_client + app = HackerApplicationFactory(status=APP_PENDING) + + response = client.post(reverse("invite_list"), data={"selected": [str(app.pk)]}) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_INVITED + + +@pytest.mark.django_db +def test_director_can_waitlist_all_pending(director_client): + client, director = director_client + app = HackerApplicationFactory(status=APP_PENDING) + + response = client.post(reverse("waitlisted")) + + app.refresh_from_db() + assert response.status_code == 200 + assert app.status == APP_REJECTED + + +@pytest.mark.django_db +def test_director_can_view_dubious_list(director_client): + client, director = director_client + HackerApplication.objects.filter(pk=HackerApplicationFactory().pk).update(status=APP_DUBIOUS) + + response = client.get(reverse("dubious")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_blacklist(director_client): + client, director = director_client + HackerApplication.objects.filter(pk=HackerApplicationFactory().pk).update(status=APP_BLACKLISTED) + + response = client.get(reverse("blacklist")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_mentor_list(director_client): + client, director = director_client + MentorApplicationFactory() + + response = client.get(reverse("mentor_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_sponsor_list(director_client): + client, director = director_client + SponsorApplicationFactory() + + response = client.get(reverse("sponsor_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_sponsor_user_list(director_client): + client, director = director_client + + response = client.get(reverse("sponsor_user_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_mentor_detail(director_client): + client, director = director_client + app = MentorApplicationFactory() + + response = client.get(reverse("mentor_detail", kwargs={"id": app.uuid_str})) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_volunteer_detail(director_client): + client, director = director_client + app = VolunteerApplicationFactory() + + response = client.get(reverse("volunteer_detail", kwargs={"id": app.uuid_str})) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_invite_volunteer(director_client): + client, director = director_client + app = VolunteerApplicationFactory() + + response = client.post( + reverse("volunteer_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "invite": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_INVITED diff --git a/tests/flows/test_organizer_review.py b/tests/flows/test_organizer_review.py new file mode 100644 index 000000000..d7c024409 --- /dev/null +++ b/tests/flows/test_organizer_review.py @@ -0,0 +1,164 @@ +from datetime import timedelta + +import pytest +from django.core import mail +from django.urls import reverse +from django.utils import timezone + +from applications.models import APP_CONFIRMED, APP_DUBIOUS, APP_INVITED, APP_REJECTED +from organizers.models import ApplicationComment, Vote +from tests.factories import HackerApplicationFactory + + +def reviewable_application(**kwargs): + return HackerApplicationFactory(submission_date=timezone.now() - timedelta(hours=3), **kwargs) + + +@pytest.fixture +def director_client(client, director_user): + client.force_login(director_user) + return client, director_user + + +@pytest.mark.django_db +def test_review_shows_oldest_pending_application(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.get(reverse("review")) + + assert response.status_code == 200 + assert response.context["app"].pk == app.pk + + +@pytest.mark.django_db +def test_review_shows_nothing_when_all_voted(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + Vote.objects.create(application=app, user=organizer) + + response = client.get(reverse("review")) + + assert response.status_code == 200 + assert response.context["app"] is None + + +@pytest.mark.django_db +def test_organizer_can_skip_application(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.post(reverse("review"), data={"app_id": str(app.pk), "skip": "true"}) + + assert response.status_code == 302 + assert Vote.objects.filter(application=app, user=organizer, tech=None, personal=None).count() == 1 + + +@pytest.mark.django_db +def test_organizer_can_comment_from_review(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.post( + reverse("review"), data={"app_id": str(app.pk), "add_comment": "true", "comment_text": "Solid application"} + ) + + assert response.status_code == 302 + assert ApplicationComment.objects.filter(hacker=app, author=organizer, text="Solid application").count() == 1 + + +@pytest.mark.django_db +def test_organizer_can_mark_application_dubious(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.post( + reverse("review"), + data={ + "app_id": str(app.pk), + "set_dubious": "true", + "dubious_type": "Other", + "dubious_comment_text": "Suspicious description", + }, + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_DUBIOUS + + +@pytest.mark.django_db +def test_organizer_can_view_application_detail(organizer_client): + client, organizer = organizer_client + app = HackerApplicationFactory() + + response = client.get(reverse("app_detail", kwargs={"id": app.uuid_str})) + + assert response.status_code == 200 + assert response.context["app"].pk == app.pk + + +@pytest.mark.django_db +def test_application_detail_unknown_id_returns_404(organizer_client): + client, organizer = organizer_client + + response = client.get(reverse("app_detail", kwargs={"id": "00000000000000000000000000000000"})) + + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_director_can_invite_application(director_client): + client, director = director_client + app = HackerApplicationFactory() + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "invite": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_INVITED + assert len(mail.outbox) == 1 + + +@pytest.mark.django_db +def test_director_can_confirm_invited_application(director_client): + client, director = director_client + app = HackerApplicationFactory(status=APP_INVITED) + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "confirm": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_director_can_waitlist_pending_application(director_client): + client, director = director_client + app = HackerApplicationFactory() + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "waitlist": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_REJECTED + + +@pytest.mark.django_db +def test_organizer_can_comment_on_application_detail(organizer_client): + client, organizer = organizer_client + app = HackerApplicationFactory() + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), + data={"app_id": str(app.pk), "add_comment": "true", "comment_text": "Reviewed manually"}, + ) + + assert response.status_code == 302 + assert ApplicationComment.objects.filter(hacker=app, author=organizer, text="Reviewed manually").count() == 1 diff --git a/tests/flows/test_profile.py b/tests/flows/test_profile.py new file mode 100644 index 000000000..f969a9f30 --- /dev/null +++ b/tests/flows/test_profile.py @@ -0,0 +1,22 @@ +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_hacker_can_view_profile(hacker_client): + client, user = hacker_client + + response = client.get(reverse("user_profile")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_hacker_can_update_name(hacker_client): + client, user = hacker_client + + response = client.post(reverse("user_profile"), data={"name": "Gerard Màdrid", "type": "H"}) + + user.refresh_from_db() + assert response.status_code == 200 + assert user.name == "Gerard Màdrid"