Nobody has started this yet — be first.
Business impact
A user signs up as Jane.DOE@Example.com. Registration returns 201 with tokens -- everything looks like it worked. The very next time they try to log in -- typing the exact same email, just as their keyboard or autofill happened to case it, or normalized to lowercase the way most people type emails out of habit -- login fails with "invalid email or password." To the user this is indistinguishable from a hacked account or a forgotten password. They'll try resetting their password (which won't fix anything, since the password was never wrong), then give up or file a support ticket. This is a silent, permanent account lockout for an unknown fraction of signups, with no error at signup time to warn anyone.
Problem
register() stores the email exactly as submitted (after only .strip()), while login() normalizes the submitted email to lowercase before looking the user up. Register with Jane.DOE@Example.com, then log in with jane.doe@example.com (or any different casing) -- login fails with 401 invalid_credentials, even though the password is correct and it is unambiguously the same email address. As a second symptom of the same root cause: registering DUPE@EXAMPLE.COM after dupe@example.com already exists succeeds instead of being rejected as a duplicate -- the "is this email taken" check is case-sensitive too.
Current behavior
A user who registers with any uppercase letters in their email gets a 201 that looks successful, but the very next login attempt with a differently-cased version of the same email fails with 401 invalid_credentials -- and registering the same email again in a different casing succeeds instead of being rejected as a duplicate.
Expected behavior
A user can log in with their email in any casing, regardless of the casing they originally registered with. Registering the same email in a different casing must still be rejected as 409 email_taken.
Steps to reproduce
curl -s -X POST localhost:5000/api/auth/register -H "Content-Type: application/json"
-d '{"email": "Jane.DOE@Example.com", "password": "strongpassword"}'
curl -s -X POST localhost:5000/api/auth/login -H "Content-Type: application/json"
-d '{"email": "jane.doe@example.com", "password": "strongpassword"}'
Why this matters
register() computes email as data["email"].strip() -- no .lower() -- while login() computes it as data["email"].strip().lower(). Two call sites that are supposed to treat "the same email" as the same value disagree on normalization. Whichever one is "correct" doesn't matter in isolation -- what matters is that they must agree, and right now they don't. users.email has a unique=True database constraint, which only prevents byte-for-byte duplicate strings -- it does nothing to enforce case-insensitivity, so this has to be handled in application code before the value ever reaches the database.
Suggested approach
Look at the one line in register() that computes email from the request payload, and compare it to the equivalent line a few lines down in login(). Both call sites need to agree on the same normalization rule for what counts as "the same email address."
Acceptance criteria
Verification
cd flask/movie_watchlist && .venv/bin/python -m pytest practicetickets/test_ticket04_email_case_lockout.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b fix/email-case-lockoutFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/email-case-lockoutSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inRun the existing test suite (uses an in-memory SQLite DB, no setup needed):
.venv/bin/python -m pytest -v # 33 passed, 0 failed on a clean checkout
cp .env.example .env
# edit .env: set SECRET_KEY, JWT_SECRET_KEY, POSTGRES_PASSWORD to real values
docker compose up --build
This builds the web image, starts Postgres (db), waits for its healthcheck, then runs flask db upgrade and starts gunicorn -- all with one command, no manual migration step. The API is then available at http://localhost:5000.
Work the tickets in practicetickets/ (TICKET_01 through TICKET_07); each names one pytest test file in the same directory. This directory is outside pytest.ini's testpaths = tests, so a bare pytest run from the project root never picks these up -- they only run when pointed at directly:
cd flask/movie_watchlist
.venv/bin/python -m pytest practicetickets/test_ticket01_movie_list_ordering.py -v # a single ticket
./practicetickets/run_tickets.sh # all 7, clean pass/fail summary
run_tickets.sh also unsets TEST_DATABASE_URL/DATABASE_URL for its own run, so a leftover Postgres URL from a different project in your shell doesn't get picked up instead of the in-memory SQLite DB these tests are written against.
Level 1
Fix a bug
Read existing behaviour, correct it.