Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
name: Running tests
command: |
. env/bin/activate
python manage.py test
pytest --cov
- run:
name: Linting code
command: |
Expand Down
Empty file removed discord/tests.py
Empty file.
226 changes: 226 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -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/<uuid>/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/<uid>/<token>/`), 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_<type>.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.
3 changes: 0 additions & 3 deletions hardware/tests.py

This file was deleted.

Empty file removed meals/tests.py
Empty file.
7 changes: 7 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[coverage:run]
source = applications,organizers,user
omit = */migrations/*, */tests/*

[coverage:report]
fail_under = 60
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Don't delete this file, pytest needs it to find the source of tests hehe
83 changes: 83 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Loading