diff --git a/app/crud/service.py b/app/crud/service.py index 8a550c4..557297e 100644 --- a/app/crud/service.py +++ b/app/crud/service.py @@ -96,6 +96,14 @@ def get_regions_by_depth(redis: Redis, depth: int): result = fetch_hgetall(redis, hkey) return result +def get_region_by_name(redis: Redis, name: str): + """주어진 이름에 해당하는 동네 목록을 반환하는 함수""" + regions = get_regions(redis, include_depth=False) + for region in regions: + if region["name"] == name: + return region + return None + def get_region_by_source_id(db: Session, source_id: int): """주어진 source_id와 depth에 해당하는 동네 목록을 반환하는 함수""" return db.query(Region).filter(Region.source_id == source_id, Region.deleted_at.is_(None)).first() @@ -124,13 +132,13 @@ def sync_high_schools_to_redis(db: Session, redis: Redis): redis.hset(HIGH_SCHOOLS_ALL_KEY, mapping=mapping) return len(mapping) -def get_high_schools(redis: Redis): +def get_high_schools(redis: Redis, sort: bool = True): """고등학교 인프라 목록을 Redis에서 조회하여 반환하는 함수""" - return fetch_hgetall(redis, HIGH_SCHOOLS_ALL_KEY, sort=False) + return fetch_hgetall(redis, HIGH_SCHOOLS_ALL_KEY, sort=sort) -def get_high_school_map(redis: Redis, sort: bool = True, **extra_dict): +def get_high_school_map(redis: Redis, sort: bool = True): """고등학교 인프라 목록을 Redis에서 조회하여 반환하는 함수""" - result = get_high_schools(redis) + result = get_high_schools(redis, sort=sort) school_map = {s["id"]: s for s in result} return school_map diff --git a/app/dependencies.py b/app/dependencies.py index 1f75fda..d96133a 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -6,11 +6,10 @@ from .core.exception import AppException, RedirectException from .core.session import get_session from .database import get_db -from .models import User, SearchLog +from .models import User from .schemas.auth import UserSession from .crud.user import get_user_by_cuid from .crud.service import ( - get_recommendations_by_user_id_and_task_id, get_search_log_by_user_id_and_task_id, get_search_log_by_user_id, ) @@ -48,22 +47,6 @@ def only_self_access( ) return user -def get_current_recommendation( - task_id: str, - request: Request, - db: Session = Depends(get_db), - user: User = Depends(only_self_access), -): - """ - `task_id` 값으로 시작하는 추천이 현재 세션의 사용자에게 속한 것인지 검증하는 의존성 함수. - - task_id에 해당하는 추천을 조회할 수 없거나 2개 이상 존재하면 404 에러를 발생시킴 - - task_id가 full id가 아닐 경우 302 리다이렉션 처리 - """ - - search_log = get_current_search_log(task_id, request, db, user) - recommendation = search_log.recommendation - return search_log.recommendation - def get_current_search_log( task_id: str, request: Request, diff --git a/app/manage/seeds/insert.py b/app/manage/seeds/insert.py index 58bb0b3..4817ec0 100644 --- a/app/manage/seeds/insert.py +++ b/app/manage/seeds/insert.py @@ -77,10 +77,11 @@ def generate_seed_recommendations( random.shuffle(selected_infra_types) rec = None - created_at, finished_at = random_range(start_ts, end_ts) - created_at = datetime.fromtimestamp(created_at) + created_at, finished_at = map(datetime.fromtimestamp, random_range(start_ts, end_ts)) + updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if random.choice([True, [False] * 4]) else None + for user in request_users: - _, rec = generate_recommendation( + rec = generate_recommendation( db, background_tasks=None, task_id=task_id, @@ -103,13 +104,8 @@ def generate_seed_recommendations( ).first() search_log.last_viewed_at = last_viewed_at - failed_at = (created_at + timedelta(minutes=random.randint(1, 3))) if random.choice([True] + [False] * 4) else None - finished_at = datetime.fromtimestamp(finished_at) if not failed_at and random.choice([True, False]) else None - updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if finished_at and random.choice([True, [False] * 4]) else None - rec.created_at = created_at rec.finished_at = finished_at - rec.failed_at = failed_at rec.updated_at = updated_at db.commit() diff --git a/app/models/recommendation.py b/app/models/recommendation.py index 693638e..05c55ac 100644 --- a/app/models/recommendation.py +++ b/app/models/recommendation.py @@ -1,5 +1,5 @@ from typing import TYPE_CHECKING -from sqlalchemy import CheckConstraint, Column, DateTime, JSON, String +from sqlalchemy import Column, DateTime, JSON, String, Boolean from sqlalchemy.dialects.mysql import INTEGER, BIGINT from sqlalchemy.orm import Mapped, relationship from sqlalchemy.sql import func @@ -39,18 +39,19 @@ class Recommendation(Base): id = Column(INTEGER(unsigned=True), primary_key=True, index=True) task_id = Column(String(64), unique=True, nullable=False) region = Column(String(255)) - infrastructure_priorities: list[str] = Column(JSON(none_as_null=True), nullable=False, default={}) + infrastructure_priorities: list[str] = Column(JSON(none_as_null=True), nullable=False, default=[]) sale_price_min = Column(BIGINT(unsigned=True)) sale_price_max = Column(BIGINT(unsigned=True)) jeonse_price_min = Column(BIGINT(unsigned=True)) jeonse_price_max = Column(BIGINT(unsigned=True)) - top_properties: list[dict] = Column(JSON(none_as_null=True), nullable=True, default={}) + top_properties: list[dict] = Column(JSON(none_as_null=True), nullable=True, default=[]) school_district_types: list[str] = Column(JSON(none_as_null=True), nullable=True, default=[]) high_school_ids: list[int] = Column(JSON(none_as_null=True), nullable=True, default=[]) created_at = Column(DateTime, nullable=False, default=func.now()) finished_at = Column(DateTime) updated_at = Column(DateTime) failed_at = Column(DateTime) + in_progress = Column(Boolean, nullable=False, default=True) users: Mapped[list["SearchLog"]] = relationship("SearchLog", back_populates="recommendation", cascade="all, delete-orphan") diff --git a/app/routers/recommendations.py b/app/routers/recommendations.py index b2372b1..c3c877b 100644 --- a/app/routers/recommendations.py +++ b/app/routers/recommendations.py @@ -1,16 +1,17 @@ """추천 엔드포인트 라우트 모듈.""" from typing import Union -from fastapi import APIRouter, Depends, BackgroundTasks, status +from fastapi import APIRouter, Depends, BackgroundTasks, Request, status +from fastapi.exceptions import HTTPException from sqlalchemy.orm import Session from ..redis import get_redis from ..database import get_db from ..core.enums import SchoolDistrictTypeEnum, InfrastructureTypeEnum from ..core.validate import verify_region, verify_high_schools -from ..dependencies import only_self_access, get_current_recommendation, get_current_search_log -from ..models import User, SearchLog, Recommendation -from ..crud.service import get_high_schools +from ..dependencies import only_self_access, get_current_search_log +from ..models import User, SearchLog, Property, Infrastructure +from ..crud.service import get_high_school_map, get_region_by_name from ..schemas.error import RegionError from ..schemas.common import TaskID, PK_AI from ..schemas.service import ( @@ -67,7 +68,7 @@ def request_generate_recommendation( jeonse_price_min = body.jeonse_price.min if body.jeonse_price else None jeonse_price_max = body.jeonse_price.max if body.jeonse_price else None - task_id, _ = generate_recommendation( + rec = generate_recommendation( db, background_tasks, request_user=user, @@ -83,7 +84,7 @@ def request_generate_recommendation( ) return { - "task_id": task_id, + "task_id": rec.task_id, } @@ -111,105 +112,69 @@ def change_recommendation_name( ) def get_recommendation_summary( task_id: TaskID, - recommendation: Recommendation = Depends(get_current_recommendation), + search_log: SearchLog = Depends(get_current_search_log), db: Session = Depends(get_db), redis = Depends(get_redis), ) -> RecommendationReport: """추천 결과 요약 정보 조회""" - print("################### DEBUG: Get Recommendation ###################") - print("task_id:", task_id) - print("추천 정보:", recommendation) - print("################### DEBUG END: Get Recommendation ###################") + recommendation = search_log.recommendation + high_schools = get_high_school_map(redis) + + in_progress = recommendation.in_progress + top_properties = recommendation.top_properties + request_data = { + "name": search_log.name, + "region": get_region_by_name(redis, recommendation.region) if recommendation.region else None, + "infrastructure_types": [InfrastructureTypeEnum[x].meta for x in recommendation.infrastructure_priorities], + "high_schools": [high_schools[x] for x in recommendation.high_school_ids if high_schools.get(x)], + "school_districts": [SchoolDistrictTypeEnum[x].meta for x in recommendation.school_district_types], + "sale_price": { + "min": recommendation.sale_price_min, + "max": recommendation.sale_price_max, + }, + "jeonse_price": { + "min": recommendation.jeonse_price_min, + "max": recommendation.jeonse_price_max, + }, + } - return { - "task_id": "full_task_id", - "status": "completed", - "total": 2, - "request_data": { - "name": "this is custom name", - "region": { - "id": 1, - "name": "서울특별시 용산구 도원동", + db_properties = db.query(Property).filter(Property.id.in_([p["id"] for p in top_properties])).all() + property_map = {prop.id: prop for prop in db_properties} + + properties = [ + { + **p, + "region": get_region_by_name(redis, p["region"]), + "address": { + "land_lot": property_map[p["id"]].land_lot_address, + "road_name": property_map[p["id"]].road_name_address, + "latitude": property_map[p["id"]].latitude, + "longitude": property_map[p["id"]].longitude, }, - "infrastructure_types": [ - InfrastructureTypeEnum.SUBWAY_STATION.meta, - InfrastructureTypeEnum.PARK.meta, - ], - "high_schools": get_high_schools(redis)[0:3], - "school_districts": [ - SchoolDistrictTypeEnum.INTENSIVE.meta, - SchoolDistrictTypeEnum.BALANCED.meta, - ], "sale_price": { - "min": 0, - "max": 999999999999, + "min": p["sale_price_min"], + "max": p["sale_price_max"], }, "jeonse_price": { - "min": 0, - "max": 999999999999, - }, - }, - "properties": [ - { - "id": 1, - "name": "삼성래미안", - "score": 87, - "region": { - "id": 1, - "name": "서울특별시 용산구 도원동", - }, - "address": { - "land_lot": "서울특별시 용산구 도원동 23", - "road_name": "서울특별시 용산구 새창로 70", - "latitude": 37.53830000, - "longitude": 126.95532000, - }, - "sale_price": 1200000000, - "jeonse_price": 440000000, - "infrastructure": [ - { - **InfrastructureTypeEnum.SUBWAY_STATION.meta._asdict(), - "distance": 0.6, - "walking_duration": 13, - }, - { - **InfrastructureTypeEnum.PARK.meta._asdict(), - "distance": 1.5, - "walking_duration": 21, - }, - ], - }, - { - "id": 2, - "name": "도원", - "score": 79.3, - "region": { - "id": 1, - "name": "서울특별시 용산구 도원동", - }, - "address": { - "land_lot": "서울특별시 용산구 도원동 3-7", - "road_name": "서울특별시 용산구 새창로12길 11-15", - "latitude": 37.53895000, - "longitude": 126.95842000, - }, - "sale_price": None, - "jeonse_price": 150000000, - "infrastructure": [ - { - **InfrastructureTypeEnum.SUBWAY_STATION.meta._asdict(), - "distance": 1.3, - "walking_duration": 18, - }, - { - **InfrastructureTypeEnum.PARK.meta._asdict(), - "distance": 1.1, - "walking_duration": 15, - }, - ], + "min": p["jeonse_price_min"], + "max": p["jeonse_price_max"], }, - ], + "infrastructure": [ + { + **InfrastructureTypeEnum[infra["type"]].meta._asdict(), + **infra, + } for infra in p["infrastructure_scores"][:2] + ], + } for p in top_properties + ] + + return { + "task_id": task_id, + "status": "in_progress" if in_progress else "completed", + "total": len(properties), + "request_data": request_data, + "properties": properties, } @@ -221,56 +186,58 @@ def get_recommendation_summary( def get_recommendation_property_detail( task_id: TaskID, property_id: PK_AI, + request: Request, db: Session = Depends(get_db), - recommendation: Recommendation = Depends(get_current_recommendation), + redis = Depends(get_redis), + user: User = Depends(only_self_access), + search_log: SearchLog = Depends(get_current_search_log), ) -> RecommendationReportItemDetail: """추천 결과 중 특정 매물의 상세 정보 조회""" - print("################### DEBUG: Get Recommendation ###################") - properties = recommendation.top_properties - property = properties[0] if len(properties) > 0 else None - print("task_id:", task_id) - print("property_id:", property_id) - print("추천 정보:", recommendation) - print("매물 정보:", property) - print("################### DEBUG END: Get Recommendation ###################") + recommendation = search_log.recommendation + + search_log = get_current_search_log(task_id, request, db, user) + ids = [x["id"] for x in search_log.recommendation.top_properties] + if property_id not in ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + ) + + db_property = db.query(Property).filter(Property.id == property_id).first() + top_property_map = {prop["id"]: prop for prop in recommendation.top_properties} + info = top_property_map[property_id] + + db_infras = db.query(Infrastructure).filter(Infrastructure.id.in_([i["id"] for i in info["infrastructure_scores"]])).all() + infra_map = {infra.id: infra for infra in db_infras} + infrastructure = [ + { + **InfrastructureTypeEnum[infra["type"]].meta._asdict(), + **infra, + "latitude": infra_map[infra["id"]].latitude, + "longitude": infra_map[infra["id"]].longitude, + } for infra in info["infrastructure_scores"] + ] return { - "id": 1, - "name": "삼성래미안", - "score": 87, - "region": { - "id": 1, - "name": "서울특별시 용산구 도원동", - }, + "id": info["id"], + "name": info["name"], + "score": info["score"], + "region": get_region_by_name(redis, info["region"]), "address": { - "land_lot": "서울특별시 용산구 도원동 23", - "road_name": "서울특별시 용산구 새창로 70", - "latitude": 37.53830000, - "longitude": 126.95532000, + "land_lot": db_property.land_lot_address, + "road_name": db_property.road_name_address, + "latitude": db_property.latitude, + "longitude": db_property.longitude, }, - "sale_price": 1200000000, - "jeonse_price": 440000000, - "infrastructure": [ - { - **InfrastructureTypeEnum.SUBWAY_STATION.meta._asdict(), - "name": "효창공원앞", - "score": 93.3, - "distance": 0.6, - "walking_duration": 13, - "latitude": 37.53895534, - "longitude": 126.96173072, - }, - { - **InfrastructureTypeEnum.PARK.meta._asdict(), - "name": "효창근린공원", - "score": 57.8, - "distance": 1.5, - "walking_duration": 21, - "latitude": 37.54523000, - "longitude": 126.95993000, - }, - ], + "sale_price": { + "min": info["sale_price_min"], + "max": info["sale_price_max"], + }, + "jeonse_price": { + "min": info["jeonse_price_min"], + "max": info["jeonse_price_max"], + }, + "infrastructure": infrastructure, } diff --git a/app/routers/users.py b/app/routers/users.py index 10a5ff0..4cf081a 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,6 +1,5 @@ """사용자 엔드포인트 라우트 모듈.""" -from datetime import datetime from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session @@ -9,7 +8,7 @@ from ..redis import get_redis from ..core.enums import AppErrorCodeEnum, SchoolDistrictTypeEnum, InfrastructureTypeEnum from ..core.exception import AppException -from ..crud.service import get_high_schools +from ..crud.service import get_high_school_map, get_region_by_name from ..schemas.error import IncorrectCurrentPasswordError from ..schemas.user import UserInfoUpdateRequest, UserInfo, UserPasswordChangeRequest from ..schemas.service import UserRecommendations @@ -96,73 +95,33 @@ def user_recommendations( ) -> UserRecommendations: """추천 요청 목록 조회""" - print("################### DEBUG: User Recommendations ###################") - print("Received user recommendations request for user_id:", user.id) - print("Retrieved user recommendations:", [x.recommendation_id for x in user_recommendations]) - print("################### DEBUG END: User Recommendations ###################") - - return { - "total": 2, - "items": [ - { - "requested_at": datetime.now(), - "last_viewed_at": datetime.now(), - "task_id": "unique_task_id", - "status": "completed", - "request_data": { - "name": "사용자 지정 추천 이름", - "region": { - "id": 1, - "name": "서울특별시 용산구 도원동" - }, - "infrastructure_types": [ - InfrastructureTypeEnum.SUBWAY_STATION.meta, - ], - "high_schools": [ - get_high_schools(redis)[0], - ], - "school_districts": [ - SchoolDistrictTypeEnum.INTENSIVE.meta, - ], - "sale_price": { - "min": 0, - "max": 999999999999, - }, - "jeonse_price": { - "min": 0, - "max": 999999999999, - }, + high_schools = get_high_school_map(redis) + items = [] + for search_log in user_recommendations: + recommendation = search_log.recommendation + items.append({ + "task_id": search_log.task_id, + "status": "in_progress" if recommendation.in_progress else "completed", + "requested_at": search_log.requested_at, + "last_viewed_at": search_log.last_viewed_at, + "request_data": { + "name": search_log.name, + "region": get_region_by_name(redis, recommendation.region) if recommendation.region else None, + "infrastructure_types": [InfrastructureTypeEnum[x].meta for x in recommendation.infrastructure_priorities], + "high_schools": [high_schools[x] for x in recommendation.high_school_ids if high_schools.get(x)], + "school_districts": [SchoolDistrictTypeEnum[x].meta for x in recommendation.school_district_types], + "sale_price": { + "min": recommendation.sale_price_min, + "max": recommendation.sale_price_max, }, - }, - { - "requested_at": datetime.now(), - "last_viewed_at": datetime.now(), - "task_id": "unique_task_idw", - "status": "in_progress", - "request_data": { - "name": None, - "region": { - "id": 2, - "name": "서울특별시 용산구 새창로", - }, - "infrastructure_types": [ - InfrastructureTypeEnum.SUBWAY_STATION.meta, - InfrastructureTypeEnum.PARK.meta, - ], - "high_schools": [], - "school_districts": [ - SchoolDistrictTypeEnum.INTENSIVE.meta, - SchoolDistrictTypeEnum.RELAXED.meta, - ], - "sale_price": { - "min": 0, - "max": 999999999999, - }, - "jeonse_price": { - "min": 0, - "max": 999999999999, - }, + "jeonse_price": { + "min": recommendation.jeonse_price_min, + "max": recommendation.jeonse_price_max, }, }, - ], + }) + + return { + "total": len(items), + "items": items, } diff --git a/app/schemas/service.py b/app/schemas/service.py index 49fc847..6f73b06 100644 --- a/app/schemas/service.py +++ b/app/schemas/service.py @@ -134,8 +134,8 @@ class RecommendationReportItemSummary(BaseModel): score: Score region: RegionItem = Field(description="동네 이름") address: AddressDetails = Field(description="매물 주소 정보") - sale_price: int | None = Field(description="매물의 매매 최소 가격") - jeonse_price: int | None = Field(description="매물의 전세 최소 가격") + sale_price: PriceRange = Field(description="매물의 매매 가격 범위") + jeonse_price: PriceRange = Field(description="매물의 전세 가격 범위") infrastructure: list[RecommendationReportItemInfrastructureSummary] = Field(description="매물 주변 인프라 요약 정보 (최대 2개)", max_length=2) class RecommendationReport(RecommendationCreateResponse): diff --git a/app/services/recommendation.py b/app/services/recommendation.py index 30ce06b..d6f0f09 100644 --- a/app/services/recommendation.py +++ b/app/services/recommendation.py @@ -1,3 +1,4 @@ +import math import json import hashlib from enum import Enum @@ -61,6 +62,7 @@ def _main( ) -> None: # 사용자가 선택한 인프라 유형 순서, 순서대로 가중치 적용 (1개 이상 7개 이하) infra_types: list[str] = recommendation.infrastructure_priorities + infra_types_set: set[str] = set(infra_types) # infra_types 내 학교 유형이 있을 때만 school_district_types가 의미 있음 # depth=1 region의 academy_count 기준: 상위 15% → INTENSIVE, 15~50% → BALANCED, 나머지 → RELAXED @@ -132,8 +134,10 @@ def _main( inner_stmt = ( select( PropertyInfrastructure.property_id, + PropertyInfrastructure.infrastructure_id, PropertyInfrastructure.infrastructure_type, PropertyInfrastructure.score, + PropertyInfrastructure.distance, Infrastructure.name.label("infra_name"), rn_col, ) @@ -164,8 +168,10 @@ def _main( best_rows = db.execute( select( ranked_subq.c.property_id, + ranked_subq.c.infrastructure_id, ranked_subq.c.infrastructure_type, ranked_subq.c.score, + ranked_subq.c.distance, ranked_subq.c.infra_name, ) .where(ranked_subq.c.rn == 1) @@ -183,15 +189,21 @@ def _main( if t.value not in weight_map: weight_map[t.value] = unselected_weight + # 모든 인프라 score=100일 때의 이론적 최댓값 + max_score = 100.0 * sum(weight_map.values()) + property_infra_scores: dict[int, list[dict]] = defaultdict(list) property_total_scores: dict[int, float] = defaultdict(float) for row in best_rows: infra_type_str = getattr(row.infrastructure_type, "value", row.infrastructure_type) raw_score = float(row.score) property_infra_scores[row.property_id].append({ + "id": row.infrastructure_id, "type": infra_type_str, "name": row.infra_name, "score": raw_score, + "distance": row.distance, + "walking_duration": math.ceil(row.distance / 60), }) property_total_scores[row.property_id] += raw_score * weight_map.get(infra_type_str, 0) @@ -244,7 +256,7 @@ def _main( final_properties.append({ "id": pid, "name": detail.name, - "score": property_total_scores[pid], + "score": property_total_scores[pid] / max_score * 100, "region": detail.region_name, "sale_price_min": p.get("sale_price_min"), "sale_price_max": p.get("sale_price_max"), @@ -253,8 +265,7 @@ def _main( "naver_url": p.get("naver_url"), "infrastructure_scores": sorted( property_infra_scores[pid], - key=lambda x: x["score"], - reverse=True, + key=lambda x: (x["type"] not in infra_types_set, -x["score"]), ), }) @@ -290,6 +301,8 @@ def _bg_run( conn.commit() finally: # DB 세션 닫기 + rec.in_progress = False + conn.commit() if not db: conn.close() @@ -340,28 +353,29 @@ def generate_recommendation( if recommendation: if recommendation.failed_at: # 이전에 실패했다면 다시 로직 실행 - failed = True recommendation.failed_at = None + failed = True - if not recommendation or failed: - if not recommendation: - recommendation = create_recommendation( - db, - task_id=task_id, - **request_data, - ) + if not recommendation: + recommendation = create_recommendation( + db, + task_id=task_id, + **request_data, + ) recommendation.add_user(request_user, rec_name) + in_progress = recommendation.in_progress + recommendation.in_progress = True db.commit() - # 테스트용, 테스트 끝나면 들여쓰기 1번 추가 - if background_tasks: - # 백그라운드에서 비동기로 추천 생성 실행 - background_tasks.add_task( - _bg_run, - task_id=task_id, - ) - else: - # 스크립트로 실행 시 동기적으로 추천 생성 실행 - _bg_run(task_id, db=db) + if in_progress is not True and (failed or recommendation.finished_at is None): + if background_tasks: + # 백그라운드에서 비동기로 추천 생성 실행 + background_tasks.add_task( + _bg_run, + task_id=task_id, + ) + else: + # 스크립트로 실행 시 동기적으로 추천 생성 실행 + _bg_run(task_id, db=db) - return task_id, recommendation + return recommendation diff --git a/tests/test_public.py b/tests/test_public.py index b25161b..0dde76a 100644 --- a/tests/test_public.py +++ b/tests/test_public.py @@ -14,6 +14,7 @@ def test_get_regions(client, mock_redis) -> None: "2": json.dumps({"id": 2, "name": "서울특별시 마포구 합정동"}, ensure_ascii=False), } + mock_redis.get.return_value = "0" mock_redis.hgetall.return_value = mock_regions_data # 2. API 호출 @@ -46,7 +47,7 @@ def test_get_infrastructure_types(client) -> None: # 각 인프라 아이템의 구조 검증 labels = [x["label"] for x in data["items"]] assert "지하철역" in labels - assert "공원·녹지" in labels + assert "공원" in labels types = [x["type"] for x in data["items"]] assert "SUBWAY_STATION" in types diff --git a/tests/test_recommendations.py b/tests/test_recommendations.py index f12f839..67797e0 100644 --- a/tests/test_recommendations.py +++ b/tests/test_recommendations.py @@ -1,16 +1,23 @@ """추천(recommendations) 라우터 엔드포인트 테스트.""" from unittest.mock import MagicMock, patch -from app.models import User, Recommendation, SearchLog +from app.models import User, Recommendation, SearchLog, Property, Infrastructure +from app.core.enums import InfrastructureTypeEnum, SchoolDistrictTypeEnum +from app.services.recommendation import generate_recommendation_task_id + +from datetime import datetime def make_mock_user(id=1, email="test@example.com", name="Test User", cuid="cuid123"): """테스트용 가짜 User 모델 인스턴스 생성""" - user = MagicMock(spec=User) - user.id = id - user.email = email - user.name = name - user.cuid = cuid - user.verify_password.return_value = True + user = User( + id=id, + email=email, + name=name, + cuid=cuid, + ) + user.password = "password123" + user.created_at = datetime.utcnow() + user.updated_at = datetime.utcnow() return user @@ -19,7 +26,7 @@ def make_mock_user(id=1, email="test@example.com", name="Test User", cuid="cuid1 @patch("app.core.validate.get_region_by_id") @patch("app.routers.recommendations.verify_high_schools") def test_request_generate_recommendation_success( - mock_verify_high_schools, mock_get_region_by_id, mock_get_user_by_email, mock_get_user_by_cuid, client + mock_verify_high_schools, mock_get_region_by_id, mock_get_user_by_email, mock_get_user_by_cuid, client, mock_db ) -> None: """추천 생성 요청 성공 테스트.""" # 1. 모킹 설정 @@ -29,7 +36,9 @@ def test_request_generate_recommendation_success( # 유효한 지역 반환 설정 mock_get_region_by_id.return_value = {"id": 1, "name": "서울특별시 용산구 도원동"} - mock_verify_high_schools.return_value = [MagicMock(), MagicMock()] + mock_verify_high_schools.return_value = [{"id": 1, "name": "고등학교1"}, {"id": 2, "name": "고등학교2"}] + + mock_db.query().filter().first.return_value = None # 2. 로그인 client.post("/auth/login", json={"email": "test@example.com", "password": "password123"}) @@ -50,8 +59,19 @@ def test_request_generate_recommendation_success( assert response.status_code == 202 data = response.json() - assert data["task_id"] == "unique_task_id" - assert data["status"] == "in_progress" + + expected_task_id = generate_recommendation_task_id( + region_id=1, + infrastructure_types=[InfrastructureTypeEnum.SUBWAY_STATION, InfrastructureTypeEnum.PARK], + high_school_ids=[1, 2], + school_district_types=[SchoolDistrictTypeEnum.INTENSIVE, SchoolDistrictTypeEnum.BALANCED], + sale_price_min=0, + sale_price_max=1000000000, + jeonse_price_min=0, + jeonse_price_max=500000000 + ) + assert data["task_id"] == expected_task_id + @patch("app.dependencies.get_user_by_cuid") @@ -223,7 +243,7 @@ def test_change_recommendation_name_success( @patch("app.routers.auth.get_user_by_email") @patch("app.dependencies.get_search_log_by_user_id_and_task_id") def test_get_recommendation_summary_success( - mock_get_search_log, mock_get_user_by_email, mock_get_user_by_cuid, client + mock_get_search_log, mock_get_user_by_email, mock_get_user_by_cuid, client, mock_db, mock_redis ) -> None: """추천 결과 요약 조회(GET /recommendations/{task_id}) 성공 테스트.""" mock_user = make_mock_user() @@ -233,12 +253,62 @@ def test_get_recommendation_summary_success( # 1개의 search_log 및 recommendation 리턴하도록 설정 mock_rec = MagicMock(spec=Recommendation) mock_rec.task_id = "unique_task_id" + mock_rec.region = "서울특별시 용산구 도원동" + mock_rec.infrastructure_priorities = ["SUBWAY_STATION"] + mock_rec.school_district_types = ["INTENSIVE"] + mock_rec.high_school_ids = [1] + mock_rec.sale_price_min = 0 + mock_rec.sale_price_max = 1000000000 + mock_rec.jeonse_price_min = 0 + mock_rec.jeonse_price_max = 500000000 + mock_rec.in_progress = False + + mock_rec.top_properties = [ + { + "id": 1, + "name": "삼성래미안", + "score": 87, + "region": "서울특별시 용산구 도원동", + "sale_price_min": 1200000000, + "sale_price_max": 1200000000, + "jeonse_price_min": 440000000, + "jeonse_price_max": 440000000, + "infrastructure_scores": [ + {"type": "SUBWAY_STATION", "distance": 0.6, "walking_duration": 13} + ] + } + ] mock_search_log = MagicMock(spec=SearchLog) mock_search_log.task_id = "unique_task_id" + mock_search_log.name = "내 맞춤 추천" mock_search_log.recommendation = mock_rec mock_get_search_log.return_value = [mock_search_log] + # Redis 모킹 + import json as py_json + def mock_hgetall(key): + if key == "regions:all": + return { + "1": py_json.dumps({"id": 1, "name": "서울특별시 용산구 도원동"}, ensure_ascii=False), + } + elif key == "high_schools:all": + return { + "1": py_json.dumps({"id": 1, "name": "고등학교1", "latitude": 37.5383, "longitude": 126.9553}, ensure_ascii=False), + } + return {} + mock_redis.hgetall.side_effect = mock_hgetall + mock_redis.get.return_value = "0" # max_depth + + # DB 모킹 + mock_property = MagicMock(spec=Property) + mock_property.id = 1 + mock_property.land_lot_address = "서울특별시 용산구 도원동 23" + mock_property.road_name_address = "서울특별시 용산구 새창로 70" + mock_property.latitude = 37.53830000 + mock_property.longitude = 126.95532000 + mock_db.query().filter().all.return_value = [mock_property] + # 1. 로그인 client.post("/auth/login", json={"email": "test@example.com", "password": "password123"}) @@ -247,10 +317,9 @@ def test_get_recommendation_summary_success( assert response.status_code == 200 data = response.json() - # 하드코딩 리턴 구조 검증 - assert data["task_id"] == "full_task_id" + assert data["task_id"] == "unique_task_id" assert data["status"] == "completed" - assert len(data["properties"]) == 2 + assert len(data["properties"]) == 1 assert data["properties"][0]["name"] == "삼성래미안" @@ -258,7 +327,7 @@ def test_get_recommendation_summary_success( @patch("app.routers.auth.get_user_by_email") @patch("app.dependencies.get_search_log_by_user_id_and_task_id") def test_get_recommendation_property_detail_success( - mock_get_search_log, mock_get_user_by_email, mock_get_user_by_cuid, client + mock_get_search_log, mock_get_user_by_email, mock_get_user_by_cuid, client, mock_db, mock_redis ) -> None: """추천 매물 상세 조회(GET /recommendations/{task_id}/properties/{property_id}) 성공 테스트.""" mock_user = make_mock_user() @@ -268,14 +337,77 @@ def test_get_recommendation_property_detail_success( # 1개의 search_log 및 recommendation 리턴하도록 설정 mock_rec = MagicMock(spec=Recommendation) mock_rec.task_id = "unique_task_id" + mock_rec.region = "서울특별시 용산구 도원동" + mock_rec.infrastructure_priorities = ["SUBWAY_STATION"] + mock_rec.school_district_types = ["INTENSIVE"] + mock_rec.high_school_ids = [1] + mock_rec.sale_price_min = 0 + mock_rec.sale_price_max = 1000000000 + mock_rec.jeonse_price_min = 0 + mock_rec.jeonse_price_max = 500000000 + mock_rec.in_progress = False + # top_properties 모킹 - mock_rec.top_properties = [MagicMock()] + mock_rec.top_properties = [ + { + "id": 1, + "name": "삼성래미안", + "score": 87, + "region": "서울특별시 용산구 도원동", + "sale_price_min": 1200000000, + "sale_price_max": 1200000000, + "jeonse_price_min": 440000000, + "jeonse_price_max": 440000000, + "infrastructure_scores": [ + {"id": 1, "type": "SUBWAY_STATION", "distance": 0.6, "walking_duration": 13, "name": "효창공원앞", "score": 90} + ] + } + ] mock_search_log = MagicMock(spec=SearchLog) mock_search_log.task_id = "unique_task_id" + mock_search_log.name = "내 맞춤 추천" mock_search_log.recommendation = mock_rec mock_get_search_log.return_value = [mock_search_log] + # Redis 모킹 + import json as py_json + def mock_hgetall(key): + if key == "regions:all": + return { + "1": py_json.dumps({"id": 1, "name": "서울특별시 용산구 도원동"}, ensure_ascii=False), + } + elif key == "high_schools:all": + return { + "1": py_json.dumps({"id": 1, "name": "고등학교1", "latitude": 37.5383, "longitude": 126.9553}, ensure_ascii=False), + } + return {} + mock_redis.hgetall.side_effect = mock_hgetall + mock_redis.get.return_value = "0" # max_depth + + # DB 모킹 + mock_property = MagicMock(spec=Property) + mock_property.id = 1 + mock_property.land_lot_address = "서울특별시 용산구 도원동 23" + mock_property.road_name_address = "서울특별시 용산구 새창로 70" + mock_property.latitude = 37.53830000 + mock_property.longitude = 126.95532000 + + mock_infra = MagicMock(spec=Infrastructure) + mock_infra.id = 1 + mock_infra.name = "효창공원앞" + mock_infra.latitude = 37.53895534 + mock_infra.longitude = 126.96173072 + + def mock_query(model): + q = MagicMock() + if model == Property: + q.filter().first.return_value = mock_property + elif model == Infrastructure: + q.filter().all.return_value = [mock_infra] + return q + mock_db.query.side_effect = mock_query + # 1. 로그인 client.post("/auth/login", json={"email": "test@example.com", "password": "password123"}) @@ -284,10 +416,9 @@ def test_get_recommendation_property_detail_success( assert response.status_code == 200 data = response.json() - # 하드코딩 리턴 구조 검증 assert data["id"] == 1 assert data["name"] == "삼성래미안" - assert len(data["infrastructure"]) == 2 + assert len(data["infrastructure"]) == 1 assert data["infrastructure"][0]["name"] == "효창공원앞" diff --git a/tests/test_users.py b/tests/test_users.py index d95a3b2..22904b5 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,17 +1,21 @@ """사용자(users) 라우터 엔드포인트 테스트.""" from unittest.mock import MagicMock, patch -from app.models import User +from app.models import User, SearchLog, Recommendation +from datetime import datetime def make_mock_user(id=1, email="test@example.com", name="Test User", cuid="cuid123"): """테스트용 가짜 User 모델 인스턴스 생성""" - user = MagicMock(spec=User) - user.id = id - user.email = email - user.name = name - user.cuid = cuid - user.verify_password.return_value = True + user = User( + id=id, + email=email, + name=name, + cuid=cuid, + ) + user.password = "password123" + user.created_at = datetime.utcnow() + user.updated_at = datetime.utcnow() return user @@ -88,7 +92,7 @@ def test_change_password_success(mock_get_user_by_email, mock_get_user_by_cuid, ) assert response.status_code == 204 - assert mock_user.password == "newpassword123" + assert mock_user.verify_password("newpassword123") mock_db.commit.assert_called_once() @@ -97,8 +101,6 @@ def test_change_password_success(mock_get_user_by_email, mock_get_user_by_cuid, def test_change_password_invalid_current(mock_get_user_by_email, mock_get_user_by_cuid, client) -> None: """현재 비밀번호가 틀려 비밀번호 변경에 실패하는 케이스 테스트.""" mock_user = make_mock_user() - # 로그인 시("password123")는 True를 반환하고, 비밀번호 변경 시("wrongpassword")는 False를 반환하도록 세팅 - mock_user.verify_password.side_effect = lambda password: password == "password123" mock_get_user_by_email.return_value = mock_user mock_get_user_by_cuid.return_value = mock_user @@ -126,16 +128,67 @@ def test_get_user_recommendations(mock_get_user_by_email, mock_get_user_by_cuid, mock_get_user_by_email.return_value = mock_user mock_get_user_by_cuid.return_value = mock_user - # 고등학교 mock 데이터 설정 - import json - mock_redis.hgetall.return_value = { - "1": json.dumps({ - "id": 1, - "name": "서울고등학교", - "latitude": 37.1234, - "longitude": 127.1234 - }) - } + # Recommendation & SearchLog mock 데이터 생성 + mock_rec1 = Recommendation( + task_id="task_id_1", + region="서울특별시 용산구 도원동", + infrastructure_priorities=["SUBWAY_STATION"], + school_district_types=["INTENSIVE"], + high_school_ids=[1], + sale_price_min=0, + sale_price_max=1000000000, + jeonse_price_min=0, + jeonse_price_max=500000000, + in_progress=False, + ) + + mock_rec2 = Recommendation( + task_id="task_id_2", + region="서울특별시 마포구 합정동", + infrastructure_priorities=["SUBWAY_STATION"], + school_district_types=["BALANCED"], + high_school_ids=[1], + sale_price_min=0, + sale_price_max=1000000000, + jeonse_price_min=0, + jeonse_price_max=500000000, + in_progress=True, + ) + + mock_log1 = SearchLog( + task_id="task_id_1", + name="첫번째 추천", + user_id=mock_user.id, + recommendation=mock_rec1, + ) + mock_log1.requested_at = datetime.utcnow() + mock_log1.last_viewed_at = datetime.utcnow() + + mock_log2 = SearchLog( + task_id="task_id_2", + name="두번째 추천", + user_id=mock_user.id, + recommendation=mock_rec2, + ) + mock_log2.requested_at = datetime.utcnow() + mock_log2.last_viewed_at = datetime.utcnow() + + mock_db.query().join().filter().all.return_value = [mock_log1, mock_log2] + + # Redis 모킹 설정 + import json as py_json + def mock_hgetall(key): + if key == "regions:all": + return { + "1": py_json.dumps({"id": 1, "name": "서울특별시 용산구 도원동"}, ensure_ascii=False), + "2": py_json.dumps({"id": 2, "name": "서울특별시 마포구 합정동"}, ensure_ascii=False), + } + elif key == "high_schools:all": + return { + "1": py_json.dumps({"id": 1, "name": "서울고등학교", "latitude": 37.1234, "longitude": 127.1234}, ensure_ascii=False), + } + return {} + mock_redis.hgetall.side_effect = mock_hgetall # 1. 로그인 client.post("/auth/login", json={"email": "test@example.com", "password": "password123"}) @@ -145,7 +198,7 @@ def test_get_user_recommendations(mock_get_user_by_email, mock_get_user_by_cuid, assert response.status_code == 200 data = response.json() - # 더미 데이터 응답 포맷 검증 + # 추천 요청 목록 구조 및 실제 값 검증 assert "total" in data assert "items" in data assert len(data["items"]) == 2