From 1720dfe9cc06c41d0ba23fafda6dcb1123e15a45 Mon Sep 17 00:00:00 2001 From: neilblaze Date: Sun, 5 Apr 2026 22:07:08 +0530 Subject: [PATCH 1/6] fix: db cascade issue / cc: test_delete_url --- backend/app/models/event_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/event_model.py b/backend/app/models/event_model.py index 11db164..f2911d8 100644 --- a/backend/app/models/event_model.py +++ b/backend/app/models/event_model.py @@ -21,7 +21,7 @@ class Event(BaseModel): id = BigAutoField() url_id = ForeignKeyField( - ShortURL, backref="events", column_name="url_id", index=True + ShortURL, backref="events", column_name="url_id", index=True, on_delete="CASCADE" ) user_id = ForeignKeyField( User, backref="events", column_name="user_id", null=True, index=True From bfa57d25317e9162565b04ba5fc1b19b0b2a52c8 Mon Sep 17 00:00:00 2001 From: neilblaze Date: Sun, 5 Apr 2026 22:17:47 +0530 Subject: [PATCH 2/6] feat: advanced challenges (refactored logic) --- backend/app/controllers/base_controller.py | 14 +++++++++++++- backend/app/controllers/url_controller.py | 4 ++++ backend/app/controllers/user_controller.py | 4 ++++ backend/app/routes/link_routes.py | 13 ++++++++++++- backend/app/validators/schemas.py | 16 +++++++++++++++- 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/backend/app/controllers/base_controller.py b/backend/app/controllers/base_controller.py index d92ccd9..ff60173 100644 --- a/backend/app/controllers/base_controller.py +++ b/backend/app/controllers/base_controller.py @@ -1,4 +1,4 @@ -from flask import current_app, g, jsonify +from flask import current_app, g, jsonify, request try: import sentry_sdk @@ -12,6 +12,18 @@ class BaseController: def handle_success(self, data, status_code=200): return jsonify(data), status_code + def require_json(self): + """Return a 415 response tuple if Content-Type is not application/json, else None.""" + content_type = request.content_type or "" + if "application/json" not in content_type: + return jsonify( + { + "error": "unsupported_media_type", + "message": "Content-Type must be application/json.", + } + ), 415 + return None + def handle_error(self, error, operation_name): error_type = type(error).__name__ msg = str(error) diff --git a/backend/app/controllers/url_controller.py b/backend/app/controllers/url_controller.py index fcc4329..94f2fda 100644 --- a/backend/app/controllers/url_controller.py +++ b/backend/app/controllers/url_controller.py @@ -16,6 +16,10 @@ def set_config(self, config): def create_url(self, request): try: + err = self.require_json() + if err: + return err + idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: cache_key = f"idempotency:url:{idempotency_key}" diff --git a/backend/app/controllers/user_controller.py b/backend/app/controllers/user_controller.py index 21fe67a..102d41f 100644 --- a/backend/app/controllers/user_controller.py +++ b/backend/app/controllers/user_controller.py @@ -18,6 +18,10 @@ def set_config(self, config): def create_user(self, request): try: + err = self.require_json() + if err: + return err + data = request.get_json() if not data: raise ValueError("Payload cannot be empty") diff --git a/backend/app/routes/link_routes.py b/backend/app/routes/link_routes.py index e23544c..d440ba0 100644 --- a/backend/app/routes/link_routes.py +++ b/backend/app/routes/link_routes.py @@ -1,5 +1,6 @@ from flask import Blueprint, current_app, jsonify, redirect +from backend.app.config.errors import ForbiddenError, NotFoundError from backend.app.services.url_service import UrlService links_bp = Blueprint("links", __name__) @@ -16,7 +17,17 @@ def _get_service(): @links_bp.get("/") def follow_short_link(code): - destination = _get_service().resolve_redirect(code) + try: + destination = _get_service().resolve_redirect(code) + except ForbiddenError: + return jsonify( + {"error": "gone", "message": "This link has been deactivated."} + ), 410 + except NotFoundError: + return jsonify( + {"error": "not_found", "message": "Link not found."} + ), 404 + if not destination: return jsonify( {"error": "not_found", "message": "Link not found or inactive"} diff --git a/backend/app/validators/schemas.py b/backend/app/validators/schemas.py index 6e9873b..2fceec6 100644 --- a/backend/app/validators/schemas.py +++ b/backend/app/validators/schemas.py @@ -10,12 +10,26 @@ class CreateUserSchema(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) + @field_validator("username", mode="before") + @classmethod + def username_must_be_string(cls, v) -> str: + if not isinstance(v, str): + raise ValueError("username must be a string, not a number or other type") + return v + + @field_validator("email", mode="before") + @classmethod + def email_must_be_string(cls, v) -> str: + if not isinstance(v, str): + raise ValueError("email must be a string, not a number or other type") + return v + @field_validator("email") @classmethod def validate_email(cls, v: str) -> str: if not re.match( r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v - ): # TODO: Revisit this regex + ): raise ValueError( "value is not a valid email address: An email address must have an @-sign." ) From b713953e9fa55651b0049d74bd0c5fb557defa4f Mon Sep 17 00:00:00 2001 From: neilblaze Date: Sun, 5 Apr 2026 22:27:01 +0530 Subject: [PATCH 3/6] fix: resolve CI failures from require_json() request context + quest riddle fixes - require_json() now accepts request as param instead of using Flask global proxy (global proxy requires active request context; unit tests only push app_context) - Add content_type='application/json' default to test _request() helper - Add on_delete=CASCADE to Event.url_id FK (fix cascade delete 409) - Fix link_routes: catch ForbiddenError->410, NotFoundError->404 (inactive URL) - Add strict mode validators to CreateUserSchema (reject numeric username/email) - Fix REDIS_URL/RATE_LIMIT_STORAGE_URI auth in backend/.env --- backend/app/controllers/base_controller.py | 7 ++++--- backend/app/controllers/url_controller.py | 2 +- backend/app/controllers/user_controller.py | 2 +- tests/unit/test_controllers.py | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/app/controllers/base_controller.py b/backend/app/controllers/base_controller.py index ff60173..5e82cb8 100644 --- a/backend/app/controllers/base_controller.py +++ b/backend/app/controllers/base_controller.py @@ -1,4 +1,4 @@ -from flask import current_app, g, jsonify, request +from flask import current_app, g, jsonify try: import sentry_sdk @@ -12,9 +12,9 @@ class BaseController: def handle_success(self, data, status_code=200): return jsonify(data), status_code - def require_json(self): + def require_json(self, request): """Return a 415 response tuple if Content-Type is not application/json, else None.""" - content_type = request.content_type or "" + content_type = getattr(request, "content_type", None) or "" if "application/json" not in content_type: return jsonify( { @@ -24,6 +24,7 @@ def require_json(self): ), 415 return None + def handle_error(self, error, operation_name): error_type = type(error).__name__ msg = str(error) diff --git a/backend/app/controllers/url_controller.py b/backend/app/controllers/url_controller.py index 94f2fda..f92051a 100644 --- a/backend/app/controllers/url_controller.py +++ b/backend/app/controllers/url_controller.py @@ -16,7 +16,7 @@ def set_config(self, config): def create_url(self, request): try: - err = self.require_json() + err = self.require_json(request) if err: return err diff --git a/backend/app/controllers/user_controller.py b/backend/app/controllers/user_controller.py index 102d41f..363e0e0 100644 --- a/backend/app/controllers/user_controller.py +++ b/backend/app/controllers/user_controller.py @@ -18,7 +18,7 @@ def set_config(self, config): def create_user(self, request): try: - err = self.require_json() + err = self.require_json(request) if err: return err diff --git a/tests/unit/test_controllers.py b/tests/unit/test_controllers.py index edb1dc3..0587f01 100644 --- a/tests/unit/test_controllers.py +++ b/tests/unit/test_controllers.py @@ -17,11 +17,12 @@ from backend.app.validators.schemas import CreateUserSchema -def _request(payload=None, *, args=None, headers=None, files=None, bad_json=False): +def _request(payload=None, *, args=None, headers=None, files=None, bad_json=False, content_type="application/json"): req = MagicMock() req.args = MultiDict(args or {}) req.headers = headers or {} req.files = files or {} + req.content_type = content_type if bad_json: req.get_json.side_effect = BadRequest("malformed json") else: From e74814b4cbf5ef8dd947958aea940d41212cbd7d Mon Sep 17 00:00:00 2001 From: neilblaze Date: Sun, 5 Apr 2026 22:34:37 +0530 Subject: [PATCH 4/6] fix: CI / Backend Lint - gh-workflows --- backend/app/controllers/base_controller.py | 1 - backend/app/models/event_model.py | 6 +++++- backend/app/routes/link_routes.py | 4 +--- backend/app/validators/schemas.py | 4 +--- tests/unit/test_controllers.py | 10 +++++++++- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/app/controllers/base_controller.py b/backend/app/controllers/base_controller.py index 5e82cb8..91227d7 100644 --- a/backend/app/controllers/base_controller.py +++ b/backend/app/controllers/base_controller.py @@ -24,7 +24,6 @@ def require_json(self, request): ), 415 return None - def handle_error(self, error, operation_name): error_type = type(error).__name__ msg = str(error) diff --git a/backend/app/models/event_model.py b/backend/app/models/event_model.py index f2911d8..f5b45b3 100644 --- a/backend/app/models/event_model.py +++ b/backend/app/models/event_model.py @@ -21,7 +21,11 @@ class Event(BaseModel): id = BigAutoField() url_id = ForeignKeyField( - ShortURL, backref="events", column_name="url_id", index=True, on_delete="CASCADE" + ShortURL, + backref="events", + column_name="url_id", + index=True, + on_delete="CASCADE", ) user_id = ForeignKeyField( User, backref="events", column_name="user_id", null=True, index=True diff --git a/backend/app/routes/link_routes.py b/backend/app/routes/link_routes.py index d440ba0..ac363c5 100644 --- a/backend/app/routes/link_routes.py +++ b/backend/app/routes/link_routes.py @@ -24,9 +24,7 @@ def follow_short_link(code): {"error": "gone", "message": "This link has been deactivated."} ), 410 except NotFoundError: - return jsonify( - {"error": "not_found", "message": "Link not found."} - ), 404 + return jsonify({"error": "not_found", "message": "Link not found."}), 404 if not destination: return jsonify( diff --git a/backend/app/validators/schemas.py b/backend/app/validators/schemas.py index 2fceec6..109c503 100644 --- a/backend/app/validators/schemas.py +++ b/backend/app/validators/schemas.py @@ -27,9 +27,7 @@ def email_must_be_string(cls, v) -> str: @field_validator("email") @classmethod def validate_email(cls, v: str) -> str: - if not re.match( - r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v - ): + if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v): raise ValueError( "value is not a valid email address: An email address must have an @-sign." ) diff --git a/tests/unit/test_controllers.py b/tests/unit/test_controllers.py index 0587f01..67d0115 100644 --- a/tests/unit/test_controllers.py +++ b/tests/unit/test_controllers.py @@ -17,7 +17,15 @@ from backend.app.validators.schemas import CreateUserSchema -def _request(payload=None, *, args=None, headers=None, files=None, bad_json=False, content_type="application/json"): +def _request( + payload=None, + *, + args=None, + headers=None, + files=None, + bad_json=False, + content_type="application/json", +): req = MagicMock() req.args = MultiDict(args or {}) req.headers = headers or {} From af4851c0a5a74f71388dc180c8eda64a68d6ddef Mon Sep 17 00:00:00 2001 From: neilblaze Date: Sun, 5 Apr 2026 22:44:14 +0530 Subject: [PATCH 5/6] fix(ci): Docker Build & Publish / Backend Test Gate --- .github/workflows/ci.yml | 3 +++ .github/workflows/docker-publish.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47b9c1e..eb5589b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: pull_request: workflow_call: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: contents: read diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b0afa44..1c89816 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -11,6 +11,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" permissions: contents: read From 84755f29261aba602271d385774051c08614b8e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:19:35 +0000 Subject: [PATCH 6/6] chore(deps): bump actions/cache from 4 to 5 Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb5589b..2386870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: python-version: '3.13' - name: Cache pip dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} @@ -60,7 +60,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Cache node modules - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: frontend/node_modules key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock') }} @@ -113,7 +113,7 @@ jobs: python-version: '3.13' - name: Cache pip dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} @@ -156,7 +156,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Cache node modules - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: frontend/node_modules key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock') }}