From 6cfc75e3b8d3d56ae6604355af4106c05d522b19 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sun, 26 Apr 2026 17:59:30 +0900
Subject: [PATCH] =?UTF-8?q?test:=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=2010?=
=?UTF-8?q?=EA=B0=9C=20(=ED=95=B5=EC=8B=AC=20=EB=B9=84=EC=A6=88=EB=8B=88?=
=?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
tests/e2e.py — 컨테이너에서 직접 실행:
1. roster.json 로드 (34명, 7팀, 한지승 포함, 김태현 제외)
2. 투표 마감 토글
3. 기본 winner (priority 충돌 없음)
4. priority 1팀 모든 분야 1위 → 다음 팀에 자동 이양
5. 동률 status='tie'
6. tie_break 적용 시 status='ok'
7. voter_name UNIQUE 강제
8. 팀 제목 영속
9. archive 파일 생성
10. archive 동률 미해결 시 skip
실행:
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py
결과: 10/10 통과
Co-Authored-By: Claude Opus 4.7 (1M context)
---
tests/e2e.py | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 223 insertions(+)
create mode 100644 tests/e2e.py
diff --git a/tests/e2e.py b/tests/e2e.py
new file mode 100644
index 0000000..9e47cc1
--- /dev/null
+++ b/tests/e2e.py
@@ -0,0 +1,223 @@
+"""
+E2E 테스트 — 컨테이너에서 직접 실행.
+사용: docker exec hackathon-vote python3 /tmp/e2e.py
+"""
+import os
+import sys
+import tempfile
+
+# 테스트용 격리 DB
+TEST_DB = tempfile.mktemp(suffix=".db")
+os.environ["VOTE_DB"] = TEST_DB
+os.environ["ADMIN_TOKEN"] = "test"
+
+sys.path.insert(0, "/app")
+
+# import after env set
+from app import ( # noqa: E402
+ get_conn,
+ get_participants,
+ get_teams,
+ compute_winners,
+ save_tie_break,
+ clear_tie_break,
+ set_voting_open,
+ is_voting_open,
+ set_title,
+ get_titles,
+ archive_results,
+)
+
+
+# ---- helpers ----
+
+def reset_db():
+ """테스트 DB 초기화."""
+ if os.path.exists(TEST_DB):
+ os.unlink(TEST_DB)
+ conn = get_conn()
+ conn.close()
+
+
+def insert_vote(name, emp_id, team, fun, polish, utility):
+ conn = get_conn()
+ conn.execute(
+ "INSERT INTO votes (voter_name, employee_id, voter_team, "
+ "fun_team, polish_team, utility_team, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, datetime('now'))",
+ (name, emp_id, team, fun, polish, utility),
+ )
+ conn.commit()
+ conn.close()
+
+
+def vote_count():
+ conn = get_conn()
+ n = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0]
+ conn.close()
+ return n
+
+
+# ---- tests ----
+
+PASS = "✅"
+FAIL = "❌"
+results = []
+
+
+def test(name, fn):
+ try:
+ fn()
+ results.append((PASS, name))
+ print(f"{PASS} {name}")
+ except AssertionError as e:
+ results.append((FAIL, f"{name}: {e}"))
+ print(f"{FAIL} {name}: {e}")
+ except Exception as e:
+ results.append((FAIL, f"{name}: UNEXPECTED {type(e).__name__}: {e}"))
+ print(f"{FAIL} {name}: UNEXPECTED {type(e).__name__}: {e}")
+
+
+def t_roster_load():
+ parts = get_participants()
+ teams = get_teams()
+ assert len(parts) == 34, f"기대 34명, 실제 {len(parts)}"
+ assert len(teams) == 7, f"기대 7팀, 실제 {len(teams)}"
+ assert "한지승" in parts, "한지승 명단에 없음"
+ assert "김태현" not in parts, "김태현 진행요원이 명단에 있음"
+
+
+def t_voting_toggle():
+ reset_db()
+ assert is_voting_open() is True, "기본 open"
+ set_voting_open(False)
+ assert is_voting_open() is False, "마감 적용 안 됨"
+ set_voting_open(True)
+ assert is_voting_open() is True, "재개 적용 안 됨"
+
+
+def t_simple_winners_no_priority_collision():
+ """팀1=fun, 팀2=polish, 팀3=utility 각각 명확한 1위. priority 충돌 없음."""
+ reset_db()
+ # 팀1에 5표 (fun), 팀2에 5표 (polish), 팀3에 5표 (utility)
+ for i in range(5):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3")
+ winners, _ = compute_winners()
+ assert winners["fun_team"]["status"] == "ok"
+ assert winners["fun_team"]["team"] == "팀1"
+ assert winners["polish_team"]["team"] == "팀2"
+ assert winners["utility_team"]["team"] == "팀3"
+
+
+def t_priority_one_team_all_first():
+ """팀1이 모든 분야 1위 → 팀1은 utility만, polish/fun은 다음 팀."""
+ reset_db()
+ # 팀1에 모든 분야 5표, 팀2에 polish/fun 3표, 팀3에 fun 2표
+ for i in range(5):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀1", "팀1")
+ for i in range(5, 8):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀2", "팀2", "팀1")
+ for i in range(8, 10):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀3", "팀3", "팀2")
+ winners, _ = compute_winners()
+ # 팀1이 utility 1위 → 팀1 수상
+ assert winners["utility_team"]["team"] == "팀1", winners["utility_team"]
+ # 팀1 제외 → polish 1위 = 팀2 (3+5표=8 wait, 팀1=5, 팀2=3+...) 다시:
+ # polish 분포: 팀1=5, 팀2=3, 팀3=2 → 팀1 제외 후 팀2
+ assert winners["polish_team"]["team"] == "팀2", winners["polish_team"]
+ # fun: 팀1=5, 팀2=3, 팀3=2 → 팀1, 팀2 제외 후 팀3
+ assert winners["fun_team"]["team"] == "팀3", winners["fun_team"]
+
+
+def t_tie_pending():
+ """팀1, 팀2 utility 동률 → status='tie'."""
+ reset_db()
+ for i in range(3):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1")
+ for i in range(3, 6):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2")
+ winners, _ = compute_winners()
+ assert winners["utility_team"]["status"] == "tie", winners["utility_team"]
+ assert set(winners["utility_team"]["tied"]) == {"팀1", "팀2"}
+
+
+def t_tie_break_random():
+ """동률 → save_tie_break으로 결정 → status='ok'."""
+ reset_db()
+ for i in range(3):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1")
+ for i in range(3, 6):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2")
+ save_tie_break("utility_team", "팀1", "random")
+ winners, _ = compute_winners()
+ assert winners["utility_team"]["status"] == "ok"
+ assert winners["utility_team"]["team"] == "팀1"
+ assert winners["utility_team"]["method"] == "random"
+ clear_tie_break("utility_team")
+
+
+def t_unique_voter():
+ """같은 voter_name 두 번 INSERT 차단."""
+ reset_db()
+ insert_vote("홍길동", "E1", "팀1", "팀2", "팀3", "팀4")
+ try:
+ insert_vote("홍길동", "E2", "팀1", "팀5", "팀6", "팀7")
+ raise AssertionError("중복 INSERT 통과 (UNIQUE 위반 예상)")
+ except Exception as e:
+ # IntegrityError 기대
+ assert "UNIQUE" in str(e) or "unique" in str(e).lower()
+
+
+def t_titles_persist():
+ reset_db()
+ set_title("팀1", "Slack 자동 분류기")
+ titles = get_titles()
+ assert titles["팀1"] == "Slack 자동 분류기"
+
+
+def t_archive_writes_file():
+ """ceremony archive — 모든 winners ok 시 파일 생성."""
+ reset_db()
+ # 명확한 1위 만들기
+ for i in range(5):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3")
+ archive_dir = os.path.dirname(TEST_DB) or "."
+ before = set(f for f in os.listdir(archive_dir) if f.startswith("results_"))
+ path = archive_results()
+ assert path is not None, "archive 결과 None"
+ assert os.path.exists(path)
+ after = set(f for f in os.listdir(archive_dir) if f.startswith("results_"))
+ assert len(after) > len(before), "archive 파일 생성 안 됨"
+ # 정리
+ os.unlink(path)
+
+
+def t_archive_skips_when_pending():
+ """동률 미해결 시 archive 안 함."""
+ reset_db()
+ for i in range(3):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1")
+ for i in range(3, 6):
+ insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2")
+ path = archive_results()
+ assert path is None, f"동률 미해결인데 archive 됨: {path}"
+
+
+# ---- run ----
+
+if __name__ == "__main__":
+ print(f"# E2E 테스트 (DB={TEST_DB})\n")
+ test("roster.json 로드 (34명, 7팀)", t_roster_load)
+ test("투표 마감 토글", t_voting_toggle)
+ test("기본 winner (priority 충돌 없음)", t_simple_winners_no_priority_collision)
+ test("priority 1팀 모든 분야 1위", t_priority_one_team_all_first)
+ test("동률 status='tie'", t_tie_pending)
+ test("tie_break 적용 시 status='ok'", t_tie_break_random)
+ test("voter_name UNIQUE 강제", t_unique_voter)
+ test("팀 제목 영속", t_titles_persist)
+ test("archive 파일 생성", t_archive_writes_file)
+ test("archive 동률 미해결 시 skip", t_archive_skips_when_pending)
+
+ fails = sum(1 for r, _ in results if r == FAIL)
+ print(f"\n# 합계: {len(results)} 중 통과 {len(results) - fails}, 실패 {fails}")
+ sys.exit(0 if fails == 0 else 1)