umla-sending 수정 반영 안내
요약 상태 갱신 결함(UPDATE 가 0행에 걸려 요약이 10분마다 다시 나가던 문제) 수정분이다. 운영 코드 3개 파일에 6곳을 고치고, 테스트 파일 1개를 새로 만든다(선택). 기준 커밋 5b671b8 대비 추가 31줄, 삭제 4줄.
읽는 법
행 번호 두 칸은 전(아무것도 고치지 않은 원본 파일 기준)과 후(모든 단계를 끝낸 파일 기준)이다. 같은 파일의 앞 단계를 끝낸 뒤에는 줄이 밀려 있으므로, 각 단계의 "위치"와 "찾기" 문장은 앞 단계까지 반영한 상태의 행 번호를 따로 적어 두었다. 다 고친 뒤 맞춰 볼 때는 "후"를 본다.
들여쓰기는 공백 4칸이고 탭은 쓰지 않는다. 행 번호 옆 ×4 열의 숫자가 들여쓰기 단계다. 6 이면 공백 24칸, 6+2 는 26칸이다. 코드 앞의 옅은 세로선도 한 단계마다 하나씩 그렸는데, 좁은 화면에서는 폭을 줄여 그리므로 칸 수는 숫자로 센다. 긴 줄은 화면 폭에 맞춰 접혀 보이며, 접힌 둘째 줄부터는 코드 시작 열보다 두 칸 안쪽에서 시작한다. 접지 않고 옆으로 밀며 보려면 상단 "줄바꿈" 버튼을 끈다.
각 단계의 grep 명령은 umla-sending 폴더 안에서 실행한다. 결과가 정확히 1줄이어야 하며, 행 번호가 "찾기" 문장에 적힌 값과 같아야 한다. 단계 제목 왼쪽 체크박스는 이 핸드폰에만 저장된다.
작업 순서 (1번을 먼저 해야 2번 이후의 import 가 깨지지 않는다)
- 1. summary.py — 상수 SUMMARY_DATA_SOURCE 추가
- 2. external_sender.py — import 한 줄 추가
- 3. external_sender.py — 집계 변수 2개 선언
- 4. external_sender.py — UPDATE 결과로 행수를 세고 로그를 바꾼다
- 5. stocktwits_deliverer_helper.py — import 한 줄 추가
- 6. stocktwits_deliverer_helper.py — data_source 바인딩을 상수로 교체
- 7. 테스트 파일 신규 작성 (선택)
- 8. 반입 전 확인
community/stocktwits/models/summary.py
from .base import Base 아래, class 선언 위에 주석 5줄과 상수 1줄을 넣는다. 상수 아래에는 빈 줄 2줄을 둔다. (추가할 줄 8)
위치: 37행 바로 아래에 넣는다. 찾기: 아래 명령의 결과가 1줄, 35행이어야 한다.
grep -nF 'from .base import Base' community/stocktwits/models/summary.py
| 전 | 후 | ×4 | ||
|---|---|---|---|---|
| 35 | 35 | from .base import Base | ||
| 36 | 36 | | ||
| 37 | 37 | | ||
| 38 | + | # * 요약 테이블(community_ticker_daily_summaries_processed)의 data_source 고정값 | ||
| 39 | + | # ! DDL(doc/community/stocktwits/datamodels/sql/7.ddl_summary.sql)의 data_source 주석과 | ||
| 40 | + | # ! 요약 생성 측(umla-translation StocktwitsSummaryPostprocessor.DATA_SOURCE)이 같은 값을 쓴다. | ||
| 41 | + | # ! 전송용 조회 SQL(deliverer_summary_message_query_v4.sql)은 이 컬럼을 'Samsung' 별칭으로 | ||
| 42 | + | # ! 바꿔 돌려주므로, 상태를 갱신할 때는 payload 값이 아니라 이 상수를 PK 조건에 바인딩한다. | ||
| 43 | + | SUMMARY_DATA_SOURCE = "StockTwits" | ||
| 44 | + | | ||
| 45 | + | | ||
| 38 | 46 | class CommunityTickerDailySummaryProcessed(Base): | ||
| 39 | 47 | 1 | """ | |
| 40 | 48 | 1 | * 티커별 일일 요약 결과 테이블 |
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
| 후 | ×4 | |
|---|---|---|
| 35 | from .base import Base | |
| 36 | | |
| 37 | | |
| 38 | # * 요약 테이블(community_ticker_daily_summaries_processed)의 data_source 고정값 | |
| 39 | # ! DDL(doc/community/stocktwits/datamodels/sql/7.ddl_summary.sql)의 data_source 주석과 | |
| 40 | # ! 요약 생성 측(umla-translation StocktwitsSummaryPostprocessor.DATA_SOURCE)이 같은 값을 쓴다. | |
| 41 | # ! 전송용 조회 SQL(deliverer_summary_message_query_v4.sql)은 이 컬럼을 'Samsung' 별칭으로 | |
| 42 | # ! 바꿔 돌려주므로, 상태를 갱신할 때는 payload 값이 아니라 이 상수를 PK 조건에 바인딩한다. | |
| 43 | SUMMARY_DATA_SOURCE = "StockTwits" | |
| 44 | | |
| 45 | | |
| 46 | class CommunityTickerDailySummaryProcessed(Base): | |
| 47 | 1 | """ |
| 48 | 1 | * 티커별 일일 요약 결과 테이블 |
community/services/external_sender.py
community.interfaces import 바로 아래에 한 줄을 추가한다. (추가할 줄 1)
위치: 15행 바로 아래에 넣는다. 찾기: 아래 명령의 결과가 1줄, 15행이어야 한다.
grep -nF 'from community.interfaces import DeliveryItem, StatusUpdateInfo' community/services/external_sender.py
| 전 | 후 | ×4 | ||
|---|---|---|---|---|
| 13 | 13 | from core.log import LogHandler | ||
| 14 | 14 | from core.utils import sftp_utils | ||
| 15 | 15 | from community.interfaces import DeliveryItem, StatusUpdateInfo | ||
| 16 | + | from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE | ||
| 16 | 17 | | ||
| 17 | 18 | | ||
| 18 | 19 | class CommunityExternalSender: |
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
| 후 | ×4 | |
|---|---|---|
| 13 | from core.log import LogHandler | |
| 14 | from core.utils import sftp_utils | |
| 15 | from community.interfaces import DeliveryItem, StatusUpdateInfo | |
| 16 | from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE | |
| 17 | | |
| 18 | | |
| 19 | class CommunityExternalSender: |
community/services/external_sender.py
_update_summary_statuses 안, import json 다음의 for item in items: 위에 두 줄과 빈 줄을 넣는다. (추가할 줄 3)
위치: 345행 바로 아래에 넣는다 (이 파일의 앞 단계를 아직 안 했으면 344행). 찾기: 아래 명령의 결과가 1줄, 344행 (앞 단계를 아직 안 했으면 343행)이어야 한다.
grep -nF 'import json' community/services/external_sender.py
| 전 | 후 | ×4 | ||
|---|---|---|---|---|
| 342 | 343 | | ||
| 343 | 344 | 3 | import json | |
| 344 | 345 | | ||
| 346 | + | 3 | updated_rows = 0 | |
| 347 | + | 3 | unmatched_tickers: List[str] = [] | |
| 348 | + | | ||
| 345 | 349 | 3 | for item in items: | |
| 346 | 350 | 4 | payload = item.payload | |
| 347 | 351 | 4 | tickers_raw = payload.get("tickers", "[]") |
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
| 후 | ×4 | |
|---|---|---|
| 343 | | |
| 344 | 3 | import json |
| 345 | | |
| 346 | 3 | updated_rows = 0 |
| 347 | 3 | unmatched_tickers: List[str] = [] |
| 348 | | |
| 349 | 3 | for item in items: |
| 350 | 4 | payload = item.payload |
| 351 | 4 | tickers_raw = payload.get("tickers", "[]") |
community/services/external_sender.py
session.execute 결과를 result 에 받고, data_source 바인딩을 상수로 바꾸고, 완료 로그를 실제 갱신 행수로 바꾸고, 누락 경고를 추가한다. (지울 줄 3, 추가할 줄 16)
위치: 377행을 고친다 (이 파일의 앞 단계를 아직 안 했으면 373행). 찾기: 아래 명령의 결과가 1줄, 374행 (앞 단계를 아직 안 했으면 370행)이어야 한다.
grep -nF 'if not ticker:' community/services/external_sender.py
| 전 | 후 | ×4 | ||
|---|---|---|---|---|
| 370 | 374 | 4 | if not ticker: | |
| 371 | 375 | 5 | continue | |
| 372 | 376 | | ||
| 373 | − | 4 | session.execute( | |
| 377 | + | 4 | result = session.execute( | |
| 374 | 378 | 5 | update_query, | |
| 375 | 379 | 5 | { | |
| 376 | 380 | 6 | "status": mapped_status, | |
| 377 | − | 6 | "data_source": payload.get("data_source", "StockTwits"), | |
| 381 | + | 6 | # ! 조회 SQL 은 data_source 를 'Samsung' 별칭으로 돌려주므로 payload 값을 | |
| 382 | + | 6 | # ! 바인딩하면 PK(data_source = 'StockTwits')와 어긋나 0행 갱신이 된다 | |
| 383 | + | 6 | "data_source": SUMMARY_DATA_SOURCE, | |
| 378 | 384 | 6 | "ticker": ticker, | |
| 379 | 385 | 6 | "summary_date": payload.get("created_at"), | |
| 380 | 386 | 5 | }, | |
| 381 | 387 | 4 | ) | |
| 388 | + | 4 | if result.rowcount > 0: | |
| 389 | + | 5 | updated_rows += result.rowcount | |
| 390 | + | 4 | else: | |
| 391 | + | 5 | unmatched_tickers.append(ticker) | |
| 382 | 392 | | ||
| 383 | 393 | 3 | session.commit() | |
| 384 | 394 | 3 | self.logger.info( | |
| 385 | − | 4 | f"[요약] 상태 업데이트 완료: {len(items)}개 -> {mapped_status}" | |
| 395 | + | 4 | f"[요약] 상태 업데이트 완료: 대상 {len(items)}건, " | |
| 396 | + | 4 | f"갱신 {updated_rows}행 -> {mapped_status}" | |
| 386 | 397 | 3 | ) | |
| 398 | + | 3 | if unmatched_tickers: | |
| 399 | + | 4 | self.logger.warning( | |
| 400 | + | 5 | f"[요약] 상태 갱신 누락: {len(unmatched_tickers)}건이 상태 테이블의 " | |
| 401 | + | 5 | f"PK(data_source, ticker, summary_date)와 매칭되지 않음 " | |
| 402 | + | 5 | f"(ticker: {', '.join(unmatched_tickers)}) -> {mapped_status}" | |
| 403 | + | 4 | ) | |
| 387 | 404 | | ||
| 388 | 405 | 2 | except Exception as e: | |
| 389 | 406 | 3 | session.rollback() |
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
| 후 | ×4 | |
|---|---|---|
| 374 | 4 | if not ticker: |
| 375 | 5 | continue |
| 376 | | |
| 377 | 4 | result = session.execute( |
| 378 | 5 | update_query, |
| 379 | 5 | { |
| 380 | 6 | "status": mapped_status, |
| 381 | 6 | # ! 조회 SQL 은 data_source 를 'Samsung' 별칭으로 돌려주므로 payload 값을 |
| 382 | 6 | # ! 바인딩하면 PK(data_source = 'StockTwits')와 어긋나 0행 갱신이 된다 |
| 383 | 6 | "data_source": SUMMARY_DATA_SOURCE, |
| 384 | 6 | "ticker": ticker, |
| 385 | 6 | "summary_date": payload.get("created_at"), |
| 386 | 5 | }, |
| 387 | 4 | ) |
| 388 | 4 | if result.rowcount > 0: |
| 389 | 5 | updated_rows += result.rowcount |
| 390 | 4 | else: |
| 391 | 5 | unmatched_tickers.append(ticker) |
| 392 | | |
| 393 | 3 | session.commit() |
| 394 | 3 | self.logger.info( |
| 395 | 4 | f"[요약] 상태 업데이트 완료: 대상 {len(items)}건, " |
| 396 | 4 | f"갱신 {updated_rows}행 -> {mapped_status}" |
| 397 | 3 | ) |
| 398 | 3 | if unmatched_tickers: |
| 399 | 4 | self.logger.warning( |
| 400 | 5 | f"[요약] 상태 갱신 누락: {len(unmatched_tickers)}건이 상태 테이블의 " |
| 401 | 5 | f"PK(data_source, ticker, summary_date)와 매칭되지 않음 " |
| 402 | 5 | f"(ticker: {', '.join(unmatched_tickers)}) -> {mapped_status}" |
| 403 | 4 | ) |
| 404 | | |
| 405 | 2 | except Exception as e: |
| 406 | 3 | session.rollback() |
community/stocktwits/services/api/stocktwits_deliverer_helper.py
deliverer_message_query import 바로 아래에 한 줄을 추가한다. (추가할 줄 1)
위치: 21행 바로 아래에 넣는다. 찾기: 아래 명령의 결과가 1줄, 21행이어야 한다.
grep -nF 'from .queries.deliverer_message_query import DelivererMessageQueries' community/stocktwits/services/api/stocktwits_deliverer_helper.py
| 전 | 후 | ×4 | ||
|---|---|---|---|---|
| 19 | 19 | from core.log import LogHandler | ||
| 20 | 20 | from core.utils.json_handler import JsonHandler | ||
| 21 | 21 | from .queries.deliverer_message_query import DelivererMessageQueries | ||
| 22 | + | from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE | ||
| 22 | 23 | from community.stocktwits.models.published import ( | ||
| 23 | 24 | 1 | CommunityMessagePublished, | |
| 24 | 25 | 1 | CommunityUserPublished, |
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
| 후 | ×4 | |
|---|---|---|
| 19 | from core.log import LogHandler | |
| 20 | from core.utils.json_handler import JsonHandler | |
| 21 | from .queries.deliverer_message_query import DelivererMessageQueries | |
| 22 | from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE | |
| 23 | from community.stocktwits.models.published import ( | |
| 24 | 1 | CommunityMessagePublished, |
| 25 | 1 | CommunityUserPublished, |
community/stocktwits/services/api/stocktwits_deliverer_helper.py
update_summaries_processing_status 안의 summary["data_source"] 를 상수로 바꾸고 주석 한 줄을 붙인다. (지울 줄 1, 추가할 줄 2)
위치: 723행을 고친다 (이 파일의 앞 단계를 아직 안 했으면 722행). 찾기: 아래 명령의 결과가 1줄, 720행 (앞 단계를 아직 안 했으면 719행)이어야 한다.
grep -nF 'update_query,' community/stocktwits/services/api/stocktwits_deliverer_helper.py
| 전 | 후 | ×4 | ||
|---|---|---|---|---|
| 719 | 720 | 5 | update_query, | |
| 720 | 721 | 5 | { | |
| 721 | 722 | 6 | "status": status, | |
| 722 | − | 6 | "data_source": summary["data_source"], | |
| 723 | + | 6 | # ! 조회 SQL 의 'Samsung' 별칭 대신 PK 값('StockTwits')을 바인딩한다 | |
| 724 | + | 6 | "data_source": SUMMARY_DATA_SOURCE, | |
| 723 | 725 | 6 | "ticker": ticker, | |
| 724 | 726 | 6 | "summary_date": summary["created_at"], | |
| 725 | 727 | 5 | }, |
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
| 후 | ×4 | |
|---|---|---|
| 720 | 5 | update_query, |
| 721 | 5 | { |
| 722 | 6 | "status": status, |
| 723 | 6 | # ! 조회 SQL 의 'Samsung' 별칭 대신 PK 값('StockTwits')을 바인딩한다 |
| 724 | 6 | "data_source": SUMMARY_DATA_SOURCE, |
| 725 | 6 | "ticker": ticker, |
| 726 | 6 | "summary_date": summary["created_at"], |
| 727 | 5 | }, |
_update_summary_statuses 수정 후 전체 (community/services/external_sender.py 308~409행, 102줄)
| 후 | ×4 | |
|---|---|---|
| 308 | 1 | def _update_summary_statuses( |
| 309 | 2 | self, |
| 310 | 2 | session: Session, |
| 311 | 2 | items: List[DeliveryItem], |
| 312 | 2 | target_status: str, |
| 313 | 1 | ) -> None: |
| 314 | 2 | """ |
| 315 | 2 | * 요약 상태 업데이트 |
| 316 | | |
| 317 | 2 | Args: |
| 318 | 3 | session: SQLAlchemy 세션 |
| 319 | 3 | items: 요약 DeliveryItem 리스트 |
| 320 | 3 | target_status: 변경할 상태값 |
| 321 | 2 | """ |
| 322 | 2 | if not items: |
| 323 | 3 | return |
| 324 | | |
| 325 | 2 | try: |
| 326 | 3 | # * processing_status가 DELIVERED/DELIVERY_FAILED로 매핑됨 |
| 327 | 3 | status_map = { |
| 328 | 4 | "PIPELINE_COMPLETED": "DELIVERED", |
| 329 | 4 | "DELIVERY_FAILED": "DELIVERY_FAILED", |
| 330 | 3 | } |
| 331 | 3 | mapped_status = status_map.get(target_status, target_status) |
| 332 | | |
| 333 | 3 | update_query = text( |
| 334 | 4 | """ |
| 335 | 4 | UPDATE suml.community_ticker_daily_summaries_processed |
| 336 | 4 | SET processing_status = :status, |
| 337 | 5 | _updated_at = CURRENT_TIMESTAMP |
| 338 | 4 | WHERE data_source = :data_source |
| 339 | 5 | AND ticker = :ticker |
| 340 | 5 | AND summary_date = :summary_date |
| 341 | 3 | """ |
| 342 | 3 | ) |
| 343 | | |
| 344 | 3 | import json |
| 345 | | |
| 346 | 3 | updated_rows = 0 |
| 347 | 3 | unmatched_tickers: List[str] = [] |
| 348 | | |
| 349 | 3 | for item in items: |
| 350 | 4 | payload = item.payload |
| 351 | 4 | tickers_raw = payload.get("tickers", "[]") |
| 352 | | |
| 353 | 4 | # * tickers 파싱 |
| 354 | 4 | if isinstance(tickers_raw, str): |
| 355 | 5 | tickers_json = json.loads(tickers_raw) |
| 356 | 4 | else: |
| 357 | 5 | tickers_json = tickers_raw |
| 358 | | |
| 359 | 4 | if not tickers_json: |
| 360 | 5 | continue |
| 361 | | |
| 362 | 4 | # * 첫 번째 요소에서 ticker 추출 |
| 363 | 4 | ticker_exchange = tickers_json[0] |
| 364 | 4 | if isinstance(ticker_exchange, str): |
| 365 | 5 | parts = ticker_exchange.split("|") |
| 366 | 5 | ticker = parts[0] if parts else None |
| 367 | 4 | else: |
| 368 | 5 | ticker = ( |
| 369 | 6 | ticker_exchange.get("symbol") |
| 370 | 6 | if isinstance(ticker_exchange, dict) |
| 371 | 6 | else None |
| 372 | 5 | ) |
| 373 | | |
| 374 | 4 | if not ticker: |
| 375 | 5 | continue |
| 376 | | |
| 377 | 4 | result = session.execute( |
| 378 | 5 | update_query, |
| 379 | 5 | { |
| 380 | 6 | "status": mapped_status, |
| 381 | 6 | # ! 조회 SQL 은 data_source 를 'Samsung' 별칭으로 돌려주므로 payload 값을 |
| 382 | 6 | # ! 바인딩하면 PK(data_source = 'StockTwits')와 어긋나 0행 갱신이 된다 |
| 383 | 6 | "data_source": SUMMARY_DATA_SOURCE, |
| 384 | 6 | "ticker": ticker, |
| 385 | 6 | "summary_date": payload.get("created_at"), |
| 386 | 5 | }, |
| 387 | 4 | ) |
| 388 | 4 | if result.rowcount > 0: |
| 389 | 5 | updated_rows += result.rowcount |
| 390 | 4 | else: |
| 391 | 5 | unmatched_tickers.append(ticker) |
| 392 | | |
| 393 | 3 | session.commit() |
| 394 | 3 | self.logger.info( |
| 395 | 4 | f"[요약] 상태 업데이트 완료: 대상 {len(items)}건, " |
| 396 | 4 | f"갱신 {updated_rows}행 -> {mapped_status}" |
| 397 | 3 | ) |
| 398 | 3 | if unmatched_tickers: |
| 399 | 4 | self.logger.warning( |
| 400 | 5 | f"[요약] 상태 갱신 누락: {len(unmatched_tickers)}건이 상태 테이블의 " |
| 401 | 5 | f"PK(data_source, ticker, summary_date)와 매칭되지 않음 " |
| 402 | 5 | f"(ticker: {', '.join(unmatched_tickers)}) -> {mapped_status}" |
| 403 | 4 | ) |
| 404 | | |
| 405 | 2 | except Exception as e: |
| 406 | 3 | session.rollback() |
| 407 | 3 | tb = traceback.format_exc() |
| 408 | 3 | self.logger.error(f"[요약] 상태 업데이트 실패: {e}\n스택 트레이스:\n{tb}") |
| 409 | 3 | raise |
test/test_external_sender.py
새 파일이다. 144줄 전체를 옮겨 적는다. 시간이 없으면 건너뛰고 아래 "반입 전 확인"의 2번부터 진행해도 된다. 테스트는 DB 없이 돈다(MagicMock).
파일 전체 보기 (144줄)
| 행 | ×4 | |
|---|---|---|
| 1 | """ | |
| 2 | * CommunityExternalSender 테스트 코드 | |
| 3 | ! 요약 상태 업데이트(_update_summary_statuses)의 바인딩 값과 결과 로그 검증 | |
| 4 | ! 배경: 요약 조회 SQL(deliverer_summary_message_query_v4.sql)은 data_source 를 | |
| 5 | ! 'Samsung' 별칭으로 돌려주지만, 상태 테이블(community_ticker_daily_summaries_processed)의 | |
| 6 | ! PK 는 data_source = 'StockTwits' 이므로 payload 값을 그대로 바인딩하면 UPDATE 가 0행에 걸린다. | |
| 7 | """ | |
| 8 | | |
| 9 | import os | |
| 10 | import sys | |
| 11 | import unittest | |
| 12 | from datetime import date | |
| 13 | from unittest.mock import MagicMock | |
| 14 | | |
| 15 | # * 프로젝트 루트 경로 추가 | |
| 16 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| 17 | | |
| 18 | from core.log import LogHandler | |
| 19 | from core.adapters import DatabaseAdapter | |
| 20 | from community.interfaces import DeliveryItem | |
| 21 | from community.services.external_sender import CommunityExternalSender | |
| 22 | from community.stocktwits.services.api.stocktwits_deliverer_helper import ( | |
| 23 | 1 | StocktwitsDelivererHelper, |
| 24 | ) | |
| 25 | | |
| 26 | | |
| 27 | def _summary_payload(**overrides): | |
| 28 | 1 | """* v4 요약 조회 SQL 이 돌려주는 형태의 payload (상태 갱신에 쓰이는 키만)""" |
| 29 | 1 | payload = { |
| 30 | 2 | "data_source": "Samsung", |
| 31 | 2 | "record_type": "SUMMARY_AAPL", |
| 32 | 2 | "id": -2026103001000000001, |
| 33 | 2 | "tickers": '["AAPL|NASDAQ"]', |
| 34 | 2 | "created_at": date(2026, 10, 30), |
| 35 | 1 | } |
| 36 | 1 | payload.update(overrides) |
| 37 | 1 | return payload |
| 38 | | |
| 39 | | |
| 40 | def _summary_item(**overrides) -> DeliveryItem: | |
| 41 | 1 | return DeliveryItem( |
| 42 | 2 | id="-2026103001000000001", |
| 43 | 2 | source="stocktwits", |
| 44 | 2 | data_type="summary", |
| 45 | 2 | payload=_summary_payload(**overrides), |
| 46 | 2 | table_name="suml.community_ticker_daily_summaries_processed", |
| 47 | 2 | id_column="id", |
| 48 | 1 | ) |
| 49 | | |
| 50 | | |
| 51 | def _session_with_rowcount(rowcount: int) -> MagicMock: | |
| 52 | 1 | session = MagicMock() |
| 53 | 1 | session.execute.return_value = MagicMock(rowcount=rowcount) |
| 54 | 1 | return session |
| 55 | | |
| 56 | | |
| 57 | class TestUpdateSummaryStatuses(unittest.TestCase): | |
| 58 | 1 | """* CommunityExternalSender._update_summary_statuses 테스트""" |
| 59 | | |
| 60 | 1 | def setUp(self): |
| 61 | 2 | self.mock_logger = MagicMock(spec=LogHandler) |
| 62 | 2 | self.sender = CommunityExternalSender( |
| 63 | 3 | logger=self.mock_logger, config={}, hostname="test-host" |
| 64 | 2 | ) |
| 65 | | |
| 66 | 1 | def test_binds_stocktwits_data_source_even_when_payload_says_samsung(self): |
| 67 | 2 | """* payload 의 data_source 가 'Samsung' 이어도 UPDATE 는 PK 값 'StockTwits' 로 바인딩한다""" |
| 68 | 2 | session = _session_with_rowcount(1) |
| 69 | | |
| 70 | 2 | self.sender._update_summary_statuses( |
| 71 | 3 | session, [_summary_item()], "PIPELINE_COMPLETED" |
| 72 | 2 | ) |
| 73 | | |
| 74 | 2 | params = session.execute.call_args.args[1] |
| 75 | 2 | self.assertEqual(params["data_source"], "StockTwits") |
| 76 | 2 | self.assertEqual(params["ticker"], "AAPL") |
| 77 | 2 | self.assertEqual(params["summary_date"], date(2026, 10, 30)) |
| 78 | 2 | self.assertEqual(params["status"], "DELIVERED") |
| 79 | | |
| 80 | 1 | def test_logs_actual_updated_row_count_not_item_count(self): |
| 81 | 2 | """* 완료 로그는 요청 건수가 아니라 실제 갱신 행수를 적는다""" |
| 82 | 2 | session = _session_with_rowcount(0) |
| 83 | | |
| 84 | 2 | self.sender._update_summary_statuses( |
| 85 | 3 | session, [_summary_item()], "PIPELINE_COMPLETED" |
| 86 | 2 | ) |
| 87 | | |
| 88 | 2 | info_messages = [c.args[0] for c in self.mock_logger.info.call_args_list] |
| 89 | 2 | self.assertTrue( |
| 90 | 3 | any("대상 1건" in m and "갱신 0행" in m for m in info_messages), |
| 91 | 3 | f"완료 로그에 실제 갱신 행수가 없음: {info_messages}", |
| 92 | 2 | ) |
| 93 | | |
| 94 | 1 | def test_warns_when_updated_rows_fall_short_of_items(self): |
| 95 | 2 | """* 갱신 행수가 대상 건수보다 적으면 경고를 남긴다""" |
| 96 | 2 | session = _session_with_rowcount(0) |
| 97 | | |
| 98 | 2 | self.sender._update_summary_statuses( |
| 99 | 3 | session, [_summary_item()], "PIPELINE_COMPLETED" |
| 100 | 2 | ) |
| 101 | | |
| 102 | 2 | self.mock_logger.warning.assert_called_once() |
| 103 | 2 | self.assertIn("AAPL", self.mock_logger.warning.call_args.args[0]) |
| 104 | | |
| 105 | 1 | def test_no_warning_when_every_item_is_updated(self): |
| 106 | 2 | """* 모든 건이 갱신되면 경고를 남기지 않는다""" |
| 107 | 2 | session = _session_with_rowcount(1) |
| 108 | | |
| 109 | 2 | self.sender._update_summary_statuses( |
| 110 | 3 | session, [_summary_item()], "PIPELINE_COMPLETED" |
| 111 | 2 | ) |
| 112 | | |
| 113 | 2 | self.mock_logger.warning.assert_not_called() |
| 114 | | |
| 115 | | |
| 116 | class TestLegacyHelperUpdateSummariesProcessingStatus(unittest.TestCase): | |
| 117 | 1 | """* StocktwitsDelivererHelper.update_summaries_processing_status 테스트 (비운영 경로, 동일 결함)""" |
| 118 | | |
| 119 | 1 | def setUp(self): |
| 120 | 2 | self.mock_logger = MagicMock(spec=LogHandler) |
| 121 | 2 | self.helper = StocktwitsDelivererHelper( |
| 122 | 3 | logger=self.mock_logger, |
| 123 | 3 | db_adapter=MagicMock(spec=DatabaseAdapter), |
| 124 | 3 | config={}, |
| 125 | 3 | hostname="test-host", |
| 126 | 2 | ) |
| 127 | | |
| 128 | 1 | def test_binds_stocktwits_data_source_even_when_payload_says_samsung(self): |
| 129 | 2 | """* 레거시 경로도 payload 의 'Samsung' 대신 PK 값 'StockTwits' 로 바인딩한다""" |
| 130 | 2 | session = _session_with_rowcount(1) |
| 131 | | |
| 132 | 2 | self.helper.update_summaries_processing_status( |
| 133 | 3 | session, [_summary_payload()], "DELIVERED" |
| 134 | 2 | ) |
| 135 | | |
| 136 | 2 | params = session.execute.call_args.args[1] |
| 137 | 2 | self.assertEqual(params["data_source"], "StockTwits") |
| 138 | 2 | self.assertEqual(params["ticker"], "AAPL") |
| 139 | 2 | self.assertEqual(params["summary_date"], date(2026, 10, 30)) |
| 140 | 2 | self.assertEqual(params["status"], "DELIVERED") |
| 141 | | |
| 142 | | |
| 143 | if __name__ == "__main__": | |
| 144 | 1 | unittest.main() |
실행(umla-sending 폴더에서, pytest 가 없어도 된다):
python3.11 test/test_external_sender.py -v
마지막 줄이 OK 이고 Ran 5 tests 여야 한다.
- 문법 확인(umla-sending 폴더에서). 아무 출력 없이 끝나야 한다.
python3.11 -m py_compile community/stocktwits/models/summary.py community/services/external_sender.py community/stocktwits/services/api/stocktwits_deliverer_helper.py && echo OK
- 상수 사용처 확인. 결과가 정확히 5줄이어야 한다(summary.py 정의 1, external_sender.py 2, deliverer_helper.py 2).
grep -rn --include='*.py' SUMMARY_DATA_SOURCE community/ | wc -l
- import 확인. 아무 오류 없이 끝나야 한다.
python3.11 -c "from community.services.external_sender import CommunityExternalSender; from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE; print(SUMMARY_DATA_SOURCE)"
출력이StockTwits여야 한다. - 테스트 파일을 만들었다면
python3.11 test/test_external_sender.py -v가Ran 5 tests와OK로 끝나야 한다. - 1회 실행 확인. config.env 에
IS_SFTP_ENABLE="False"를 두고python3.11 -m community.entrypoint.sending_all을 1회 실행한다. 로그/log/sending/community/sending_all.log에서[요약] 상태 업데이트 완료: 대상 N건, 갱신 N행 -> DELIVERED의 두 숫자가 같고상태 갱신 누락경고가 없어야 한다. 09시 이전이라 그날 요약이 없으면[StockTwits 요약] 전송할 요약이 없습니다.가 찍히므로 09시 이후에 실행한다. - DB 확인. 전부 DELIVERED 여야 한다.
SELECT ticker, processing_status FROM suml.community_ticker_daily_summaries_processed WHERE data_source = 'StockTwits' AND summary_date = CURRENT_DATE ORDER BY ticker;
- 바로 다음 실행에서
[StockTwits 요약] 전송할 요약이 없습니다.가 찍히면 재전송이 멈춘 것이다.
동작 변화: 수정 뒤에는 그날 요약이 09시 이후 첫 회차에 한 번만 나간다. 전송이 실패하면 다음 회차에 자동으로 다시 나가지 않으므로, 실패한 행은 사람이 되돌린다. 먼저 SELECT 로 건수를 보고(0건이면 조건이 틀린 것), 그 다음에 UPDATE 한다.
SELECT ticker, processing_status, _updated_at FROM suml.community_ticker_daily_summaries_processed WHERE data_source = 'StockTwits' AND summary_date = CURRENT_DATE AND processing_status IN ('DELIVERY_QUEUED','DELIVERY_IN_PROGRESS','DELIVERY_FAILED') ORDER BY ticker;
UPDATE suml.community_ticker_daily_summaries_processed SET processing_status = 'COMPLETED' WHERE data_source = 'StockTwits' AND summary_date = CURRENT_DATE AND processing_status IN ('DELIVERY_QUEUED','DELIVERY_IN_PROGRESS','DELIVERY_FAILED');