Skip to content

Commit

Permalink
[Issue #3293] Create POST /users/:userId/saved-opportunities API sche…
Browse files Browse the repository at this point in the history
…ma 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
  • Loading branch information
mikehgrantsgov authored Dec 20, 2024
1 parent c4fab20 commit 9f9d41f
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 1 deletion.
63 changes: 63 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 39 additions & 1 deletion api/src/api/users/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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("/<uuid:user_id>/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")
8 changes: 8 additions & 0 deletions api/src/api/users/user_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
106 changes: 106 additions & 0 deletions api/tests/src/api/users/test_user_save_opportunity_post.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 9f9d41f

Please sign in to comment.