1616from unittest import mock
1717
1818from .models import ProblemTag , ProblemIOMode , get_default_week_info
19- from .models import Problem , ProblemRuleType
19+ from .models import Problem , ProblemRuleType , ProblemAIHintLog
2020from .tasks import update_weekly_stats , update_bonus_problem
2121from contest .models import Contest
2222from contest .tests import DEFAULT_CONTEST_DATA
23- from .llm_hint import (CLUSTER_VLLM_CHAT_COMPLETIONS_URL , LOCAL_VLLM_CHAT_COMPLETIONS_URL ,
24- VLLM_CONNECT_TIMEOUT_SEC , VLLM_MODEL , VLLM_STREAM_READ_TIMEOUT_SEC ,
25- get_vllm_chat_completions_url )
23+ from .llm_hint import (CLUSTER_VLLM_CHAT_COMPLETIONS_URL , LOCAL_VLLM_CHAT_COMPLETIONS_URL , VLLM_CONNECT_TIMEOUT_SEC ,
24+ VLLM_MODEL , VLLM_STREAM_READ_TIMEOUT_SEC , get_vllm_chat_completions_url )
2625
2726from .views .admin import TestCaseAPI
2827from .utils import parse_problem_template
@@ -248,19 +247,21 @@ def test_get_one_problem(self):
248247class ProblemLLMHintAPITest (ProblemCreateTestBase ):
249248
250249 def setUp (self ):
250+ os .environ ["IS_LOCAL_TEST" ] = "False"
251+
251252 self .create_school_fixtures (college_id = 1 , college_name = "Test" , department_id = 1 , department_name = "Test" )
252253 self .url = self .reverse ("problem_llm_hint_api" )
253254 self .admin = self .create_admin (login = False )
254255 self .problem = self .add_problem (DEFAULT_PROBLEM_DATA , self .admin )
255256 self .hidden_problem = self .create_problem_with_custom_field (self .admin , _id = "A-211" , visible = False )
256- self .create_user (email = "test@test.com" , username = "test" , password = "test1234!" )
257+ # self.create_user(email="test@test.com", username="test", password="test1234!")
258+ self .user = self .create_user (email = "test@test.com" , username = "test" , password = "test1234!" )
259+ self .client .force_login (self .user )
257260
258261 @staticmethod
259262 def _streaming_body (response ):
260263 return "" .join (
261- chunk .decode ("utf-8" ) if isinstance (chunk , bytes ) else chunk
262- for chunk in response .streaming_content
263- )
264+ chunk .decode ("utf-8" ) if isinstance (chunk , bytes ) else chunk for chunk in response .streaming_content )
264265
265266 @staticmethod
266267 def _mock_streaming_response (lines ):
@@ -286,22 +287,81 @@ def test_stream_llm_hint(self, mocked_post):
286287 self .assertIn (json .dumps ({"text" : "힌트 " }, ensure_ascii = False ), body )
287288 self .assertIn (json .dumps ({"text" : "스트림" }, ensure_ascii = False ), body )
288289 self .assertIn ('event: done' , body )
290+ self .assertEqual (ProblemAIHintLog .objects .filter (user = self .user , problem = self .problem ).count (), 1 )
291+ saved_log = ProblemAIHintLog .objects .filter (user = self .user , problem = self .problem ).first ()
292+ self .assertEqual (saved_log .hint_content , "힌트 스트림" )
293+
289294 mocked_post .assert_called_once ()
290295 self .assertEqual (mocked_post .call_args .args [0 ], get_vllm_chat_completions_url ())
291296 self .assertEqual (mocked_post .call_args .kwargs ["json" ]["model" ], VLLM_MODEL )
292- self .assertIn ("힌트는 정확히 1개만 제공하라." , mocked_post .call_args .kwargs ["json" ]["messages" ][0 ]["content" ])
293- self .assertIn ("너무 추상적인 조언 대신 " , mocked_post .call_args .kwargs ["json" ]["messages" ][0 ]["content" ])
297+ self .assertIn ("힌트는 정확히 1개의 핵심 아이디어만 제공하라." , mocked_post .call_args .kwargs ["json" ]["messages" ][0 ]["content" ])
298+ self .assertIn ("너무 추상적인 조언은 피하고, " , mocked_post .call_args .kwargs ["json" ]["messages" ][0 ]["content" ])
294299 self .assertIn (self .problem ._id , mocked_post .call_args .kwargs ["json" ]["messages" ][1 ]["content" ])
295300 self .assertNotIn ("<p>" , mocked_post .call_args .kwargs ["json" ]["messages" ][1 ]["content" ])
296301 self .assertIn ("짧은 힌트 하나만 작성해라." , mocked_post .call_args .kwargs ["json" ]["messages" ][1 ]["content" ])
297302 self .assertIn ("관찰 포인트를 짚어라." , mocked_post .call_args .kwargs ["json" ]["messages" ][1 ]["content" ])
298- self .assertEqual (mocked_post .call_args .kwargs ["json" ]["temperature" ], 0.65 )
303+ self .assertEqual (mocked_post .call_args .kwargs ["json" ]["temperature" ], 0.55 )
299304 self .assertEqual (mocked_post .call_args .kwargs ["stream" ], True )
300305 self .assertEqual (
301306 mocked_post .call_args .kwargs ["timeout" ],
302307 (VLLM_CONNECT_TIMEOUT_SEC , VLLM_STREAM_READ_TIMEOUT_SEC ),
303308 )
304309
310+ @mock .patch ("problem.llm_hint.requests.post" )
311+ def test_stream_llm_hint_problem_limit (self , mocked_post ):
312+ """한 문제당 5회 제한에 걸리는지 테스트"""
313+ # 해당 문제에 대해 이미 5개의 힌트 로그가 존재하도록 세팅
314+ for i in range (5 ):
315+ ProblemAIHintLog .objects .create (user = self .user , problem = self .problem , hint_content = f"더미 { i } " )
316+
317+ resp = self .client .get (f"{ self .url } ?problem_id={ self .problem ._id } " )
318+ body = self ._streaming_body (resp )
319+
320+ self .assertIn ('event: app-error' , body )
321+ self .assertIn ("limit-exceeded" , body )
322+ self .assertIn ("소진" , body )
323+ mocked_post .assert_not_called () # 제한에 걸리면 LLM 호출이 아예 안 일어나야 함
324+
325+ @mock .patch ("problem.llm_hint.requests.post" )
326+ def test_stream_llm_hint_admin_bypass (self , mocked_post ):
327+ """관리자는 횟수 제한 없이 무제한으로 사용 가능한지 테스트"""
328+ mocked_post .return_value = self ._mock_streaming_response ([
329+ 'data: {"choices":[{"delta":{"content":"어드민 패스"}}]}' ,
330+ "data: [DONE]" ,
331+ ])
332+
333+ # Admin 계정으로 5회 꽉 채움
334+ for i in range (5 ):
335+ ProblemAIHintLog .objects .create (user = self .admin , problem = self .problem , hint_content = f"더미 { i } " )
336+
337+ # 일반 유저를 로그아웃시키고 Admin으로 로그인
338+ self .client .force_login (self .admin )
339+ resp = self .client .get (f"{ self .url } ?problem_id={ self .problem ._id } " )
340+ body = self ._streaming_body (resp )
341+
342+ # 제한에 걸리지 않고 정상 응답이 와야 함
343+ self .assertNotIn ("limit-exceeded" , body )
344+ self .assertIn ("어드민 패스" , body )
345+ mocked_post .assert_called_once ()
346+
347+ @mock .patch ("problem.llm_hint.requests.post" )
348+ def test_stream_llm_hint_empty_response (self , mocked_post ):
349+ """정상적으로 스트리밍이 종료되었으나 텍스트가 비어있는 경우, 선제 생성된 로그가 삭제되는지 테스트"""
350+ # 공백만 리턴하는 모델 응답 Mocking
351+ mocked_post .return_value = self ._mock_streaming_response ([
352+ 'data: {"choices":[{"delta":{"content":" "}}]}' ,
353+ "data: [DONE]" ,
354+ ])
355+
356+ resp = self .client .get (f"{ self .url } ?problem_id={ self .problem ._id } " )
357+ body = self ._streaming_body (resp )
358+
359+ # 1. 정상적으로 done 이벤트가 와야 함
360+ self .assertIn ('event: done' , body )
361+
362+ # 2. 가장 중요: 빈 텍스트였기 때문에 DB에 로그가 남아있으면 안 됨 (횟수 차감 방어 성공)
363+ self .assertEqual (ProblemAIHintLog .objects .filter (user = self .user , problem = self .problem ).count (), 0 )
364+
305365 def test_stream_llm_hint_requires_login (self ):
306366 self .client .logout ()
307367
@@ -372,6 +432,80 @@ def test_get_vllm_chat_completions_url_for_kubernetes(self):
372432 self .assertEqual (get_vllm_chat_completions_url (), CLUSTER_VLLM_CHAT_COMPLETIONS_URL )
373433
374434
435+ class AIHintHistoryAPITest (ProblemCreateTestBase ):
436+
437+ def setUp (self ):
438+ self .create_school_fixtures (college_id = 1 , college_name = "Test" , department_id = 1 , department_name = "Test" )
439+ self .url = self .reverse ("problem_ai_hint_history_api" ) # urls/oj.py 에 등록했던 name과 일치해야 함
440+ self .admin = self .create_admin (login = False )
441+ self .problem = self .add_problem (DEFAULT_PROBLEM_DATA , self .admin )
442+ self .user = self .create_user (email = "history@test.com" , username = "history_test" , password = "test1234!" )
443+ self .hidden_problem = self .create_problem_with_custom_field (self .admin , _id = "A-211" , visible = False )
444+
445+ def test_get_history_require_login (self ):
446+ """로그인하지 않은 유저가 조회 요청 시 에러"""
447+ self .client .logout ()
448+ resp = self .client .get (f"{ self .url } ?problem_id={ self .problem ._id } " )
449+ # 일반 APIView 응답 구조에 맞춰 검증 (에러 메시지 포함 여부)
450+ self .assertIsNotNone (resp .data .get ("error" ))
451+
452+ def test_get_history_success_and_structure (self ):
453+ """정상적으로 로그와 횟수 데이터가 넘어오는지 구조 검증"""
454+ self .client .force_login (self .user )
455+
456+ # 더미 데이터 생성
457+ ProblemAIHintLog .objects .create (user = self .user , problem = self .problem , hint_content = "과거의 힌트 1" )
458+ ProblemAIHintLog .objects .create (user = self .user , problem = self .problem , hint_content = "과거의 힌트 2" )
459+
460+ resp = self .client .get (f"{ self .url } ?problem_id={ self .problem ._id } " )
461+ self .assertEqual (resp .status_code , 200 )
462+ self .assertIsNone (resp .data .get ("error" ))
463+
464+ payload = resp .data .get ("data" )
465+
466+ # 응답 구조 검증
467+ self .assertIn ("logs" , payload )
468+
469+ # 값 검증
470+ self .assertEqual (len (payload ["logs" ]), 2 )
471+ self .assertEqual (payload ["logs" ][0 ]["hint_content" ], "과거의 힌트 1" )
472+
473+ def test_get_history_with_hidden_problem (self ):
474+ """비공개 문제의 힌트 히스토리는 조회할 수 없어야 함"""
475+ self .client .force_login (self .user )
476+
477+ # setUp에서 미리 만들어둔 hidden_problem 사용
478+ resp = self .client .get (f"{ self .url } ?problem_id={ self .hidden_problem ._id } " )
479+
480+ self .assertIsNotNone (resp .data .get ("error" ))
481+ self .assertIn ("문제를 찾을 수 없습니다." , resp .data .get ("data" , "" ))
482+
483+ def test_get_history_with_contest_problem (self ):
484+ """대회용 문제의 힌트 히스토리는 일반 API로 조회할 수 없어야 함"""
485+ self .client .force_login (self .user )
486+
487+ # 대회 및 대회용 문제 임시 생성
488+ from contest .models import Contest
489+ from contest .tests import DEFAULT_CONTEST_DATA
490+ contest = Contest .objects .create (created_by = self .admin , ** DEFAULT_CONTEST_DATA )
491+ contest_problem = self .create_problem_with_custom_field (self .admin , _id = "C-999" )
492+ contest_problem .contest = contest
493+ contest_problem .save (update_fields = ["contest" ])
494+
495+ resp = self .client .get (f"{ self .url } ?problem_id={ contest_problem ._id } " )
496+
497+ self .assertIsNotNone (resp .data .get ("error" ))
498+ self .assertIn ("문제를 찾을 수 없습니다." , resp .data .get ("data" , "" ))
499+
500+ def test_get_history_with_invalid_problem_id (self ):
501+ """존재하지 않는 문제 ID를 넣었을 때 처리"""
502+ self .client .force_login (self .user )
503+ resp = self .client .get (f"{ self .url } ?problem_id=INVALID_ID" )
504+
505+ self .assertIsNotNone (resp .data .get ("error" ))
506+ self .assertIn ("문제를 찾을 수 없습니다." , resp .data .get ("data" , "" ))
507+
508+
375509class ContestProblemAdminTest (APITestCase ):
376510
377511 def setUp (self ):
0 commit comments