From 9f9d41f9c93d2b01cd421c6ffec7a72a37f72151 Mon Sep 17 00:00:00 2001 From: Mike H Date: Fri, 20 Dec 2024 15:27:07 -0500 Subject: [PATCH] [Issue #3293] Create POST /users/:userId/saved-opportunities API schema and stub endpoint (#3330) ## Summary Fixes #3293 ### Time to review: 20 mins ## Changes proposed Create an API for users to save opportunities. Make sure this API is scoped to the currently logged in user Response body a basic success message ## Additional information See attached unit tests --- api/openapi.generated.yml | 63 +++++++++++ api/src/api/users/user_routes.py | 40 ++++++- api/src/api/users/user_schemas.py | 8 ++ .../users/test_user_save_opportunity_post.py | 106 ++++++++++++++++++ 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 api/tests/src/api/users/test_user_save_opportunity_post.py diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index a3caaa040..50e6b7f47 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -431,6 +431,49 @@ paths: ' security: - ApiKeyAuth: [] + /v1/users/{user_id}/saved-opportunities: + post: + parameters: + - in: path + name: user_id + schema: + type: string + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSaveOpportunityResponse' + description: Successful response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Validation error + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Not found + tags: + - User v1 + summary: User Save Opportunity + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserSaveOpportunityRequest' + security: + - ApiJwtAuth: [] /v1/opportunities/{opportunity_id}/versions: get: parameters: @@ -1868,6 +1911,26 @@ components: type: integer description: The HTTP status code example: 200 + UserSaveOpportunityRequest: + type: object + properties: + opportunity_id: + type: integer + required: + - opportunity_id + UserSaveOpportunityResponse: + type: object + properties: + message: + type: string + description: The message to return + example: Success + data: + example: null + status_code: + type: integer + description: The HTTP status code + example: 200 OpportunityVersionV1: type: object properties: diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 6e4e18eb3..b180a7ef9 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -11,13 +11,15 @@ from src.api.users.user_blueprint import user_blueprint from src.api.users.user_schemas import ( UserGetResponseSchema, + UserSaveOpportunityRequestSchema, + UserSaveOpportunityResponseSchema, UserTokenLogoutResponseSchema, UserTokenRefreshResponseSchema, ) from src.auth.api_jwt_auth import api_jwt_auth, refresh_token_expiration from src.auth.auth_utils import with_login_redirect_error_handler from src.auth.login_gov_jwt_auth import get_final_redirect_uri, get_login_gov_redirect_uri -from src.db.models.user_models import UserTokenSession +from src.db.models.user_models import UserSavedOpportunity, UserTokenSession from src.services.users.get_user import get_user from src.services.users.login_gov_callback_handler import ( handle_login_gov_callback_request, @@ -146,3 +148,39 @@ def user_get(db_session: db.Session, user_id: UUID) -> response.ApiResponse: return response.ApiResponse(message="Success", data=user) raise_flask_error(401, "Unauthorized user") + + +@user_blueprint.post("//saved-opportunities") +@user_blueprint.input(UserSaveOpportunityRequestSchema, location="json") +@user_blueprint.output(UserSaveOpportunityResponseSchema) +@user_blueprint.doc(responses=[200, 401]) +@user_blueprint.auth_required(api_jwt_auth) +@flask_db.with_db_session() +def user_save_opportunity( + db_session: db.Session, user_id: UUID, json_data: dict +) -> response.ApiResponse: + logger.info("POST /v1/users/:user_id/saved-opportunities") + + user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore + + # Verify the authenticated user matches the requested user_id + if user_token_session.user_id != user_id: + raise_flask_error(401, "Unauthorized user") + + # Create the saved opportunity record + saved_opportunity = UserSavedOpportunity( + user_id=user_id, opportunity_id=json_data["opportunity_id"] + ) + + with db_session.begin(): + db_session.add(saved_opportunity) + + logger.info( + "Saved opportunity for user", + extra={ + "user.id": str(user_id), + "opportunity.id": json_data["opportunity_id"], + }, + ) + + return response.ApiResponse(message="Success") diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index 3b90581ca..5e9a9ce59 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -67,3 +67,11 @@ class UserTokenLogoutResponseSchema(AbstractResponseSchema): class UserGetResponseSchema(AbstractResponseSchema): data = fields.Nested(UserSchema) + + +class UserSaveOpportunityRequestSchema(Schema): + opportunity_id = fields.Integer(required=True) + + +class UserSaveOpportunityResponseSchema(AbstractResponseSchema): + data = fields.MixinField(metadata={"example": None}) diff --git a/api/tests/src/api/users/test_user_save_opportunity_post.py b/api/tests/src/api/users/test_user_save_opportunity_post.py new file mode 100644 index 000000000..f50082b42 --- /dev/null +++ b/api/tests/src/api/users/test_user_save_opportunity_post.py @@ -0,0 +1,106 @@ +import uuid + +import pytest + +from src.auth.api_jwt_auth import create_jwt_for_user +from src.db.models.user_models import UserSavedOpportunity +from tests.src.db.models.factories import OpportunityFactory, UserFactory + + +@pytest.fixture +def user(enable_factory_create, db_session): + user = UserFactory.create() + db_session.commit() + return user + + +@pytest.fixture +def user_auth_token(user, db_session): + token, _ = create_jwt_for_user(user, db_session) + return token + + +@pytest.fixture(autouse=True) +def clear_opportunities(db_session): + db_session.query(UserSavedOpportunity).delete() + db_session.commit() + yield + + +def test_user_save_opportunity_post_unauthorized_user( + client, db_session, user, user_auth_token, enable_factory_create +): + # Create an opportunity + opportunity = OpportunityFactory.create() + + # Try to save an opportunity for a different user ID + different_user_id = uuid.uuid4() + response = client.post( + f"/v1/users/{different_user_id}/saved-opportunities", + headers={"X-SGG-Token": user_auth_token}, + json={"opportunity_id": opportunity.opportunity_id}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Unauthorized user" + + # Verify no opportunity was saved + saved_opportunities = db_session.query(UserSavedOpportunity).all() + assert len(saved_opportunities) == 0 + + +def test_user_save_opportunity_post_no_auth(client, db_session, user, enable_factory_create): + # Create an opportunity + opportunity = OpportunityFactory.create() + + # Try to save an opportunity without authentication + response = client.post( + f"/v1/users/{user.user_id}/saved-opportunities", + json={"opportunity_id": opportunity.opportunity_id}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Unable to process token" + + # Verify no opportunity was saved + saved_opportunities = db_session.query(UserSavedOpportunity).all() + assert len(saved_opportunities) == 0 + + +def test_user_save_opportunity_post_invalid_request( + client, user, user_auth_token, enable_factory_create, db_session +): + # Make request with missing opportunity_id + response = client.post( + f"/v1/users/{user.user_id}/saved-opportunities", + headers={"X-SGG-Token": user_auth_token}, + json={}, + ) + + assert response.status_code == 422 # Validation error + + # Verify no opportunity was saved + saved_opportunities = db_session.query(UserSavedOpportunity).all() + assert len(saved_opportunities) == 0 + + +def test_user_save_opportunity_post( + client, user, user_auth_token, enable_factory_create, db_session +): + # Create an opportunity + opportunity = OpportunityFactory.create() + + # Make the request to save an opportunity + response = client.post( + f"/v1/users/{user.user_id}/saved-opportunities", + headers={"X-SGG-Token": user_auth_token}, + json={"opportunity_id": opportunity.opportunity_id}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Success" + + # Verify the opportunity was saved in the database + saved_opportunity = db_session.query(UserSavedOpportunity).one() + assert saved_opportunity.user_id == user.user_id + assert saved_opportunity.opportunity_id == opportunity.opportunity_id