From e9f371a2633e206cb99e54c549c8750938dbdbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 04:05:26 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs(qa):=20QA=20=ED=9A=8C=EC=B0=A8=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EA=B3=BC=20=ED=98=84=ED=99=A9=EC=9D=84=20?= =?UTF-8?q?=EB=85=B8=EC=85=98=EC=97=90=EC=84=9C=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EA=B4=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1차(7/19)·2차(7/28) 회차 문서를 노션 QA DB 에서 옮기고, 현재 QA 상태를 한자리에 정리한 status.md 를 신설한다. 노션 원본에는 이 문서를 가리키는 링크만 남긴다. status.md 는 QA 를 네 층(자동 검증·PR 리뷰·런타임 실측·회차)으로 나눠 각 워터마크를 적고, 기능별 커버리지와 3차 QA 우선순위를 담았다. 증거 대장(.codex/qa-evidence)은 기록기가 8/20 에 생겨 그 이전 3주치가 남지 않았고 .gitignore 대상이라 로컬에만 있다. 총량 근거로 쓰지 말 것을 문서에 명시했다. 실제 QA 는 7/28~8/24 내내 있었고, 실측 흔적이 담긴 이슈만 180건이다. 2차 QA 발견 42건은 이슈 #560~#639 로 등재돼 있고 현재 33건 해결됐다. --- docs/qa/round-1.md | 54 ++++++++++++++++ docs/qa/round-2.md | 107 ++++++++++++++++++++++++++++++++ docs/qa/status.md | 150 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 docs/qa/round-1.md create mode 100644 docs/qa/round-2.md create mode 100644 docs/qa/status.md diff --git a/docs/qa/round-1.md b/docs/qa/round-1.md new file mode 100644 index 0000000000..37b506c81d --- /dev/null +++ b/docs/qa/round-1.md @@ -0,0 +1,54 @@ +# 1차 QA — 2026-07-19 + +담당: 이준혁 · 상태: 완료 · 원본: 노션 QA DB에서 이관(2026-08-25) + +## 테스트 환경 + +| 항목 | 내용 | +| --- | --- | +| 기기 | Android Emulator Medium_Phone (arm64, Android 17 / targetSdk 36) | +| 빌드 | develop 브랜치 `78551075` 기준 debug 빌드 (`:app:installDebug`), versionName 1.0 | +| 서버 | https://afternote.kro.kr/api/v1/ (라이브) | +| 계정 | QA 전용 네이버 계정 (이메일 로그인) — 계정 정보는 레포에 두지 않는다 | +| 방식 | 에뮬레이터 수동 시나리오 QA + logcat(OkHttp) 네트워크 로그 + 소스코드 교차 확인 | + +## 발견 사항 + +| # | 구분 | 발생 경로 | 문제 현상 | 기대 동작 | 원인 / 비고 | +| --- | --- | --- | --- | --- | --- | +| 1 | 서버 | 노트 탭 → 애프터노트 작성(갤러리 및 파일) → 전체 입력 → 등록 | `POST /api/v1/afternotes` → 500 `Field 'created_at' doesn't have a default value` — 등록이 서버에서 항상 실패 | 등록 성공 | 서버 DB `afternote.created_at` 기본값/INSERT 누락 | +| 2 | 클라 | #1과 동일 경로 | 500으로 실패해도 안내 없이 무반응. 사용자는 등록된 것으로 오인 | 실패 안내 + 재시도 유도 | Repository가 에러를 로그만 남기고 UI 전달 안 함 | +| 3 | 클라 | 노트 탭 → 작성 → 종류=소셜네트워크 → 등록 | "수신자를 한 명 이상 선택해 주세요" 검증에 걸리지만 수신자 지정 UI가 없어 등록 원천 불가 | 갤러리/추모처럼 수신자 지정 섹션 노출 | `SocialNetworkEditorContent.kt`가 `recipientSection`을 받고도 렌더링 안 함 | +| 4 | 클라+서버 | 타임레터 목록 → ⋯ → 삭제 | 클라는 `POST /time-letters/delete`, 서버는 500 "Request method 'POST' is not supported" → 삭제 불가. 확인 팝업·실패 안내도 없음 | 삭제 성공(또는 실패 안내), 삭제 전 확인 팝업 | HTTP 메서드 계약 불일치 | +| 5 | 클라 | 로그인 → 올바른 이메일 + 틀린 비밀번호 | 서버 401을 반환해도 화면 무변화, 에러 문구 없음 | 실패 사유 표시 | 실패 상태가 UI에 미반영 | +| 6 | 클라 | 애프터노트/타임레터 작성 중 → 백 키 또는 ← | 경고 없이 종료, 내용 전부 소실. 백 키 동작도 비일관 | "작성 중인 내용이 사라집니다" 확인 팝업 | 이탈 가드 미구현 | +| 7 | 클라 | 기록 탭 → 데일리 질문 → + → 임시저장 | 서버 저장은 성공(200, `draft:true`)하나 목록에 안 보이고 이어쓰기도 안 됨 → 사실상 유실 | 임시저장 목록 표시 또는 이어쓰기 제안 | `/daily-questions/today` 응답의 `draft:true`를 클라가 무시 | +| 8 | 클라 | 데일리질문·일기 저장 → 목록 복귀 | 저장 직후 목록 미갱신(빈 화면 유지). 홈의 일기 TOTAL·수신인 지정 배지도 앱 재시작 시에만 갱신 | 저장/복귀 시 재조회 | 화면 복귀 시 refetch 누락 | +| 9 | 클라 | 홈 탭 → TODAY'S QUESTION 카드 | 날짜가 항상 "2026.04.10", 질문도 실제 오늘 질문이 아닌 기본 문구 고정 | 실제 오늘 날짜·질문 | `TodayQuestionCard.kt:37` 기본값 하드코딩, `HomeTabMindRecordLazyItems.kt:41`에서 실데이터 미전달 | +| 10 | 클라 | 홈 탭 → "수신인 지정 미완료" 칩 탭 | 클릭 가능한 UI인데 무동작 | 수신자 등록 화면으로 이동 | 클릭 핸들러 미연결 | +| 11 | 클라 | 설정 → 상단 바로가기 3종 | 고객센터·공지사항·수신자목록 아이콘 모두 무반응 (하단 리스트의 같은 메뉴는 정상) | 각 화면으로 이동 | 클릭 핸들러 미연결 | +| 12 | 클라 | 타임레터 탭 → 우상단 프로필/설정 아이콘 | 둘 다 무반응 (홈의 같은 아이콘은 정상) | 설정/프로필 이동 | 타임레터 헤더에만 핸들러 미연결 | +| 13 | 클라 | 노트 탭 → 작성 → 남기실 말씀 카드의 "등록" | 카드별 "등록" 버튼이 무동작 | 정의된 동작 수행(또는 버튼 제거) | `AfternoteEditorContent.kt:268,300` `onMessageRegisterClick = {}` 빈 람다 | +| 14 | 클라 | 노트 탭 → 작성 → 추억 노트 → 추억 플레이리스트 | 카드엔 "16개의 노래"인데 진입하면 "총 0곡". 곡 추가 버튼도 없음 | 실제 곡 수 표시, 곡 추가 진입점 | `EditorFormState.kt:49` `playlistSongCount: Int = 16` 하드코딩 | +| 15 | 클라 | 기록 탭 → 주간리포트 | 상단 카운트는 데일리질문 1·일기 1인데 본문은 "0일의 마음을 기록하셨네요" | 기록 일수 정확 표시 | 집계 로직/필드 불일치 | +| 16 | 클라 | 타임레터 목록 → 상세 | 헤더의 제목·수신인·발송예정일이 밝은 배경 위 흰 텍스트로 판독 불가 | 대비 확보 | 헤더 배경 이미지 없을 때 대비 미처리 | +| 17 | 클라 | 설정 → 수신자 목록 / 타임레터 → 수신인 선택 | 검색창 placeholder가 리터럴 "Text Field" | 한국어 안내 문구 | placeholder 미지정 | +| 18 | 클라 | 기록 탭 → 데일리 질문(0건) / 설정 → 공지사항(0건) | 빈 목록일 때 안내 없이 완전 빈 화면 | 빈 상태 안내 | 빈 상태 UI 누락 | +| 19 | 클라 | 기록 탭 → 일기(0건) | 빈 상태 문구가 노트 탭 문구 재사용이라 맥락 불일치 | 일기 맥락 문구 | 문구 재사용 | + +## 서버 보안 + +| # | 심각도 | 발생 경로 | 문제 현상 | 기대 동작 | +| --- | --- | --- | --- | --- | +| S1 | 중간 | 애프터노트 등록 실패 응답 (#1) | 500 응답 `message`에 SQL문·컬럼명 등 내부 정보 노출 | 내부 오류는 일반화된 메시지로 치환, 상세는 서버 로그로만 | + +## 기획 의도 확인 필요 + +| # | 발생 경로 | 내용 | +| --- | --- | --- | +| O1 | 설정 → 수신자 등록/선택 완료 | 성공 시 피드백 없이 조용히 이전 화면 복귀 | +| O2 | 노트 작성(갤러리) → 수신자 추가 | 등록해 둔 수신자 선택이 아니라 이름/관계/전화번호를 새로 입력받음. 재사용이 안 되는 게 의도인지 | +| O3 | 설정 → 패스키 관리 → 비밀번호로 인증하기 | 숫자 4자리 키패드가 뜨는데 계정 비밀번호는 영문+특수문자 | +| O4 | 타임레터 작성 → 발송 시간 미선택 → 등록 | 시간 없이 등록되며 00:00으로 저장. 시간이 필수인지 | +| O5 | 타임레터 4건째 → 무료 플랜 팝업 → "구독 후 기록하기" | 팝업만 닫힘. 코드상 TODO(구독 화면 미구현) | +| O6 | Welcome → 전달 받은 기록 확인하기 | 빈 상태 문구가 "수신자를 등록하여" — 수신자 입장에선 발신자 등록. "마지막 확인: " 뒤 빈 값 | diff --git a/docs/qa/round-2.md b/docs/qa/round-2.md new file mode 100644 index 0000000000..9d00e3dd2c --- /dev/null +++ b/docs/qa/round-2.md @@ -0,0 +1,107 @@ +# 2차 QA — 2026-07-28 + +담당: 정일혁 · 상태: 완료 · 원본: 노션 QA DB에서 이관(2026-08-25) + +발견 사항은 저장소 이슈 **#560~#639** 로 등록됐다. 2026-08-25 기준 69건 중 33건 해결(48%). + +## 테스트 환경 + +| 항목 | 내용 | +| --- | --- | +| 기기 | Android Emulator (Pixel 계열, targetSdk 36) 2대 병행 | +| 빌드 | develop 기준 debug 빌드 | +| 서버 | https://afternote.kro.kr/api/v1/ (라이브) | +| 계정 | QA 전용 계정 + 신규 가입 계정 각 1개, 수신자용 메일 별도 | +| 방식 | 정적 코드 실측 → 서버 재개 후 에뮬레이터 수동 시나리오 6회차 순회 + 네트워크 로그 대조 | +| 범위 | 전 기능. 1차 QA 잔여분 재확인 포함 | + +## 타임레터 · 설정 + +| # | 발생 경로 | 문제 현상 | 기대 동작 | +| --- | --- | --- | --- | +| 1 | 타임레터 목록 → 삭제 | 서버가 받지 않는 방식으로 요청이 나가 삭제 실패. 확인 팝업도 실패 안내도 없음 | 삭제 확인 후 삭제, 실패 시 안내 | +| 2 | 설정 → 상단 바로가기 3종 | 고객센터·공지사항·수신자목록 아이콘 모두 무반응 (하단 리스트의 같은 메뉴는 정상) | 각 화면으로 이동 | +| 3 | 타임레터 → 우상단 프로필·설정 아이콘 | 둘 다 무반응 (홈의 같은 아이콘은 정상) | 설정·프로필 이동 | +| 4 | 타임레터 상세 | 제목·수신인·발송예정일이 밝은 배경 위 흰 글씨라 판독 불가 | 대비 확보 | +| 5 | 설정 → 수신자 목록 / 타임레터 수신인 선택 | 검색창 안내문이 "Text Field"로 그대로 노출 | 한국어 안내 문구 | +| 6 | 수신자 목록 (0건) | 빈 목록 안내가 없고, 그 화면에 수신자 등록 진입점도 없음 | 빈 상태 안내 + 등록 경로 | +| 7 | 설정 → 고객지원 6종 | FAQ·고객센터·이용약관·개인정보·서비스 안내·비밀번호 변경이 전부 무반응 (공지사항만 연결) | 각 화면으로 이동 | +| 8 | 설정 → 사후 전달 조건 등록 | 폐지된 API를 호출해 실패, 안내 없음 | 현행 명세로 호출 | +| 9 | 설정 → 알림 → 마케팅 수신 | 선택이 저장되지 않아 재진입하면 초기화 | 저장 후 유지 | +| 10 | 설정 → 앱 잠금 PIN 등록 | 입력한 PIN이 저장되지 않아 재시작해도 잠금 없음. 확인 재입력 단계도 없음 | PIN 저장 + 확인 입력 | +| 11 | 설정 → 패스키 → 비밀번호로 인증하기 | 4자리 숫자 키패드가 뜨는데 계정 비밀번호는 영문+특수문자라 무엇을 넣을지 알 수 없음 | 요구하는 값 구분 안내 | +| 12 | 설정 → 소셜 계정 연결 | 연결 실패 시 빈 토큰으로 요청이 나가고 안내도 없음 | 실패 안내 | +| 13 | 타임레터 작성 → 수신인 선택 왕복 | 작성 중이던 제목·내용이 전부 사라짐 | 입력 유지 | +| 14 | 타임레터 작성 → 임시저장 | 실패 시 서버가 준 사유 대신 일반 문구만 노출 | 사유 전달 | +| 15 | 타임레터 → 임시저장 목록 | 레이아웃이 깨지고 날짜가 원문 그대로 노출. 진입점이 작성 화면의 숫자뿐 | 정상 레이아웃 + 명확한 진입점 | + +## 마음의 기록 + +| # | 발생 경로 | 문제 현상 | 기대 동작 | +| --- | --- | --- | --- | +| 16 | 데일리질문 → 저장·임시저장 | 요청 자체가 나가지 않고 안내도 없음 | 저장 또는 실패 안내 | +| 17 | 주간리포트 캘린더 | 서버가 기록 있는 날만 보내는데 요일 순서대로 채워 날짜가 엉뚱한 칸에 찍히고 중복 표시 | 실제 날짜 위치에 표시 | +| 18 | 일기 저장 → 목록 복귀 | 저장 후 재조회 결과가 0건 | 저장분 표시 | +| 19 | 홈 → MEMORIES / 추억 공간 | 고정 문구·더미 데이터. 돌아가기 버튼이 상태바에 가려 눌리지 않음 | 실데이터 + 조작 가능한 버튼 | +| 20 | 설정 → 공지사항 (0건) | 완전 빈 화면 | 빈 상태 안내 | + +## 애프터노트 · 홈 · 코어 + +| # | 발생 경로 | 문제 현상 | 기대 동작 | +| --- | --- | --- | --- | +| 21 | 전 화면 (네트워크 불안정 시) | 만료가 아닌 통신·서버 오류로 재발급이 실패해도 세션을 지워 강제 로그아웃 | 일시 오류는 세션 유지 | +| 22 | 애프터노트 수정 진입 | 기존 수신자가 채워지지 않아 검증에 걸려 수정 저장 자체가 불가 | 기존 수신자 표시 | +| 23 | 노트 작성 → 소셜네트워크 | 수신자 추가 진입점이 무반응이라 등록 불가 | 수신인 선택 화면으로 이동 | +| 24 | 노트 작성 → 수신자 추가 다이얼로그 | 입력한 수신자가 실제 등록분이 아닌 화면 내 순번으로 저장 | 실제 수신자 참조 | +| 25 | 노트·타임레터 작성 중 뒤로가기 | 경고 없이 화면이 닫히고 작성 내용 전부 소실 | 이탈 확인 팝업 | +| 26 | 노트 작성 → 추억 노트 → 플레이리스트 | 카드에는 16곡이라 적혀 있는데 들어가면 0곡 | 실제 곡 수 | +| 27 | 홈 → TODAY'S QUESTION | 날짜·질문이 고정값이라 실제 오늘 질문과 다름 | 실데이터 | +| 28 | 홈 → 수신인 지정 미완료 칩 | 탭해도 아무 동작 없음 | 지정 화면으로 이동 | +| 29 | 홈 → 주간 요약 | 기록 수·제목이 자리표시자 값으로 노출 | 실데이터 | +| 30 | 홈 복귀 | 일기 총계·수신인 지정 배지가 앱 재시작 전까지 갱신되지 않음 | 복귀 시 재조회 | +| 31 | 노트 탭 (0건) | 빈 목록 문구가 데일리질문 문구를 그대로 써 맥락 불일치 | 노트 맥락 문구 | +| 32 | 받은 기록함 | 빈 상태 문구가 수신자 관점과 반대. "마지막 확인" 뒤 값이 비어 있음 | 수신자 기준 문구 | + +## 서버 + +| # | 발생 경로 | 문제 현상 | 기대 동작 | +| --- | --- | --- | --- | +| 33 | 작성자 애프터노트 상세 | 수신자 항목에 식별자만 오고 이름·관계가 없어 화면에 표시할 값이 없음 | 이름·관계 포함 | +| 34 | 타임레터 임시저장 | 임시저장인데도 정식 등록과 같은 필수값 검증이 걸려 제목·본문·발송일시·수신자가 전부 있어야 저장됨 | 임시저장은 완화된 검증 | +| 35 | 수신자 안내 메일 2종 | 등록 안내 메일과 인증 요청 메일의 제목·본문이 완전히 같아 어느 값을 어디에 넣을지 알 수 없음 | 메일별 구분 | + +## 회원 탈퇴 (추가 회차) + +미확인으로 남겨 두었던 회원 탈퇴를 마지막에 진행했다. 탈퇴는 되돌릴 수 없어 조건을 바꾼 임시 계정을 여러 개 만들어 원인을 좁혔다. + +| # | 발생 경로 | 문제 현상 | 기대 동작 | +| --- | --- | --- | --- | +| 36 | 설정 → 회원 탈퇴 → 탈퇴하기 | 요청을 보내기도 전에 "회원 탈퇴가 완료되었습니다" 팝업이 먼저 뜬다. 이 시점에 계정은 그대로 살아 있다 | 탈퇴 성공 후에만 완료 안내 | +| 37 | 위 팝업 → 확인하기 | 탈퇴가 실패해도 안내가 없고 같은 팝업이 유지. 팝업 바깥·뒤로가기로도 닫히지 않아 강제 종료해야 함 | 실패 사유 안내 + 닫기 경로 | +| 38 | 탈퇴 성공 후 앱 재시작 | 로컬 토큰이 지워지지 않아 홈이 먼저 뜨고, 삭제된 계정으로 요청이 여러 건 실패한 뒤에야 로그인 화면으로 이동 | 탈퇴 시 세션 정리 | +| 39 | 로그아웃 후 다른 계정 로그인 | 이전 사용자의 수신자 목록이 그대로 보인다(이름·관계 포함). 홈의 수신인 지정 배지도 이전 계정 값 유지 | 계정 전환 시 이전 데이터 제거 | +| 40 | 회원 탈퇴 (서버) | 타임레터를 1건이라도 가진 계정은 탈퇴가 서버 오류로 실패. 데이터 없는 계정만 탈퇴됨 | 사용 이력이 있어도 탈퇴 가능 | +| 41 | 탈퇴 후 재가입 (서버) | 탈퇴한 이메일로 즉시 재가입된다. 화면은 30일 이후 가능하다고 고지 | 고지와 서버 정책 일치 | +| 42 | 탈퇴 후 토큰 (서버) | 삭제된 계정의 인증 토큰이 일부 조회에서 계속 통한다 | 탈퇴 즉시 토큰 무효화 | + +정상 확인 — 확인 문구("탈퇴하겠습니다") 불일치 가드, 탈퇴 후 재로그인 차단, 탈퇴 성공 시 초기 화면 복귀와 데이터 승계 없음. + +## 확인된 개선 + +1차 QA에서 지적한 애프터노트 등록 실패(서버 오류)와 오류 응답의 내부 정보 노출은 이번 회차에서 정상 동작으로 확인했다. 공지사항 빈 화면(20번)도 수정이 반영돼 빈 상태 안내가 표시된다. 패스키 목록 화면은 기기 지문을 등록해 도달에 성공했고, 항목이 자리표시자 그대로 표시되는 문제를 따로 등록했다. + +## 정상 확인 + +애프터노트 삭제(확인 팝업 → 삭제 → 목록 반영) · 타임레터 수정과 목록 필터 · 프로필 설정 값 채우기 · 타임레터 수정 시 수신자·날짜·시간 채우기 · 재산 처리 카테고리 준비 중 안내 · 수신자 인증 4단계 전 구간(이메일 인증 → 마스터 키 → 서류 제출 → 열람 신청) + +## 미확인 + +- 수신자 열람 승인 이후 화면 (관리자 승인 필요) +- 비즈니스·추억 노트 등록 완주 (수신자 추가 진입점 문제가 선행) + +## 기획 확인 필요 + +- 타임레터 발송 시간을 고르지 않아도 등록되고 자정으로 저장된다. 시간이 필수인지 확인 필요. +- 노트 작성에서 수신자를 새로 입력받는데, 설정에 등록해 둔 수신자를 재사용하는 게 맞는지 확인 필요. +- 수신자 등록·선택 완료 시 성공 안내 없이 이전 화면으로 돌아간다. 피드백이 필요한지 확인 필요. +- 무료 플랜 제한 팝업의 "구독 후 기록하기"는 팝업만 닫힌다. 구독 화면이 아직 없다. diff --git a/docs/qa/status.md b/docs/qa/status.md new file mode 100644 index 0000000000..2afce3a0a4 --- /dev/null +++ b/docs/qa/status.md @@ -0,0 +1,150 @@ +# QA 현황 정리 — 2026-08-25 + +> **QA 문서 위치** — 회차 기록은 [1차](round-1.md) · [2차](round-2.md), 시안에 없어 판단한 것은 +> [assumptions.md](assumptions.md), 런타임 증거는 `.codex/qa-evidence/emulator/.json`(로컬 전용). + +회차 QA 와 PR 단위 검증이 따로 쌓이면서 "어디까지 했는지" 가 흐려져, 2026-08-25 기준으로 실측해 한자리에 모은다. + +## 한 줄 판정 + +**QA 는 7/28 이후로도 계속 돌아갔다. 멈춘 것은 "회차 QA" 라는 형식과, 그것을 기록하는 수단이다.** + +- **회차 QA**(전 기능 순회)는 2차(7/28)가 마지막이고 3차는 미착수. +- 그 사이에도 상시 QA 는 활발했다 — 실측 흔적이 담긴 이슈만 7/28~8/24 에 **180건**. +- 다만 **런타임 증거 대장은 이걸 거의 못 담았다.** 기록기·게이트가 8/20 에야 도입돼서, 그 이전 3주치 QA 는 증거 파일이 남지 않았다. + +## 1. 실제 QA 타임라인 (7/28 ~ 8/24) + +세 지표를 겹쳐 놓으면 QA 가 실제로 언제 돌았는지 보인다. **증거 파일 열만 보면 공백처럼 보이지만, 그건 기록 수단의 공백이지 QA 의 공백이 아니다.** + +| 날짜 | 실측 흔적 이슈 | 리뷰 실측 | 증거 파일 | 비고 | +| --- | --- | --- | --- | --- | +| 7/19 | — | — | — | **1차 QA 회차** (이준혁) | +| 7/27 | — | 6 | — | | +| 7/28 | 26 | 6 | — | **2차 QA 회차** (정일혁) | +| 7/29 | 15 | 1 | — | | +| 7/30 | 12 | — | — | | +| 7/31 | 10 | 1 | — | | +| 8/01 | 6 | — | — | | +| 8/02 | 8 | — | — | | +| 8/03 | **18** | — | — | 실패 처리·재진입 갱신 전수 점검 (#693~#725) | +| 8/04 | 1 | 1 | 1 | 증거 대장 첫 기록 | +| 8/05 | 1 | — | — | | +| 8/07 | 1 | — | — | | +| 8/08 | 9 | — | — | | +| 8/09 | 1 | — | 1 | | +| 8/10 | — | — | 1 | | +| 8/13 | — | 1 | — | | +| 8/14 | — | — | 1 | | +| 8/16 | 2 | — | — | | +| 8/17 | — | 1 | — | | +| 8/20 | — | — | — | **QA 기록기·게이트 도입** (`record-emulator-qa.py`) | +| 8/21 | 7 | — | — | | +| 8/22 | 1 | — | 7 | | +| 8/23 | 20 | 22 | 12 | | +| 8/24 | 42 | 40 | 1 | | +| **합계** | **180** | **79** | **23** | | + +**8/10~8/20 이 비어 보이는 이유**는 그 기간에 CI·테스트 인프라 작업(#835~#852 androidTest·릴리스 파이프라인)을 했기 때문이고, QA 를 안 한 게 아니다. + +## 2. 증거 대장을 얼마나 믿을 수 있나 + +`.codex/qa-evidence/emulator/.json` 은 **부분 기록이다.** 총량 판단의 근거로 쓰면 안 된다. + +| 한계 | 실태 | +| --- | --- | +| 도입 시점 | 기록기·게이트 파일이 **8/20** 생성. 23건 중 **19건이 8/22 이후**에 몰린 이유가 이것 | +| 저장 위치 | `.gitignore` 52행에 `.codex/` — **git 에 안 올라간다.** 이 체크아웃에만 존재하고, 워크트리 55개 전부 0건 | +| 유실 위험 | 로컬 전용이라 체크아웃을 지우면 증거도 사라진다 | +| 기록 방식 | 자동 15 · 수동 8. 스키마는 동일 | + +**결론: "런타임 실측 23건" 은 "8/20 이후 규약대로 남긴 것이 23건" 으로 읽어야 한다.** + +## 3. 회차 QA 이력 + +| 회차 | 날짜 | 담당 | 결과 | +| --- | --- | --- | --- | +| 1차 | 2026-07-19 | 이준혁 | 결함 19건 + 서버 보안 1건 + 기획 확인 6건 | +| 2차 | 2026-07-28 | 정일혁 | 결함 42건 (+ 회원 탈퇴 추가 회차 7건) | +| 3차 | 미착수 | — | 팀 캘린더 8/29 "QA마무리?" 만 잡혀 있음 | + +## 4. 2차 QA 결함의 현재 진척 + +2차 QA 발견분은 저장소 이슈 **#560~#639** 로 등록됐다. **회차 QA 중 진척을 셀 수 있는 유일한 회차**다 — 결함이 이슈로 떨어졌기 때문이다. + +| 구분 | 건수 | +| --- | --- | +| 해결(Closed) | **33건** | +| 잔여(Open) | **36건** | +| 합계 | 69건 (48% 해결) | + +**잔여 36건의 분포** + +| 기능 | 잔여 | 담당 | +| --- | --- | --- | +| 애프터노트 | 14 | 정일혁 | +| 설정 | 9 | 박경민 | +| 타임레터 | 5 | 박경민 | +| app (수신자 라우팅 등) | 3 | 정일혁·이준혁 | +| 마음의 기록 | 2 | 이준혁 | +| 홈 · 코어 | 2 | 이준혁·정일혁 | +| 타임레터+설정 공통 | 1 | 박경민 | + +담당자별로는 박경민 16 · 정일혁 16 · 이준혁 4건이다. + +## 5. 기능별 커버리지 — 어디가 안 봐졌나 + +| 기능 | 화면 수 | 머지 PR | 리뷰 실측 | 증거 파일 | 스크린샷 baseline | 2차 QA 잔여 | +| --- | --- | --- | --- | --- | --- | --- | +| 애프터노트 | 7 | 60 | **0** | 3 | 27장 | **14** | +| 설정 | 17 | 4 | **0** | 1 (차단) | **0장** | **9** | +| 타임레터 | 7 | 9 | 3 | 4 | **0장** | 6 | +| 마음의 기록 | 9 | 21 | 64 | 8 | **0장** | 2 | +| 수신자 | 14 | 4 | 1 | 1 (차단) | 34장 | — | +| 온보딩 | 9 | 27 | 1 | **0** | 16장 | 0 | +| 홈 | 1 | 9 | 6 | 1 | 4장 | 1 | + +**주의: 이 표의 "리뷰 실측" 열은 8/23~24 리뷰가 마음의 기록에 집중된 결과다.** 그 이전 상시 QA(1절의 이슈 180건)는 기능별로 귀속돼 있지 않아 이 표에 반영되지 않는다. 따라서 **0 이 "한 번도 안 봤다" 를 뜻하지는 않는다** — 다만 *증거로 되짚을 수단이 없다*는 뜻이다. + +그 유보를 감안해도 겹치는 구멍은 뚜렷하다: + +- **애프터노트**: 머지 PR 60건으로 최다, 2차 QA 잔여 14건으로 최다. 되짚을 증거는 3건. +- **설정**: 화면 17개로 최다인데 스크린샷 0장 · 증거 1건(차단) · 잔여 9건. +- **마음의 기록**: 유일하게 깊게 파인 영역이지만 스크린샷 baseline 0장이라 시각 회귀는 못 잡는다. + +## 6. 운영 규약 — 앞으로 증거를 어디에 남기나 + +| 무엇을 했나 | 어디에 남기나 | +| --- | --- | +| 회차 QA (전 기능 순회) | 노션 QA DB 에 새 회차 페이지 | +| PR 리뷰에서 지적별 실측 | 해당 PR 리뷰 본문의 검증 섹션 | +| 에뮬레이터·실서버 실측 | `.codex/qa-evidence/emulator/<검증한 HEAD sha>.json` | +| 시안·명세에 없어 판단으로 정한 것 | `docs/qa/assumptions.md` | + +**지금 이 규약의 구멍 두 개:** + +1. **증거 대장이 `.gitignore` 라 로컬에만 있다.** 팀이 공유할 수도, 유실을 막을 수도 없다. 추적 대상으로 올리든지(`.gitignore` 예외), 결과를 이슈·PR 본문으로 옮기든지 정해야 한다. +2. **상시 QA 는 발견된 결함(이슈)만 남고, "무엇을 봤는데 멀쩡했나" 는 안 남는다.** 그래서 되짚으면 공백처럼 보인다. 회차 QA 문서의 "정상 확인" 절이 이걸 메우는 유일한 수단이다. + +**원칙: "누가 했나" 는 기록하지 않는다.** 증거 대장은 도구·사람 무관 공용이고, 남아야 하는 것은 *어느 리비전에서 무엇이 검증됐나* 뿐이다. + +## 7. 3차 QA 계획 + +**우선순위** — 5절의 구멍이 겹치는 순서. + +1. **애프터노트** (잔여 14 · 머지 60건 · 증거 3건) — 최우선 +2. **설정** (잔여 9 · 스크린샷 0 · 증거 1건 차단) — 화면 17개 전수 순회 +3. **타임레터** (잔여 6 · 스크린샷 0장) +4. **마음의 기록·온보딩·수신자** — 회귀 확인 수준 + +**미확인으로 남아 있는 것** (2차 QA 에서 이월) + +- 수신자 열람 승인 이후 화면 — 관리자 승인 수단이 없어 dev 환경에서 도달 불가 +- 비즈니스·추억 노트 등록 완주 — 수신자 추가 진입점 문제가 선행 +- 구독 화면 — 미구현("구독 후 기록하기" 는 팝업만 닫힘) + +**실행 시 제약** + +- dev 서버(afternote.kro.kr)는 **03~12시(KST) 닫힌다.** 그 시간대 실패는 코드보다 시간대를 먼저 의심할 것. +- develop 기준 debug 빌드로 진행하고, 검증한 HEAD sha 를 문서 상단에 적는다. +- **결함뿐 아니라 "정상 확인" 항목도 적는다.** 그게 다음 회차에서 공백을 없애는 유일한 방법이다. From 5b8ae754eb33a89b5980a2f3d06deff088cb6699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 04:05:37 +0900 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20README=20=EB=A5=BC=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=A4=84=EC=9D=B4?= =?UTF-8?q?=EA=B3=A0=20=EB=82=A1=EC=9D=80=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=ED=98=84=ED=96=89=EC=9C=BC=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 304줄 중 절반이 절차 문서였고, 뒤쪽 83줄은 앱잼 시절 템플릿이 그대로 남아 현재 관행과 어긋나 있었다. 분리 — 비개발자 APK 배포 125줄을 docs/release/distribution.md 로, 스크린샷 baseline 37줄을 docs/testing/screenshot.md 로 옮긴다. 교체 — 삭제한 컨벤션은 전부 실측과 달랐다. XML 네이밍 예시 (@+id/tvPostNovelTitle)는 Compose 프로젝트에 해당 없고, 브랜치 언더바 규칙(feat/post_novel)은 실제 fix/910 형식과 다르며, 커밋 타입 ([FEAT] 이모지)은 Conventional Commits 로 바뀐 지 오래다. '리뷰 2명 승인'도 실제 ruleset 은 승인 1건 + 필수 체크 8종이다. 이슈·PR 템플릿 본문은 .github/ 에 파일로 있어 중복이었다. 추가 — 상단에 모듈 구조와 docs/ 문서 지도를 둔다. 모듈은 settings.gradle.kts 기준이며 삭제된 core:di·core:startup 은 뺐다. --- README.md | 273 +++++------------------------------ docs/release/distribution.md | 125 ++++++++++++++++ docs/testing/screenshot.md | 37 +++++ 3 files changed, 201 insertions(+), 234 deletions(-) create mode 100644 docs/release/distribution.md create mode 100644 docs/testing/screenshot.md diff --git a/README.md b/README.md index 0dfec5770a..931201d2f5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,26 @@ # Afternote-FE +애프터노트 안드로이드 앱 — Kotlin · Jetpack Compose · Hilt 멀티모듈. + +``` +app/ 진입점 · 네비게이션 · androidTest +core/ common · data · datastore · domain · model · network · ui +feature/ afternote · mindrecord · onboarding · receiver · setting · timeletter + (각 data · domain · presentation) + home/presentation · timeletter/res +konsist/ 아키텍처 규칙 테스트 +``` + +## 문서 + +| 문서 | 내용 | +|---|---| +| [docs/release/distribution.md](docs/release/distribution.md) | 비개발자 APK 배포 (Firebase App Distribution) | +| [docs/testing/screenshot.md](docs/testing/screenshot.md) | Compose Preview 스크린샷 baseline (docker) | +| [docs/qa/status.md](docs/qa/status.md) | QA 현황 · 회차 기록 · 커버리지 | +| [docs/qa/assumptions.md](docs/qa/assumptions.md) | 시안 · 명세에 없어 판단으로 정한 것 | + +--- + # 🚀 신규 팀원 빌드 셋업 `local.properties` 는 `.gitignore` 에 등록되어 있어 **git 으로 받아지지 않는다**. clone 직후 다음 두 키를 루트 `local.properties` 에 직접 채워야 카카오·구글 로그인이 정상 동작한다. 추가로 아래 **공유 debug keystore** 섹션까지 마치면 카카오 키 해시를 본인 머신용으로 따로 등록할 필요가 없다. @@ -57,252 +78,36 @@ debug 빌드는 기본적으로 머신마다 다른 `~/.android/debug.keystore` keytool -exportcert -alias afternote-debug-shared -keystore ~/afternote-debug-shared.jks | openssl sha1 -binary | openssl base64 ``` -# 📦 비개발자 APK 배포 (Firebase App Distribution) - -디자이너·PM·QA·외부 베타테스터에게 release APK 를 자동 배포하는 흐름. Firebase 프로젝트 `afternote-android` + 테스터 그룹 `afternote` 사용. - -## 셋업 (1hyok 만 1회 — 신규 인계자도 동일) - -1. **Release keystore 생성** (분실 시 앱 업데이트 영구 불가 → 1Password / iCloud 등 2곳 이상 백업 필수) - - ```bash - keytool -genkeypair -v \ - -keystore ~/afternote-release.jks \ - -keyalg RSA -keysize 4096 -validity 10000 \ - -alias afternote-release - ``` - -2. **`local.properties` 끝에 4개 키 추가** (signing config 가 읽음) - - ```properties - RELEASE_STORE_FILE=/Users//afternote-release.jks - RELEASE_STORE_PASSWORD= - RELEASE_KEY_ALIAS=afternote-release - RELEASE_KEY_PASSWORD= - ``` - -3. **`google-services.json` 배치** — Firebase Console → 프로젝트 설정 → 일반 → Android 앱 `com.afternote.afternote_fe` 카드에서 다운로드 → `app/google-services.json` - -4. **Firebase CLI 설치 + 인증** (자동 업로드용) - - ```bash - npm install -g firebase-tools - firebase login - ``` - -5. **콘솔에 신규 keystore SHA 등록** (배포 받은 사람의 카카오/구글 로그인 동작 위해) - - Release SHA-1 추출: `keytool -list -v -keystore ~/afternote-release.jks -alias afternote-release | grep SHA1` - - 카카오 키 해시 추출: `keytool -exportcert -alias afternote-release -keystore ~/afternote-release.jks | openssl sha1 -binary | openssl base64` - - **Kakao Developers** → 앱 → 플랫폼 키 → Android → 키 해시 추가 - - **Firebase Console** → 프로젝트 설정 → Android 앱 → SHA 인증서 지문 추가 - -## 배포 (매 회) - -모든 배포의 릴리스 노트에는 `포함 이슈`와 `QA 포인트`가 필요하다. 둘 중 하나라도 비어 있거나 포함 이슈에 `#123` 형식의 번호가 없으면 Firebase 업로드 전에 실패한다. - -### 배포 판단 기준 - -배포 시점은 일 단위 주기가 아니라 `develop`에 머지된 변경 묶음의 크기와 위험도로 정한다. - -- 인증·온보딩·데이터 손실·API 계약·빌드/서명처럼 영향이 큰 변경은 다른 변경을 기다리지 않고 단독 배포한다. -- 작은 변경은 하나의 QA 세션에서 회귀 원인을 구분할 수 있는 범위까지만 묶는다. 서로 다른 사용자 흐름을 한꺼번에 확인해야 하거나 함께 롤백하기 어려워지는 시점이 배포 경계다. -- 수정 결과를 테스터가 확인해야 하는 결함이 머지되면, 묶음 크기와 관계없이 확인 가능한 빌드를 배포한다. -- 현재 묶음의 모든 QA 포인트가 통과한 뒤 `develop`을 `main`으로 승격한다. - -### 릴리스 PR 범위 자동 산출 - -배포 시점은 위 기준에 따라 사람이 정한다 — `develop` → `main` 릴리스 PR을 여는 것이 곧 배포 결정이다. - -릴리스 PR이 열리거나 head가 갱신되면 [`release-scope.yml`](.github/workflows/release-scope.yml)이 마지막 성공 배포 이후 `develop`에 머지된 PR과 그 연결 이슈를 모아 PR 본문의 `## 포함 이슈`를 채운다. head가 움직일 때마다 다시 채우므로 머지 직전에 목록을 손으로 대조할 필요가 없다. +--- -`## QA 포인트`는 비어 있을 때만 구성 PR 본문에서 모은 초안으로 채우고, 사람이 쓴 문장이 있으면 건드리지 않는다. 두 섹션은 main push 시 그대로 릴리스 노트가 되므로 배포 전에 테스터가 실행할 문장으로 다듬는다. +# 🧭 개발 규칙 -별도 API나 유료 AI를 호출하지 않으며 기존 GitHub Actions 실행량만 사용한다. Actions의 **Collect Release Scope**에서 릴리스 PR 번호를 입력해 다시 산출할 수도 있다. +## 브랜치 -### QA 배포 — `develop` → Firebase App Distribution (수동, 기본 경로) +`/<이슈번호>` 형식을 쓴다. 예: `fix/910`, `ci/1028`, `perf/996` -GitHub Actions의 **Release Distribution**에서 `Run workflow`를 누르고 ref를 `develop`으로 선택한 뒤 다음 값을 입력한다. +`feat` · `fix` · `refactor` · `test` · `chore` · `docs` · `ci` · `build` · `perf` · `security` · `release` -- `issue_numbers`: 포함된 이슈 번호. 예: `#716, #723` -- `qa_points`: 확인할 동작과 기대 결과. 여러 건은 세미콜론(`;`)으로 구분 +## 커밋 -워크플로가 위 입력을 릴리스 노트로 만들어 APK를 빌드하고 Firebase App Distribution의 `afternote` 그룹에 배포한다. +Conventional Commits — `type(scope): 한글 설명` -### 릴리스 후보 배포 — `main` → Firebase App Distribution (자동) - -여기서 릴리스 후보 배포는 검증할 `main` 빌드를 Firebase 테스터에게 전달하는 단계이며, Play Store 프로덕션 릴리스를 뜻하지 않는다. - -`develop` → `main` 릴리스 PR 본문에 다음 섹션을 채운다. - -```markdown -## 포함 이슈 -- #716 -- #723 - -## QA 포인트 -- 오프라인에서 오류 안내와 재시도 수단이 표시되는지 확인 -- 주차를 변경한 뒤 최신 리포트가 표시되는지 확인 ``` - -PR이 `main`에 머지되면 워크플로가 두 섹션을 릴리스 노트로 사용한다. 연결된 PR이나 필수 섹션을 찾지 못하면 배포하지 않는다. - -CI 가 사용하는 GitHub Secrets (Settings → Secrets and variables → Actions): - -| 키 | 용도 | -|---|---| -| `RELEASE_STORE_FILE_B64` | release keystore 파일 (`~/afternote-release.jks`) 의 base64 인코딩 | -| `RELEASE_STORE_PASSWORD` | keystore 비밀번호 | -| `RELEASE_KEY_ALIAS` | key alias (`afternote-release`) | -| `RELEASE_KEY_PASSWORD` | key 비밀번호 | -| `FIREBASE_SERVICE_ACCOUNT_JSON` | App Distribution Admin 권한 부여된 service account JSON 원문 | -| `KAKAO_NATIVE_APP_KEY` · `GOOGLE_WEB_CLIENT_ID` · `GOOGLE_SERVICES_JSON_B64` | 배포 APK용 실서비스 앱 설정 (`release-distribution.yml` 전용) | - -> base64 인코딩: `base64 -i ~/afternote-release.jks | pbcopy` (macOS) - -PR 검증용 lint·unit-test·screenshot은 repository secret 대신 -`.github/actions/setup-ci-config`가 만드는 결정적 CI 전용 placeholder를 사용한다. 이 fixture는 -배포에 사용할 수 없으며, `release-distribution.yml`은 계속 승인된 환경의 위 secret만 사용한다. - -### 로컬 — 1hyok 머신 (fallback / 긴급 시) - -```bash -EVENT_NAME=workflow_dispatch \ -ISSUE_NUMBERS="#716, #723" \ -QA_POINTS="오프라인 오류 안내 확인;주차 변경 후 재시도 확인" \ -SOURCE_REF=develop \ -SOURCE_SHA="$(git rev-parse HEAD)" \ -bash .github/scripts/render-distribution-release-notes.sh /tmp/afternote-release-notes.txt - -./gradlew assembleRelease appDistributionUploadRelease \ - --releaseNotesFile=/tmp/afternote-release-notes.txt -``` - -→ 동일하게 APK 빌드 + Firebase 업로드. CI 장애 시에만 사용한다. - -> 같은 `versionCode` 로 재업로드하면 기존 release 갱신. 새 release 만들려면 `app/build.gradle.kts` 의 `versionCode` 증가. - -## 테스터 관리 - -- 추가/제거: Firebase Console → App Distribution → 테스터 및 그룹 → `afternote` 그룹 편집 -- 신규 테스터는 첫 초대 이메일에서 **App Tester** 앱 설치 안내를 받음 → 이후 빌드는 자동 알림 - -# 📸 Compose Preview Screenshot Testing (docker baseline) - -`Compose Preview Screenshot Testing` 의 anti-aliasing / font hinting / scale 등 host 환경 의존 렌더링 차이로 CI rendered PNG 를 baseline 으로 교체하는 ping-pong 이 발생해 왔다 (PR [#302](https://github.com/Afternote/Afternote-FE/pull/302) / [#322](https://github.com/Afternote/Afternote-FE/pull/322)). 본 리포의 `Dockerfile.screenshot` + `.github/workflows/screenshot.yml` 의 container 단계가 baseline 생성·검증을 동일 환경에서 수행해 환경 차이 root fix. - -## 사전 준비 - -- Docker Desktop 설치 (로컬 macOS · Linux 모두 동일) - -## 로컬 baseline 갱신 (의도된 시각 변경 시) - -```bash -docker build -t afternote-screenshot:latest -f Dockerfile.screenshot . -docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ - ./gradlew :core:ui:updateScreenshotTest \ - :app:updateScreenshotTest \ - :feature:onboarding:presentation:updateScreenshotTest \ - :feature:afternote:presentation:updateScreenshotTest -``` - -→ 변경된 PNG 가 각 모듈 `src/screenshotTestDebug/reference/...` 에 갱신. `git add` 후 commit. - -## 로컬 baseline 검증 (CI 실패 재현) - -```bash -docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ - ./gradlew :core:ui:validateScreenshotTest \ - :app:validateScreenshotTest \ - :feature:onboarding:presentation:validateScreenshotTest \ - :feature:afternote:presentation:validateScreenshotTest +fix(mindrecord): 주차 조회 실패 뒤에도 화면 안에서 복구할 수 있게 한다 +refactor(core): core:di 모듈 삭제 — 바인딩을 구현 옆으로 옮긴다 ``` -→ baseline 과 docker 환경에서 새로 그린 PNG 비교. 실패 시 `build/outputs/screenshotTest-results/preview/debug/diffs/` 에서 diff PNG 확인. +## 코드 스타일 -## 호스트 직접 실행은 더 이상 권장하지 않음 +ktlint 가 강제한다. 커밋 전 `./gradlew ktlintFormat` 을 돌린다. -`./gradlew ::updateScreenshotTest` 를 host 에서 직접 실행하면 macOS / Linux / JDK 마이너 버전 / 폰트 캐시 차이로 CI 와 baseline 이 어긋난다. docker 환경 통일이 root fix. +## PR · 머지 -# 💻 코딩 컨벤션 +이슈 · PR 템플릿은 [`.github/`](.github/) 에 있다. -> **네이밍 컨벤션** -> -- 네이밍 항목 순서는 android-style-guide를 준수한다. -- 단, Layout을 제외한 네이밍은 CamelCase를 사용한다. - - 예시) `android:id="@+id/tvPostNovelTitle"` - - 자세한 정보는 아래 링크를 참고하였다. - -[](https://github.com/PRNDcompany/android-style-guide/blob/main/Resource.md) - -- Coding Style은 객체지향 생활 체조 원칙을 준수한다. - - 자세한 정보는 아래 링크를 참고하였다. - -[[Java] 객체지향 생활 체조 원칙 9가지 (from 소트웍스 앤솔러지)](https://jamie95.tistory.com/99) - -# 🦥 깃 전략 및 컨벤션 - -> **브랜치 전략** -> -- GitHub Flow를 사용한다. - - 수시로 코드가 변하는 앱잼의 특성을 고려하였다. - - 브랜치 이름은 다음과 같이 언더바를 사용한다. - - 예시) `feat/post_novel` - - 자세한 정보는 아래 링크를 참고하였다. - -[[GIT] 📈 깃 브랜치 전략 정리 - Github Flow / Git Flow](https://inpa.tistory.com/entry/GIT-⚡️-github-flow-git-flow-📈-브랜치-전략) - -> **Commit 컨벤션** -> -- 사용할 커밋 타입은 다음과 같다. - - 🍯 [FEAT] 새로운 기능 추가 - - ♻️ [REFACTOR] 코드 리팩토링 - - 🔨 [FIX] 버그 수정 - - 🚧 [BUILD] 빌드 업무 수정, 패키지 매니저 수정 -- 커밋 메시지 예시는 다음과 같다. - - 예시) `feat: color system 구성` -- 커밋 메시지는 한글로 작성하고, 이슈 번호는 별도로 표기하지 않는다. - -> **Issue 컨벤션** -> -- 제목 예시는 다음과 같다. - - 예시) `feat: library view 구현` - -```kotlin -## ⚔️ Kind (Required) -`FEATURE` `BUG` - -## 📜 Overview (Required) - -> **✔️ To do** -> - [ ] color system 구성 _(예시)_ - -## 📍 Note (Optional) -``` - -> **PR 컨벤션** -> -- 제목 예시는 다음과 같다. - - 예시) `feat: bottomNavigation color system 적용` - -```kotlin -## 📌𝘐𝘴𝘴𝘶𝘦𝘴 -- closed # - -## 📎𝘞𝘰𝘳𝘬 𝘋𝘦𝘴𝘤𝘳𝘪𝘱𝘵𝘪𝘰𝘯 -- -- - -## 📷𝘚𝘤𝘳𝘦𝘦𝘯𝘴𝘩𝘰𝘵 - -## 💬𝘛𝘰 𝘙𝘦𝘷𝘪𝘦𝘸𝘦𝘳𝘴 -``` +`develop` 머지 요건은 **승인 1건 + 필수 체크 8종**이다. -> **Code Review 컨벤션 및 추가정보** -> -- Merge는 리뷰 인원 2명의 승인을 받는다. -- 리뷰 인원으로 할당받은 사람은 12시간 이내에 코드리뷰를 완료한다. -- RCA룰을 통해 Prefix를 적고, 코드 리뷰 반영의 우선순위를 표시한다. - - R (Request Changes) : 적극적으로 반영을 고려해주세요. - - C (Comment) : 웬만하면 반영해주세요. - - A (Approve) : 반영해도 좋고, 넘어가도 좋습니다. 사소한 의견입니다. - - 예시) `R: @Data 어노테이션 사용은 지양해야 할 것 같습니다. 참고자료 별첨합니다.` +| 필수 체크 | +|---| +| `guard` · `Run Unit Tests` · `Check Code Quality (Ktlint)` · `Check Project Issues (Android Lint)` | +| `Validate Compose Preview Screenshots` · `Repository Quality` · `Analyze (java-kotlin)` · `Analyze (actions)` | diff --git a/docs/release/distribution.md b/docs/release/distribution.md new file mode 100644 index 0000000000..48fdfed27d --- /dev/null +++ b/docs/release/distribution.md @@ -0,0 +1,125 @@ +# 비개발자 APK 배포 (Firebase App Distribution) + +디자이너·PM·QA·외부 베타테스터에게 release APK 를 자동 배포하는 흐름. Firebase 프로젝트 `afternote-android` + 테스터 그룹 `afternote` 사용. + +## 셋업 (1hyok 만 1회 — 신규 인계자도 동일) + +1. **Release keystore 생성** (분실 시 앱 업데이트 영구 불가 → 1Password / iCloud 등 2곳 이상 백업 필수) + + ```bash + keytool -genkeypair -v \ + -keystore ~/afternote-release.jks \ + -keyalg RSA -keysize 4096 -validity 10000 \ + -alias afternote-release + ``` + +2. **`local.properties` 끝에 4개 키 추가** (signing config 가 읽음) + + ```properties + RELEASE_STORE_FILE=/Users//afternote-release.jks + RELEASE_STORE_PASSWORD= + RELEASE_KEY_ALIAS=afternote-release + RELEASE_KEY_PASSWORD= + ``` + +3. **`google-services.json` 배치** — Firebase Console → 프로젝트 설정 → 일반 → Android 앱 `com.afternote.afternote_fe` 카드에서 다운로드 → `app/google-services.json` + +4. **Firebase CLI 설치 + 인증** (자동 업로드용) + + ```bash + npm install -g firebase-tools + firebase login + ``` + +5. **콘솔에 신규 keystore SHA 등록** (배포 받은 사람의 카카오/구글 로그인 동작 위해) + - Release SHA-1 추출: `keytool -list -v -keystore ~/afternote-release.jks -alias afternote-release | grep SHA1` + - 카카오 키 해시 추출: `keytool -exportcert -alias afternote-release -keystore ~/afternote-release.jks | openssl sha1 -binary | openssl base64` + - **Kakao Developers** → 앱 → 플랫폼 키 → Android → 키 해시 추가 + - **Firebase Console** → 프로젝트 설정 → Android 앱 → SHA 인증서 지문 추가 + +## 배포 (매 회) + +모든 배포의 릴리스 노트에는 `포함 이슈`와 `QA 포인트`가 필요하다. 둘 중 하나라도 비어 있거나 포함 이슈에 `#123` 형식의 번호가 없으면 Firebase 업로드 전에 실패한다. + +### 배포 판단 기준 + +배포 시점은 일 단위 주기가 아니라 `develop`에 머지된 변경 묶음의 크기와 위험도로 정한다. + +- 인증·온보딩·데이터 손실·API 계약·빌드/서명처럼 영향이 큰 변경은 다른 변경을 기다리지 않고 단독 배포한다. +- 작은 변경은 하나의 QA 세션에서 회귀 원인을 구분할 수 있는 범위까지만 묶는다. 서로 다른 사용자 흐름을 한꺼번에 확인해야 하거나 함께 롤백하기 어려워지는 시점이 배포 경계다. +- 수정 결과를 테스터가 확인해야 하는 결함이 머지되면, 묶음 크기와 관계없이 확인 가능한 빌드를 배포한다. +- 현재 묶음의 모든 QA 포인트가 통과한 뒤 `develop`을 `main`으로 승격한다. + +### 머지별 자동 판단 + +`develop` 대상 PR이 머지되면 [`deployment-decision.yml`](.github/workflows/deployment-decision.yml)이 마지막 성공 QA 배포 이후의 누적 PR·연결 이슈·실제 diff를 읽는다. 고위험 경로, 버그·기능 PR, 누적 이슈 수, 명시 QA 포인트 수, 영향 스코프 수를 위 기준으로 판정해 `QA 배포 권장` 또는 `QA 배포 보류`, 포함 이슈, QA 포인트를 머지된 PR에 코멘트한다. 판단만 자동화하며 APK 업로드는 실행하지 않는다. + +별도 API나 유료 AI를 호출하지 않으며 기존 GitHub Actions 실행량만 사용한다. Actions의 **Evaluate QA Distribution Candidate**에서 이미 머지된 PR 번호를 입력해 같은 규칙으로 수동 재검증할 수도 있다. + +### QA 배포 — `develop` → Firebase App Distribution (수동, 기본 경로) + +GitHub Actions의 **Release Distribution**에서 `Run workflow`를 누르고 ref를 `develop`으로 선택한 뒤 다음 값을 입력한다. + +- `issue_numbers`: 포함된 이슈 번호. 예: `#716, #723` +- `qa_points`: 확인할 동작과 기대 결과. 여러 건은 세미콜론(`;`)으로 구분 + +워크플로가 위 입력을 릴리스 노트로 만들어 APK를 빌드하고 Firebase App Distribution의 `afternote` 그룹에 배포한다. + +### 릴리스 후보 배포 — `main` → Firebase App Distribution (자동) + +여기서 릴리스 후보 배포는 검증할 `main` 빌드를 Firebase 테스터에게 전달하는 단계이며, Play Store 프로덕션 릴리스를 뜻하지 않는다. + +`develop` → `main` 릴리스 PR 본문에 다음 섹션을 채운다. + +```markdown +## 포함 이슈 +- #716 +- #723 + +## QA 포인트 +- 오프라인에서 오류 안내와 재시도 수단이 표시되는지 확인 +- 주차를 변경한 뒤 최신 리포트가 표시되는지 확인 +``` + +PR이 `main`에 머지되면 워크플로가 두 섹션을 릴리스 노트로 사용한다. 연결된 PR이나 필수 섹션을 찾지 못하면 배포하지 않는다. + +CI 가 사용하는 GitHub Secrets (Settings → Secrets and variables → Actions): + +| 키 | 용도 | +|---|---| +| `RELEASE_STORE_FILE_B64` | release keystore 파일 (`~/afternote-release.jks`) 의 base64 인코딩 | +| `RELEASE_STORE_PASSWORD` | keystore 비밀번호 | +| `RELEASE_KEY_ALIAS` | key alias (`afternote-release`) | +| `RELEASE_KEY_PASSWORD` | key 비밀번호 | +| `FIREBASE_SERVICE_ACCOUNT_JSON` | App Distribution Admin 권한 부여된 service account JSON 원문 | +| `KAKAO_NATIVE_APP_KEY` · `GOOGLE_WEB_CLIENT_ID` · `GOOGLE_SERVICES_JSON_B64` | 배포 APK용 실서비스 앱 설정 (`release-distribution.yml` 전용) | + +> base64 인코딩: `base64 -i ~/afternote-release.jks | pbcopy` (macOS) + +PR 검증용 lint·unit-test·screenshot은 repository secret 대신 +`.github/actions/setup-ci-config`가 만드는 결정적 CI 전용 placeholder를 사용한다. 이 fixture는 +배포에 사용할 수 없으며, `release-distribution.yml`은 계속 승인된 환경의 위 secret만 사용한다. + +### 로컬 — 1hyok 머신 (fallback / 긴급 시) + +```bash +EVENT_NAME=workflow_dispatch \ +ISSUE_NUMBERS="#716, #723" \ +QA_POINTS="오프라인 오류 안내 확인;주차 변경 후 재시도 확인" \ +SOURCE_REF=develop \ +SOURCE_SHA="$(git rev-parse HEAD)" \ +bash .github/scripts/render-distribution-release-notes.sh /tmp/afternote-release-notes.txt + +./gradlew assembleRelease appDistributionUploadRelease \ + --releaseNotesFile=/tmp/afternote-release-notes.txt +``` + +→ 동일하게 APK 빌드 + Firebase 업로드. CI 장애 시에만 사용한다. + +> 같은 `versionCode` 로 재업로드하면 기존 release 갱신. 새 release 만들려면 `app/build.gradle.kts` 의 `versionCode` 증가. + +## 테스터 관리 + +- 추가/제거: Firebase Console → App Distribution → 테스터 및 그룹 → `afternote` 그룹 편집 +- 신규 테스터는 첫 초대 이메일에서 **App Tester** 앱 설치 안내를 받음 → 이후 빌드는 자동 알림 + diff --git a/docs/testing/screenshot.md b/docs/testing/screenshot.md new file mode 100644 index 0000000000..fd2a42a2ee --- /dev/null +++ b/docs/testing/screenshot.md @@ -0,0 +1,37 @@ +# Compose Preview Screenshot Testing (docker baseline) + +`Compose Preview Screenshot Testing` 의 anti-aliasing / font hinting / scale 등 host 환경 의존 렌더링 차이로 CI rendered PNG 를 baseline 으로 교체하는 ping-pong 이 발생해 왔다 (PR [#302](https://github.com/Afternote/Afternote-FE/pull/302) / [#322](https://github.com/Afternote/Afternote-FE/pull/322)). 본 리포의 `Dockerfile.screenshot` + `.github/workflows/screenshot.yml` 의 container 단계가 baseline 생성·검증을 동일 환경에서 수행해 환경 차이 root fix. + +## 사전 준비 + +- Docker Desktop 설치 (로컬 macOS · Linux 모두 동일) + +## 로컬 baseline 갱신 (의도된 시각 변경 시) + +```bash +docker build -t afternote-screenshot:latest -f Dockerfile.screenshot . +docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ + ./gradlew :core:ui:updateScreenshotTest \ + :app:updateScreenshotTest \ + :feature:onboarding:presentation:updateScreenshotTest \ + :feature:afternote:presentation:updateScreenshotTest +``` + +→ 변경된 PNG 가 각 모듈 `src/screenshotTestDebug/reference/...` 에 갱신. `git add` 후 commit. + +## 로컬 baseline 검증 (CI 실패 재현) + +```bash +docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ + ./gradlew :core:ui:validateScreenshotTest \ + :app:validateScreenshotTest \ + :feature:onboarding:presentation:validateScreenshotTest \ + :feature:afternote:presentation:validateScreenshotTest +``` + +→ baseline 과 docker 환경에서 새로 그린 PNG 비교. 실패 시 `build/outputs/screenshotTest-results/preview/debug/diffs/` 에서 diff PNG 확인. + +## 호스트 직접 실행은 더 이상 권장하지 않음 + +`./gradlew ::updateScreenshotTest` 를 host 에서 직접 실행하면 macOS / Linux / JDK 마이너 버전 / 폰트 캐시 차이로 CI 와 baseline 이 어긋난다. docker 환경 통일이 root fix. + From 8325a703ab3275330b30713b0f4d542c975a2024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 04:11:59 +0900 Subject: [PATCH 03/12] =?UTF-8?q?docs(qa):=203=EC=B0=A8=20QA=20=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20=EC=84=9C=EC=88=A0=20=EC=A0=95=EC=A0=95=20=E2=80=94?= =?UTF-8?q?=20=EC=BA=98=EB=A6=B0=EB=8D=94=20Task=20=EB=8A=94=208/23=20?= =?UTF-8?q?=EC=9E=90=20=EB=AF=B8=EC=A0=95=20=EB=A9=94=EB=AA=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노션 팀 홈 캘린더를 8/29 마감으로 적었으나 실측하니 틀렸다. "QA마무리?" Task 의 날짜 속성은 2026-08-23 이고, 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 캘린더 셀 순서를 잘못 읽어 23일 칸 항목을 29일 것으로 본 오독이었다. 3차 QA 에 정해진 기한이 없음을 명시하고, 착수 시점은 결함 진척과 커버리지 갭으로 판단하도록 적는다. --- docs/qa/status.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/qa/status.md b/docs/qa/status.md index 2afce3a0a4..2780afb8db 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -66,7 +66,9 @@ | --- | --- | --- | --- | | 1차 | 2026-07-19 | 이준혁 | 결함 19건 + 서버 보안 1건 + 기획 확인 6건 | | 2차 | 2026-07-28 | 정일혁 | 결함 42건 (+ 회원 탈퇴 추가 회차 7건) | -| 3차 | 미착수 | — | 팀 캘린더 8/29 "QA마무리?" 만 잡혀 있음 | +| 3차 | 미착수 | — | 기한 없음 (아래 참고) | + +**3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. ## 4. 2차 QA 결함의 현재 진척 From efe5c625cb069f39b6e30226c40690e4786949c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 04:42:26 +0900 Subject: [PATCH 04/12] =?UTF-8?q?docs(qa):=203=EC=B0=A8=20QA=20=EC=98=88?= =?UTF-8?q?=EB=B9=84=20=EC=88=9C=ED=9A=8C=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=ED=98=84=ED=99=A9=EC=97=90=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-25 04:20~04:40 KST, dev 서버 폐쇄 시간대를 조건으로 활용해 실패 경로에 집중한 오프라인 순회를 돌렸다. develop 4e0a648eb 를 Pixel_7_Claude_QA 에 클린 설치하고 만료 access + 유효 refresh 를 주입해 '세션은 있으나 서버가 죽은' 상태로 검증했다. 수정 확인 5건 — 2차 QA #21·#25·#26, 1차 QA #3·#9. 재현 4건 — #708 설정, #714 타임레터, #716 마음의 기록(서버·내부 IP 노출 포함), 첫 진입 요청 중복. 신규 등재 1건 — #1100 추억 플레이리스트 빈 목록 안내 부재. 가장 큰 발견은 같은 서버 무응답 조건에서 실패 처리가 기능마다 네 갈래로 갈린다는 것이다. 개별 PR 리뷰로는 드러나지 않고 기능 간 대조에서만 보이므로, 회차 QA 를 따로 두는 근거가 된다. --- docs/qa/status.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/qa/status.md b/docs/qa/status.md index 2780afb8db..2bdfd723cf 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -66,7 +66,15 @@ | --- | --- | --- | --- | | 1차 | 2026-07-19 | 이준혁 | 결함 19건 + 서버 보안 1건 + 기획 확인 6건 | | 2차 | 2026-07-28 | 정일혁 | 결함 42건 (+ 회원 탈퇴 추가 회차 7건) | -| 3차 | 미착수 | — | 기한 없음 (아래 참고) | +| 3차 | 예비 순회 2026-08-25 | 정일혁 | 서버 폐쇄 시간대 오프라인 순회 — 수정 확인 5건 · 재현 4건 · 신규 1건(#1100) | + +**2026-08-25 예비 순회 결과** — dev 서버 폐쇄 시간대(04:20~04:40 KST)를 이용해 *실패 경로* 에 집중한 오프라인 순회를 돌렸다. 증거는 `.codex/qa-evidence/emulator/8325a703….json`. + +- **수정이 실동작함** — 2차 QA #21(통신 오류 강제 로그아웃) · #25(작성 중 이탈 가드) · #26(플레이리스트 유령 곡수) · 1차 QA #3(소셜네트워크 수신자 UI) · #9(TODAY'S QUESTION 날짜) +- **재현 확인** — #708(설정) · #714(타임레터) · #716(마음의 기록, 서버·내부 IP 노출 포함) · 요청 중복 +- **신규 등재** — #1100 추억 플레이리스트 빈 목록 안내 부재 +- **가장 큰 발견**: 같은 서버 무응답 조건에서 **실패 처리가 기능마다 네 갈래로 갈린다.** 홈·노트는 오류 UI + 재시도, 설정은 로그아웃 항목만, 타임레터는 빈 목록 위장, 마음의 기록은 예외 원문 노출. 개별 PR 리뷰로는 드러나지 않고 기능 간 대조에서만 보인다 — 회차 QA 가 필요한 이유의 실증이다. +- **미검증** — 성공 경로 전반(서버 폐쇄), 온보딩·수신자 열람(재로그인 불가), 설정 잔여 4건(화면 도달 불가) **3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. From 7c99dbbd16aa67c6862bc49690b2847030760e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 16:40:43 +0900 Subject: [PATCH 05/12] =?UTF-8?q?docs(qa):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=ED=9B=84=202=EC=B0=A8=20=EC=8B=A4?= =?UTF-8?q?=EC=B8=A1=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=ED=98=84=ED=99=A9?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 리비전(develop 4e0a648eb)으로 15:09~15:15 KST 에 이어서 실측했다. 수정 확인 — refresh 무효(1107)를 인증 거절로 분류해 세션 정리. 새벽의 통신 실패 시 세션 유지와 합쳐 만료·거절·일시실패 3분류가 의도대로 갈린다. 받은 기록함 문구·빈 값 안내(2차 QA #32, 1차 QA O6)도 해소. 재현 — #599 발신자 카드 재시작 소실, #607 등록에 HTTP 요청 0건. 서버가 정상 응답하는 상태에서 재현해 네트워크 실패가 원인이 아님을 확인했다. 신규 등재 — #1126 동시 재발급 경합. #701 에는 프로세스 백그라운드 복귀 축을 코멘트로 덧붙였다. --- docs/qa/status.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/qa/status.md b/docs/qa/status.md index 2bdfd723cf..3eb9d3ed29 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -74,7 +74,14 @@ - **재현 확인** — #708(설정) · #714(타임레터) · #716(마음의 기록, 서버·내부 IP 노출 포함) · 요청 중복 - **신규 등재** — #1100 추억 플레이리스트 빈 목록 안내 부재 - **가장 큰 발견**: 같은 서버 무응답 조건에서 **실패 처리가 기능마다 네 갈래로 갈린다.** 홈·노트는 오류 UI + 재시도, 설정은 로그아웃 항목만, 타임레터는 빈 목록 위장, 마음의 기록은 예외 원문 노출. 개별 PR 리뷰로는 드러나지 않고 기능 간 대조에서만 보인다 — 회차 QA 가 필요한 이유의 실증이다. -- **미검증** — 성공 경로 전반(서버 폐쇄), 온보딩·수신자 열람(재로그인 불가), 설정 잔여 4건(화면 도달 불가) +- **미검증** — 성공 경로 전반, 설정 잔여 4건(#560·#564·#572·#574, 화면 도달 불가). 로그인 세션이 필요한 범위는 QA 계정 재로그인 뒤로 미뤘다. + +**서버 복구 후 2차 실측 (15:09~15:15 KST)** — 같은 리비전으로 이어서 확인했다. + +- **수정 확인** — refresh 무효(400/1107)를 인증 거절로 분류해 세션 정리. 새벽의 "통신 실패 시 세션 유지" 와 합쳐 **만료·거절·일시실패 3분류가 의도대로** 동작한다. 2차 QA #32 · 1차 QA O6(받은 기록함 문구·빈 값 안내)도 해소. #919 대규모 이전 후 수신자 인증 진입 회귀 없음. +- **재현** — #599(발신자 카드 재시작 소실) · #607(등록에 HTTP 요청 0건). **서버 정상 상태에서 재현**했으므로 네트워크 탓이 아니다. +- **신규 등재** — #1126 동시 요청이 각자 재발급을 시도하는 경합. 이번엔 "인증 거절" 이 먼저 반영돼 결과가 맞았지만 순서가 바뀌면 무효 refresh 가 세션에 남아 401 이 반복될 수 있다. +- **#701 에 축 추가** — 오류 화면인 채로 백그라운드에 있던 앱이 서버 복구 후 포그라운드로 돌아와도 자동 재조회가 없다. **3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. From 1f3db7ea06976cdc280a8373d62c031298c0be72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 17:10:18 +0900 Subject: [PATCH 06/12] =?UTF-8?q?docs(qa):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=84=A4=EC=A0=95=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=20=EC=8B=A4=EC=B8=A1=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA823 재로그인 뒤 설정 전 구간을 돌려 2차 QA 잔여 설정 4건이 모두 남아 있음을 확인했다. #572 는 본문 진단이 오진임을 확인했다 — PIN 이 저장되지 않는 게 아니라 저장은 되고(패스키 인증에서 같은 PIN 통과) 앱 시작 시 잠금 게이트가 적용되지 않는 것이다. #574 의 '비밀번호로 인증하기' 가 요구하는 값도 계정 비밀번호가 아니라 그 앱 잠금 PIN 이었다. 새벽에 미확정으로 남긴 홈 인사말 '…님' 은 결함이 아니었다. 클린 설치와 서버 무응답이 겹쳐 이름을 받을 경로가 없던 정상 폴백이다. --- docs/qa/status.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/qa/status.md b/docs/qa/status.md index 3eb9d3ed29..706778dd94 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -83,6 +83,18 @@ - **신규 등재** — #1126 동시 요청이 각자 재발급을 시도하는 경합. 이번엔 "인증 거절" 이 먼저 반영돼 결과가 맞았지만 순서가 바뀌면 무효 refresh 가 세션에 남아 401 이 반복될 수 있다. - **#701 에 축 추가** — 오류 화면인 채로 백그라운드에 있던 앱이 서버 복구 후 포그라운드로 돌아와도 자동 재조회가 없다. +**로그인 상태 실측 (16:58~17:06 KST)** — QA823 재로그인 후 설정 전 구간을 돌렸다. **2차 QA 잔여 설정 4건이 전부 그대로 남아 있다.** + +| 이슈 | 결과 | +| --- | --- | +| #560 마케팅 수신 저장 | 재현 — **토글 시 저장 요청이 아예 안 나간다**(진입·재진입 GET 만 있음) | +| #572 앱 잠금 PIN | 재현 — 단 **본문 진단이 오진**이다. PIN 은 저장된다(패스키 인증에서 통과). 실제 원인은 **잠금 게이트 미적용** | +| #574 패스키 비밀번호 | 재현 — "비밀번호로 인증하기" 가 요구하는 값은 **앱 잠금 PIN**. 라벨이 어긋난 것 | +| #564 고객지원 6종 | 재현 — **공지사항만 동작**. 나머지 5종은 화면 미구현(#785·#786) | +| #555 패스키 목록 | 재현 — 자리표시자 1건('이름'·'생성일시'·애플 아이콘)이 항목처럼 노출 | + +**해소 확인** — 1차 QA #9(TODAY'S QUESTION 고정값) · #17/2차 #5(placeholder `Text Field`) · 2차 #2 부분(상단 바로가기 3종 중 공지사항·수신자 목록 연결). 새벽의 미확정 관찰이던 홈 인사말 "…님" 은 **결함이 아니었다** — 클린 설치 + 서버 무응답이라 이름을 받을 경로가 없던 정상 폴백이고, 로그인 상태에서는 "QA823님" 으로 나온다. + **3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. ## 4. 2차 QA 결함의 현재 진척 From f1a7a90cf5d9ddf554eeccbfc48f7154b66fb034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 17:29:28 +0900 Subject: [PATCH 07/12] =?UTF-8?q?docs(qa):=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=8B=A4=EC=B8=A1=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=99=80=203=EC=B0=A8=20=EC=88=9C=ED=9A=8C=20=EC=B4=9D?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에뮬레이터에 화면잠금·지문을 등록해 애프터노트 조회 인증 게이트를 통과한 뒤 성공 경로를 돌렸다. 목록·상세·수정·저장 왕복이 전부 정상이고 1차 QA #1(등록 500)·#2(실패 무음)는 재현되지 않는다. 해소 확인 — 2차 QA #22/#679 수정 진입 수신자 prefill, 2차 QA #33 서버 항목(수신자 이름·관계), #690 카드 아이콘. #706 은 CLOSED 인데 재발했다. 같은 검증 에러가 연속되면 안내가 발행되지 않고, 에러 값이 바뀌어야 다시 뜬다. 후속 #1127 로 등록했다. 3차 순회 총계는 증거 39건 · 수정 확인 13건 · 재현 11건 · 신규 3건이다. --- docs/qa/status.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/qa/status.md b/docs/qa/status.md index 706778dd94..d28f3dc120 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -66,7 +66,7 @@ | --- | --- | --- | --- | | 1차 | 2026-07-19 | 이준혁 | 결함 19건 + 서버 보안 1건 + 기획 확인 6건 | | 2차 | 2026-07-28 | 정일혁 | 결함 42건 (+ 회원 탈퇴 추가 회차 7건) | -| 3차 | 예비 순회 2026-08-25 | 정일혁 | 서버 폐쇄 시간대 오프라인 순회 — 수정 확인 5건 · 재현 4건 · 신규 1건(#1100) | +| 3차 | 순회 2026-08-25 | 정일혁 | 오프라인·실서버 5회차 — 증거 39건 · 수정 확인 13건 · 재현 11건 · 신규 3건(#1100 · #1126 · #1127) | **2026-08-25 예비 순회 결과** — dev 서버 폐쇄 시간대(04:20~04:40 KST)를 이용해 *실패 경로* 에 집중한 오프라인 순회를 돌렸다. 증거는 `.codex/qa-evidence/emulator/8325a703….json`. @@ -95,6 +95,13 @@ **해소 확인** — 1차 QA #9(TODAY'S QUESTION 고정값) · #17/2차 #5(placeholder `Text Field`) · 2차 #2 부분(상단 바로가기 3종 중 공지사항·수신자 목록 연결). 새벽의 미확정 관찰이던 홈 인사말 "…님" 은 **결함이 아니었다** — 클린 설치 + 서버 무응답이라 이름을 받을 경로가 없던 정상 폴백이고, 로그인 상태에서는 "QA823님" 으로 나온다. +**성공 경로 실측 (17:14~17:26 KST)** — 에뮬레이터에 화면잠금·지문을 등록해 애프터노트 인증 게이트를 통과한 뒤 돌렸다. + +- **애프터노트 전 구간 정상** — 목록·상세·수정 진입·저장(`PATCH /afternotes/39` → 200)·목록 자동 재조회. 1차 QA #1(등록 500)·#2(실패 무음)는 재현되지 않는다. +- **해소 확인** — 2차 QA #22/#679(수정 진입 수신자 prefill) · 2차 QA #33 서버 항목(수신자에 이름·관계가 온다) · #690(카드 아이콘이 서버 category 기준) +- **#706 재발** — CLOSED 이슈인데 **같은 검증 에러가 연속되면 안내가 발행되지 않는다.** 에러 값이 바뀌면 뜨고 같으면 안 뜬다. 후속 #1127 등록. +- **관찰** — 상세 응답이 계정 비밀번호를 평문으로 내려준다. 애프터노트 조회 게이트가 앱 재시작마다 지문을 요구하고, 화면 제목이 '지문 로그인' 인데 실제로는 조회 인증이라 라벨이 어긋난다. + **3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. ## 4. 2차 QA 결함의 현재 진척 From 960044a1d4a1ff7c28094fe17e365a53b04f4f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 18:37:45 +0900 Subject: [PATCH 08/12] =?UTF-8?q?docs(qa):=20=EC=88=98=EC=8B=A0=EC=9E=90?= =?UTF-8?q?=20=EC=97=B4=EB=9E=8C=20=ED=9D=90=EB=A6=84=20=EA=B7=9C=EB=AA=85?= =?UTF-8?q?=20=E2=80=94=20admin=20=EC=8A=B9=EC=9D=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=92=B7=EB=B6=80=EB=B6=84=EA=B9=8C=EC=A7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin 계정 승인 API 로 2차 QA 이래 못 보던 '승인 이후 화면' 구간을 열어 검증했다. curl 로 서류 제출·승인까지 무대를 세우고 그 뒤를 앱으로 보는 방식(무대는 우회, 검증 대상은 앱). - #915 는 FE 버그가 아니라 서버 계약 미반영 — contentLength 필수화가 BE 27fa442(8/9)에서 들어왔고 FE DTO 가 안 따라갔다. - 승인해도 수신자가 기록을 못 보는 건 #607(record-boxes 미사용) 탓 — 서버 APPROVED 를 읽는 경로가 없어 카드가 '열람 불가' 에 고정되고 열람 신청이 다시 서류 화면으로 되돌아간다. #599·#612 와 한 뿌리. 세 결함이 겹쳐 admin 승인이 수신자에게 도달하지 못한다. --- docs/qa/status.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/qa/status.md b/docs/qa/status.md index d28f3dc120..faac9be283 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -66,7 +66,7 @@ | --- | --- | --- | --- | | 1차 | 2026-07-19 | 이준혁 | 결함 19건 + 서버 보안 1건 + 기획 확인 6건 | | 2차 | 2026-07-28 | 정일혁 | 결함 42건 (+ 회원 탈퇴 추가 회차 7건) | -| 3차 | 순회 2026-08-25 | 정일혁 | 오프라인·실서버 5회차 — 증거 39건 · 수정 확인 13건 · 재현 11건 · 신규 3건(#1100 · #1126 · #1127) | +| 3차 | 순회 2026-08-25 | 정일혁 | 7회차(오프라인·실서버·admin 승인) — 증거 45건 · 신규 3건 · 재현 다수 · 열람 흐름 규명 | **2026-08-25 예비 순회 결과** — dev 서버 폐쇄 시간대(04:20~04:40 KST)를 이용해 *실패 경로* 에 집중한 오프라인 순회를 돌렸다. 증거는 `.codex/qa-evidence/emulator/8325a703….json`. @@ -102,6 +102,16 @@ - **#706 재발** — CLOSED 이슈인데 **같은 검증 에러가 연속되면 안내가 발행되지 않는다.** 에러 값이 바뀌면 뜨고 같으면 안 뜬다. 후속 #1127 등록. - **관찰** — 상세 응답이 계정 비밀번호를 평문으로 내려준다. 애프터노트 조회 게이트가 앱 재시작마다 지문을 요구하고, 화면 제목이 '지문 로그인' 인데 실제로는 조회 인증이라 라벨이 어긋난다. +**수신자 열람 흐름 — admin 승인으로 무대를 세워 끝까지 검증 (18:34 KST)** + +2차 QA 이래 '관리자 승인 수단 부재' 로 못 보던 구간을, admin 계정 승인 API 로 열었다. curl 로 서류 제출·승인까지 무대를 세우고 그 뒤를 앱으로 검증하는 방식(무대는 우회, 대상은 앱). + +- **admin 승인 경로 실재·동작** — `POST /admin/verifications/{id}/approve` 로 PENDING→APPROVED. 열람 = 사망확인 승인 + 콘텐츠별 전달 조건, 둘 다(AND)여야 열림. +- **#915 = 서버 계약 미반영** — presigned 요청의 `contentLength` 필수화는 BE 커밋 27fa442(8/9). FE DTO 가 안 따라감. curl 로는 성공, 앱은 400. +- **승인 이후 화면이 통째로 막힘 = #607** — 받은 기록함이 `record-boxes` 를 안 써서 서버 APPROVED 를 못 읽는다. 승인해도 카드는 '열람 불가' 고정, 열람 신청은 다시 서류 화면으로. `getDeliveryVerificationStatus()` 는 구현돼 있으나 화면이 호출 안 함. #599·#612 와 한 뿌리. + +이 세 개가 겹쳐 **admin 이 승인해도 수신자는 앱에서 기록을 못 본다.** record-boxes 연동(#607) 하나로 대부분 풀린다. + **3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. ## 4. 2차 QA 결함의 현재 진척 From a3a777d35892d8a806c93ad162df23e0a77ef738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 18:47:14 +0900 Subject: [PATCH 09/12] =?UTF-8?q?docs(qa):=20=EC=88=98=EC=8B=A0=EC=9E=90?= =?UTF-8?q?=20=EC=97=B4=EB=9E=8C=EC=9D=84=20=EB=81=9D=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EB=9A=AB=EC=96=B4=20BE=20#98=20=ED=9A=8C=EA=B7=80=20=EB=B0=9C?= =?UTF-8?q?=EA=B2=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발신자 토큰으로 전달 조건까지 설정해(무대) 수신자 애프터노트 열람을 열고 작성자 원본과 대조했다. 수신자가 받는 SOCIAL 애프터노트 상세에 credentials(계정 ID·비번)가 없다. 작성자는 저장했는데 수신자 응답에서 빠진다. actions 는 오지만 대상 계정이 없어 사후 계정 인계가 성립하지 않는다. BE #98(7/30 완료로 닫힘)과 동일 증상이라 회귀로 판단, BE 레포에 재현 코멘트로 재오픈 요청했다(담당 sooooa16). FE 결함 아님. 전달 조건(RECEIVER_REQUEST)+admin 승인 경로는 정상 동작을 확인했다. --- docs/qa/status.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/qa/status.md b/docs/qa/status.md index faac9be283..d1c224970b 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -112,6 +112,11 @@ 이 세 개가 겹쳐 **admin 이 승인해도 수신자는 앱에서 기록을 못 본다.** record-boxes 연동(#607) 하나로 대부분 풀린다. +**수신자 열람을 끝까지 뚫어 대조 (18:5x KST)** — 발신자 토큰으로 전달 조건까지 설정(무대), 수신자 열람을 열어 작성자 원본과 대조했다. + +- **BE #98 회귀 발견** — 수신자가 받는 SOCIAL 애프터노트 상세에 **credentials(계정 ID·비번)가 없다.** 작성자는 저장했는데(qa935_dummy_id/pw) 수신자 응답 8개 키에 credentials 가 빠진다. `actions:['delete account']` 는 오는데 대상 계정이 없어 사후 계정 인계가 성립하지 않는다. BE #98(2026-07-30 '완료' 로 닫힘)과 동일 증상 → 회귀. BE 레포에 재현 코멘트로 재오픈 요청(담당 sooooa16). **FE 결함 아님.** +- **전달 조건 + 승인 경로 실동작** — AFTERNOTE=RECEIVER_REQUEST 설정 → 서류 재제출 → admin 승인 → 403→200 열림. fulfillByReceiverRequest 가 승인 시 조건을 충족시키는 설계 확인. + **3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. ## 4. 2차 QA 결함의 현재 진척 From c52fab7d301f3ea83550da1d680144c8364b2dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 27 Aug 2026 20:27:46 +0900 Subject: [PATCH 10/12] docs(qa): migrate sanitized runtime evidence --- .github/scripts/qa-evidence.mjs | 699 ++++++++++++++++++ .github/scripts/qa-evidence.test.mjs | 149 ++++ README.md | 1 + app/build.gradle.kts | 2 +- .../src/main/kotlin/BuildFingerprint.kt | 2 +- docs/qa/QA_2026-07-19.md | 6 +- docs/qa/device-baseline.md | 29 +- ...5a49170952a4c6f9dea6e1aa4145ba097ad54.json | 49 ++ ...b63b2c0c5e1d3c602c15e79283496f8a6553a.json | 52 ++ ...91cb21ffcb519ad03933f419a6586782a6e5d.json | 48 ++ ...57a142a69be5fd3581bae67a459cf09c1b4a7.json | 52 ++ ...f937d102a3e81d5c30be76c85aa7971c20268.json | 50 ++ ...5d93228ce184e4e4634361d27486f6576035c.json | 42 ++ ...7e3d574e7efa91a0222509f50952cdb1d25c4.json | 52 ++ ...022ff3f56a78ba1d4a4cf341e581292fc560e.json | 229 ++++++ ...4833b65a7023ae5c4ff16c71b6dc0fc085706.json | 52 ++ ...2f7bc43c4922c96f00914052621eb95b6d75e.json | 60 ++ ...349852a1bc004faf7660174c745835cabbc93.json | 50 ++ ...c6bd344606397b5cd7167c7a54de2e8338361.json | 48 ++ ...b893434b158eec050c7f71d35d2e207a2e824.json | 52 ++ ...30cef8a2c36b4ed605e31ef22ef3904d4df75.json | 43 ++ ...3af45330a6afff6c73128e57863e72407cfef.json | 53 ++ ...5a703ab3275330b30713b0f4d542c975a2024.json | 370 +++++++++ ...175c4916d01f1bd69a5879f72990664e58659.json | 42 ++ ...9bb4b17b762df47492fd98a910e1cc5d8d202.json | 43 ++ ...13b4a0a1a887dbcb3b0b2795d6dd681082b6c.json | 65 ++ ...ea962c84de74092fd6160a7f8d8feb4d7f962.json | 41 + ...4cff751927d36117b27f8d58c6bd986d801f4.json | 58 ++ ...c361f0a690681dd7c430a44fe5daf37f46f2b.json | 46 ++ docs/qa/evidence/README.md | 36 + ...25a1e1ca5a732500deedad75234fe066e20e6.json | 52 ++ ...3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json | 52 ++ ...5ddb642b55e341a8d213f5787cf05019200c5.json | 55 ++ ...4c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json | 80 ++ ...78fd6d13caedfbf6b1292ab07bbd545b07262.json | 47 ++ ...4b8717a1f2eb588b8a85805e0c823e1e0dea5.json | 59 ++ ...25be52498551cac20774f17e768180d3942b8.json | 43 ++ ...9deb5c867fa4d7ab4123bb2cb65b9b44925ce.json | 49 ++ ...23c67ccd95f8a6cc2524564acec480c02c801.json | 45 ++ ...4d2322ef5383e2b1995f0134f414187c0f047.json | 63 ++ ...fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json | 48 ++ ...1fe76c5ee04036029109b0a82a25629dd79b7.json | 43 ++ docs/qa/evidence/manifest.json | 212 ++++++ docs/qa/status.md | 65 +- scripts/qa-device-profile.sh | 11 +- scripts/test-qa-device-profile.sh | 19 +- 46 files changed, 3407 insertions(+), 57 deletions(-) create mode 100644 .github/scripts/qa-evidence.mjs create mode 100644 .github/scripts/qa-evidence.test.mjs create mode 100644 docs/qa/evidence/05b5a49170952a4c6f9dea6e1aa4145ba097ad54.json create mode 100644 docs/qa/evidence/0c7b63b2c0c5e1d3c602c15e79283496f8a6553a.json create mode 100644 docs/qa/evidence/16591cb21ffcb519ad03933f419a6586782a6e5d.json create mode 100644 docs/qa/evidence/18e57a142a69be5fd3581bae67a459cf09c1b4a7.json create mode 100644 docs/qa/evidence/280f937d102a3e81d5c30be76c85aa7971c20268.json create mode 100644 docs/qa/evidence/3445d93228ce184e4e4634361d27486f6576035c.json create mode 100644 docs/qa/evidence/3767e3d574e7efa91a0222509f50952cdb1d25c4.json create mode 100644 docs/qa/evidence/428022ff3f56a78ba1d4a4cf341e581292fc560e.json create mode 100644 docs/qa/evidence/4a74833b65a7023ae5c4ff16c71b6dc0fc085706.json create mode 100644 docs/qa/evidence/53a2f7bc43c4922c96f00914052621eb95b6d75e.json create mode 100644 docs/qa/evidence/549349852a1bc004faf7660174c745835cabbc93.json create mode 100644 docs/qa/evidence/5e3c6bd344606397b5cd7167c7a54de2e8338361.json create mode 100644 docs/qa/evidence/64fb893434b158eec050c7f71d35d2e207a2e824.json create mode 100644 docs/qa/evidence/77730cef8a2c36b4ed605e31ef22ef3904d4df75.json create mode 100644 docs/qa/evidence/7c13af45330a6afff6c73128e57863e72407cfef.json create mode 100644 docs/qa/evidence/8325a703ab3275330b30713b0f4d542c975a2024.json create mode 100644 docs/qa/evidence/867175c4916d01f1bd69a5879f72990664e58659.json create mode 100644 docs/qa/evidence/8fd9bb4b17b762df47492fd98a910e1cc5d8d202.json create mode 100644 docs/qa/evidence/90913b4a0a1a887dbcb3b0b2795d6dd681082b6c.json create mode 100644 docs/qa/evidence/92dea962c84de74092fd6160a7f8d8feb4d7f962.json create mode 100644 docs/qa/evidence/9434cff751927d36117b27f8d58c6bd986d801f4.json create mode 100644 docs/qa/evidence/98ac361f0a690681dd7c430a44fe5daf37f46f2b.json create mode 100644 docs/qa/evidence/README.md create mode 100644 docs/qa/evidence/a1e25a1e1ca5a732500deedad75234fe066e20e6.json create mode 100644 docs/qa/evidence/a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json create mode 100644 docs/qa/evidence/b765ddb642b55e341a8d213f5787cf05019200c5.json create mode 100644 docs/qa/evidence/b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json create mode 100644 docs/qa/evidence/c1e78fd6d13caedfbf6b1292ab07bbd545b07262.json create mode 100644 docs/qa/evidence/c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5.json create mode 100644 docs/qa/evidence/cfb25be52498551cac20774f17e768180d3942b8.json create mode 100644 docs/qa/evidence/d459deb5c867fa4d7ab4123bb2cb65b9b44925ce.json create mode 100644 docs/qa/evidence/d6123c67ccd95f8a6cc2524564acec480c02c801.json create mode 100644 docs/qa/evidence/db14d2322ef5383e2b1995f0134f414187c0f047.json create mode 100644 docs/qa/evidence/ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json create mode 100644 docs/qa/evidence/ef21fe76c5ee04036029109b0a82a25629dd79b7.json create mode 100644 docs/qa/evidence/manifest.json diff --git a/.github/scripts/qa-evidence.mjs b/.github/scripts/qa-evidence.mjs new file mode 100644 index 0000000000..ea8d104a52 --- /dev/null +++ b/.github/scripts/qa-evidence.mjs @@ -0,0 +1,699 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const PUBLIC_QA_EVIDENCE_SCHEMA = 1; +const NUMERIC_SENSITIVE_PREFIX = "numeric-sensitive:"; +const AUTH_NUMERIC_SENSITIVE_PREFIX = "auth-numeric-sensitive:"; + +const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/; +const SHA_PATTERN = /^[0-9a-f]{7,40}$/; +const EVIDENCE_FILE_PATTERN = /^[0-9a-f]{40}\.json$/; +const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi; +const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi; +const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g; +const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi; +const EMULATOR_SERIAL_PATTERN = /\bemulator-\d+\b/gi; +const QA_ACCOUNT_PATTERN = /\bQA(?=[A-Z0-9_-]*\d)[A-Z0-9_-]+\b/gi; +const QA_ROLE_ALIAS_PATTERN = /\bQA\s+(?:Receiver|Sender|Tester|User)(?:[ _-][A-Za-z0-9_-]+)?\b/gi; +const QA_COMPACT_ROLE_ALIAS_PATTERN = /QA(?:수신자|발신자|테스터|사용자)/g; +const ENGLISH_QA_DISPLAY_ALIAS_PATTERN = /(?),;]*/g; +const JSON_PRIVATE_NUMERIC_ID_PATTERN = /(["'](?:(?:afternote|receiver|content|account|verification|user|record|delivery)[_-]?id|sub|subject)["']\s*:\s*)["']?\d+["']?/gi; +const PRIVATE_NUMERIC_ID_PATTERN = /(\b(?:(?:afternote|receiver|content|account|verification|user|record|delivery)[_-]?id|sub|subject)\s*[:=#]\s*)\d+\b/gi; +const KOREAN_NUMERIC_ID_PATTERN = /((?:계정|수신자|콘텐츠|인증|사용자)\s*(?:ID|id|식별자)\s*[:=#]?\s*)\d+\b/gi; +const AUTH_ASSIGNMENT_PATTERN = /(\b(?:auth(?:entication)?[_-]?code|verification[_-]?code|otp|pin|passcode|master[_-]?key|access[_ -]?token|refresh[_ -]?token|token|password|credential)\b["']?\s*(?:[:=#]|\()\s*["']?)[A-Za-z0-9][A-Za-z0-9._-]{1,}(?:…)?/gi; +const DIRECT_KOREAN_AUTH_CODE_PATTERN = /((?:인증\s*(?:번호|코드))\s*(?:(?:[:=#]|은|는|이|가)\s*)?)\d{2,8}\b/gi; +const NARRATIVE_KOREAN_AUTH_CODE_PATTERN = /(인증\s*\(\s*(?:(?:실제|수신)\s*)*코드\s*)\d{2,8}\b/gi; +const PIN_VALUE_PATTERN = /((?:앱\s*잠금\s*)?\bPIN\b(?:\s*(?:[:=#]|은|는|이|가)\s*|\s*\(\s*|\s+))\d{2,8}\b/gi; +const MASTER_KEY_VALUE_PATTERN = /(마스터\s*키(?:\s*(?:[:=#]|은|는|이|가)\s*|\s*\(\s*|\s+))[A-Za-z0-9][A-Za-z0-9._-]{3,}(?:…)?/gi; +const NARRATIVE_AUTH_VALUE_PATTERN = /(\bauth(?:entication)?[_-]?code\b(?:\s+[가-힣]+){1,4}\s*\(\s*)[A-Za-z0-9][A-Za-z0-9._-]{3,}(?:…)?/gi; +const AUTH_VALUE_PATTERNS = [ + AUTH_ASSIGNMENT_PATTERN, + DIRECT_KOREAN_AUTH_CODE_PATTERN, + NARRATIVE_KOREAN_AUTH_CODE_PATTERN, + PIN_VALUE_PATTERN, + MASTER_KEY_VALUE_PATTERN, + NARRATIVE_AUTH_VALUE_PATTERN, +]; +const QUOTED_PERSON_PATTERN = /((?:수신자|발신자|사용자)\s*)["“](?!\[PERSON_REDACTED\])(?=[^"”\n]{1,40}["”])(?=[^"”\n]*[\p{L}\p{N}])[^"”\n]{1,40}["”]/gu; +const JSON_NAMED_PERSON_PATTERN = /(["']?(?:name|이름|성명)["']?\s*:\s*)(["'“])(?!\[PERSON_REDACTED\])[^"'”\n]{1,40}(["'”])/gi; +const NAMED_PERSON_PATTERN = /((?:(?:수신자|발신자|사용자|계정)\s*)?(?:이름|성명|name)\s*[:=#]\s*)[A-Za-z가-힣][A-Za-z가-힣._-]{1,30}/gi; +const PERSON_HONORIFIC_PATTERN = /(?= 2) values.add(`${NUMERIC_SENSITIVE_PREFIX}${numericValue}`); + return; + } + if (typeof value === "string") { + if (inheritedSensitive && value.trim().length >= 3) { + values.add(value.trim()); + } + for (const match of value.matchAll(URL_PATTERN)) { + if (!isPublicGitHubReference(match[0])) values.add(match[0]); + } + EMAIL_PATTERN.lastIndex = 0; + for (const match of value.matchAll(EMAIL_PATTERN)) { + values.add(match[0]); + const localPart = match[0].split("@", 1)[0]; + if (localPart.length >= 3) values.add(localPart); + } + for (const pattern of [IPV4_PATTERN, UUID_PATTERN, EMULATOR_SERIAL_PATTERN]) { + pattern.lastIndex = 0; + for (const match of value.matchAll(pattern)) values.add(match[0]); + } + for (const pattern of AUTH_VALUE_PATTERNS) { + pattern.lastIndex = 0; + for (const match of value.matchAll(pattern)) { + const assignedValue = match[0].slice(match[1].length); + if (/^\d{2,8}$/.test(assignedValue)) { + values.add(`${AUTH_NUMERIC_SENSITIVE_PREFIX}${assignedValue}`); + } else if (assignedValue.length >= 4) { + values.add(assignedValue); + } + } + } + return; + } + if (Array.isArray(value)) { + value.forEach((item) => visit(item, keys, inheritedSensitive)); + return; + } + if (!isPlainObject(value)) return; + + for (const [key, child] of Object.entries(value)) { + const sensitive = + inheritedSensitive || + SENSITIVE_KEY_PATTERN.test(key) || + SENSITIVE_COLLECTION_KEYS.has(key.toLowerCase()); + visit(child, [...keys, key], sensitive); + } + } + + visit(raw); + return [...values].sort((left, right) => right.length - left.length); +} + +function redactText(rawText, sensitiveLiterals, redactions) { + let value = rawText; + const publicUrls = []; + + value = value.replace(URL_PATTERN, (url) => { + if (isPublicGitHubReference(url)) { + const token = `PUBLIC_GITHUB_REFERENCE_${publicUrls.length}`; + publicUrls.push(url); + return token; + } + redactions.add("text.network_endpoint"); + return "[NETWORK_ENDPOINT_REDACTED]"; + }); + + for (const literal of sensitiveLiterals) { + if (literal.startsWith(NUMERIC_SENSITIVE_PREFIX)) continue; + if (literal.startsWith(AUTH_NUMERIC_SENSITIVE_PREFIX)) { + const numericValue = literal.slice(AUTH_NUMERIC_SENSITIVE_PREFIX.length); + const pattern = new RegExp(`\\b${escapeRegExp(numericValue)}\\b`, "g"); + if (!pattern.test(value)) continue; + pattern.lastIndex = 0; + value = value.replace(pattern, "[AUTH_VALUE_REDACTED]"); + redactions.add("text.auth_value"); + continue; + } + const pattern = /^\d+$/.test(literal) + ? new RegExp(`\\b${escapeRegExp(literal)}\\b`, "g") + : new RegExp(escapeRegExp(literal), "gi"); + if (!pattern.test(value)) continue; + pattern.lastIndex = 0; + value = value.replace(pattern, "[SENSITIVE_VALUE_REDACTED]"); + redactions.add("text.sensitive_value"); + } + + for (const [pattern, replacement, category] of [ + [JSON_PRIVATE_NUMERIC_ID_PATTERN, "$1\"[PRIVATE_ID_REDACTED]\"", "text.private_identifier"], + [PRIVATE_NUMERIC_ID_PATTERN, "$1[PRIVATE_ID_REDACTED]", "text.private_identifier"], + [KOREAN_NUMERIC_ID_PATTERN, "$1[PRIVATE_ID_REDACTED]", "text.private_identifier"], + ...AUTH_VALUE_PATTERNS.map((pattern) => [ + pattern, + "$1[AUTH_VALUE_REDACTED]", + "text.auth_value", + ]), + [JSON_NAMED_PERSON_PATTERN, "$1$2[PERSON_REDACTED]$3", "text.person_name"], + [QUOTED_PERSON_PATTERN, "$1\"[PERSON_REDACTED]\"", "text.person_name"], + [NAMED_PERSON_PATTERN, "$1[PERSON_REDACTED]", "text.person_name"], + [PERSON_HONORIFIC_PATTERN, "[PERSON_REDACTED]님", "text.person_name"], + ]) { + pattern.lastIndex = 0; + if (pattern.test(value)) { + pattern.lastIndex = 0; + value = value.replace(pattern, replacement); + redactions.add(category); + } + } + + const replacements = [ + [EMAIL_PATTERN, "[QA_ACCOUNT_REDACTED]", "text.qa_account"], + [IPV4_PATTERN, "[NETWORK_ADDRESS_REDACTED]", "text.network_address"], + [UUID_PATTERN, "[PRIVATE_IDENTIFIER_REDACTED]", "text.private_identifier"], + [EMULATOR_SERIAL_PATTERN, "[EMULATOR_REDACTED]", "text.device_identifier"], + [PRIVATE_AVD_NAME_PATTERN, "[DEVICE_NAME_REDACTED]", "text.device_identifier"], + [QA_CREDENTIAL_PATTERN, "[QA_CREDENTIAL_REDACTED]", "text.qa_credential"], + [QA_ROLE_ALIAS_PATTERN, "[QA_ACCOUNT_REDACTED]", "text.qa_account"], + [QA_COMPACT_ROLE_ALIAS_PATTERN, "[QA_ACCOUNT_REDACTED]", "text.qa_account"], + [ENGLISH_QA_DISPLAY_ALIAS_PATTERN, "[QA_ACCOUNT_REDACTED]", "text.qa_account"], + [QA_ACCOUNT_PATTERN, "[QA_ACCOUNT_REDACTED]", "text.qa_account"], + [LOCAL_HOST_PATTERN, "[LOCAL_HOST_REDACTED]", "text.local_host"], + [LOCAL_PATH_PATTERN, "[LOCAL_PATH_REDACTED]", "text.local_path"], + [DOMAIN_PATTERN, "[NETWORK_ENDPOINT_REDACTED]", "text.network_endpoint"], + ]; + + for (const [pattern, replacement, category] of replacements) { + pattern.lastIndex = 0; + if (pattern.test(value)) { + pattern.lastIndex = 0; + value = value.replace(pattern, replacement); + redactions.add(category); + } + } + + publicUrls.forEach((url, index) => { + value = value.replace(`PUBLIC_GITHUB_REFERENCE_${index}`, url); + }); + value = value.replace(BARE_JSON_REDACTION_PATTERN, "$1\"$2\""); + return value; +} + +function sanitizeNode(value, pathParts, sensitiveLiterals, redactions) { + if (typeof value === "string") { + return redactText(value, sensitiveLiterals, redactions); + } + if (Array.isArray(value)) { + return value.map((item, index) => + sanitizeNode(item, [...pathParts, String(index)], sensitiveLiterals, redactions), + ); + } + if (!isPlainObject(value)) return value; + + const output = {}; + for (const [key, child] of Object.entries(value)) { + const childPath = [...pathParts, key]; + if (SENSITIVE_KEY_PATTERN.test(key) || SENSITIVE_COLLECTION_KEYS.has(key.toLowerCase())) { + redactions.add(childPath.join(".")); + continue; + } + output[key] = sanitizeNode(child, childPath, sensitiveLiterals, redactions); + } + return output; +} + +function resolveFullHeadSha(rawHeadSha) { + const value = String(rawHeadSha ?? "").trim().toLowerCase(); + if (FULL_SHA_PATTERN.test(value)) return value; + const known = KNOWN_FULL_HEAD_SHA_BY_PREFIX.get(value); + if (known) return known; + throw new Error(`전체 HEAD SHA로 해석할 수 없는 기록이 있습니다: ${value || ""}`); +} + +function normalizeResult(raw) { + if (typeof raw.result === "string" && raw.result.trim()) return raw.result.trim(); + if (Array.isArray(raw.checks) && raw.checks.length > 0) { + const checkResults = raw.checks + .map((check) => (typeof check?.result === "string" ? check.result.trim().toUpperCase() : "")) + .filter(Boolean); + if (checkResults.length === raw.checks.length && checkResults.every((result) => result === "PASS")) { + return "PASS"; + } + if (checkResults.length > 0) return `CHECK_RESULTS: ${[...new Set(checkResults)].join(", ")}`; + } + return "NOT_RECORDED"; +} + +function normalizeScenario(raw) { + if (typeof raw.scenario === "string" && raw.scenario.trim()) return raw.scenario.trim(); + if (typeof raw.purpose === "string" && raw.purpose.trim()) return raw.purpose.trim(); + if (Number.isInteger(raw.pr) && Number.isInteger(raw.issue)) { + return `PR #${raw.pr} / issue #${raw.issue} 수정 검증`; + } + return "원본 기록에 시나리오 요약이 없음"; +} + +function normalizeNotCovered(raw) { + const value = raw.not_covered ?? raw.limits; + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +function sourceFieldMapping(raw) { + return { + result: typeof raw.result === "string" ? "result" : Array.isArray(raw.checks) ? "checks[].result" : null, + scenario: + typeof raw.scenario === "string" + ? "scenario" + : typeof raw.purpose === "string" + ? "purpose" + : Number.isInteger(raw.pr) && Number.isInteger(raw.issue) + ? "derived:pr+issue" + : null, + not_covered: raw.not_covered !== undefined ? "not_covered" : raw.limits !== undefined ? "limits" : null, + recorded_at: + typeof raw.recorded_at === "string" + ? "recorded_at" + : typeof raw.performed_at === "string" + ? "performed_at" + : null, + }; +} + +export function buildPublicQaEvidence(raw, originalFile) { + if (!isPlainObject(raw)) throw new Error(`${originalFile}: 최상위 값은 JSON 객체여야 합니다.`); + const sensitiveLiterals = collectSensitiveLiterals(raw); + const redactions = new Set(); + const headSha = resolveFullHeadSha(raw.head_sha); + const sourceSchema = Number.isInteger(raw.schema) ? raw.schema : "legacy"; + + const detailsSource = Object.fromEntries( + Object.entries(raw).filter(([key]) => !NORMALIZED_TOP_LEVEL_KEYS.has(key)), + ); + if ( + typeof raw.scenario === "string" && + typeof raw.purpose === "string" && + raw.purpose.trim() && + raw.purpose.trim() !== raw.scenario.trim() + ) { + detailsSource.purpose = raw.purpose; + } + const details = sanitizeNode(detailsSource, ["details"], sensitiveLiterals, redactions); + if (typeof raw.device === "string" && raw.device.trim()) { + redactions.add("device.identifier"); + } + const device = sanitizeNode( + isPlainObject(raw.device) + ? raw.device + : { is_emulator: typeof raw.device === "string" && /emulator/i.test(raw.device) }, + ["device"], + sensitiveLiterals, + redactions, + ); + const result = redactText(normalizeResult(raw), sensitiveLiterals, redactions); + const scenario = redactText(normalizeScenario(raw), sensitiveLiterals, redactions); + const notCovered = sanitizeNode( + normalizeNotCovered(raw), + ["not_covered"], + sensitiveLiterals, + redactions, + ); + + if (Array.isArray(raw.screenshots) && raw.screenshots.length > 0) { + details.redacted_artifacts = { + screenshot_references: raw.screenshots.length, + }; + } + + const record = { + schema: PUBLIC_QA_EVIDENCE_SCHEMA, + source: { + original_file: path.basename(originalFile), + schema: sourceSchema, + field_mapping: sourceFieldMapping(raw), + redactions: [...redactions].sort(), + ...(INTERPRETATION_NOTES_BY_HEAD.has(headSha) + ? { interpretation_notes: INTERPRETATION_NOTES_BY_HEAD.get(headSha) } + : {}), + }, + repository: typeof raw.repository === "string" ? raw.repository : "Afternote/Afternote-FE", + head_sha: headSha, + head_ref: + typeof raw.head_ref === "string" + ? raw.head_ref + : typeof raw.branch === "string" + ? raw.branch + : null, + base_sha: typeof raw.base_sha === "string" ? raw.base_sha.toLowerCase() : null, + base_ref: typeof raw.base_ref === "string" ? raw.base_ref : null, + recorded_at: + typeof raw.recorded_at === "string" + ? raw.recorded_at + : typeof raw.performed_at === "string" + ? raw.performed_at + : null, + result, + scenario, + not_covered: notCovered, + device, + details, + }; + + validatePublicQaEvidence(record, `${headSha}.json`); + const publicText = jsonText(record); + for (const literal of sensitiveLiterals) { + if (literal.startsWith(NUMERIC_SENSITIVE_PREFIX)) continue; + if (literal.startsWith(AUTH_NUMERIC_SENSITIVE_PREFIX)) { + const numericValue = literal.slice(AUTH_NUMERIC_SENSITIVE_PREFIX.length); + if (new RegExp(`\\b${escapeRegExp(numericValue)}\\b`).test(publicText)) { + throw new Error(`${originalFile}: 인증 값이 공개 기록에 남았습니다.`); + } + continue; + } + const pattern = /^\d+$/.test(literal) + ? new RegExp(`\\b${escapeRegExp(literal)}\\b`, "g") + : new RegExp(escapeRegExp(literal), "i"); + if (literal.length >= 3 && pattern.test(publicText)) { + throw new Error(`${originalFile}: 제거 대상 값이 공개 기록에 남았습니다.`); + } + } + return record; +} + +function walkKeys(value, visitor, pathParts = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => walkKeys(item, visitor, [...pathParts, String(index)])); + return; + } + if (!isPlainObject(value)) return; + for (const [key, child] of Object.entries(value)) { + visitor(key, child, [...pathParts, key]); + walkKeys(child, visitor, [...pathParts, key]); + } +} + +function walkStrings(value, visitor, pathParts = []) { + if (typeof value === "string") { + visitor(value, pathParts); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => walkStrings(item, visitor, [...pathParts, String(index)])); + return; + } + if (!isPlainObject(value)) return; + for (const [key, child] of Object.entries(value)) { + walkStrings(child, visitor, [...pathParts, key]); + } +} + +export function validatePublicQaEvidence(record, fileName = "") { + const errors = []; + if (!isPlainObject(record)) return [`${fileName}: 최상위 값은 JSON 객체여야 합니다.`]; + if (record.schema !== PUBLIC_QA_EVIDENCE_SCHEMA) errors.push(`${fileName}: 공개 schema가 다릅니다.`); + if (!FULL_SHA_PATTERN.test(String(record.head_sha ?? ""))) errors.push(`${fileName}: HEAD SHA가 40자가 아닙니다.`); + if (fileName !== "" && EVIDENCE_FILE_PATTERN.test(fileName)) { + if (fileName !== `${record.head_sha}.json`) errors.push(`${fileName}: 파일명과 HEAD SHA가 다릅니다.`); + } + if (record.base_sha !== null && !SHA_PATTERN.test(String(record.base_sha))) { + errors.push(`${fileName}: base SHA 형식이 올바르지 않습니다.`); + } + if (typeof record.result !== "string" || !record.result.trim()) errors.push(`${fileName}: result가 없습니다.`); + if (typeof record.scenario !== "string" || !record.scenario.trim()) errors.push(`${fileName}: scenario가 없습니다.`); + if (!Array.isArray(record.not_covered)) errors.push(`${fileName}: not_covered는 배열이어야 합니다.`); + if (!isPlainObject(record.source) || typeof record.source.original_file !== "string") { + errors.push(`${fileName}: source 원본 매핑이 없습니다.`); + } + + walkKeys(record, (key, _value, keyPath) => { + if (SENSITIVE_KEY_PATTERN.test(key) || SENSITIVE_COLLECTION_KEYS.has(key.toLowerCase())) { + errors.push(`${fileName}: 공개 금지 키가 남았습니다: ${keyPath.join(".")}`); + } + }); + + const text = JSON.stringify(record); + for (const [pattern, label] of [ + [EMAIL_PATTERN, "email"], + [IPV4_PATTERN, "network address"], + [UUID_PATTERN, "private identifier"], + [EMULATOR_SERIAL_PATTERN, "emulator serial"], + [PRIVATE_AVD_NAME_PATTERN, "private AVD name"], + [QA_CREDENTIAL_PATTERN, "QA credential"], + [QA_ROLE_ALIAS_PATTERN, "QA role alias"], + [QA_COMPACT_ROLE_ALIAS_PATTERN, "QA compact role alias"], + [ENGLISH_QA_DISPLAY_ALIAS_PATTERN, "QA display alias"], + [QA_ACCOUNT_PATTERN, "QA account"], + [LOCAL_HOST_PATTERN, "local host"], + [LOCAL_PATH_PATTERN, "local path"], + ]) { + pattern.lastIndex = 0; + if (pattern.test(text)) errors.push(`${fileName}: ${label} 패턴이 남았습니다.`); + } + walkStrings(record, (value, valuePath) => { + BARE_JSON_REDACTION_PATTERN.lastIndex = 0; + if (BARE_JSON_REDACTION_PATTERN.test(value)) { + errors.push(`${fileName}: ${valuePath.join(".")}에 따옴표 없는 JSON 비식별화 표지가 남았습니다.`); + } + for (const [pattern, label] of [ + [JSON_PRIVATE_NUMERIC_ID_PATTERN, "private numeric identifier"], + [PRIVATE_NUMERIC_ID_PATTERN, "private numeric identifier"], + [KOREAN_NUMERIC_ID_PATTERN, "private numeric identifier"], + ...AUTH_VALUE_PATTERNS.map((pattern) => [pattern, "authentication value"]), + [JSON_NAMED_PERSON_PATTERN, "person name"], + [QUOTED_PERSON_PATTERN, "person name"], + [NAMED_PERSON_PATTERN, "person name"], + [PERSON_HONORIFIC_PATTERN, "person name"], + ]) { + pattern.lastIndex = 0; + if (pattern.test(value)) { + errors.push(`${fileName}: ${valuePath.join(".")}에 ${label} 패턴이 남았습니다.`); + } + } + }); + URL_PATTERN.lastIndex = 0; + for (const match of text.matchAll(URL_PATTERN)) { + if (!isPublicGitHubReference(match[0])) errors.push(`${fileName}: 비공개 URL이 남았습니다.`); + } + + if (errors.length > 0) throw new Error(errors.join("\n")); + return []; +} + +function evidenceFiles(directory) { + if (!fs.existsSync(directory)) return []; + return fs + .readdirSync(directory) + .filter((name) => EVIDENCE_FILE_PATTERN.test(name)) + .sort(); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function buildManifest(directory, migratedOn, initialMigrationCount) { + const records = evidenceFiles(directory).map((file) => { + const text = fs.readFileSync(path.join(directory, file), "utf8"); + const record = JSON.parse(text); + validatePublicQaEvidence(record, file); + return { + file, + source_file: record.source.original_file, + head_sha: record.head_sha, + sha256: sha256(text), + }; + }); + return { + schema: 1, + initial_migration_count: initialMigrationCount, + record_count: records.length, + last_updated_on: migratedOn, + records, + }; +} + +export function migrateQaEvidence(sourceDirectory, destinationDirectory, options = {}) { + const migratedOn = options.migratedOn ?? new Date().toISOString().slice(0, 10); + const sourceFiles = fs + .readdirSync(sourceDirectory) + .filter((name) => name.endsWith(".json")) + .sort(); + if (sourceFiles.length === 0) throw new Error("이관할 QA JSON이 없습니다."); + + fs.mkdirSync(destinationDirectory, { recursive: true }); + const generated = []; + for (const sourceFile of sourceFiles) { + const raw = readJson(path.join(sourceDirectory, sourceFile)); + const record = buildPublicQaEvidence(raw, sourceFile); + const destinationFile = `${record.head_sha}.json`; + const destinationPath = path.join(destinationDirectory, destinationFile); + const nextText = jsonText(record); + if ( + fs.existsSync(destinationPath) && + fs.readFileSync(destinationPath, "utf8") !== nextText && + options.replaceExisting !== true + ) { + throw new Error( + `${destinationFile}: 기존 공개 기록과 다릅니다. 시나리오를 합친 원본을 확인한 뒤 --replace-existing을 명시하세요.`, + ); + } + fs.writeFileSync(destinationPath, nextText); + generated.push(destinationFile); + } + + const existingManifestPath = path.join(destinationDirectory, "manifest.json"); + const existingManifest = fs.existsSync(existingManifestPath) ? readJson(existingManifestPath) : null; + const initialMigrationCount = existingManifest?.initial_migration_count ?? generated.length; + const manifest = buildManifest(destinationDirectory, migratedOn, initialMigrationCount); + fs.writeFileSync(existingManifestPath, jsonText(manifest)); + return verifyQaEvidenceMigration(sourceDirectory, destinationDirectory); +} + +export function verifyQaEvidenceMigration(sourceDirectory, destinationDirectory) { + const sourceFiles = fs + .readdirSync(sourceDirectory) + .filter((name) => name.endsWith(".json")) + .sort(); + const seenHeads = new Set(); + const mappings = { result: 0, scenario: 0, not_covered: 0 }; + + for (const sourceFile of sourceFiles) { + const raw = readJson(path.join(sourceDirectory, sourceFile)); + const expected = buildPublicQaEvidence(raw, sourceFile); + const publicFile = `${expected.head_sha}.json`; + const actual = readJson(path.join(destinationDirectory, publicFile)); + validatePublicQaEvidence(actual, publicFile); + if (jsonText(actual) !== jsonText(expected)) { + throw new Error(`${sourceFile}: 공개 기록이 정규화 결과와 다릅니다.`); + } + if (seenHeads.has(actual.head_sha)) throw new Error(`${sourceFile}: 중복 HEAD SHA입니다.`); + seenHeads.add(actual.head_sha); + mappings.result += 1; + mappings.scenario += 1; + mappings.not_covered += 1; + } + + const publicSummary = validatePublicQaEvidenceDirectory(destinationDirectory); + return { + source_count: sourceFiles.length, + migrated_count: seenHeads.size, + fields_verified: mappings, + public_record_count: publicSummary.record_count, + }; +} + +export function validatePublicQaEvidenceDirectory(directory) { + const manifestPath = path.join(directory, "manifest.json"); + if (!fs.existsSync(manifestPath)) throw new Error("QA evidence manifest.json이 없습니다."); + const manifest = readJson(manifestPath); + const files = evidenceFiles(directory); + if (manifest.record_count !== files.length || manifest.records?.length !== files.length) { + throw new Error("QA evidence manifest 건수가 실제 파일 수와 다릅니다."); + } + + const manifestByFile = new Map(manifest.records.map((entry) => [entry.file, entry])); + for (const file of files) { + const text = fs.readFileSync(path.join(directory, file), "utf8"); + const record = JSON.parse(text); + validatePublicQaEvidence(record, file); + const entry = manifestByFile.get(file); + if (!entry) throw new Error(`${file}: manifest 항목이 없습니다.`); + if (entry.head_sha !== record.head_sha || entry.source_file !== record.source.original_file) { + throw new Error(`${file}: manifest 원본 매핑이 다릅니다.`); + } + if (entry.sha256 !== sha256(text)) throw new Error(`${file}: manifest 해시가 다릅니다.`); + } + return { record_count: files.length }; +} + +function parseCliArguments(argv) { + const [command, firstPath, secondPath, ...rest] = argv; + const options = {}; + for (let index = 0; index < rest.length; index += 1) { + if (rest[index] === "--replace-existing") { + options.replaceExisting = true; + continue; + } + if (rest[index] === "--migrated-on" && rest[index + 1]) { + options.migratedOn = rest[index + 1]; + index += 1; + continue; + } + throw new Error("Usage: qa-evidence.mjs migrate [--migrated-on YYYY-MM-DD] [--replace-existing] | verify | validate "); + } + return { command, firstPath, secondPath, options }; +} + +function runCli() { + const { command, firstPath, secondPath, options } = parseCliArguments(process.argv.slice(2)); + let summary; + if (command === "migrate" && firstPath && secondPath) { + summary = migrateQaEvidence(firstPath, secondPath, options); + } else if (command === "verify" && firstPath && secondPath) { + summary = verifyQaEvidenceMigration(firstPath, secondPath); + } else if (command === "validate" && firstPath && !secondPath) { + summary = validatePublicQaEvidenceDirectory(firstPath); + } else { + throw new Error("Usage: qa-evidence.mjs migrate [--migrated-on YYYY-MM-DD] [--replace-existing] | verify | validate "); + } + process.stdout.write(`${JSON.stringify(summary)}\n`); +} + +const invokedFile = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedFile === import.meta.url) { + try { + runCli(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/qa-evidence.test.mjs b/.github/scripts/qa-evidence.test.mjs new file mode 100644 index 0000000000..5796361b0b --- /dev/null +++ b/.github/scripts/qa-evidence.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + buildPublicQaEvidence, + migrateQaEvidence, + validatePublicQaEvidenceDirectory, + verifyQaEvidenceMigration, +} from "./qa-evidence.mjs"; + +function rawFixture() { + return { + schema: 1, + repository: "Afternote/Afternote-FE", + head_ref: "fix/fixture", + head_sha: "05b5a49170952a4c6f9dea6e1aa4145ba097ad54", + base_ref: "develop", + base_sha: "5f0b7bae84726a15a08023fecbf2fb939c9876a2", + recorded_at: "2026-08-27T10:00:00+09:00", + result: "PASS", + scenario: + "Pixel_7_Example_QA에서 앱 잠금 PIN과 PIN 인증 방식을 사용했다. 이슈 #39에서 QA123 user@example.com 계정의 afternoteId=39, sub=4321, PIN=1234를 입력한 뒤 같은 값 1234와 인증(실제 수신 코드 654321), 같은 코드 654321, 마스터 키(abc123-…) 및 authCode(def456-…), authCode 필드로 얻는다(ghi789-…), 같은 값 def456-…·ghi789-…, 수신자 \"홍길동\"과 빈 수신자 \"—\", ExampleQA 홈, 응답 {\"receiverId\":14,\"name\":\"QA수신자\",\"relation\":\"FAMILY\"}, credentials:{id:qa123_secret, password:secret42}을 http://10.0.2.2:8080에서 확인", + purpose: "로그인 실패 경로와 복구 동작을 함께 확인", + not_covered: ["실기기"], + qa_account: { + email: "user@example.com", + afternote_id: 39, + receiver: { receiverId: "2db6a7cf-70b5-4b70-99f6-4b6046f424af", name: "테스트수신자" }, + }, + app: { + package: "com.afternote.afternote_fe", + session: "private-session", + note: "기기 내부 /data/user/0/com.afternote.afternote_fe/files/session.json 확인", + }, + device: { + serial: "emulator-5554", + avd: "Private_QA_Device", + is_emulator: true, + api_level: 35, + screen: { physical: "1080x2400", density: 420 }, + }, + reviews: ["https://github.com/Afternote/Afternote-FE/pull/1098#discussion_r1"], + screenshots: ["https://github.com/user-attachments/assets/2db6a7cf-70b5-4b70-99f6-4b6046f424af"], + }; +} + +test("sanitizes account, endpoint, device, session, and screenshot identifiers", () => { + const record = buildPublicQaEvidence(rawFixture(), "fixture.json"); + const text = JSON.stringify(record); + + assert.equal(record.head_sha, rawFixture().head_sha); + assert.equal(record.result, "PASS"); + assert.deepEqual(record.not_covered, ["실기기"]); + assert.match(record.scenario, /REDACTED/); + assert.match(record.scenario, /이슈 #39/); + assert.doesNotMatch(record.scenario, /afternoteId=39/); + assert.doesNotMatch(record.scenario, /PIN=1234/); + assert.doesNotMatch(record.scenario, /1234/); + assert.doesNotMatch(record.scenario, /654321/); + assert.doesNotMatch(record.scenario, /abc123/); + assert.doesNotMatch(record.scenario, /def456/); + assert.doesNotMatch(record.scenario, /ghi789/); + assert.doesNotMatch(record.scenario, /Pixel_7_Example_QA/); + assert.doesNotMatch(record.scenario, /ExampleQA/); + assert.doesNotMatch(record.scenario, /receiverId\":14/); + assert.match(record.scenario, /앱 잠금 PIN과 PIN 인증 방식/); + assert.match(record.scenario, /\"name\":\"\[PERSON_REDACTED\]\",\"relation\"/); + const embeddedObject = record.scenario.match(/응답 (\{[^}]+\}), credentials/)?.[1]; + assert.ok(embeddedObject); + assert.doesNotThrow(() => JSON.parse(embeddedObject)); + assert.equal(JSON.parse(embeddedObject).receiverId, "[PRIVATE_ID_REDACTED]"); + assert.doesNotMatch(record.scenario, /:\s*\[[A-Z_]+_REDACTED\]/); + assert.match(record.scenario, /id:\"\[QA_CREDENTIAL_REDACTED\]\"/); + assert.match(record.scenario, /password:\"\[[A-Z_]+_REDACTED\]\"/); + assert.doesNotMatch(record.scenario, /authCode=abcd/); + assert.doesNotMatch(record.scenario, /4321/); + assert.doesNotMatch(record.scenario, /홍길동/); + assert.match(record.scenario, /빈 수신자 \"—\"/); + assert.equal(record.details.purpose, "로그인 실패 경로와 복구 동작을 함께 확인"); + assert.match(text, /github\.com\/Afternote\/Afternote-FE\/pull\/1098/); + for (const secret of [ + "user@example.com", + "QA123", + "10.0.2.2", + "emulator-5554", + "Private_QA_Device", + "private-session", + "테스트수신자", + "2db6a7cf-70b5-4b70-99f6-4b6046f424af", + "user-attachments", + "/data/user/0/", + ]) { + assert.equal(text.includes(secret), false, secret); + } + assert.equal(record.details.redacted_artifacts.screenshot_references, 1); +}); + +test("migrates and verifies a one-to-one public record", () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qa-evidence-test-")); + const source = path.join(temporaryRoot, "source"); + const destination = path.join(temporaryRoot, "destination"); + fs.mkdirSync(source); + fs.writeFileSync(path.join(source, "fixture.json"), `${JSON.stringify(rawFixture())}\n`); + + const migrated = migrateQaEvidence(source, destination, { migratedOn: "2026-08-27" }); + assert.equal(migrated.source_count, 1); + assert.equal(migrated.migrated_count, 1); + assert.deepEqual(migrated.fields_verified, { result: 1, scenario: 1, not_covered: 1 }); + assert.equal(verifyQaEvidenceMigration(source, destination).migrated_count, 1); + assert.equal(validatePublicQaEvidenceDirectory(destination).record_count, 1); + + const changed = rawFixture(); + changed.result = "FAIL"; + fs.writeFileSync(path.join(source, "fixture.json"), `${JSON.stringify(changed)}\n`); + assert.throws( + () => migrateQaEvidence(source, destination, { migratedOn: "2026-08-27" }), + /--replace-existing/, + ); + assert.equal( + migrateQaEvidence(source, destination, { + migratedOn: "2026-08-27", + replaceExisting: true, + }).migrated_count, + 1, + ); +}); + +test("validates the committed public QA evidence corpus", () => { + const directory = path.join(process.cwd(), "docs", "qa", "evidence"); + if (!fs.existsSync(path.join(directory, "manifest.json"))) return; + + const summary = validatePublicQaEvidenceDirectory(directory); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); + assert.equal(manifest.initial_migration_count, 34); + assert.equal(summary.record_count, manifest.record_count); + assert.ok(summary.record_count >= 34); + + const multiPass = JSON.parse( + fs.readFileSync( + path.join(directory, "8325a703ab3275330b30713b0f4d542c975a2024.json"), + "utf8", + ), + ); + assert.match(multiPass.source.interpretation_notes[0], /후속 패스/); + assert.match(multiPass.source.interpretation_notes[0], /#607/); +}); diff --git a/README.md b/README.md index ccda5398ae..c0291b6110 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ konsist/ 아키텍처 규칙 테스트 | [docs/release/distribution.md](docs/release/distribution.md) | 비개발자 APK 배포 (Firebase App Distribution) | | [docs/testing/screenshot.md](docs/testing/screenshot.md) | Compose Preview 스크린샷 baseline (docker) | | [docs/qa/status.md](docs/qa/status.md) | QA 현황 · 회차 기록 · 커버리지 | +| [docs/qa/evidence/README.md](docs/qa/evidence/README.md) | 커밋별 런타임 QA 증거 · 기록 규약 | | [docs/qa/assumptions.md](docs/qa/assumptions.md) | 시안 · 명세에 없어 판단으로 정한 것 | --- diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96dcd1266f..98cf49691a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -167,7 +167,7 @@ android { buildTypes { debug { // 설치된 앱이 어느 커밋으로 빌드됐는지 `adb shell dumpsys package` 한 줄로 읽히게 한다. - // 실기 QA 증거는 커밋 sha 로 대장에 남으므로(`.codex/qa-evidence/emulator/.json`), + // 실기 QA 증거는 전체 커밋 sha 로 대장에 남으므로(`docs/qa/evidence/.json`), // 앱이 스스로 커밋을 들고 있지 않으면 검증한 코드를 특정할 수 없다 — #1135. // release `versionName` 은 사용자에게 보이므로 건드리지 않는다. versionNameSuffix = resolveDebugVersionNameSuffix() diff --git a/build-logic/src/main/kotlin/BuildFingerprint.kt b/build-logic/src/main/kotlin/BuildFingerprint.kt index 53a4d3e26d..21197cc51b 100644 --- a/build-logic/src/main/kotlin/BuildFingerprint.kt +++ b/build-logic/src/main/kotlin/BuildFingerprint.kt @@ -35,7 +35,7 @@ fun debugVersionNameSuffix( * 커밋 sha 를 [SHORT_COMMIT_SHA_LENGTH] 자리로 정규화한다. 16진수가 아니거나 짧으면 null. * * 입력이 두 갈래라 정규화가 필요하다 — CI 의 `GITHUB_SHA` 도 로컬 `git rev-parse HEAD` 도 40자다. - * QA 대장 파일명(`.codex/qa-evidence/emulator/.json`)과 대조하려면 자릿수가 일정해야 한다. + * QA 대장 파일명(`docs/qa/evidence/.json`)과 대조하려면 자릿수가 일정해야 한다. */ fun shortCommitSha(rawCommitSha: String?): String? { val value = rawCommitSha?.trim()?.lowercase() ?: return null diff --git a/docs/qa/QA_2026-07-19.md b/docs/qa/QA_2026-07-19.md index c73c1fe891..d369011f5a 100644 --- a/docs/qa/QA_2026-07-19.md +++ b/docs/qa/QA_2026-07-19.md @@ -7,7 +7,7 @@ | 기기 | Android Emulator `Medium_Phone` (arm64, Android 17 / targetSdk 36) | | 빌드 | `develop` 브랜치 `78551075` 기준 debug 빌드 (`:app:installDebug`), versionName 1.0 | | 서버 | `https://afternote.kro.kr/api/v1/` (라이브 서버) | -| 계정 | rugsn@naver.com (이메일 로그인) | +| 계정 | QA용 이메일 계정(식별정보 비공개) | | 테스트 방식 | 에뮬레이터 수동 시나리오 QA + logcat(OkHttp) 네트워크 로그 + 소스코드 교차 확인 | > 소셜 로그인(카카오/구글)은 `local.properties`에 키가 없어 검증 불가. 이메일 로그인 기준으로 진행. @@ -82,10 +82,10 @@ ## 테스트 중 생성된 데이터 (정리 필요) -- 수신자 "QA Tester" (아들, 010-1234-5678, rugsn12345@gmail.com) +- QA용 수신자(관계·연락처 식별정보 비공개) - 타임레터 3건: "QA letter 3 edited", "QA letter 2", "QA timeletter title" — **#4 삭제 버그로 앱에서 삭제 불가** - 데일리 질문 답변 1건(2026-07-19), 일기 1건("QA diary title") -- 수신자 플로우: 발신자 "QA Sender" 등록, `rugsn12345@gmail.com`으로 실제 인증 메일 1통 발송됨 +- 수신자 플로우: QA용 발신자 등록, QA용 수신 주소로 실제 인증 메일 1통 발송됨 ## 미검증 항목 diff --git a/docs/qa/device-baseline.md b/docs/qa/device-baseline.md index 9e3d07ab96..2f6f7e2725 100644 --- a/docs/qa/device-baseline.md +++ b/docs/qa/device-baseline.md @@ -4,15 +4,17 @@ ## 왜 필요했나 -2026-08-25 기준 에뮬레이터 QA 증거(`.codex/qa-evidence/emulator/.json`) 24건은 전부 같은 -기기였다 — android-35 / 1080×2400 @420dpi, 전부 에뮬레이터, 실기기 0건. +2026-08-25에 조사한 에뮬레이터 QA 증거 24건(schema 1 23건 + legacy 1건, 현재 +`docs/qa/evidence/.json`으로 이관)은 전부 같은 기기였다 — android-35 / +1080×2400 @420dpi, 전부 에뮬레이터, 실기기 0건. 8/27 이관한 전체 34건의 원본 스키마 분포는 +schema 1 32건 + schema 2 1건 + legacy 1건이다. -문제는 그 기기가 표준도 아니었다는 점이다. `Pixel_7_Claude_QA` 에 `wm size 1080x2340` + +문제는 그 기기가 표준도 아니었다는 점이다. 당시 QA AVD에 `wm size 1080x2340` + `wm density 387` override 가 남아 있어 실효 **446×967dp** 로 돌고 있었다. 표준 Pixel 7(411×914dp) 보다 넓고, 국내 보급형(360×800dp) 대비 폭 24%·높이 21% 여유다. 잘림과 오버플로가 QA 에서 구조적으로 잡히지 않는 화면이었다. -증거에는 `"avd": "Pixel_7_Claude_QA"` 라고만 적혀 있었다. `wm` override 는 재부팅으로 풀리지 +비공개 원본 증거에는 AVD 이름만 적혀 있었다. `wm` override 는 재부팅으로 풀리지 않고 세션 사이에 조용히 남기 때문에, **기기 이름만으로는 어떤 화면에서 검증했는지 사후에 알 수 없다.** ## 프로파일 두 가지 @@ -33,7 +35,7 @@ `compact` 의 dpi 를 320 으로 잡는 것은 실기기의 리소스 버킷(xhdpi)을 맞추기 위해서다. 같은 360×800dp 라도 160dpi(mdpi)로 만들면 다른 `drawable`/`dimen` 이 선택돼 기준으로 쓸 수 없다. -보유 AVD 중 `KUIT_7th_Device` 가 360×800dp @160dpi 라 이 이유로 부적합하다. +다른 보유 AVD 하나도 360×800dp @160dpi 라 이 이유로 부적합하다. ## 실측 전에 두 가지를 확정한다 @@ -43,7 +45,7 @@ ### 2. 앱이 어느 커밋인가 -**증거 대장은 파일 이름이 커밋 sha 다**(`.codex/qa-evidence/emulator/.json`). 증거를 남긴다는 +**증거 대장은 파일 이름이 전체 커밋 sha 다**(`docs/qa/evidence/.json`). 증거를 남긴다는 것은 "이 커밋의 앱을 돌렸다"는 선언이므로, 돌린 앱의 출처가 확정돼야 한다. 그런데 지금 앱은 커밋 정보를 들고 있지 않다. `versionCode` 는 Play 워크플로가 주입할 때만 바뀌고, @@ -70,14 +72,14 @@ adb install -r .claude/worktrees/qa-dev/app/build/outputs/apk/debug/app-debug.ap ``` `google-services.json` 은 gitignore 라 새 워크트리에 없다 — 복사하지 않으면 빌드가 깨진다. -`-r` 재설치는 DataStore 를 지우지 않으므로 QA 계정 세션이 그대로 유지된다. +`-r` 재설치는 DataStore 를 지우지 않으므로 로그인 세션이 그대로 유지된다. 앱이 커밋을 들고 다니게 되면(#1135) 이 단계는 "설치된 앱의 sha 를 읽어 확인"으로 줄어든다. ### 3. 기기를 나 혼자 쓰는가 에이전트 세션 여러 개가 같은 개발 머신에서 돌면 **에뮬레이터 한 대를 공유하게 된다.** 2026-08-25 -이 저장소 작업 중에는 세션 17개가 `emulator-5554` 하나를 함께 쓰고 있었고, 좁은 화면 QA 도중 +이 저장소 작업 중에는 세션 17개가 에뮬레이터 하나를 함께 쓰고 있었고, 좁은 화면 QA 도중 다른 세션이 APK 를 재설치하고(`lastUpdateTime` 갱신), 토큰을 비우고(로그아웃), 화면 override 를 풀었다. **아무 신호도 없다** — 검사 결과만 조용히 오염된다. @@ -85,7 +87,7 @@ adb install -r .claude/worktrees/qa-dev/app/build/outputs/apk/debug/app-debug.ap ```bash $ANDROID_HOME/emulator/emulator -avd -no-snapshot-load & -adb devices # 새로 붙은 serial 확인 (emulator-5556 …) +adb devices # 새로 붙은 serial 확인 ``` 그 뒤 모든 명령에 `-s ` 을 붙인다. 붙이지 않으면 기기가 여러 대일 때 어디로 갔는지 @@ -108,7 +110,7 @@ adb -s shell dumpsys package com.afternote.afternote_fe | grep lastUpda ## 증거에 남기는 것 (schema 2) **실측을 시작하기 전에** `status` 로 override 부터 확인하고, `--json` 출력을 증거의 `device` -블록에 그대로 넣는다. +블록에 넣는다. 이 JSON은 공개 기록용이라 로컬 serial과 AVD 이름을 포함하지 않는다. ```bash ./scripts/qa-device-profile.sh status --json @@ -116,8 +118,6 @@ adb -s shell dumpsys package com.afternote.afternote_fe | grep lastUpda ```json "device": { - "serial": "emulator-5554", - "avd": "Pixel_7_Claude_QA", "is_emulator": true, "api_level": 35, "screen": { @@ -130,8 +130,9 @@ adb -s shell dumpsys package com.afternote.afternote_fe | grep lastUpda } ``` -schema 1 로 기록된 기존 24건은 당시 화면 값을 복원할 수 없으므로 소급하지 않는다. schema 1 증거는 -**화면 스펙 미상**으로 읽는다. +8/25 조사 대상 24건은 schema 1 23건과 legacy 1건이었다. 이 기록들은 당시 화면 값을 복원할 수 +없으므로 소급하지 않고 **화면 스펙 미상**으로 읽는다. 8/27 이관한 전체 34건 중에서는 schema 2 +1건만 구조화된 화면 값을 보존하며, 나머지는 schema 1 32건과 legacy 1건이다. ## 매 PR 에 기기를 늘리지 않는 이유 diff --git a/docs/qa/evidence/05b5a49170952a4c6f9dea6e1aa4145ba097ad54.json b/docs/qa/evidence/05b5a49170952a4c6f9dea6e1aa4145ba097ad54.json new file mode 100644 index 0000000000..604250d2aa --- /dev/null +++ b/docs/qa/evidence/05b5a49170952a4c6f9dea6e1aa4145ba097ad54.json @@ -0,0 +1,49 @@ +{ + "schema": 1, + "source": { + "original_file": "05b5a49170952a4c6f9dea6e1aa4145ba097ad54.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.serial", + "text.device_identifier" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "05b5a49170952a4c6f9dea6e1aa4145ba097ad54", + "head_ref": "HEAD", + "base_sha": "68dfef7440ce578cf3510f0d8755b1a3a7e2a598", + "base_ref": "origin/develop", + "recorded_at": "2026-08-09T15:04:56.555591+00:00", + "result": "pass", + "scenario": "[DEVICE_NAME_REDACTED]에서 FCM notification 메시지 백그라운드 수신과 포그라운드 onMessageReceived 커스텀 알림 표시 확인; 현재 HEAD APK 재설치 후 MainActivity 전면 확인", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "0fa7f67b7945f4cc9a27a6c830d02299fa596be303e731f7a2adb1b561f0c429", + "changed_files": [ + "app/build.gradle.kts", + "app/src/main/AndroidManifest.xml", + "app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt", + "app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt", + "app/src/main/java/com/afternote/afternote_fe/messaging/FcmNotificationChannel.kt", + "app/src/main/res/values/strings.xml", + "app/src/test/java/com/afternote/afternote_fe/messaging/FcmNotificationContentResolverTest.kt", + "gradle/libs.versions.toml" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "05b5a49170952a4c6f9dea6e1aa4145ba097ad54", + "installed_at_epoch": 1786287812, + "ui_sha256": "5cb91e03dea16e15fbc6c6e9f1f22001e208c4c98870b9f0c9cb95c7c725863c" + } + } +} diff --git a/docs/qa/evidence/0c7b63b2c0c5e1d3c602c15e79283496f8a6553a.json b/docs/qa/evidence/0c7b63b2c0c5e1d3c602c15e79283496f8a6553a.json new file mode 100644 index 0000000000..2f3ba554d0 --- /dev/null +++ b/docs/qa/evidence/0c7b63b2c0c5e1d3c602c15e79283496f8a6553a.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "source": { + "original_file": "0c7b63b2c0c5e1d3c602c15e79283496f8a6553a.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "0c7b63b2c0c5e1d3c602c15e79283496f8a6553a", + "head_ref": "fix/1048", + "base_sha": "817578e8d2d1bc80fba63906ecce08eae9ede04d", + "base_ref": "develop", + "recorded_at": "2026-08-26T07:41:32.017267+00:00", + "result": "pass", + "scenario": "PR #1160(#1048) 실서버 QA — 서버 열린 시간대 실기동. 앱 에디터로 사업자(비즈니스) 애프터노트를 만들어 서버에 category=BUSINESS 로 저장됨을 API 로 확인(afternoteId 40). 목록·상세·편집 모두 정상. 핵심 대조: 서비스명이 AfternoteService 카탈로그에 있으면 아이콘이 서비스명 우선이라 develop 과 차이가 안 난다 — 카탈로그 밖 이름('우리회사 ERP')으로 BUSINESS 를 만들어 대조하니 develop(817578e8d)은 소셜네트워크 카테고리 아이콘으로 앉고(categoryToAfternoteType 의 SOCIAL_NETWORK 폴백) 이 브랜치는 아니다 — 이슈가 말한 오표시 재현·해소 확인. 어긋난 것 2건: (1) 생성·수정 요청은 toServerCategory() 를 안 타서 develop 도 BUSINESS 를 POST/PATCH 로 보내 200 을 받는다(코덱은 목록 필터 전용), (2) PR 본문 확인항목 1 의 «비즈니스 탭» 은 CATEGORY_FILTER_TABS 에 없어 현 UI 로 도달 불가. 단 서버는 ?category=BUSINESS 필터를 200/3건으로 정상 지원하므로 제외 사유(BE#110)는 이미 해소됨 — 탭 복원은 후속 이슈감.", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 35, + "size": "1080x2400", + "density": 420 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "8586231b004f4f412323454d66ded570dd21db932589327a6e2d77dba195a040", + "changed_files": [ + "feature/afternote/data/build.gradle.kts", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteListItemDtoToDomain.kt", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapper.kt", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteRepositoryImpl.kt", + "feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodecTest.kt", + "feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteListItemDtoToDomainTest.kt", + "feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapperTest.kt", + "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapper.kt", + "feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt" + ] + } +} diff --git a/docs/qa/evidence/16591cb21ffcb519ad03933f419a6586782a6e5d.json b/docs/qa/evidence/16591cb21ffcb519ad03933f419a6586782a6e5d.json new file mode 100644 index 0000000000..250d5dd609 --- /dev/null +++ b/docs/qa/evidence/16591cb21ffcb519ad03933f419a6586782a6e5d.json @@ -0,0 +1,48 @@ +{ + "schema": 1, + "source": { + "original_file": "16591cb21ffcb519ad03933f419a6586782a6e5d.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial", + "text.qa_account", + "text.sensitive_value" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "16591cb21ffcb519ad03933f419a6586782a6e5d", + "head_ref": "fix/1126", + "base_sha": "817578e8d2d1bc80fba63906ecce08eae9ede04d", + "base_ref": "develop", + "recorded_at": "2026-08-26T07:11:45.963507+00:00", + "result": "pass", + "scenario": "PR #1194(#1126) 실기 검증: 재발급 확정 거절 처리. 전용 AVD [SENSITIVE_VALUE_REDACTED](API 35, [SENSITIVE_VALUE_REDACTED])를 새로 띄워 다른 세션과 격리했다. 재현 무대는 [QA_ACCOUNT_REDACTED] 유효 토큰을 DataStore 에 주입해 홈까지 도달시킨 뒤(홈 진입 요청 5건: users/me/activity·users/me·diary·users/receivers·daily-questions/today), 앱을 force-stop 하고 토큰만 무효 세트(access=expired-access-[QA_ACCOUNT_REDACTED] / refresh=rejected-refresh-[QA_ACCOUNT_REDACTED])로 갈아 끼워 콜드 스타트하는 것이다. pm clear 로 시작하면 온보딩 화면에 멈춰 요청이 1건으로 줄고 경합 조건 자체가 성립하지 않는다 — 이 절차가 핵심이다. \n\n(1) 서버 응답 실측: POST /api/v1/auth/reissue 에 무효 refresh 를 주면 400 {\"status\":400,\"code\":1107,\"message\":\"유효하지 않은 리프레시 토큰\",\"data\":null}. 이 PR 이 400 을 상태코드만으로 거절로 좁힌 근거와 맞물린다. \n(2) fix 빌드(16591cb2): 홈 진입 5건이 전부 401 → 재발급 HTTP 1회 → 400 → '리이슈 인증 거절: 세션 만료 확정(정리 완료), 요청 중단' 1건. 이후 4건은 '직전 요청이 애초에 토큰을 포함하지 않았음' (세션 정리가 끝난 뒤라 정상). 재발급 중복·분류 갈림 0건. \n(3) develop baseline(817578e8): 같은 절차 6회 반복 — 매 회 재발급 HTTP 1회, '세션 만료 처리' 1건, '세션 유지, 요청 중단' 0건. 즉 이슈 본문이 기록한 경합(HTTP 2회 + 분류 2종 갈림)은 이 기기에서 재현되지 않았다. 창이 3ms 라 확률적이라는 이슈의 분석과 일치하며, 그래서 계약은 유닛 테스트로 잠갔다(신규 5건). \n\n실기로 확정한 것: 서버의 400/1107 응답, fix 빌드의 회귀 부재(재발급 1회·거절 확정·세션 정리 완료). 실기로 확정하지 못한 것: 경합 창의 중복 재발급(확률적 미재현), 그리고 400 본문 파싱 실패 경로 — 실서버가 code 를 정확히 실어 주므로 실기로는 만들 수 없고 유닛 테스트가 가드한다.", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 35 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "afd818afbc12548c9fbea25c14ce737603c49e2ee908148c20aed72b5bafc115", + "changed_files": [ + "core/network/src/main/kotlin/com/afternote/core/network/interceptor/AuthInterceptor.kt", + "core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt", + "core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt", + "core/network/src/test/kotlin/com/afternote/core/network/FakeAuthRepository.kt", + "core/network/src/test/kotlin/com/afternote/core/network/interceptor/AuthInterceptorTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt" + ] + } +} diff --git a/docs/qa/evidence/18e57a142a69be5fd3581bae67a459cf09c1b4a7.json b/docs/qa/evidence/18e57a142a69be5fd3581bae67a459cf09c1b4a7.json new file mode 100644 index 0000000000..b5172000a0 --- /dev/null +++ b/docs/qa/evidence/18e57a142a69be5fd3581bae67a459cf09c1b4a7.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "source": { + "original_file": "18e57a142a69be5fd3581bae67a459cf09c1b4a7.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "18e57a142a69be5fd3581bae67a459cf09c1b4a7", + "head_ref": "refs/pull/767/head", + "base_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "base_ref": "origin/develop", + "recorded_at": "2026-08-22T18:45:00+00:00", + "result": "fail", + "scenario": "오프라인+더미 세션+debug 목업으로 PR HEAD 실측. 발신 타임레터 목록은 mock 2건 정상 표시(이 HEAD의 직렬화 완화 커밋 유효 — omitted nullable 필드 수용 확인). 작성 화면 임시저장 카운트 1 정상. 임시저장 목록 진입 시 결함 발견.", + "not_covered": [ + { + "pr": 767, + "reason": "리뷰 지적 1(일반 모드 임시저장 항목 이어쓰기 진입 불가)과 지적 2(삭제 중복 입력 레이스·실패 복원)는 목록 Success 상태가 전제인데, 위 결함으로 오프라인에서는 목록 자체가 Error라 직접 재현 불가. 수신인 API가 살아 있는 세션에서 재검증 필요." + } + ], + "device": { + "android": "15", + "is_emulator": true + }, + "details": { + "dynamic": true, + "findings": [ + { + "pr": 767, + "severity": "defect", + "title": "수신인 이름 조회 실패가 임시저장 목록 전체 실패로 승격", + "detail": "DraftLetterViewModel.loadDrafts()가 getTemporaryTimeLetters() 성공 후 userRepository.getReceivers()를 같은 try에서 호출해, 이름 장식용 조회 실패만으로 전체가 DraftLetterUiState.Error('임시 저장된 레터를 불러올 수 없습니다')가 됨. 목업 로그로 draft GET 200 성공을 확인한 상태에서 재현. 같은 화면의 구버전(develop)은 동일 조건에서 '수신인 : 미지정'으로 목록을 정상 렌더하고, TimeletterViewModel은 동일 패턴을 getOrElse(emptyList())로 격리함. 리뷰(8/22 22:52)가 확인했다고 적은 '누락 수신인 count fallback'은 이 경로에서 도달 불가. PR 코멘트(전후 스크린샷 포함): https://github.com/Afternote/Afternote-FE/pull/767#issuecomment-5382096280" + } + ], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "18e57a142a69be5fd3581bae67a459cf09c1b4a7", + "mock": "debug_mock_mode=on (time-letters only)" + }, + "purpose": "8/20 이후 리뷰 QA 감사 후속 — PR #767(임시저장 목록 UI 개선) 리뷰 시점 HEAD 소급 런타임 QA" + } +} diff --git a/docs/qa/evidence/280f937d102a3e81d5c30be76c85aa7971c20268.json b/docs/qa/evidence/280f937d102a3e81d5c30be76c85aa7971c20268.json new file mode 100644 index 0000000000..d775da9595 --- /dev/null +++ b/docs/qa/evidence/280f937d102a3e81d5c30be76c85aa7971c20268.json @@ -0,0 +1,50 @@ +{ + "schema": 1, + "source": { + "original_file": "280f937d102a3e81d5c30be76c85aa7971c20268.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial", + "text.private_identifier", + "text.qa_account" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "280f937d102a3e81d5c30be76c85aa7971c20268", + "head_ref": "fix/678", + "base_sha": "feba0a899f31ae24443b09f565583963e0ed5e4d", + "base_ref": "refactor/677", + "recorded_at": "2026-08-23T06:30:18.293042+00:00", + "result": "pass", + "scenario": "PR #886(#678): 실서버 왕복 — [QA_ACCOUNT_REDACTED] 계정(토큰 DataStore 주입 로그인), API 생성(POST /afternotes PLAYLIST + leaveMessage, FE 직렬화 동형 바디) → afternoteId=[PRIVATE_ID_REDACTED] 저장·재조회 확인(BE#104 동작), 앱 수정 진입 시 서버 말씀 프리필 등록·접힘 복원, 앱에서 2번째 말씀 입력·등록 후 저장 → 서버에 2건 반영(updatedAt 갱신). 앱 UI 단독 생성은 #540(수신자 추가 무동작)으로 원천 불가라 생성은 API 경유. UI 경로(빈 상태 렌더 위치·입력·등록 접힘·펼침 토글)는 새벽 오프라인 실측에서 선검증(PR 코멘트 1~4컷).", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "48ffb52afc16f6a26a844acde6f77ddb943b39331a23424c37ab5270e94460be", + "changed_files": [ + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToRequest.kt", + "feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt", + "feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialEditorContent.kt", + "feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "280f937d102a3e81d5c30be76c85aa7971c20268" + } + } +} diff --git a/docs/qa/evidence/3445d93228ce184e4e4634361d27486f6576035c.json b/docs/qa/evidence/3445d93228ce184e4e4634361d27486f6576035c.json new file mode 100644 index 0000000000..bc378cae20 --- /dev/null +++ b/docs/qa/evidence/3445d93228ce184e4e4634361d27486f6576035c.json @@ -0,0 +1,42 @@ +{ + "schema": 1, + "source": { + "original_file": "3445d93228ce184e4e4634361d27486f6576035c.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial", + "text.person_name", + "text.private_identifier" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "3445d93228ce184e4e4634361d27486f6576035c", + "head_ref": "fix/993", + "base_sha": "71679cd235565d22faee08952b2f4983b99fb16e", + "base_ref": "develop", + "recorded_at": "2026-08-26T09:13:09.345746+00:00", + "result": "pass", + "scenario": "PR #1165(#993) 실서버 QA — 카카오 실계정으로 소셜 로그인해 isNewUser 계약을 실응답으로 확정. POST /api/v1/auth/social/login → 200 {\"accessToken\":…,\"refreshToken\":…,\"expiresIn\":3600,\"isNewUser\":false}. 종전 FE 가 쓰던 newUser 키는 응답에 없다. 이 PR 이 isNewUser 를 non-null 로 좁혔으므로 키가 어긋나면 역직렬화 실패로 로그인 자체가 죽는 구조 — 로그인이 홈까지 완주한 사실이 곧 키 계약 검증이다(users/me·daily-questions/today·diary 후속 호출 모두 200, 홈에 «안녕하세요, [PERSON_REDACTED]님» 렌더). isNewUser=false 라 WelcomeRoute 로 가지 않고 홈으로 가는 분기 정상. 사전에 배포 OpenAPI 로 SocialLoginResponse.isNewUser(boolean primitive) 도 확인. 남은 것: isNewUser=true(신규 가입→WelcomeRoute) 는 Afternote 미가입 소셜 계정이 있어야 재현되며 이번 계정은 이미 가입(sub=[PRIVATE_ID_REDACTED])이라 미실시. 기기 함정: 카카오 로그인 폼은 uiautomator 덤프에 입력값이 평문으로 나온다 — 비밀번호 입력 뒤에는 덤프를 찍지 말 것.", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 35, + "size": "1080x2400", + "density": 420 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "changed_files": [] + } +} diff --git a/docs/qa/evidence/3767e3d574e7efa91a0222509f50952cdb1d25c4.json b/docs/qa/evidence/3767e3d574e7efa91a0222509f50952cdb1d25c4.json new file mode 100644 index 0000000000..9942fff58c --- /dev/null +++ b/docs/qa/evidence/3767e3d574e7efa91a0222509f50952cdb1d25c4.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "source": { + "original_file": "3767e3d574e7efa91a0222509f50952cdb1d25c4.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "details.screenshots", + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "3767e3d574e7efa91a0222509f50952cdb1d25c4", + "head_ref": "refactor/929 (#929)", + "base_sha": "6123a68a", + "base_ref": "origin/develop", + "recorded_at": "2026-08-23T14:44:48.140426+00:00", + "result": "pass", + "scenario": "#929 core:startup 삭제 후 App Startup 동작 실측. (1) 매니페스트 머지 — debug·release 양쪽 merged manifest 의 androidx.startup.InitializationProvider 에 app 패키지의 DailyNotificationInitializer·KakaoInitializer meta-data 가 WorkManagerInitializer·EmojiCompat·ProcessLifecycle·ProfileInstaller 와 함께 병합됨을 확인. (2) 카카오 SDK 초기화 — SDK 가 앱키 이름으로 만드는 shared_prefs 파일을 run-as 로 삭제(백업 후 원복 대신 재생성 확인) → force-stop → 재기동하니 같은 파일이 23:19 에 재생성. KakaoSdk.init 이 app 모듈 BuildConfig 키로 실행됐고 파일명이 곧 그 키라 주입 값 정합성까지 확인. (3) 일일 알림 예약 — dumpsys jobscheduler 에 Trace tag: DailyNotificationWorker 잡이 Minimum latency +9h43m(= 다음 09:00), Requires batteryNotLow=true 로 등록. WorkManagerInitializer 선행 의존 유지 상태에서 NotificationScheduler 가 실행된 증거. (4) R8 — release mapping.txt 에서 두 Initializer 가 난독화 없이 원래 이름 유지, release dex 에 클래스 실존(리플렉션 인스턴스화 안전). 빌드: :app:assembleDebug·:app:compileDebugUnitTestKotlin·:konsist:test·:app:ktlintCheck·:app:assembleRelease 모두 BUILD SUCCESSFUL.", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "74caeb3e563da13001f6c18c3c479199a541a65f0e98ceadebaea1570c3b2944", + "changed_files": [ + "app/build.gradle.kts", + "app/src/main/AndroidManifest.xml", + "app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt", + "app/src/main/java/com/afternote/afternote_fe/startup/DailyNotificationInitializer.kt", + "app/src/main/java/com/afternote/afternote_fe/startup/KakaoInitializer.kt", + "core/startup/build.gradle.kts", + "core/startup/consumer-rules.pro", + "core/startup/proguard-rules.pro", + "core/startup/src/main/AndroidManifest.xml", + "konsist/src/test/kotlin/com/afternote/konsist/LayerDependencyKonsistTest.kt", + "settings.gradle.kts" + ], + "reviews": [] + } +} diff --git a/docs/qa/evidence/428022ff3f56a78ba1d4a4cf341e581292fc560e.json b/docs/qa/evidence/428022ff3f56a78ba1d4a4cf341e581292fc560e.json new file mode 100644 index 0000000000..d1a535e452 --- /dev/null +++ b/docs/qa/evidence/428022ff3f56a78ba1d4a4cf341e581292fc560e.json @@ -0,0 +1,229 @@ +{ + "schema": 1, + "source": { + "original_file": "428022ff3f56a78ba1d4a4cf341e581292fc560e.json", + "schema": 2, + "field_mapping": { + "result": "result", + "scenario": "purpose", + "not_covered": "limits", + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.app.session", + "device.avd", + "device.serial", + "text.sensitive_value" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "428022ff3f56a78ba1d4a4cf341e581292fc560e", + "head_ref": "develop", + "base_sha": null, + "base_ref": "origin/develop", + "recorded_at": "2026-08-25T12:06:53.705843+00:00", + "result": "로그인 전 5개 + 로그인 후 19개 = 24개 화면을 360x800dp 로 검사했다. 화면 밖으로 나간 텍스트 노드 0건. 텍스트 겹침은 애프터노트 홈·상세의 카테고리 탭 행 1건이 실제 결함으로 확인됐다(#1141) — 411x914dp 에서는 재현되지 않는 좁은 화면 전용이다. 프로필 이미지 위 + 버튼, 접근성 라벨과 표시 텍스트 쌍의 겹침은 오탐으로 확인했다.", + "scenario": "좁은 화면(360x800dp @320dpi)에서 잘림·오버플로 확인 — #1128 / #1129", + "not_covered": [ + "겹침·잘림은 uiautomator 노드 bounds 로 자동 판정했고, 검출된 건만 스크린샷으로 눈검증했다. 검출되지 않은 시각 결함(색·간격·정렬)은 이 실측의 범위가 아니다.", + "앞선 시도([SENSITIVE_VALUE_REDACTED], 17시대)는 다른 세션과 기기를 공유해 오염됐다 — 그 결과는 채택하지 않았다." + ], + "device": { + "is_emulator": true, + "api_level": 35, + "screen": { + "physical": "1080x2400", + "density": 420, + "override": "720x1600", + "override_density": 320, + "effective_dp": "360x800" + } + }, + "details": { + "app": { + "package": "com.afternote.afternote_fe", + "built_from": "worktree .claude/worktrees/qa-dev (origin/develop)", + "installed_at": "2026-08-25 18:43:19", + "note": "이 sha 를 직접 assembleDebug 해서 adb install -r 로 설치한 뒤 검사했다. 설치 시각은 검사 시작 전이고, 검사 중 lastUpdateTime 변화 없음." + }, + "screens": [ + { + "name": "welcome", + "label": "진입 화면", + "offscreen_nodes": 0 + }, + { + "name": "login", + "label": "로그인", + "offscreen_nodes": 0 + }, + { + "name": "signup_email", + "label": "회원가입 1/4 이메일 인증", + "offscreen_nodes": 0 + }, + { + "name": "received_records_empty", + "label": "받은 기록함(빈 상태)", + "offscreen_nodes": 0 + }, + { + "name": "sender_registration", + "label": "발신자 등록하기", + "offscreen_nodes": 0 + }, + { + "name": "home", + "label": "홈 탭", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "tab_record", + "label": "기록 탭", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "tab_timeletter", + "label": "타임레터 탭", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "tab_afternote", + "label": "노트 탭", + "offscreen_nodes": 0, + "overlaps": 1 + }, + { + "name": "afternote_detail", + "label": "애프터노트 상세", + "offscreen_nodes": 0, + "overlaps": 1 + }, + { + "name": "afternote_editor", + "label": "애프터노트 편집기", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "setting_list", + "label": "설정", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "setting_bottom", + "label": "설정 하단", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_profile", + "label": "프로필 수정", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_password", + "label": "비밀번호 변경", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_accounts", + "label": "연결된 계정", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_notification", + "label": "알림 설정", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_receiver_add", + "label": "수신자 등록", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_delivery_cond", + "label": "사후 전달 조건", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_passkey", + "label": "패스키 관리", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_applock", + "label": "앱 잠금 설정", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "set_withdraw", + "label": "회원 탈퇴", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "daily_question_write", + "label": "데일리질문 작성", + "offscreen_nodes": 0, + "overlaps": 0 + }, + { + "name": "record_fab", + "label": "일기 작성", + "offscreen_nodes": 0, + "overlaps": 0 + } + ], + "isolation": "[SENSITIVE_VALUE_REDACTED] 은 이 실측을 위해 따로 부팅했고, 세션 기기 락(~/.claude/hooks/emulator-lease-guard.sh)으로 다른 세션의 접근을 막았다.", + "defects": [ + { + "issue": 1141, + "screen": "afternote_home / afternote_detail", + "summary": "카테고리 탭 `추억 노트` 가 더보기 화살표와 겹침 (360dp 전용, 겹침비 0.34)" + }, + { + "issue": 1147, + "screen": "setting_withdraw", + "summary": "글자 1.5배에서 '탈퇴할 계정' 카드의 이메일이 잘림 (고정 높이 그릇)" + } + ], + "font_scale_pass": { + "scale": 1.5, + "screen_dp": "411x914", + "screens": 17, + "note": "글자 확대는 화면 크기와 독립된 축이라 따로 훑었다. 자동 판정(화면 밖 노드·텍스트 겹침·말줄임)에 더해 대표 화면은 스크린샷을 눈으로 확인했다. 1.3 배에서는 검사한 화면이 모두 정상이었다.", + "defects": [ + { + "issue": 1141, + "screen": "afternote_home", + "type": "겹침", + "summary": "카테고리 탭이 더보기 화살표와 겹침 — 411dp 에서도 재현(겹침비 1.0). 좁은 화면 전용이 아니었다" + }, + { + "issue": 1147, + "screen": "setting_withdraw", + "type": "잘림", + "summary": "'탈퇴할 계정' 카드가 고정 높이라 이메일 줄이 카드 밖으로 클리핑" + } + ], + "false_positives": [ + "프로필 이미지 위 + 버튼 오버레이('기본' ↔ '추가') — 의도된 디자인", + "접근성 라벨과 표시 텍스트 쌍('계정 정보, 필수 항목' ↔ '계정 정보') — 같은 요소의 두 표현" + ], + "limit": "클리핑 잘림은 노드 bounds 로 잡히지 않는다(bounds 가 컨테이너 안으로 잘려 나온다). #1147 도 자동 신호는 '말줄임 0건'이었고 스크린샷을 봐야 발견됐다." + } + } +} diff --git a/docs/qa/evidence/4a74833b65a7023ae5c4ff16c71b6dc0fc085706.json b/docs/qa/evidence/4a74833b65a7023ae5c4ff16c71b6dc0fc085706.json new file mode 100644 index 0000000000..eb06169614 --- /dev/null +++ b/docs/qa/evidence/4a74833b65a7023ae5c4ff16c71b6dc0fc085706.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "source": { + "original_file": "4a74833b65a7023ae5c4ff16c71b6dc0fc085706.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.serial", + "text.device_identifier" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "4a74833b65a7023ae5c4ff16c71b6dc0fc085706", + "head_ref": "HEAD", + "base_sha": "62d5935c614ba2ac00a015e182a1769fd4f275bc", + "base_ref": "origin/develop", + "recorded_at": "2026-08-14T05:25:45.463167+00:00", + "result": "pass", + "scenario": "QA 전용 [DEVICE_NAME_REDACTED]에서 실서버 refresh 무효 400/code 1107을 인증 거절로 분류해 세션을 정리한 뒤, 현재 HEAD APK 재설치·재기동 시 시작·로그인 화면 유지 확인", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "31ab9f830905749470761a4f32c96fd0a1c806a5fd3cd6c68ac84e050d534ce5", + "changed_files": [ + "core/network/build.gradle.kts", + "core/network/src/main/kotlin/com/afternote/core/network/di/NetworkModule.kt", + "core/network/src/main/kotlin/com/afternote/core/network/interceptor/AuthInterceptor.kt", + "core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt", + "core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt", + "core/network/src/test/kotlin/com/afternote/core/network/FakeAuthRepository.kt", + "core/network/src/test/kotlin/com/afternote/core/network/di/NetworkModuleTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/interceptor/AuthInterceptorTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt", + "git-hooks/pre-commit" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "4a74833b65a7023ae5c4ff16c71b6dc0fc085706", + "installed_at_epoch": 1786685111, + "ui_sha256": "acbd09b7fda383bc23bbf8fa73e0dc206091402d8e2957b4f3c5a6b50e4ac104" + } + } +} diff --git a/docs/qa/evidence/53a2f7bc43c4922c96f00914052621eb95b6d75e.json b/docs/qa/evidence/53a2f7bc43c4922c96f00914052621eb95b6d75e.json new file mode 100644 index 0000000000..56def0bdbc --- /dev/null +++ b/docs/qa/evidence/53a2f7bc43c4922c96f00914052621eb95b6d75e.json @@ -0,0 +1,60 @@ +{ + "schema": 1, + "source": { + "original_file": "53a2f7bc43c4922c96f00914052621eb95b6d75e.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "53a2f7bc43c4922c96f00914052621eb95b6d75e", + "head_ref": "refs/pull/440/head", + "base_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "base_ref": "origin/develop", + "recorded_at": "2026-08-22T19:00:00+00:00", + "result": "pass-with-observations", + "scenario": "오프라인+더미 세션으로 PR HEAD 실측(에뮬레이터 -no-audio, 무음 입력). 작성 화면→미디어 추가→음성 추가하기→RECORD_AUDIO 권한 프롬프트→허용→녹음 시트. 녹음 시작→타이머 진행(00:05)→녹음 중 홈 이탈 후 복귀 시 녹음 폐기 확인(시트·타이머·블록 없음)→재녹음→녹음 완료→'녹음 듣기/다시 녹음/음성 메시지 등록' 상태→듣기 트리거 시 오류 없음→등록 시 본문에 .m4a 오디오 블록 추가·삭제 버튼 노출.", + "not_covered": [ + { + "pr": 440, + "reason": "실제 마이크 음질/재생 청취는 에뮬레이터 호스트 마이크 경유 시 QEMU 행·크래시(환경 문제)로 -no-audio 무음 입력 사용 — 앱 결함 아님. 등록(SCHEDULED) 제출·업로드는 수신자 선택 전제(오프라인)라 미검증. 리뷰의 blocking 1·4(이슈 연결 어긋남·develop 충돌)는 정적 사안." + } + ], + "device": { + "android": "15", + "is_emulator": true, + "note": "호스트 오디오 입력 사용 시 emulator 37.1.11 QEMU 메인 루프 행 → 크래시 재현. 이 AVD에서 녹음 QA는 -no-audio 권장." + }, + "details": { + "dynamic": true, + "findings": [ + { + "pr": 440, + "severity": "observation-confirming-review", + "title": "음성 추가하기는 녹음 전용 — 기존 오디오 파일 첨부 경로 제거 확인", + "detail": "PR HEAD에서 음성 추가하기가 권한→녹음기만 열고 파일 선택이 없음. develop 동일 진입점은 GET_CONTENT audio/* 파일 선택기를 열음(런타임 대조·ActivityTaskManager 로그). 리뷰(8/22 15:44) 지적 2(기존 음성 파일 첨부 제거)와 일치." + }, + { + "pr": 440, + "severity": "observation", + "title": "오디오 블록에 내부 UUID 파일명 노출", + "detail": "등록된 블록 라벨이 '9a0a01f7-....m4a' 형태의 내부 파일명. 표시명 개선 여지." + } + ], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "53a2f7bc43c4922c96f00914052621eb95b6d75e", + "mock": "debug_mock_mode=on (time-letters only)", + "note": "이 HEAD의 debug 서명이 현행과 달라 install -r 불가(INSTALL_FAILED_UPDATE_INCOMPATIBLE) — uninstall 후 클린 설치로 진행했고 QA 종료 시 제거함." + }, + "purpose": "8/20 이후 리뷰 QA 감사 후속 — PR #440(음성 메시지 녹음) 리뷰 시점 HEAD 소급 런타임 QA" + } +} diff --git a/docs/qa/evidence/549349852a1bc004faf7660174c745835cabbc93.json b/docs/qa/evidence/549349852a1bc004faf7660174c745835cabbc93.json new file mode 100644 index 0000000000..16da78123b --- /dev/null +++ b/docs/qa/evidence/549349852a1bc004faf7660174c745835cabbc93.json @@ -0,0 +1,50 @@ +{ + "schema": 1, + "source": { + "original_file": "549349852a1bc004faf7660174c745835cabbc93.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial", + "text.sensitive_value" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "549349852a1bc004faf7660174c745835cabbc93", + "head_ref": "fix/936", + "base_sha": "6123a68a55006dcbcaba39a53c86e2c74d5ac775", + "base_ref": "origin/develop", + "recorded_at": "2026-08-23T12:15:57+00:00", + "result": "pass", + "scenario": "수정 6클래스만 필터 실행: AfternoteAuthorExtendedAndroidTest, AppAndReceiverCompletionAndroidTest, ReceiverRuntimeCompletionAndroidTest, ReceiverAdvancedAndroidTest, SettingAccountSecurityAndroidTest, SettingCompletionAndroidTest. 1차 실행(컴파일 수정만 반영)에서 7건 실패 → 런타임 드리프트로 판정·수정 → 2차 29/29 통과 (0 failures, 0 errors, XML 교차 확인).", + "not_covered": [ + { + "reason": "나머지 androidTest 13파일은 이번 diff 미접촉이라 미실행 — 화·금 GMD 전량 실행이 커버. 이번 적발로 확인된 '컴파일 게이트가 못 잡는 런타임 드리프트' 부류는 GMD 가 유일한 그물." + } + ], + "device": { + "android": "15", + "is_emulator": true + }, + "details": { + "dynamic": true, + "findings": [ + { + "issue": 936, + "note": "1차 실측이 이슈에 없던 런타임 드리프트 7건 적발: SavedStateHandle String id 주입 5건(toRoute Long 읽기와 충돌, LongNavType 크래시), 마스터 키 [SENSITIVE_VALUE_REDACTED] 형식 게이트(#887) 반려 1건, 탈퇴 확인 플로우 변경(즉시 호출·결과 팝업) 1건. 전부 head 에 수정 포함. androidTest 잔여 파일의 SavedStateHandle 주입 전수 grep — String id 패턴 추가 발견 없음(RECEIVER_ID·timeLetterId·draftId 모두 Long/null)." + } + ], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": true, + "mock": "테스트 내 fake 리포지토리 (실서버 미사용)" + }, + "purpose": "#936 androidTest 컴파일 44건 수정 + 런타임 드리프트 정리 — 화요일 GMD 정기 실행 전 수정 클래스 런타임 green 확정" + } +} diff --git a/docs/qa/evidence/5e3c6bd344606397b5cd7167c7a54de2e8338361.json b/docs/qa/evidence/5e3c6bd344606397b5cd7167c7a54de2e8338361.json new file mode 100644 index 0000000000..0f0941cd68 --- /dev/null +++ b/docs/qa/evidence/5e3c6bd344606397b5cd7167c7a54de2e8338361.json @@ -0,0 +1,48 @@ +{ + "schema": 1, + "source": { + "original_file": "5e3c6bd344606397b5cd7167c7a54de2e8338361.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.serial", + "text.device_identifier", + "text.qa_account" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "5e3c6bd344606397b5cd7167c7a54de2e8338361", + "head_ref": "HEAD", + "base_sha": "d8e2c8afb3ed568139af07707b535ff5e34a8d70", + "base_ref": "origin/feat/697", + "recorded_at": "2026-08-10T07:44:13.216067+00:00", + "result": "pass", + "scenario": "[DEVICE_NAME_REDACTED]: [QA_ACCOUNT_REDACTED] 홈에서 네트워크 차단 후 오류 화면 진입, 연결 복구 뒤 오류 화면 당겨서 새로고침으로 홈 정상 복구 확인", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "13b078a6dfb88c6640f4006a498a1e187f2693b271f0705fb11fec4b7212e400", + "changed_files": [ + "app/src/main/java/com/afternote/afternote_fe/screen/HomeTabScreen.kt", + "app/src/main/java/com/afternote/afternote_fe/screen/HomeTabViewModel.kt", + "app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTest.kt", + "app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png", + "core/network/src/main/kotlin/com/afternote/core/network/di/NetworkModule.kt", + "core/network/src/test/kotlin/com/afternote/core/network/di/NetworkModuleTest.kt" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "5e3c6bd344606397b5cd7167c7a54de2e8338361", + "installed_at_epoch": 1786347737, + "ui_sha256": "5cb91e03dea16e15fbc6c6e9f1f22001e208c4c98870b9f0c9cb95c7c725863c" + } + } +} diff --git a/docs/qa/evidence/64fb893434b158eec050c7f71d35d2e207a2e824.json b/docs/qa/evidence/64fb893434b158eec050c7f71d35d2e207a2e824.json new file mode 100644 index 0000000000..d44f916b49 --- /dev/null +++ b/docs/qa/evidence/64fb893434b158eec050c7f71d35d2e207a2e824.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "source": { + "original_file": "64fb893434b158eec050c7f71d35d2e207a2e824.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial", + "text.qa_account" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "64fb893434b158eec050c7f71d35d2e207a2e824", + "head_ref": "feat/559", + "base_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "base_ref": "develop", + "recorded_at": "2026-08-23T11:38:07.938930+00:00", + "result": "pass", + "scenario": "PR #721(#559) 재리뷰 QA — [QA_ACCOUNT_REDACTED] 실서버. API 로 데일리질문 답변 1건 생성(userDailyQuestionId=24) 후: 홈 MEMORIES 카드가 실답변(질문+본문 미리보기) 표시, 추억 공간 진입 시 실기록 카드 렌더. 홈 진입~카드 노출 로그캣 집계로 GET /api/v1/daily-questions 정확히 1회(중복 조회 수정 확인, 64fb8934). 네트워크 차단 후 추억 공간 재진입 → '기록을 불러오지 못했습니다.'+다시 시도하기(빈 성공 수렴 대신 에러 승격, 5d3485cf) → 복구 후 재시도 → 4요청(일기 3개월+데일리질문) 각 1회 200, 카드 복원. 부분 실패+합산 0건 경계는 선택적 실패 유발 수단 부재로 코드 근거. '그날의 기록 다시 읽기' no-op 은 KDoc 문서화된 미확정 재량. 돌아가기 인셋 정상.", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "9d5f6662efc55d07dded3b1a2678a3e71d089c37851b89d39b250e5448d6ff44", + "changed_files": [ + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt", + "feature/mindrecord/presentation/src/main/res/values/strings.xml" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "64fb893434b158eec050c7f71d35d2e207a2e824" + } + } +} diff --git a/docs/qa/evidence/77730cef8a2c36b4ed605e31ef22ef3904d4df75.json b/docs/qa/evidence/77730cef8a2c36b4ed605e31ef22ef3904d4df75.json new file mode 100644 index 0000000000..7d17ac9702 --- /dev/null +++ b/docs/qa/evidence/77730cef8a2c36b4ed605e31ef22ef3904d4df75.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "source": { + "original_file": "77730cef8a2c36b4ed605e31ef22ef3904d4df75.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial", + "text.network_endpoint", + "text.sensitive_value" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "77730cef8a2c36b4ed605e31ef22ef3904d4df75", + "head_ref": "chore/1188", + "base_sha": "e1b307eb5a621a8ea07c20db0ca9963a567d79da", + "base_ref": "develop", + "recorded_at": "2026-08-25T21:26:32.550788+00:00", + "result": "pass", + "scenario": "PR #1193(#1188) 실기 검증: Kakao SDK 2.24.0 이 :app:lintVitalRelease 를 Instantiatable 로 깨뜨리는데 그게 오탐인지. lint 를 임시 억제해 만든 R8 축소 릴리스 APK 를 debug 키로만 재서명해 설치하고 kakao<앱키>://oauth 딥링크를 던졌다. 결과: ActivityTaskManager START 가 cmp=com.afternote.afternote_fe/com.kakao.sdk.auth.AuthCodeHandlerActivity 로 잡히고 ActivityManager 가 'Start proc … for next-top-activity {…/AuthCodeHandlerActivity}' 로 프로세스를 띄웠다 — 즉 시스템이 그 액티비티를 실제로 인스턴스화했다. logcat 에 Unable to instantiate activity·ClassNotFoundException·FATAL 0건. lint 가 «시스템이 인스턴스화하지 못한다» 고 말한 동작이 실기에서 성립하므로 오탐으로 확정. 정적 대조도 함께: aar 상속 체인(AuthCodeHandlerActivity->CustomTabLauncherActivity->AppCompatActivity)·바이트코드 major 61·클래스 수 74·SDK 매니페스트 선언·proguard.txt 가 2.23.2 와 전부 동일하고, releaseRuntimeClasspath 의 appcompat 도 양쪽 1.7.1 로 같다. 유일한 차이는 2.24.0 이 Gradle Module Metadata 를 함께 배포하고 variant 가 aar 만 노출한다는 것. okhttp/okio 도 양쪽 5.4.0/3.17.0 으로 같아 #1072 하한은 유지. 이후 사용자 지시로 전용 AVD([SENSITIVE_VALUE_REDACTED], API 35)를 새로 만들어 깨끗한 기기에서 «카카오 로그인 왕복»을 끝까지 밟았다: 온보딩→로그인 화면→카카오 버튼 탭에서 AuthCodeHandlerActivity 기동, CustomTab 으로 [NETWORK_ENDPOINT_REDACTED] 폼 표시, 사용자가 실계정으로 로그인, 리다이렉트를 AuthCodeHandlerActivity 가 수신해 MainActivity 복귀, 백엔드 소셜 로그인까지 성공해 홈 화면(이름·데일리질문·수신인 상태) 정상 렌더. E AndroidRuntime: FATAL EXCEPTION 0건. 즉 lint 가 지목한 액티비티가 R8 축소 릴리스에서 왕복의 양 끝을 모두 수행했다. 기기 함정 2건: avdmanager 기본 AVD 는 hw.keyboard=no 라 호스트 키보드 입력이 게스트로 전달되지 않는다(기존 QA AVD 들은 yes) — config.ini 를 고치고 재부팅해야 한다. 그리고 hw.keyboard=yes 로 바꾸면 Gboard 가 물리 키보드 모드 미니 툴바(백스페이스·탭·이모지·메뉴)를 화면 왼쪽에 오버레이로 띄워 콘텐츠를 가리는데, show_ime_with_hard_keyboard=0 으로는 안 사라지고 `ime disable ` 로 IME 를 꺼야 사라진다.", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 35 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "7d825eb9325786a4082f31b4226a453f334c385936509b7b0bd356f6b56b2704", + "changed_files": [ + "app/src/main/AndroidManifest.xml", + "gradle/libs.versions.toml" + ] + } +} diff --git a/docs/qa/evidence/7c13af45330a6afff6c73128e57863e72407cfef.json b/docs/qa/evidence/7c13af45330a6afff6c73128e57863e72407cfef.json new file mode 100644 index 0000000000..c4f9125e8b --- /dev/null +++ b/docs/qa/evidence/7c13af45330a6afff6c73128e57863e72407cfef.json @@ -0,0 +1,53 @@ +{ + "schema": 1, + "source": { + "original_file": "7c13af45330a6afff6c73128e57863e72407cfef.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "details.screenshots", + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "7c13af45330a6afff6c73128e57863e72407cfef", + "head_ref": "fix/723-weekly-report-recovery (PR #965)", + "base_sha": "5a42cf1489be107096c4ad795c2ba697929b48be", + "base_ref": "refactor/547-weekly-date-parsing (PR #961)", + "recorded_at": "2026-08-23T14:09:44.582897+00:00", + "result": "pass", + "scenario": "PR #965(#723)+#961(#547) 리뷰 QA. 스택 head(#965, #961 포함) APK 를 실기동. (1) 주간리포트 탭에서 ViewModeSwitcher·FAB 미노출 확인 — uiautomator 덤프상 상단 액션·FAB 영역 클릭 요소 0, 데일리질문/일기 탭에서는 유지. (2) wifi·data off 후 8월 2주차 선택 → 실패 배너('8월 2주차 리포트를 불러오지 못했어요.')와 직전 8월 3주차 리포트·주차 드롭다운 동시 유지 확인. (3) 오프라인 콜드 스타트 후 주간리포트 진입 → WeeklyReportUiState.Error 화면에 주차 드롭다운·다시 시도 잔존(복구 수단 유지), 단 WeeklyReportReviewCard 가 dateRange=\"\"/counts=emptyList 로 높이 200dp 중 아래 절반이 빈 박스 — 리뷰 [normal] 4. (4) 네트워크 복구 후 다시 시도 → 8월 3주차 정상 렌더, 복구 확인. 부수 관찰: 배너에 서버 예외 원문(Unable to resolve host…) 영문 노출, '다시 시도' 좌측선 어긋남(left=48 vs 77) — 각각 리뷰 [normal] 3·5. #961 의 counts 미필터 결함은 실서버가 주 단위 스코프라 화면 재현 불가 → 임시 단위테스트로 실증(counts=3/목록=1/recordedDays=1), 리뷰 [blocking].", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "1e01114162793de0b5be59d0717f0088dc77e12c745c1be3e2ecf538e5eedd64", + "changed_files": [ + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt", + "feature/mindrecord/presentation/src/main/res/values/strings.xml", + "feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt" + ], + "reviews": [ + "https://github.com/Afternote/Afternote-FE/pull/961#pullrequestreview-5002561443", + "https://github.com/Afternote/Afternote-FE/pull/965#pullrequestreview-5002567889" + ], + "redacted_artifacts": { + "screenshot_references": 2 + } + } +} diff --git a/docs/qa/evidence/8325a703ab3275330b30713b0f4d542c975a2024.json b/docs/qa/evidence/8325a703ab3275330b30713b0f4d542c975a2024.json new file mode 100644 index 0000000000..05bcae0fc6 --- /dev/null +++ b/docs/qa/evidence/8325a703ab3275330b30713b0f4d542c975a2024.json @@ -0,0 +1,370 @@ +{ + "schema": 1, + "source": { + "original_file": "8325a703ab3275330b30713b0f4d542c975a2024.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial", + "text.auth_value", + "text.network_endpoint", + "text.person_name", + "text.private_identifier", + "text.qa_account", + "text.qa_credential", + "text.sensitive_value" + ], + "interpretation_notes": [ + "not_covered의 수신자 재인증 사유는 앞선 패스 시점 기록이다. 같은 기록의 후속 패스에서는 승인·재진입까지 진행했지만 #607로 승인 이후 FE 화면의 종단 간 검증이 남았다." + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "8325a703ab3275330b30713b0f4d542c975a2024", + "head_ref": "docs/qa-status-and-readme (코드는 origin/develop 과 동일 — .md 외 diff 0건)", + "base_sha": "4e0a648eb", + "base_ref": "origin/develop", + "recorded_at": "2026-08-25T04:30:00+09:00", + "result": "pass-with-observations", + "scenario": "dev 서버 폐쇄 시간대(04:20~04:30 KST, [NETWORK_ENDPOINT_REDACTED] 10초 타임아웃 무응답)를 조건으로 활용. 만료 access + 유효 refresh 를 DataStore protobuf 로 주입해 '세션은 있으나 서버가 죽은' 상태를 만든 뒤: 앱 재시작 → 홈 → 노트 탭 → 애프터노트 작성(소셜네트워크) → 제목 입력 → 뒤로가기 이탈 가드 → 계속 작성 → 종류 전환(추억 노트) → 추억 플레이리스트 → 곡 검색 → 홈 복귀 → 설정 순회. 2차 순회: 타임레터 탭 → 우상단 아이콘 2종 → 홈 프로필 아이콘 → 기록 탭(데일리 질문·일기·주간리포트 서브탭). 3차 순회(서버 정상 시간대 15:09~15:15 KST): 앱 재시작 → 재발급 처리 → 받은 기록함 → 발신자 등록 → 상세 → 인증 1단계 → 앱 재시작 후 잔존 확인. 4차 순회(로그인 상태, 16:58~17:06 KST): [QA_ACCOUNT_REDACTED] 재로그인 후 설정 전 구간 — 상단 바로가기 3종 · 알림 설정 · 앱 잠금 PIN · 패스키 관리 · 고객지원 6종. 5차 순회(성공 경로, 17:14~17:26 KST): 에뮬레이터에 화면잠금 PIN·지문 등록 → 애프터노트 인증 통과 → 목록·상세·수정·저장 왕복 → 신규 작성 검증 안내. 6차([SENSITIVE_VALUE_REDACTED] 열람 승인, 17:40 KST): [SENSITIVE_VALUE_REDACTED] 계정([SENSITIVE_VALUE_REDACTED]) API 로그인 → 승인 대기 목록 조회 → 승인 전/후 수신자 열람 대조 → POST approve 실행. 7차(승인 이후 화면, 18:34 KST): [SENSITIVE_VALUE_REDACTED] 으로 APPROVED 세팅 후 앱에서 수신자 흐름 재진입 — 서류 업로드(#915)·마스터키 검증·status 미조회 확인. 8차(전달 조건까지 우회, 18:5x KST): 발신자 토큰으로 애프터노트·일기에 RECEIVER_REQUEST 전달 조건 PUT → 서류 재제출(id=2)→[SENSITIVE_VALUE_REDACTED] 승인으로 조건 충족 → 수신자 애프터노트 열람 열림 → 작성자 원본과 대조.", + "not_covered": [ + { + "reason": "dev 서버가 03~12시(KST) 폐쇄되어 성공 경로 전반(등록·조회·수정·삭제 왕복)은 검증하지 못했다. access 만료 후 reissue 도 불가해 로그인 상태의 정상 흐름은 12시 이후 재실측이 필요하다." + }, + { + "reason": "타임레터·마음의 기록·온보딩·수신자 열람 흐름은 이번 순회에서 다루지 않았다. 미검증 머지 PR 이 각각 7 · 21 · 3 · (수신자 다수) 건 남아 있다." + }, + { + "reason": "2차 QA 잔여 36건 중 설정 9건(#560 마케팅 수신 저장, #564 고객지원 6종, #572 앱 잠금 PIN, #574 패스키 등)은 설정 화면이 실패 상태로 진입 불가라 확인하지 못했다." + }, + { + "reason": "온보딩·수신자 열람 흐름은 로그아웃이 필요한데 서버 폐쇄 중에는 재로그인이 불가해 다루지 않았다. 로그인 상태에서 받은 기록함 진입 불가(#596) 도 같은 이유로 미검증." + }, + { + "reason": "곡 검색 실패 스낵바(#664 수정분)는 서버가 정상 응답하는 시간대라 실패를 유발하지 못해 이번에도 미확인으로 남았다. 애프터노트 등록·조회 왕복 등 성공 경로 전반과 수신자 열람 승인 이후 화면(관리자 승인 필요)도 미검증." + }, + { + "reason": "수신자 열람 승인 이후 '앱 UI 화면' 은 미확인 — [SENSITIVE_VALUE_REDACTED] API 승인은 실행했으나(타임레터 열림 확인), 앱에서 그 화면을 보려면 수신자를 별도 이메일(+[QA_ACCOUNT_REDACTED]) 인증번호로 재인증해야 하고 이번 순회에서 거기까지 가지 않았다." + } + ], + "device": { + "android": "16", + "is_emulator": true, + "note": "-no-audio 로 기동." + }, + "details": { + "dynamic": true, + "findings": [ + { + "severity": "pass-fix-confirmed", + "title": "2차 QA #21 — 통신 오류로 인한 강제 로그아웃 해소 확인 (PR #561)", + "detail": "access 만료 + refresh 재발급이 SocketTimeout 으로 실패했는데도 세션을 지우지 않고 홈으로 진입했다. 만료(401)와 통신 실패를 구분해 처리하는 것이 실동작으로 확인됨." + }, + { + "severity": "pass-fix-confirmed", + "title": "서버 실패가 빈 성공 화면으로 위장되지 않음 — 홈·노트 (PR #704 · #705)", + "detail": "홈은 '정보를 불러오지 못했습니다.' + '다시 시도', 노트 탭은 '애프터노트 목록을 불러오지 못했습니다.' + '다시 시도'. 화면 맥락별로 문구가 갈려 있다." + }, + { + "severity": "pass-fix-confirmed", + "title": "2차 QA #25 / 1차 QA #6 — 작성 중 이탈 가드 확인 (PR #659)", + "detail": "제목 입력 후 BACK 2회(1회는 키보드 닫기) 시 '작성 중인 내용이 사라집니다. 정말 나가시겠어요?' + [계속 작성/나가기] 노출. '계속 작성' 선택 시 입력값 유지 확인. 종류만 바꾼 상태에서도 가드가 동작한다." + }, + { + "severity": "pass-fix-confirmed", + "title": "2차 QA #26 / 1차 QA #14 — 플레이리스트 유령 곡 수 해소 (PR #616)", + "detail": "카드가 '현재 0개의 노래가 담겨 있습니다', 상세도 '총 0곡' 으로 일치. 하드코딩 기본값 16 이 사라졌고 곡 추가 FAB 진입점도 존재한다." + }, + { + "severity": "pass-fix-confirmed", + "title": "1차 QA #3 · #9 — 소셜네트워크 수신자 지정 UI · TODAY'S QUESTION 날짜", + "detail": "소셜네트워크 종류에 '수신자 추가' 섹션이 렌더링된다(1차 QA #3 당시 미렌더). 홈 TODAY'S QUESTION 날짜가 고정값 2026.04.10 이 아닌 오늘(2026.08.25)로 표시된다." + }, + { + "severity": "repro-confirmed", + "issue": 708, + "title": "#708 재현 — 설정 조회 실패가 '로그아웃' 하나만 남은 화면으로 위장", + "detail": "설정 진입 시 users/me 실패 후 스피너가 사라지고 '로그아웃' 항목 하나만 남는다. 오류 안내도 재시도 수단도 없고 설정 메뉴 전체가 소실된다. 같은 실패 조건에서 홈·노트는 오류 UI + 다시 시도를 정상 노출하므로 설정만 미결선 상태. 이슈 #708(koongmai) 은 8/3 등록 후 OPEN." + }, + { + "severity": "repro-confirmed", + "issue": 966, + "title": "#966 재현 — 첫 진입 요청 중복", + "detail": "홈 진입 시 users/me · users/receivers · daily-questions/today · diary · users/me/activity 5건이 동시 발사된다. 설정 진입 시에는 users/me 가 0.8초 간격으로 2회 호출된다. 서버 폐쇄 상태에서는 각 요청이 10초 타임아웃을 소모해 체감 지연이 커진다." + }, + { + "severity": "observation", + "title": "추억 플레이리스트 빈 목록에 안내 문구가 없다", + "detail": "'총 0곡' 카운트만 있고 본문은 완전한 빈 화면이다. 1차 QA #18 · 2차 QA #20 이 지적한 '빈 상태 안내 부재' 와 같은 유형이나 이 화면에 대응하는 기존 이슈는 검색되지 않았다. 시안에 빈 상태가 정의돼 있는지 확인 필요. → 이슈 #1100 으로 등재(2026-08-25).", + "issue": 1100 + }, + { + "severity": "observation-unconfirmed", + "title": "곡 검색 실패 시 화면 안내를 포착하지 못했다", + "detail": "music/search 가 타임아웃으로 실패하고 AddSongViewModel 이 'search failed' 를 남겼으나, 실패 직후 화면에서 스낵바를 확인하지 못했다. 캡처 타이밍을 놓쳤을 가능성이 있어 미확정. 재검색 트리거(검색 아이콘 재탭)로는 새 요청이 발사되지 않아 재현에도 실패했다. PR #665 가 이 스낵바의 문구를 StringRes 로 바꾼 변경이므로 서버 가용 시간대에 재확인이 필요하다." + }, + { + "severity": "observation-unconfirmed", + "title": "홈 인사말이 '…님' 으로 표시된다", + "detail": "#697(이름 디스크 캐시 양방향 단절)은 CLOSED 이지만 화면에는 '안녕하세요, …님' 이 나온다. 다만 이번 실측은 클린 설치 + 서버 무응답이라 이름을 획득할 경로 자체가 없었으므로 캐시 미존재 시의 정상 폴백일 수 있다. 서버 가용 시 재확인 대상." + }, + { + "severity": "repro-confirmed", + "issue": 714, + "title": "#714 재현 — 타임레터 목록 실패가 '빈 목록' 성공 화면으로 위장", + "detail": "GET /time-letters 가 SocketTimeout 으로 실패했는데 화면은 '아직 등록된 타임레터가 없어요. 소중한 사람을 위해 작성해 보세요.' 를 표시한다. 오류 표시도 재시도도 없어 사용자는 자신의 타임레터가 사라진 것으로 오인한다. 같은 실패 조건에서 홈·노트는 오류 UI 를 정상 노출한다. 이슈 #714(koongmai) OPEN." + }, + { + "severity": "repro-confirmed", + "issue": 716, + "title": "#716 재현 — 마음의 기록 3개 서브탭이 예외 원문을 화면에 그대로 출력 (서버·내부 IP 노출)", + "detail": "데일리 질문·일기·주간리포트 세 서브탭 모두 실패 시 'failed to connect to [NETWORK_ENDPOINT_REDACTED] (port 443) from /[SENSITIVE_VALUE_REDACTED] (port NNNNN) after 10000ms' 를 본문에 그대로 렌더링한다. 사용자에게 무의미할 뿐 아니라 서버 IP 와 클라이언트 내부 IP·포트가 화면에 드러난다. 1차 QA S1(서버 오류 응답의 내부 정보 노출)과 같은 성격의 노출이 이번에는 클라이언트 측에서 발생. 이슈 #716(Sadturtleman) OPEN 이고 이를 고치는 PR #904 는 아직 미머지 상태라 develop 에 결함이 남아 있는 것은 예상된 상태다. 다만 이슈 본문은 '기술 원문' 으로만 적고 있어 IP 노출이라는 보안 측면이 드러나 있지 않다." + }, + { + "severity": "observation", + "title": "실패 처리 방식이 기능마다 네 갈래로 갈린다", + "detail": "동일한 서버 무응답 조건에서 홈·노트는 '오류 문구 + 다시 시도', 설정은 '로그아웃 항목만 남은 화면', 타임레터는 '빈 목록 성공 화면', 마음의 기록은 '예외 원문 노출' 로 각각 다르게 처리된다. 개별 PR 리뷰로는 드러나지 않고 기능 간 대조에서만 보이는 불일치다." + }, + { + "severity": "observation", + "title": "2차 QA #3 부분 해소 — 타임레터 설정 아이콘은 동작, 프로필 아이콘은 여전히 무반응", + "detail": "타임레터 헤더 우상단 설정(톱니) 아이콘은 설정 화면으로 이동한다(2차 QA #3 당시 무반응). 프로필(사람) 아이콘은 탭해도 화면·요소 변화가 없다." + }, + { + "severity": "observation-unconfirmed", + "title": "홈 헤더 프로필 아이콘도 무반응", + "detail": "2차 QA #3 은 '홈의 같은 아이콘은 정상' 이라고 기록했으나 이번 실측에서는 홈에서도 프로필 아이콘 탭에 반응이 없었다. 다만 홈이 조회 실패 상태였으므로 오류 상태에서만 막히는 조건부 동작일 가능성이 있어 서버 가용 시 재확인이 필요하다." + }, + { + "severity": "observation", + "issue": 705, + "title": "#705 범위 중 '목록' 은 해소된 것으로 보인다", + "detail": "이슈 #705(애프터노트 서버 실패가 무음·빈 정상 화면으로 수렴 — 목록·상세·에디터)는 OPEN 이지만, 목록에 한해서는 '애프터노트 목록을 불러오지 못했습니다.' + '다시 시도' 가 정상 노출된다. 상세·에디터 경로는 서버 폐쇄로 미검증. 이슈 범위 축소 여부 검토 가치가 있다." + }, + { + "severity": "pass-fix-confirmed", + "title": "refresh 무효(400/1107)를 인증 거절로 분류해 세션 정리 — 정상 동작", + "detail": "서버 복구 후 앱 재시작 시 POST /auth/reissue 가 {\"status\":400,\"code\":1107,\"message\":\"유효하지 않은 리프레시 토큰\"} 을 반환했고, TokenAuthenticator 가 '리이슈 인증 거절: 세션 만료 처리' 로 분류해 Welcome 화면으로 복귀했다. 새벽 순회의 '통신 실패 시 세션 유지' 와 합쳐 보면 만료·거절·일시실패 3분류가 의도대로 갈리고 있다." + }, + { + "severity": "observation", + "title": "동시 요청이 각자 재발급을 시도해 분류가 갈린다 — 경합 소지", + "detail": "앱 시작 시 5개 요청이 동시 발사되면서 POST /auth/reissue 가 2회 호출됐고(15:10:28.017 · 15:10:28.279, 둘 다 400), 로그에는 '리이슈 인증 거절: 세션 만료 처리' 1건과 '리이슈 일시 실패: 세션 유지, 요청 중단' 3건이 함께 남았다. 이번에는 거절 분류가 먼저 처리돼 세션이 정리됐지만, 순서에 따라 '일시 실패' 만 반영되면 무효 refresh 로 세션이 남아 401 이 반복될 수 있다. 요청 중복(#966)이 인증 경로까지 영향을 주는 사례." + }, + { + "severity": "repro-confirmed", + "issue": 599, + "title": "#599 재현 — 발신자 카드가 앱 재시작 시 소실 (서버 정상 상태에서도)", + "detail": "받은 기록함 → + → 이름 '[QA_ACCOUNT_REDACTED]' → 등록 → 목록·상세 정상 표시. 앱 재시작 후 '아직 등록된 발신자가 없습니다.' 로 복귀해 등록분이 사라진다. 서버가 정상 응답하는 시간대(15:12)에 재현했으므로 네트워크 실패로 인한 소실이 아니다." + }, + { + "severity": "repro-confirmed", + "issue": 607, + "title": "#607 재현 — 발신자 등록에 서버 요청이 발생하지 않는다", + "detail": "등록 시점 전후로 OkHttp 로그에 대응 요청이 한 건도 없다(마지막 로그가 15:10:28 앱 시작 조회, 등록은 15:12). 로컬 stub 으로만 동작한다는 이슈 본문과 일치하며 #599 의 소실도 같은 뿌리로 보인다." + }, + { + "severity": "pass-fix-confirmed", + "title": "2차 QA #32 / 1차 QA O6 해소 — 받은 기록함 빈 상태·빈 값 안내", + "detail": "빈 목록 문구가 '아직 등록된 발신자가 없습니다. 발신자를 등록하여 기록을 확인해 보세요.' 로 수신자 관점에 맞게 바뀌었다(1차 QA O6 은 '수신자를 등록하여' 로 반대였다). 발신자 상세의 신청일·승인일도 빈 값 대신 '신청 기록이 없습니다' · '승인 기록이 없습니다' 로 안내한다." + }, + { + "severity": "pass", + "title": "#919 대규모 이전 이후 수신자 인증 진입 회귀 없음", + "detail": "발신자 상세 → 열람 신청하기 → '수신자 인증 진행도 4단계 중 1단계 / 수신자 본인 확인' 화면까지 정상 도달. 수신자 흐름 155파일을 :feature:receiver 로 옮긴 #919(8/24 머지) 이후에도 진입 경로가 살아 있다." + }, + { + "severity": "observation", + "title": "오류 화면에서 포그라운드 복귀 시 재조회하지 않는다", + "detail": "새벽 순회에서 예외 원문을 띄운 채 백그라운드로 보낸 앱을 11시간 뒤(서버 복구 후) 다시 열었을 때, 화면은 같은 오류 문구를 그대로 유지했고 자동 재조회가 일어나지 않았다. 재진입 갱신 결선(#702·#701 계열)이 오류 상태에도 적용되는지 확인이 필요하다." + }, + { + "severity": "pass-fix-confirmed", + "title": "로그인 상태 홈 정상 — 1차 QA #9 완전 해소, #697 은 결함 아님", + "detail": "인사말이 '안녕하세요, [PERSON_REDACTED]님' 으로 실명 표시된다. 새벽 순회의 '…님' 은 클린 설치 + 서버 무응답으로 이름을 획득할 경로가 없던 정상 폴백이었고 #697(CLOSED)은 회귀가 아니다. TODAY'S QUESTION 도 실제 질문('내 인생에서 가장 긴장했던 면접이나 시험은 언제였나요?')과 오늘 날짜(2026.08.25)를 표시해 1차 QA #9(고정값 2026.04.10) 해소가 확인된다. 홈 칩도 '수신인 지정 완료' 로 정상." + }, + { + "severity": "pass-fix-confirmed", + "title": "1차 QA #17 / 2차 QA #5 해소 — 검색창 placeholder", + "detail": "설정 → 수신자 목록의 검색창 안내문이 리터럴 'Text Field' 가 아니라 '이름으로 검색하기' 로 표시된다." + }, + { + "severity": "repro-confirmed", + "issue": 560, + "title": "#560 재현 + 원인 — 마케팅 알림 토글이 저장 요청을 보내지 않는다", + "detail": "푸시 알림을 켠 뒤 화면을 나갔다 재진입하면 꺼짐으로 되돌아간다. OkHttp 로그상 진입 시 GET /users/push-settings 만 있고 토글 시점에 PUT/PATCH/POST 가 전혀 없다(17:01:03 조회 → 토글 → 17:01:55 재조회로 원복). 로컬 UI 상태만 바뀐다. 부수 관찰: 마케팅 알림 3종(문자·E-mail·푸시)이 라디오 비주얼인데 복수 선택 상태다 — #648·#649 와 같은 유형." + }, + { + "severity": "repro-confirmed", + "issue": 572, + "title": "#572 재현 — 다만 이슈 본문의 원인('PIN 이 저장되지 않아')은 오진이다", + "detail": "앱 잠금 PIN 4자리([AUTH_VALUE_REDACTED])를 설정하면 확인 재입력 단계 없이 즉시 완료되고 성공 안내도 없다. 앱을 재시작해도 잠금 화면 없이 홈으로 진입한다. 그러나 이어서 패스키 관리 → '비밀번호로 인증하기' 에서 같은 [AUTH_VALUE_REDACTED] 를 넣자 인증이 통과했다 — 즉 **PIN 은 정상 저장되고 있고, 잠금 게이트가 적용되지 않는 것**이 실제 원인이다. 수정 범위가 '저장' 이 아니라 '앱 시작 시 잠금 진입' 이다." + }, + { + "severity": "repro-confirmed", + "issue": 574, + "title": "#574 / 2차 QA #11 재현 — '비밀번호로 인증하기' 가 실제로 요구하는 값은 앱 잠금 PIN", + "detail": "패스키 등록 → '비밀번호로 인증하기' 를 누르면 4자리 숫자 키패드와 '비밀번호를 입력해 주세요.' 가 뜬다. 계정 비밀번호는 8~15자 영문+숫자+특수문자(서버 PasswordValidation.REGEX)라 입력 자체가 불가능하다. 앱 잠금 PIN [AUTH_VALUE_REDACTED] 를 넣으니 통과 — 라벨이 요구값과 어긋난 것이다. 앱 잠금 PIN 미설정 상태의 동작(1차 QA O3 이 남긴 질문)은 이번에 PIN 이 설정된 뒤라 확인하지 못했다." + }, + { + "severity": "repro-confirmed", + "issue": 555, + "title": "#555 재현 — 패스키 목록에 자리표시자 1건 고정", + "detail": "인증 통과 후 나타나는 '패스키 목록' 에 등록된 패스키가 없는데도 항목 1건이 보이고, 그 내용이 실제 데이터가 아니라 필드 라벨 그대로다 — 애플 아이콘 + '이름' + '생성일시' + 삭제(X). 안드로이드 앱에 애플 아이콘이 쓰인 것도 플랫폼 불일치." + }, + { + "severity": "repro-confirmed", + "issue": 564, + "title": "#564 / 2차 QA #7 재현 — 고객지원 6종 중 공지사항만 동작", + "detail": "FAQ · 고객센터 · 이용약관 · 개인정보 취급방침 · 애프터노트 서비스 안내 전부 탭에 반응이 없고(요소 변화 0), 공지사항만 '등록된 공지사항이 없어요.' 화면으로 이동한다. 고객센터는 설정 상단 바로가기에서도 무반응이라 화면 자체가 미구현으로 보이며, #785(FAQ 화면 구현) · #786(이용약관 화면 구현) 이 OPEN 인 것과 정합적이다." + }, + { + "severity": "observation", + "title": "2차 QA #2 부분 해소 — 설정 상단 바로가기 3종 중 고객센터만 무반응", + "detail": "공지사항·수신자 목록은 각 화면으로 정상 이동한다(2차 QA 당시 3종 모두 무반응). 고객센터만 남았고, 이는 하단 리스트의 고객센터도 동일해 화면 미구현 쪽 문제로 보인다." + }, + { + "severity": "pass", + "title": "애프터노트 성공 경로 전 구간 정상 — 목록·상세·수정·저장", + "detail": "지문 인증 통과 후 GET /afternotes(200)로 목록 2건 표시, 상세 진입 정상, 수정 진입 → PATCH /afternotes/39(200) 저장 성공 → 목록 자동 재조회까지 확인. 1차 QA #1(등록 500 실패)·#2(실패 무음)는 이 경로에서 재현되지 않는다." + }, + { + "severity": "pass-fix-confirmed", + "title": "2차 QA #22 / #679 해소 — 수정 진입 시 수신자 prefill", + "detail": "수정 화면에 종류·서비스명·계정정보·처리방법과 함께 수신자 '[QA_ACCOUNT_REDACTED] / DAUGHTER' 가 채워져 있고, 그 상태로 저장이 성공한다. '검증에 걸려 수정 저장 자체가 불가' 하던 문제가 없어졌다." + }, + { + "severity": "pass-fix-confirmed", + "title": "2차 QA #33(서버) 해소 — 수신자에 이름·관계가 온다", + "detail": "GET /afternotes/39 응답: \"receivers\":[{\"receiverId\":\"[PRIVATE_ID_REDACTED]\",\"name\":\"[PERSON_REDACTED]\",\"relation\":\"DAUGHTER\"}]. 식별자만 오던 문제가 해결돼 화면에 표시할 값이 있다." + }, + { + "severity": "pass-fix-confirmed", + "title": "#690 확인 — 카드 아이콘이 서버 category 기준으로 정확", + "detail": "목록에서 인스타그램 항목에 실제 인스타그램 로고가, 추억 노트에 노트 아이콘이 표시된다. 서비스명 추론이 아니라 서버 값 기반으로 보인다." + }, + { + "severity": "repro-confirmed", + "issue": 706, + "title": "#706 재발 — 같은 검증 에러가 연속되면 안내가 발행되지 않는다 (후속 #1127)", + "detail": "빈 필수값으로 등록 → '등록할 서비스(플랫폼)명을 선택해 주세요.' 표시. 아무것도 고치지 않고 재시도하면 안내가 나오지 않고 UI 트리에 요소 추가 자체가 없다. 서비스명을 채워 에러가 바뀌면 '계정 ID와 비밀번호를 입력해 주세요.' 가 새로 뜨고, 그 상태로 다시 누르면 또 미표출. 에러 값이 같을 때만 재발행되지 않는다. #706 은 CLOSED(PR #776)이나 이 경로가 남아 있어 후속 이슈 #1127 로 등록했다." + }, + { + "severity": "observation", + "title": "애프터노트 조회에 지문 인증 게이트 — 앱 재시작마다 반복", + "detail": "노트 탭 진입 시 '지문 로그인 / 사용자 인증 후 조회가 가능합니다' 게이트가 있고 앱을 재시작할 때마다 다시 요구한다. 지문·화면잠금 미등록 기기에서는 '등록된 지문이나 화면 잠금이 없습니다. 설정에서 등록해 주세요.' 안내가 정상 표시된다(무음 실패 아님). 앱 화면에는 대체 수단 버튼이 없으나 시스템 생체 프롬프트에 'Use PIN' 이 있어 완전한 데드엔드는 아니다. 다만 화면 제목이 '지문 로그인' 인데 실제로는 로그인이 아니라 조회 인증이라 라벨이 부정확하다." + }, + { + "severity": "observation", + "title": "상세 응답이 계정 비밀번호를 평문으로 내려준다", + "detail": "GET /afternotes/39 의 credentials 에 \"password\":\"[SENSITIVE_VALUE_REDACTED]\" 가 평문으로 포함된다. 화면은 마스킹 후 '표시' 토글로 여는데, 전송·저장 단계의 평문 취급이 의도된 설계인지 확인이 필요하다. 애프터노트가 계정 자격증명을 보관하는 기능이라 성격상 불가피할 수 있으나 1차 QA S1(내부 정보 노출)과 같은 검토 축이다." + }, + { + "severity": "observation-unconfirmed", + "title": "신규 작성 화면에 수신자가 미리 채워져 있다", + "detail": "FAB 으로 새 애프터노트를 열었는데 '수신자 추가' 에 '[QA_ACCOUNT_REDACTED] / DAUGHTER' 가 이미 들어 있었다. 등록된 수신자가 1명뿐이라 기본 선택된 설계일 수도 있고, 직전 수정 화면의 폼 상태가 남은 것일 수도 있다(#682 범위). 앱 재시작 직후 신규 작성으로 재확인이 필요해 미확정으로 남긴다." + }, + { + "severity": "pass", + "title": "[SENSITIVE_VALUE_REDACTED] 열람 승인 경로가 실재하고 동작한다 — 2차 QA 미확인 구간 해소", + "detail": "2차 QA 이래 '관리자 승인 수단 부재로 dev QA 불가' 로 남겨 둔 구간을 열었다. [SENSITIVE_VALUE_REDACTED] 계정([SENSITIVE_VALUE_REDACTED], 일반 User 에 role=[SENSITIVE_VALUE_REDACTED])으로 POST /auth/login → GET /api/v1/[SENSITIVE_VALUE_REDACTED]/verifications 로 승인 대기 목록 조회(id=1, [QA_ACCOUNT_REDACTED]→[QA_ACCOUNT_REDACTED], PENDING, 사망진단서·가족관계증명서 URL 포함) → POST /api/v1/[SENSITIVE_VALUE_REDACTED]/verifications/1/approve 로 status:APPROVED 전환까지 실동작을 확인했다. BE [SENSITIVE_VALUE_REDACTED]Controller 의 approve/reject 가 정상." + }, + { + "severity": "observation", + "title": "'열람 승인' 은 두 관문의 AND — 사망확인 승인 + 콘텐츠별 전달 조건", + "detail": "[SENSITIVE_VALUE_REDACTED] 이 사망확인을 APPROVED 로 바꿔도 수신자의 after-notes·diary 조회는 여전히 403(code 2009 '아직 전달 조건이 충족되지 않았습니다')이고, time-letters 만 200 으로 열렸다. BE DeliveryConditionService.requireFulfilled 가 콘텐츠 타입별(AFTERNOTE/TIMELETTER/DIARY…)로 발신자가 미리 설정한 전달 조건을 따로 검사하기 때문이다. [QA_ACCOUNT_REDACTED] 은 타임레터 조건만 설정돼 있어 그것만 열렸다. 즉 수신자 열람 = ①사망확인 [SENSITIVE_VALUE_REDACTED] 승인 + ②해당 콘텐츠 전달 조건 충족. 앱이 403 으로 막는 것은 정상 동작이며, 2차 QA 가 '승인 이후 화면' 을 한 번에 못 본 이유가 이 이중 관문이다." + }, + { + "severity": "observation", + "title": "승인 전/후 대조가 API 로 재현 가능 — 회귀 픽스처 확보", + "detail": "승인 전 receiver-auth/after-notes = 403/2009, 승인 후 time-letters = 200 {timeLetters:[],totalCount:0}. authCode(마스터키)는 발신자 토큰의 GET /users/receivers 응답 authCode 필드로 얻는다([SENSITIVE_VALUE_REDACTED]). 이 경로가 있으면 수신자 열람 흐름을 앱 없이도 상태별로 검증할 수 있다. 다만 앱 UI 로 이 화면을 보려면 수신자 재인증(별도 이메일 +[QA_ACCOUNT_REDACTED] 인증번호)이 필요하다." + }, + { + "severity": "repro-confirmed", + "issue": 915, + "title": "#915 재확인 + 계약 변경 시점 특정 (BE 27fa442, 8/9)", + "detail": "앱 3단계 서류 업로드에서 POST /receiver-auth/presigned-url 이 19바이트 body({\"extension\":\"png\"})로 나가 400. contentLength 필수화는 BE 커밋 27fa442(2026-08-09 '업로드 크기 상한')에서 들어왔고 FE ReceiverAuthPresignedUrlRequestDto 는 여전히 extension 만 보낸다. 응답 DTO 도 contentLength 를 안 받아 S3 PUT 헤더도 못 맞춘다. FE 로직 버그가 아니라 계약 동기화 누락. 이 경로로 curl 우회 업로드(presigned+contentLength → S3 PUT → delivery-verification)는 8/23 에 200 으로 성공한 바 있어, 지금 서버의 PENDING/APPROVED 건은 앱이 아니라 curl 로 만든 무대다." + }, + { + "severity": "repro-confirmed", + "issue": 607, + "title": "승인 이후 화면이 통째로 막힌다 — #607(record-boxes 미사용)의 직접 결과", + "detail": "[SENSITIVE_VALUE_REDACTED] 으로 사망확인을 APPROVED 로 만들어 두고(서버·status API 모두 APPROVED) 앱 수신자 흐름을 재진입했더니: (1)발신자 카드 상태가 '열람 불가' 고정, (2)열람 신청→마스터키 검증 통과 후 다시 3단계 서류 업로드 화면, (3)그 업로드는 #915 로 불가→완주 불가. 원인은 받은 기록함이 record-boxes 를 안 쓰고 로컬 stub 이라 서버 verificationStatus 를 읽는 경로가 없어서다. getDeliveryVerificationStatus() 는 service·repository·domain 까지 구현돼 있으나 presentation 호출부 0건이고 테스트에 error('호출되면 안 됨')/error('unused')로 미사용이 박제됨. record-boxes 연동(#607)을 하면 #599·#612 와 함께 승인 이후 화면 진입 자체가 풀린다. 신규 이슈 대신 #607 에 실측 코멘트로 흡수." + }, + { + "severity": "observation", + "issue": 612, + "title": "#612 회귀 가능성 — 7/30 엔 상태 전환됐는데 지금은 안 됨", + "detail": "#612 본문은 2026-07-30 실측에서 '상태 열람 가능, 버튼 기록 열람하기로 정상 전환됐는데 승인일 행만 빈 값' 이라 기록. 그러나 2026-08-25 실측에서는 상태 전환 자체가 안 되고 '열람 불가' 에 멈춘다. 그사이 회귀했을 수 있어 #607 코멘트에 확인 요청을 남겼다." + }, + { + "severity": "observation-be-design", + "title": "수신자가 받는 SOCIAL 애프터노트 상세에 credentials(계정 ID·비번)가 없다 — 기능 핵심 공백", + "detail": "전달 조건까지 충족시켜 수신자 열람을 연 뒤 작성자 원본과 대조했다. 작성자 GET /afternotes/39 는 credentials:{id:\"[QA_CREDENTIAL_REDACTED]\", password:\"[SENSITIVE_VALUE_REDACTED]\"} 를 갖는데, 수신자 GET /receiver-auth/after-notes/39 응답에는 credentials 필드가 아예 없다(title·actions·leaveMessage·senderName·playlist 만). BE ReceivedAfternoteDetailResponse.fromSocial 이 credentials 를 담지 않는다(전수 검색 0건). actions:['delete account'] 처럼 '무엇을 하라' 는 오지만 '어느 계정을' 이 없어 수신자가 실행할 수 없다. SOCIAL 애프터노트의 목적(사후 계정 처리) 자체가 성립하지 않는다. 다만 계정 비밀번호를 수신자에게 평문 전달하는 것이 보안상 의도적으로 배제된 설계일 수도 있어 FE 결함으로 단정하지 않는다 — BE 협의 필요(FE 레포 이슈 아님). → BE #98(2026-07-30 COMPLETED 로 닫힘)과 동일 증상. 회귀로 판단해 BE 에는 동일 사안의 OPEN 이슈 #45(수신자 애프터노트 상세에 계정 정보 필드 부재)가 이미 있어, 새로 파지 않고 #45 에 실측 근거·재현 절차·메타 3종(sooooa16·bug·Bug)을 채웠다. #98(7/30 COMPLETED)은 같은 걸 처리했다 재현된 회귀라 #45 로 연결. FE 레포 이슈 아님.", + "issue_be": 45 + }, + { + "severity": "pass", + "title": "전달 조건 + [SENSITIVE_VALUE_REDACTED] 승인 경로 전 구간 실동작 — 수신자 애프터노트 열람 열림", + "detail": "발신자 토큰으로 PUT /users/me/receivers/14/delivery-conditions 에 AFTERNOTE/DIARY = RECEIVER_REQUEST 설정(state WAITING_VERIFICATION) → 수신자 서류 재제출(presigned+contentLength→S3 PUT→delivery-verification, curl, id=2 PENDING) → [SENSITIVE_VALUE_REDACTED] approve → after-notes 403→200 전환. fulfillByReceiverRequest 가 승인 시 RECEIVER_REQUEST 조건을 충족시키는 설계가 실동작으로 확인됨. 열람 = 사망확인 승인 + 콘텐츠별 조건 AND 라는 앞선 규명도 재확인." + }, + { + "severity": "not-a-defect", + "title": "수신자 일기·타임레터 0건은 데이터 상태 — 결함 아님", + "detail": "수신자 diary·time-letters 가 승인 후에도 빈 배열인 것은, 발신자에게 있는 유일한 일기(diaryId=9749)가 isDraft=true·receivers=[] 인 임시저장이라 전달 대상이 아니기 때문. 앱 동작 정상." + }, + { + "severity": "observation-be-contract", + "title": "BE 타임레터 생성 DTO 와 서비스가 어긋나 있다 — DTO 는 content/mediaList, 서비스는 blocks/status", + "detail": "타임레터 생성 시 TimeLetterCreateRequest DTO 는 title·content·sendAt·status·mediaList·receiverIds·deliveredAt 를 선언하는데, TimeLetterService 는 request.getBlocks()·getStatus()·getDeliveryMode() 를 호출한다. 실제로 통하는 요청은 blocks(blockType·blockOrder≥1·textContent)+deliveryMode 형태였다(content/mediaList 로는 400). BE 내부 계약 불일치로, FE 가 어느 스펙에 맞춰야 하는지 모호하다. FE 레포 아님 — BE 확인 필요." + }, + { + "severity": "not-a-defect", + "title": "DATE 모드 타임레터의 발송 전 마스킹은 정상 설계", + "detail": "발송일(sendAt)이 미래인 SCHEDULED 타임레터를 수신자가 조회하면 목록·상세 모두 200 이지만 title·blocks·senderName 이 null 로 마스킹된다. BE ReceivedService 가 sendAt.isAfter(now) 면 markAsRead 를 건너뛰고 내용을 비운다. '발송 예정 있음' 만 알리는 의도된 동작. SCHEDULED 는 미래 sendAt 만 허용돼 발송 완료 상태는 시간 조작 없이는 못 만든다 — 내용 열람 자체는 애프터노트로 이미 검증(credentials 공백 발견)했으므로 추가 비용 불필요." + } + ], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "8325a703ab3275330b30713b0f4d542c975a2024", + "mock": "off", + "note": "uninstall 후 클린 설치. 세션은 만료 access + 유효 refresh 를 Token.preferences_pb 로 주입해 구성." + }, + "issue_followup": { + "created": [ + 1100, + 1127 + ], + "commented": [ + 716, + 714, + 708, + 705, + 966, + 599, + 607, + 560, + 572, + 574, + 555, + 564, + 706, + 566 + ], + "note": "재현 확인분은 기존 이슈에 코멘트로 남기고, 대응 이슈가 없던 플레이리스트 빈 상태만 신규 등재했다. [SENSITIVE_VALUE_REDACTED] 승인 경로 확보로 2차 QA 미확인 3건 중 '열람 승인 이후' 가 부분 해소됨(타임레터 한정, 앱 UI 확인은 수신자 재인증 필요).", + "be_commented": [ + 45, + 98 + ], + "be_note": "credentials 누락은 BE #45(OPEN)에 귀속 — 새 이슈 대신 기존 OPEN 이슈를 실행 가능하게 채움. #98 은 같은 사안의 CLOSED 회귀로 #45 에 링크." + }, + "coverage_note": "curl 우회로 열 수 있는 뒷부분을 전수 훑음: 로그인·[SENSITIVE_VALUE_REDACTED] 승인·서류 제출·전달 조건 설정까지 무대를 세워 수신자 열람(애프터노트·일기·타임레터)을 API 로 검증. 미검증으로 남는 것은 (1)FE 화면 렌더링 — #607 로 앱 진입이 막혀 API 로만 관측, (2)타임레터·일기 실데이터 열람 — 발신자에 전달 대상 데이터(비-draft·수신자지정)가 없어 미생성. 발신자 계정으로 실제 전달 데이터를 만들면 그 열람까지 볼 수 있으나, 애프터노트 credentials 공백(BE #98)이 이미 핵심 결함으로 확인돼 우선순위 낮음. 타임레터 실데이터도 생성해 수신자 열람을 확인 — DATE 모드 발송 전 마스킹은 정상 설계로 판정, 발송 완료 상태 재현은 시간 조작 비용 대비 가치 낮아 중단. 결론: curl 로 뚫을 수 있는 뒷부분 검증은 실질적으로 완료.", + "purpose": "3차 QA 예비 순회 — 2차 QA(7/28) 이후 머지된 사용자 노출 PR 98건 중 71건이 리뷰 실측을 받지 못한 상태여서, 서버 폐쇄 시간대를 이용해 '서버 실패 경로'에 집중한 오프라인 회차 QA. 2차 QA 결함을 고쳤다고 주장하는 PR 들의 실효를 우선 검증." + } +} diff --git a/docs/qa/evidence/867175c4916d01f1bd69a5879f72990664e58659.json b/docs/qa/evidence/867175c4916d01f1bd69a5879f72990664e58659.json new file mode 100644 index 0000000000..9070bb83b4 --- /dev/null +++ b/docs/qa/evidence/867175c4916d01f1bd69a5879f72990664e58659.json @@ -0,0 +1,42 @@ +{ + "schema": 1, + "source": { + "original_file": "867175c4916d01f1bd69a5879f72990664e58659.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial", + "text.sensitive_value" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "867175c4916d01f1bd69a5879f72990664e58659", + "head_ref": "chore/1135", + "base_sha": "e1b307eb5a621a8ea07c20db0ca9963a567d79da", + "base_ref": "develop", + "recorded_at": "2026-08-25T16:35:26.681212+00:00", + "result": "pass", + "scenario": "PR #1161(#1135) 실기 검증: debug versionName 에 커밋 지문 주입. 이슈가 쓰던 명령 그대로 `adb shell dumpsys package com.afternote.afternote_fe | grep versionName` 로 대조. (1) clean 트리 빌드 설치 → versionName=1.0+867175c (기존 '1.0' 에서 커밋이 읽히게 됨). (2) 이슈가 말한 «검사 전후 대조» 재현 — untracked 파일 하나 추가해 dirty 빌드로 덮어 재설치하니 versionName=1.0+867175c-dirty 로 바뀌어, 다른 빌드가 덮인 사실이 대조에서 즉시 드러남(0825 사고 갈래). (3) 원복 빌드 재설치로 1.0+867175c 확인, 기기 정상 상태로 되돌림. APK 매니페스트 측 실측은 aapt2 dump badging 으로 병행(1.0+867175c / 1.0+52baddc-dirty / GITHUB_SHA 주입 시 1.0+0123456 — CI 경로는 dirty 트리에서도 env 가 이김). configuration cache: 무변경 재빌드 reused 0.65초, dirty 인 채 편집 파일 목록만 바뀌어도 reused, 커밋 변경 시에만 재저장. 로그인·서버 호출 없는 빌드 도구 검증이라 QA 계정 미사용. 기기는 lease 가드에 따라 [SENSITIVE_VALUE_REDACTED](타 세션 점유) 대신 [SENSITIVE_VALUE_REDACTED] 신규 기동.", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "96f0c1fd319c5c6b5cd9274d785006e17080bb13f571971010dd604f1de4324b", + "changed_files": [ + "app/build.gradle.kts", + "build-logic/src/main/kotlin/BuildFingerprint.kt", + "build-logic/src/test/kotlin/BuildFingerprintTest.kt" + ] + } +} diff --git a/docs/qa/evidence/8fd9bb4b17b762df47492fd98a910e1cc5d8d202.json b/docs/qa/evidence/8fd9bb4b17b762df47492fd98a910e1cc5d8d202.json new file mode 100644 index 0000000000..489c1fd3a0 --- /dev/null +++ b/docs/qa/evidence/8fd9bb4b17b762df47492fd98a910e1cc5d8d202.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "source": { + "original_file": "8fd9bb4b17b762df47492fd98a910e1cc5d8d202.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "8fd9bb4b17b762df47492fd98a910e1cc5d8d202", + "head_ref": "fix/592-today-recommended-question", + "base_sha": "1619595236a1b8e3c9312ff10d8f5292d5abac4a", + "base_ref": "develop", + "recorded_at": "2026-08-23T07:23:01.214202+00:00", + "result": "repro-confirmed", + "scenario": "PR #909 리뷰 normal 1·2 실측: 로컬 검증 프록시(전 요청 dev 서버 포워드, GET /daily-questions/today만 500 주입) 경유 QA 빌드(BASE_URL만 교체, 앱 코드 PR head 그대로) → 배너 표시 확인 → 백그라운드→복귀(목록 성공+today 실패)에서 배너 무조작 소실 재현 → today=null 화면의 유령 간격 픽셀 측정: 첫 카드 top 390px(배너 미탑재 #904 빌드) vs 409px = 19px = 8dp(density 387). 스크린샷 2장 PR 코멘트 첨부.", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "bfd6c4ca8d22cb1ccfbba705b5c22d9312544bcf4dfbf4244afb0eb5fc091efd", + "changed_files": [ + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListUiState.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "8fd9bb4b17b762df47492fd98a910e1cc5d8d202" + } + } +} diff --git a/docs/qa/evidence/90913b4a0a1a887dbcb3b0b2795d6dd681082b6c.json b/docs/qa/evidence/90913b4a0a1a887dbcb3b0b2795d6dd681082b6c.json new file mode 100644 index 0000000000..6167b1c048 --- /dev/null +++ b/docs/qa/evidence/90913b4a0a1a887dbcb3b0b2795d6dd681082b6c.json @@ -0,0 +1,65 @@ +{ + "schema": 1, + "source": { + "original_file": "90913b4a0a1a887dbcb3b0b2795d6dd681082b6c.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "90913b4a0a1a887dbcb3b0b2795d6dd681082b6c", + "head_ref": "HEAD", + "base_sha": "c612880530339748cebe4810813dd70308ff8fef", + "base_ref": "origin/feat/601-13-save-errors", + "recorded_at": "2026-08-22T17:24:17.895369+00:00", + "result": "pass", + "scenario": "PR #883: 오프라인 더미 로컬 세션에서 빈 목록은 추가 버튼만 노출, 수동 폼 생성, 제목·내용 입력 후 항목 등록, 등록 항목 접기·펼치기, 두 번째 빈 폼 추가·삭제, 화면 회전 재생성 후 등록된 접힌 상태와 값 유지 확인", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "ed0c9236b0250e7bfda43be00f9a8f882eaa16923bde24a8a1a4587a31d50877", + "changed_files": [ + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorDialogs.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/SaveAfternotePayloadBuilder.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContent.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/gallery/GalleryAndFileEditorContent.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/EditorMessage.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/EditorMessageSection.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/EditorMessageTextBlock.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/LeaveMessageEditorItem.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorState.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorUiHolder.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormMutations.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormState.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt", + "feature/afternote/presentation/src/main/res/values/strings.xml", + "feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/EditorMessageSectionScreenshotTest.kt", + "feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredCollapsedScreenshot_748aa731_0.png", + "feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredExpandedScreenshot_748aa731_0.png", + "feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormPrefillTest.kt", + "feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorContentSignatureCompletenessTest.kt", + "feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt", + "feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/LeaveMessageEditorItemTest.kt" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "90913b4a0a1a887dbcb3b0b2795d6dd681082b6c", + "installed_at_epoch": 1787419035, + "ui_sha256": "d6c7f4e7f657faa28c5bf68231f6bbbc21fb00b38f2d5696e5ec537f4989daad" + } + } +} diff --git a/docs/qa/evidence/92dea962c84de74092fd6160a7f8d8feb4d7f962.json b/docs/qa/evidence/92dea962c84de74092fd6160a7f8d8feb4d7f962.json new file mode 100644 index 0000000000..f6b68c1f7b --- /dev/null +++ b/docs/qa/evidence/92dea962c84de74092fd6160a7f8d8feb4d7f962.json @@ -0,0 +1,41 @@ +{ + "schema": 1, + "source": { + "original_file": "92dea962c84de74092fd6160a7f8d8feb4d7f962.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "92dea962c84de74092fd6160a7f8d8feb4d7f962", + "head_ref": "chore/1110", + "base_sha": "e1b307eb5a621a8ea07c20db0ca9963a567d79da", + "base_ref": "develop", + "recorded_at": "2026-08-25T20:57:59.553801+00:00", + "result": "pass", + "scenario": "PR #1186(#1110) 실기 검증: 철회된 Room 도입의 ProGuard keep 8줄과 GMS 유령 임포트를 걷은 뒤 R8 축소된 릴리스 APK 가 살아있는지. (1) R8 전후 비교 — 같은 워크트리에서 develop 원본과 변경본을 각각 :app:minifyReleaseWithR8 로 빌드. seeds.txt 전체 8251→5618, androidx.work.impl 심볼 2434→16, androidx.room 105→8, mapping.txt 잔존 work.impl 클래스 314→126. 남은 16건이 라이브러리 자체 룰과 매니페스트 컴포넌트가 지키는 몫이며 WorkDatabase·WorkDatabase_Impl 이 생성자째 보존됨 — 이슈가 정황으로 짚은 «room-runtime 이 생성자만 keep 한다» 가 실측으로 확인됨. (2) :app:assembleRelease BUILD SUCCESSFUL (11.1MB). 기존 설치본(debug 서명)을 지우지 않으려고 R8 산출물은 그대로 두고 apksigner 로 debug 키만 재서명해 설치. (3) 기동 — 크래시 없음, Displayed MainActivity +544ms, logcat 에 FATAL·ClassNotFound·NoSuchMethod 0건. (4) WorkManager 예약(핵심 회귀 지점) — dumpsys jobscheduler 에 Service: androidx.work.impl.background.systemjob.SystemJobService / Trace tag: DailyNotificationWorker / Minimum latency: +11h13m22s871ms 로 등록 확인. 축소 후에도 WorkDatabase_Impl 리플렉션 경로가 살아있다는 뜻. (5) 알림 채널 NotificationChannel{mId='afternote_fcm'} 생성 확인. 미검증: FCM 실수신 — c2dm.intent.RECEIVE 브로드캐스트가 서명 권한에 막혀 result=0 으로 서비스에 도달하지 않고 실 푸시는 서버 발송이 필요. 다만 FCM 쪽 변경은 호출 0건인 임포트 삭제이고 mapping.txt 의 Wrappers 잔존 수가 전후 모두 2로 같아 APK 내용 무변경. 주의: 앱 클래스 난독화 이름은 전후로 달라진다(keep 집합 축소로 R8 이름 배정 변경).", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 36 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "a81901231419fd9bbc8a7722a490eca630e9e6e38713de1f1facf24588afe9ae", + "changed_files": [ + "app/proguard-rules.pro", + "app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt" + ] + } +} diff --git a/docs/qa/evidence/9434cff751927d36117b27f8d58c6bd986d801f4.json b/docs/qa/evidence/9434cff751927d36117b27f8d58c6bd986d801f4.json new file mode 100644 index 0000000000..ce3471b856 --- /dev/null +++ b/docs/qa/evidence/9434cff751927d36117b27f8d58c6bd986d801f4.json @@ -0,0 +1,58 @@ +{ + "schema": 1, + "source": { + "original_file": "9434cff751927d36117b27f8d58c6bd986d801f4.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "details.screenshots", + "device.avd", + "device.serial", + "text.network_endpoint", + "text.person_name", + "text.private_identifier", + "text.qa_account", + "text.sensitive_value" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "9434cff751927d36117b27f8d58c6bd986d801f4", + "head_ref": "fix/935 (#935)", + "base_sha": "6123a68a", + "base_ref": "origin/develop", + "recorded_at": "2026-08-24T04:33:02.531240+00:00", + "result": "pass", + "scenario": "#935 죽은 AuthorReceiverRepository 제거 후 에디터 수신자 조회 실측 (2026-08-24 02:43~02:45 KST). dev 서버([NETWORK_ENDPOINT_REDACTED] / [SENSITIVE_VALUE_REDACTED])가 02:45 시점에 이미 무응답이라 응답 데이터 검증은 못 했고, 요청 발생 여부까지만 확인했다. 메모리상 폐쇄 시간대는 03~12시인데 그보다 일찍 닫혀 있었다.\n\n확인된 것 — (1) 노트 탭 → FAB → 소셜네트워크 에디터 진입 시 '--> GET [NETWORK_ENDPOINT_REDACTED]' 요청이 OkHttp 로그에 실제로 발생. #540 의 2026-07-30 실측 코멘트가 기록한 '네트워크 호출 0건(logcat OkHttp)' 과 대조되는 증거로, 죽은 리포지토리가 네트워크를 전혀 타지 않던 상태가 해소됐음을 보인다. (2) 요청에 쿼리 파라미터가 없고 Authorization 헤더만 실린다 — 서버가 토큰으로 호출자를 식별하므로 userId 개념이 불필요하다는 이슈 주장이 런타임에서 확인됨. (3) 조회가 SocketTimeoutException(10011ms)으로 실패해도 크래시 없이 에디터가 정상 렌더 — runCatchingCancellable + AUTHOR_RECEIVER_LOAD 계측 경로 동작(#884 receiverListFlow 크래시 회피 포함). 화면의 '수신자 추가*' 섹션은 응답이 없어 빈 상태로 남았다.\n\n미확인 — 응답 수신자 목록이 replaceReceiversIfEmpty 로 폼에 실제로 채워지는지. 서버 복구(12시 이후) 후 재확인 필요.\n\n빌드: :feature:afternote:{presentation,data}:compileDebugKotlin · :app:compileDebug{,UnitTest}Kotlin · :app:assembleDebug · ktlintCheck · :app:testDebugUnitTest 모두 BUILD SUCCESSFUL. :app:compileDebugAndroidTestKotlin 은 44건 실패하나 origin/develop 기준선과 오류 집계가 완전히 동일 (기존 드리프트, PR #949 가 처리 중).\n\n=== 2차 실측 (2026-08-24 13:22~13:32 KST, 서버 복구 후) — 미확인 항목 해소 ===\n서버 생존 확인(users/me 401 = 정상 응답) 후 같은 커밋 APK 재빌드·재설치. 앱 기동 시 만료된 access 를 POST /auth/reissue 200 으로 자동 복구.\n(1) GET /api/v1/users/receivers 가 200 으로 응답: {\"receiverId\":\"[PRIVATE_ID_REDACTED]\",\"name\":\"[PERSON_REDACTED]\",\"relation\":\"DAUGHTER\",\"authCode\":\"[SENSITIVE_VALUE_REDACTED]\"} 1건. (2) 노트 탭 → FAB → 소셜네트워크 에디터 진입 시 '수신자 추가' 섹션에 '[QA_ACCOUNT_REDACTED] / DAUGHTER' 행이 실제로 렌더됨 — 새벽 1차에서 비어 있던 자리다. refreshAuthorReceivers() → getReceivers() → replaceReceiversIfEmpty 경로가 실동작함을 확인. (3) 저장 완주: 서비스명(인스타그램)·계정정보·처리방법을 채우고 등록 → POST /api/v1/afternotes 요청 본문에 \"receivers\":[{\"receiverId\":\"[PRIVATE_ID_REDACTED]\"}] 가 실려 나갔고 응답 200. 목록에 '인스타그램 / 최종 작성일 2026.08.24' 생성 확인. 수신자 미지정 475 데드엔드(#540 이 P0 로 추적하던 증상)가 신규 작성 경로에서 실제로 해소됐다.\n부수 확인: 화면의 'DAUGHTER' 는 결함이 아니다. BE `Receiver.relation` 이 `private String` 자유 문자열이고 (Swagger example 이 DAUGHTER 일 뿐) enum 정규화가 없다. 앱 등록 화면 선택지는 한글(\"어머니\",\"아버지\",\"아들\",\"딸\")이고 설정·타임레터도 relation 을 그대로 표시한다 — QA 수신자를 curl 로 만들 때 영문이 들어간 데이터 탓이다.\n미해소로 남는 것: '수신자 추가' + 버튼은 여전히 무동작(#540, #791 선행 대기). 이 PR 범위 밖이다.", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "9748ef78f5715afdce14034f37d1b083037e9de6861d59e33d9f45bf04537d5a", + "changed_files": [ + "app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt", + "app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt", + "app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt", + "feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AuthorReceiverRepositoryImpl.kt", + "feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AuthorReceiverEntry.kt", + "feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AuthorReceiverRepository.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/AuthorReceiverMapping.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/EditorReceiverMapping.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt" + ], + "reviews": [] + } +} diff --git a/docs/qa/evidence/98ac361f0a690681dd7c430a44fe5daf37f46f2b.json b/docs/qa/evidence/98ac361f0a690681dd7c430a44fe5daf37f46f2b.json new file mode 100644 index 0000000000..d919768e05 --- /dev/null +++ b/docs/qa/evidence/98ac361f0a690681dd7c430a44fe5daf37f46f2b.json @@ -0,0 +1,46 @@ +{ + "schema": 1, + "source": { + "original_file": "98ac361f0a690681dd7c430a44fe5daf37f46f2b.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial", + "text.qa_credential" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "98ac361f0a690681dd7c430a44fe5daf37f46f2b", + "head_ref": "fix/729-week-dropdown-scroll", + "base_sha": "104462fdf025a663d6009fec54eb6f23e2e3664b", + "base_ref": "develop", + "recorded_at": "2026-08-23T12:36:22.380848+00:00", + "result": "pass", + "scenario": "PR #937(#729) 리뷰 QA: 실서버+에뮬레이터. 주간리포트 주차 드롭다운 — 열린 메뉴 완전 노출 5개(8월3주차~7월3주차, 6번째는 스크롤로만 도달), 메뉴 내부 fling 스크롤로 최하단 도달(2025년 9월/8월 구간 노출), 최하단 '8월 4주차 리포트'(2025-08-25) 탭 → 헤더·dateRange '2025.08.25. - 2025.08.31.' 갱신·재조회(빈 주 0건 정상 렌더). 시안 700-35071 대조: 5개 가시·선택항목만 진한색 일치, 스크롤바 인디케이터·전폭 메뉴는 미구현 편차(리뷰에 재량 기록). 스크린샷 [QA_CREDENTIAL_REDACTED].png/[QA_CREDENTIAL_REDACTED].png/[QA_CREDENTIAL_REDACTED].png(리뷰 코멘트 첨부). 함정: 메뉴 밖(y>1195 물리px) 스와이프 시작점은 메뉴를 닫고 메인을 스크롤시킴.", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "fe8fc4bbaf68bf42d0e97e0b6498a5d77e7c0126cc1bf41eee1ecbaa37d61b75", + "changed_files": [ + "feature/mindrecord/presentation/build.gradle.kts", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptions.kt", + "feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportWeekMenuTest.kt", + "feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptionsTest.kt" + ] + } +} diff --git a/docs/qa/evidence/README.md b/docs/qa/evidence/README.md new file mode 100644 index 0000000000..9e60421f6a --- /dev/null +++ b/docs/qa/evidence/README.md @@ -0,0 +1,36 @@ +# 공개 QA 증거 대장 + +에뮬레이터·실서버 QA 결과의 정본이다. 파일 하나가 검증한 HEAD 하나에 대응하며 파일명은 +`<40자리-head-sha>.json`이다. 최초 이관일인 2026-08-27에 로컬 대장 34건을 34개 파일로 옮겼다. + +## 공개 스키마 + +- `head_sha`, `result`, `scenario`, `not_covered`는 모든 파일에 존재한다. +- `source.field_mapping`은 각 값이 이관 전 어느 필드에서 왔는지 나타낸다. 원본에 + `not_covered`가 없으면 공개 값은 빈 배열이고 매핑은 `null`이므로, 이를 전체 범위 검증으로 + 해석하면 안 된다. +- `details`에는 원본의 나머지 QA 관찰 중 공개 가능한 내용을 보존한다. +- `source.redactions`에는 제거·치환한 필드 종류만 남긴다. 실제 값은 남기지 않는다. +- 한 파일에 여러 패스가 누적돼 앞선 `not_covered` 사유가 후속 관찰로 갱신된 경우에는 + `source.interpretation_notes`가 읽는 순서를 명시한다. 원래 `not_covered` 값은 이관 증거로 보존한다. +- `manifest.json`은 전체 파일의 원본 대응 관계와 SHA-256을 기록한다. + +계정 이메일·별칭·계정/수신자 ID·인증 값·사람 이름·기기 serial/AVD·비공개 네트워크 주소· +스크린샷 비공개 링크는 저장하지 않는다. 공개 GitHub 이슈·PR 링크, 저장소 상대 경로, 커밋 SHA, +화면 크기·density·API level은 재현 근거이므로 보존한다. + +## 새 기록 추가와 검증 + +민감정보가 들어갈 수 있는 원본은 임시 디렉터리에만 두고 아래 변환기를 거친다. 변환기는 기존 +공개 기록을 유지하면서 새 HEAD 기록과 manifest를 갱신한다. + +```bash +node .github/scripts/qa-evidence.mjs migrate <원본-json-디렉터리> docs/qa/evidence --migrated-on YYYY-MM-DD +node .github/scripts/qa-evidence.mjs validate docs/qa/evidence +node --test .github/scripts/qa-evidence.test.mjs +``` + +같은 HEAD에서 검증을 다시 했다면 기존 기록을 조용히 덮어쓰지 말고, 시나리오와 결과를 한 파일에 +합쳐 이력이 사라지지 않게 한다. 변환기는 내용이 다른 기존 HEAD를 기본적으로 거부한다. 합친 원본을 +검토했거나 비식별화 규칙 변경으로 전부 다시 생성할 때만 `--replace-existing`을 명시해 manifest +해시까지 함께 갱신한다. diff --git a/docs/qa/evidence/a1e25a1e1ca5a732500deedad75234fe066e20e6.json b/docs/qa/evidence/a1e25a1e1ca5a732500deedad75234fe066e20e6.json new file mode 100644 index 0000000000..2d03b859b5 --- /dev/null +++ b/docs/qa/evidence/a1e25a1e1ca5a732500deedad75234fe066e20e6.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "source": { + "original_file": "a1e25a1e1ca5a732500deedad75234fe066e20e6.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "a1e25a1e1ca5a732500deedad75234fe066e20e6", + "head_ref": "refs/pull/771/head", + "base_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "base_ref": "origin/develop", + "recorded_at": "2026-08-22T18:55:00+00:00", + "result": "fail", + "scenario": "오프라인+더미 세션으로 PR HEAD 실측. 타임레터 작성 화면 → 수신자 카드 탭 → 수신자 선택 화면(TimeLetterRecipientRoute) 진입 직후 앱 크래시(프로세스 종료, 런처 이탈) 재현.", + "not_covered": [ + { + "pr": 771, + "reason": "리뷰가 반영 확인한 빈 이메일 안내·400/409 제한과 남은 지적(등록·수정 phone 필수성 불일치)은 수신인 등록/수정 화면 도달이 전제인데, 위 크래시(오프라인)와 설정 메뉴 게이트(프로필 조회 성공 필요) 때문에 이 환경에서 도달 불가. 유효 세션 환경에서 재검증 필요." + } + ], + "device": { + "android": "15", + "is_emulator": true + }, + "details": { + "dynamic": true, + "findings": [ + { + "pr": 771, + "severity": "crash", + "title": "수신자 선택 화면 진입 시 수신인 조회 실패가 미처리 예외로 앱 크래시", + "detail": "GET /api/v1/users/receivers가 UnknownHostException으로 실패하자 uncaught로 프로세스 사망(dropbox data_app_crash 2026-08-23 03:42:41, Suppressed: StandaloneCoroutine/Dispatchers.Main.immediate). 원인: UserRepositoryImpl.receiverListFlow = receiverRefreshRevision.map { getReceivers() } 가 무보호 네트워크 호출이고, RecipientListViewModel의 stateIn 경로에 catch가 없음. 이 코드는 develop과 바이트 동일(771 신규 아님; 유래 c9a8e19b 2026-04-09 '수신자 목록')이나, 771의 주제 영역(수신인 오류 처리)이자 771이 같은 파일(UserRepositoryImpl)을 수정한 PR이라 리뷰 시 런타임 실측이 있었다면 잡혔을 결함. 동일 계열 선행 crash가 dropbox에 존재(2026-08-21 17:16:36, ApiException '인증되지 않은 요청입니다' — ApiErrorInterceptor throw가 uncaught, 401에서도 같은 화면 계열이 앱을 죽임). 이슈: https://github.com/Afternote/Afternote-FE/issues/884 · PR 코멘트: https://github.com/Afternote/Afternote-FE/pull/771#issuecomment-5382083633" + } + ], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "a1e25a1e1ca5a732500deedad75234fe066e20e6", + "mock": "debug_mock_mode=on (time-letters only)" + }, + "purpose": "8/20 이후 리뷰 QA 감사 후속 — PR #771(수신인 입력 검증·오류 처리 개선) 리뷰 시점 HEAD 소급 런타임 QA" + } +} diff --git a/docs/qa/evidence/a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json b/docs/qa/evidence/a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json new file mode 100644 index 0000000000..290cd93ced --- /dev/null +++ b/docs/qa/evidence/a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "source": { + "original_file": "a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db", + "head_ref": "refs/pull/721/head", + "base_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "base_ref": "origin/develop", + "recorded_at": "2026-08-22T18:50:00+00:00", + "result": "pass-with-observations", + "scenario": "오프라인+더미 세션으로 PR HEAD 실측. 홈 전면 조회 실패는 전체 오류+다시 시도 UI로 정상 처리(빈 성공 아님). 기록 탭 진입·서브탭(데일리 질문/일기/주간리포트) 렌더 정상. OkHttp 디버그 로그로 요청 타임라인 계측.", + "not_covered": [ + { + "pr": 721, + "reason": "리뷰 [blocking] 지적(부분 조회 실패가 빈 성공으로 수렴 → '기록 없음' 오인)은 일부 API만 실패하는 부분 실패 상태가 전제인데, 오프라인은 전 API 동시 실패라 재현 불가(전면 실패는 오류 UI로 정상). MEMORIES 카드→추억 공간 진입 흐름도 홈 카드가 오류 상태라 미검증. mindrecord 계열은 debug 목업 미지원." + } + ], + "device": { + "android": "15", + "is_emulator": true + }, + "details": { + "dynamic": true, + "findings": [ + { + "pr": 721, + "severity": "observation-confirming-review", + "title": "기록 탭 1회 진입에 데일리질문 중복 요청 실측", + "detail": "기록 탭 진입 03:41:06~07 사이 GET /api/v1/daily-questions/today 2회(06.790, 07.505), GET /api/v1/daily-questions 2회(06.927, 07.509) — 0.7초 내 동일 리소스 중복 호출. 리뷰(8/20 21:36)의 [normal] '데일리질문 조회 중복 실행·요청 간 경합' 지적과 같은 모양의 동작을 런타임에서 관측(위치는 홈 최초 진입이 아니라 기록 탭 진입; 홈 진입 시엔 today 1회). 네트워크 성공 환경에서 늦은 응답 역전 시 경합 관찰 필요." + } + ], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db", + "mock": "debug_mock_mode=on (time-letters only, mindrecord 미해당)" + }, + "purpose": "8/20 이후 리뷰 QA 감사 후속 — PR #721(홈 MEMORIES 카드·추억 공간 실기록 연결) 리뷰 시점 HEAD 소급 런타임 QA" + } +} diff --git a/docs/qa/evidence/b765ddb642b55e341a8d213f5787cf05019200c5.json b/docs/qa/evidence/b765ddb642b55e341a8d213f5787cf05019200c5.json new file mode 100644 index 0000000000..30fd408e54 --- /dev/null +++ b/docs/qa/evidence/b765ddb642b55e341a8d213f5787cf05019200c5.json @@ -0,0 +1,55 @@ +{ + "schema": 1, + "source": { + "original_file": "b765ddb642b55e341a8d213f5787cf05019200c5.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial", + "text.network_endpoint" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "b765ddb642b55e341a8d213f5787cf05019200c5", + "head_ref": "refactor/789-dto-defaults", + "base_sha": "104462fdf025a663d6009fec54eb6f23e2e3664b", + "base_ref": "develop", + "recorded_at": "2026-08-23T12:00:00+00:00", + "result": "pass", + "scenario": "PR #933 리뷰 QA — 실서버([NETWORK_ENDPOINT_REDACTED]) + 에뮬레이터. UI: 홈 TODAY'S QUESTION(엄격 isAnswered/isDraft 파싱)·기록 탭 데일리질문 목록·일기 빈 상태(diaries:[]+monthDiaryCount:0+weeklyDominantMood:null)·주간리포트(week[] emotion:null 은 NullableTodayMoodSerializer 경유, 카운트·요약·감정키워드) 전부 정상 렌더. API 실측 3건: (1) POST /diary 임시저장(date 미전송 — DiaryCreateRequestDto 에 date 필드 없음)에도 서버가 date=생성일 로 채워 반환 → 'date 는 서버가 항상 채운다' 가 draft 케이스까지 실증, 리뷰 후보 'draft date 누락 파싱 실패' REFUTED. (2) PATCH /daily-questions/24 에 앱 동일 바디(date:null·imageUrl:null 명시 포함) 전송 → 서버가 null 필드 무시, date 컬럼 보존(mind-record daily-question[].date 및 ?date= 쿼리 생존) → '명시 null 이 값을 덮는다' 시나리오 현 서버 기준 기각, 와이어 변화 자체는 사실이라 normal 지적으로 강등. (3) GET /mind-record week[] non-diary 항목이 emotion:null 을 명시적으로 포함 → emotion 키 필수화의 현 서버 정합 실증(OpenAPI required 표기와의 괴리는 잔존 리스크). 테스트 데이터 일기 9746 생성·삭제, 데일리질문 24 content 원복 완료. 빌드: 워크트리에서 :app:assembleDebug + :app:compileDebugUnitTestKotlin 통과(#911류 fake 파손 없음). 에뮬레이터 앱 1회 소실(스냅샷 복원 경합 추정) → 재설치 후 재검증.", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "f9eb49915456d1e73aeba7029ca8548723b344d86cfabc7cbd3d352f0a03361a", + "changed_files": [ + "feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt", + "feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt", + "feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt", + "feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt", + "feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt", + "feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt", + "feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt", + "feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt", + "feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt", + "feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt", + "feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt", + "feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "b765ddb642b55e341a8d213f5787cf05019200c5" + } + } +} diff --git a/docs/qa/evidence/b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json b/docs/qa/evidence/b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json new file mode 100644 index 0000000000..41b96db3e1 --- /dev/null +++ b/docs/qa/evidence/b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json @@ -0,0 +1,80 @@ +{ + "schema": 1, + "source": { + "original_file": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "head_ref": "origin/develop", + "base_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "base_ref": "origin/develop", + "recorded_at": "2026-08-22T18:40:00+00:00", + "result": "fail", + "scenario": "오프라인(비행기 모드)+더미 로컬 세션(qa-*)+debug 타임레터 목업으로 develop HEAD를 실측. 홈 오류·재시도 UI, 타임레터 작성 화면 입력, 임시저장 목록 표시(mock GET temporary 파싱), 임시저장 목록 수정 모드→항목 선택→삭제(mock DELETE 200)→목록 갱신은 정상. 결함 2건 발견.", + "not_covered": [ + { + "pr": 792, + "reason": "카카오 인증 실패 스낵바·link 미요청 경로는 ConnectedAccountsScreen 도달이 전제인데, 설정 메뉴가 getMyProfile() 성공(Success)에서만 렌더되어 유효 서버 세션 없이는 도달 불가. 실계정/스테이징 세션 확보 시 재시도 필요." + }, + { + "pr": 763, + "reason": "등록(SCHEDULED) 생성·수정·4xx serverMessage 표시는 수신자 선택이 전제(수신자 API 미목업·오프라인)라 미검증." + } + ], + "device": { + "android": "15", + "is_emulator": true + }, + "details": { + "dynamic": true, + "findings": [ + { + "pr": 763, + "severity": "defect", + "title": "발신 타임레터 목록이 조회 실패(Error)를 빈 목록 UI로 렌더", + "detail": "TimeletterScreen.kt:106 이 TimeletterUiState.Error에 EmptyTimeLetterContent('아직 등록된 타임레터가 없어요')를 그림. 재시도 없음. 목업이 200으로 2건을 줘도 DTO 파싱 실패 시 '없어요'로 위장됨을 런타임 재현(임시저장 픽스처는 파싱되어 카운트 1 표시 — 대조 증명). 수신자용 RecipientTimeletterScreen.kt:78 은 오류 문구+다시 시도를 정상 표시. 결함 도입은 8da873a5(2026-05-19)로 #763 이전이나, #763 리뷰·승인(8/22 02:25, 실측 없음)이 이 경로를 지나침." + }, + { + "pr": 763, + "severity": "defect-candidate", + "title": "임시저장(DRAFT)도 수신자 0건이면 차단됨", + "detail": "TimeLetterWriteViewModel.kt:282 save() 공통 검증이 register뿐 아니라 saveDraft에도 적용되어 제목·내용만으로 임시저장 불가('수신자를 선택해주세요' 스낵바, 요청 미발생). 기존 이슈 #570이 정확히 추적 중(7/28엔 서버가 400으로 거부, #763 이후 FE 선차단으로 전환됨 — 정책 미해결). 실측 코멘트: https://github.com/Afternote/Afternote-FE/issues/570#issuecomment-5382083532" + }, + { + "pr": null, + "severity": "crash", + "title": "수신자 선택 화면 진입 시 수신인 조회 실패가 미처리 예외로 앱 크래시 (develop 상주)", + "detail": "UserRepositoryImpl.receiverListFlow(무보호 map{getReceivers()}) + RecipientListViewModel stateIn(catch 없음) 조합. PR #771 HEAD에서 런타임 재현했고 해당 코드는 develop과 동일(유래 c9a8e19b 2026-04-09). dropbox에 8/21 17:16 동일 계열 크래시(ApiException 401 uncaught) 기록도 존재. 타임레터 작성의 수신자 선택 진입이 오프라인·401 상황에서 앱을 죽임. 이슈: https://github.com/Afternote/Afternote-FE/issues/884" + }, + { + "pr": null, + "severity": "observation", + "title": "설정 화면 프로필 조회 실패 시 메뉴 전체 소실", + "detail": "SettingScreen.kt SettingUiState.Error 분기가 로그아웃 항목만 렌더(재시도 없음). 오프라인에서 설정의 모든 메뉴(연결된 계정·수신인 관리 등) 접근 불가." + }, + { + "pr": null, + "severity": "observation", + "title": "debug 타임레터 목업 fixture가 현행 DTO 계약과 불일치", + "detail": "TimeLetterMockFixtures.MOCK_LIST_JSON의 블록에 url/mimeType 키 누락 → 현행 TimeLetterBlockDto(기본값 없는 nullable) 기준 전체 파싱 실패. debug 목업으로 목록 화면 검증이 불가능한 상태였음. (#767 HEAD 커밋이 이 직렬화 엄격성을 완화함)" + } + ], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "mock": "debug_mock_mode=on (time-letters only)" + }, + "purpose": "8/20 이후 리뷰 QA 감사 후속 — 머지된 PR #763·#792 소급 런타임 QA (develop HEAD)" + } +} diff --git a/docs/qa/evidence/c1e78fd6d13caedfbf6b1292ab07bbd545b07262.json b/docs/qa/evidence/c1e78fd6d13caedfbf6b1292ab07bbd545b07262.json new file mode 100644 index 0000000000..fa3c466995 --- /dev/null +++ b/docs/qa/evidence/c1e78fd6d13caedfbf6b1292ab07bbd545b07262.json @@ -0,0 +1,47 @@ +{ + "schema": 1, + "source": { + "original_file": "c1e78fd6d13caedfbf6b1292ab07bbd545b07262.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial", + "text.qa_account" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "c1e78fd6d13caedfbf6b1292ab07bbd545b07262", + "head_ref": "security/1073", + "base_sha": "817578e8d2d1bc80fba63906ecce08eae9ede04d", + "base_ref": "develop", + "recorded_at": "2026-08-26T07:41:32.017267+00:00", + "result": "pass", + "scenario": "PR #1170(#1073) 실서버 QA — 본문의 «실제 앱 흐름에서의 확인» 을 끝냈다. 먼저 관문 도달 조건을 규명: AfternoteNavGraphFingerprint 가 isPasskeyRegistered==false 면 지문 화면을 스킵하는데 그 값은 서버가 아니라 로컬 DataStore(UserProfile.preferences_pb 의 is_passkey_registered)다. [QA_ACCOUNT_REDACTED] 은 서버 GET /users/passkeys 도 [] 라 관문이 통째로 스킵되고 있었다. 플래그를 세운 뒤 실기 흐름 완주: 로그인→노트 탭→「지문 로그인」 화면→「지문 인증하기」→BiometricPrompt 표시(mCurrentFocus=Window{BiometricPrompt})→Pixel Imprint 로 등록한 지문 인식→애프터노트 목록 진입. keystore2 가 challenge 실린 auth token 을 발급(add_auth_token authType=0x2, BiometricService addAuthToken: 0), 앱 FATAL·IllegalBlockSizeException 0건. 본문 실측이 DEVICE_CREDENTIAL(PIN) 경로였으므로 지문·PIN 양쪽에서 관문이 열림이 확인됨(API 35). 별건 사실: 앱에 패스키 등록 API 호출부가 없어(화면만 존재) 실사용자 중 이 경로를 지나는 사람은 사실상 없다.", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 35, + "size": "1080x2400", + "density": 420 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "4502a20ff3d87bf3580173bf24a55a0304c27ce8fc9ac30c25bf0d0ade9e65c7", + "changed_files": [ + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphFingerprint.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricAuth.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGate.kt", + "feature/afternote/presentation/src/main/res/values/strings.xml", + "feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGateTest.kt" + ] + } +} diff --git a/docs/qa/evidence/c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5.json b/docs/qa/evidence/c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5.json new file mode 100644 index 0000000000..75a798a691 --- /dev/null +++ b/docs/qa/evidence/c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5.json @@ -0,0 +1,59 @@ +{ + "schema": 1, + "source": { + "original_file": "c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial", + "text.qa_account" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5", + "head_ref": "fix/716-failure-recovery", + "base_sha": "1619595236a1b8e3c9312ff10d8f5292d5abac4a", + "base_ref": "develop", + "recorded_at": "2026-08-23T07:23:01.156250+00:00", + "result": "fail-repro", + "scenario": "PR #904 리뷰 blocking 1·2 실측: PR head 빌드 + [QA_ACCOUNT_REDACTED] 토큰 주입 로그인 → 데일리질문 답변 생성 → 기내모드에서 삭제 실패 유발 → deleteError 배너가 HorizontalPager 페이지에서 리스트를 배너 폭만큼 가로로 밀어냄(케밥 화면 밖 이탈로 재시도 조작 불가 확인) → 기내모드 해제·탭 전환 재조회 성공 후에도 배너·파손 잔존(consumeDeleteError 미호출 실증). 스크린샷 2장 PR 코멘트 첨부. 유닛테스트: :feature:mindrecord:{data,presentation}:testDebugUnitTest + :app:compileDebugUnitTestKotlin 로컬 통과(CI는 develop lint.yml 정책 모순으로 미도달).", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "abf322f04eb1fb7b3dc31b93973617a729a1281227461c0c3cc5b693690cf60b", + "changed_files": [ + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MindRecordErrorBox.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListUiState.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListUiState.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt", + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt", + "feature/mindrecord/presentation/src/main/res/values/strings.xml" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5" + } + } +} diff --git a/docs/qa/evidence/cfb25be52498551cac20774f17e768180d3942b8.json b/docs/qa/evidence/cfb25be52498551cac20774f17e768180d3942b8.json new file mode 100644 index 0000000000..076ab15ae8 --- /dev/null +++ b/docs/qa/evidence/cfb25be52498551cac20774f17e768180d3942b8.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "source": { + "original_file": "cfb25be52498551cac20774f17e768180d3942b8.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "cfb25be52498551cac20774f17e768180d3942b8", + "head_ref": "HEAD", + "base_sha": "68dfef7440ce578cf3510f0d8755b1a3a7e2a598", + "base_ref": "origin/develop", + "recorded_at": "2026-08-04T11:14:48.545723+00:00", + "result": "pass", + "scenario": "기존 계정 수신자 목록 로드 후 로그아웃·신규 QA 계정 전환 시 이전 계정 수신자 미노출", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "diff_sha256": "14ed3ff0d79f859501d7c21dd18b6da1965c82f0889605969631647e9be6d27a", + "changed_files": [ + "app/src/main/java/com/afternote/afternote_fe/screen/HomeTabViewModel.kt", + "core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt", + "core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt" + ], + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": "cfb25be52498551cac20774f17e768180d3942b8", + "installed_at_epoch": 1785841237, + "ui_sha256": "9cb7b8764123f809a58c719cb5db82439f448ddb76d884f538b541ffa517d89b" + } + } +} diff --git a/docs/qa/evidence/d459deb5c867fa4d7ab4123bb2cb65b9b44925ce.json b/docs/qa/evidence/d459deb5c867fa4d7ab4123bb2cb65b9b44925ce.json new file mode 100644 index 0000000000..75b14ac7f8 --- /dev/null +++ b/docs/qa/evidence/d459deb5c867fa4d7ab4123bb2cb65b9b44925ce.json @@ -0,0 +1,49 @@ +{ + "schema": 1, + "source": { + "original_file": "d459deb5-pr967.json", + "schema": "legacy", + "field_mapping": { + "result": "checks[].result", + "scenario": "derived:pr+issue", + "not_covered": "not_covered", + "recorded_at": "performed_at" + }, + "redactions": [ + "details.account", + "details.fixture", + "details.server", + "device.identifier" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "d459deb5c867fa4d7ab4123bb2cb65b9b44925ce", + "head_ref": "fix/582-edit-route", + "base_sha": null, + "base_ref": null, + "recorded_at": "2026-08-25T17:44:00+09:00", + "result": "PASS", + "scenario": "PR #967 / issue #582 수정 검증", + "not_covered": [ + "지난달 항목 수정 경로는 실기동 불가 — 일기 기록 날짜가 표시 전용이라(#1008) 과거 달 일기를 만들 수 없음. 달 전달 배선은 코드로 확인(MindRecordNavGraph:35 → AppNavigationActions.onEditDiary → DiaryWriteRoute.yearMonth)" + ], + "device": { + "is_emulator": true + }, + "details": { + "pr": 967, + "issue": 582, + "checks": [ + { + "name": "홈 「데일리질문 답변하기」 버튼 동작 (커밋 93efc5ed)", + "result": "PASS", + "detail": "탭 → 데일리질문 작성 화면 진입 확인 (Day 80 / 오늘의 추천 질문 / 임시저장 4). data class 라우트를 인스턴스로 넘기도록 고친 수정이 실동작으로 확인됨" + }, + { + "name": "일기 「수정하기」 프리필 (blocking 1 해소 확인)", + "result": "PASS", + "detail": "기록 → 일기 → 더보기 → 수정하기 → 편집 화면에 제목·본문·기분(SOSO)·날짜(2026년 8월 25일) 모두 프리필. 빈 폼으로 열리지 않음" + } + ] + } +} diff --git a/docs/qa/evidence/d6123c67ccd95f8a6cc2524564acec480c02c801.json b/docs/qa/evidence/d6123c67ccd95f8a6cc2524564acec480c02c801.json new file mode 100644 index 0000000000..828e1d3d38 --- /dev/null +++ b/docs/qa/evidence/d6123c67ccd95f8a6cc2524564acec480c02c801.json @@ -0,0 +1,45 @@ +{ + "schema": 1, + "source": { + "original_file": "d6123c67ccd95f8a6cc2524564acec480c02c801.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": "not_covered", + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "d6123c67ccd95f8a6cc2524564acec480c02c801", + "head_ref": "refs/pull/882/head", + "base_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "base_ref": "origin/develop", + "recorded_at": "2026-08-22T18:58:00+00:00", + "result": "blocked", + "scenario": "미실행(빌드·설치 생략). 도달성 분석으로 차단 확정.", + "not_covered": [ + { + "pr": 882, + "reason": "CustomerCenterRoute 진입점이 설정 메뉴 onInquiryClick 단 하나이고(diff 확인), 설정 메뉴는 SettingUiState.Success(getMyProfile() 성공)에서만 렌더됨. 리뷰 지적 2의 프로필 상단 고객센터 진입점(무동작) 역시 SettingProfile이 Success 분기 내부라 동일 게이트. 더미 세션+오프라인으로는 어느 진입점도 도달 불가 → 전화 다이얼·이메일 복사 동작과 무동작 진입점의 런타임 확인은 유효 서버 세션 확보 후 가능. 리뷰의 두 finding(Android Lint FAIL, #783 진입점 무동작)은 정적 근거(CI 결과·null 콜백)로 성립 유지." + } + ], + "device": { + "android": "15", + "is_emulator": true + }, + "details": { + "dynamic": true, + "findings": [], + "app": { + "package": "com.afternote.afternote_fe", + "installed_for_head": null, + "mock": "n/a" + }, + "purpose": "8/20 이후 리뷰 QA 감사 후속 — PR #882(고객센터 화면) 소급 런타임 QA 시도" + } +} diff --git a/docs/qa/evidence/db14d2322ef5383e2b1995f0134f414187c0f047.json b/docs/qa/evidence/db14d2322ef5383e2b1995f0134f414187c0f047.json new file mode 100644 index 0000000000..76e5a2b3fb --- /dev/null +++ b/docs/qa/evidence/db14d2322ef5383e2b1995f0134f414187c0f047.json @@ -0,0 +1,63 @@ +{ + "schema": 1, + "source": { + "original_file": "db14d2322ef5383e2b1995f0134f414187c0f047.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial", + "text.auth_value", + "text.qa_account", + "text.sensitive_value" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "db14d2322ef5383e2b1995f0134f414187c0f047", + "head_ref": "fix/777", + "base_sha": "817578e8d2d1bc80fba63906ecce08eae9ede04d", + "base_ref": "develop", + "recorded_at": "2026-08-26T07:41:32.017267+00:00", + "result": "pass", + "scenario": "PR #1172(#777) 실서버 QA — 본문에 «실기 미확인» 으로 남긴 2번 네비게이션을 닫았다. 수신자 흐름을 끝까지 실기로 열었다: 온보딩→전달받은 기록 확인하기→발신자 등록([QA_ACCOUNT_REDACTED])→열람 신청 1단계 이메일 인증(실제 수신 코드 [AUTH_VALUE_REDACTED])→2단계 마스터 키([SENSITIVE_VALUE_REDACTED])→attachIdentity 성공→발신자 상세가 «열람 불가»→«열람 가능» 전환→수신자 홈→애프터노트 5건→추억 노트 상세. 하단 「애프터노트 확인하기」 탭 → 수신자 애프터노트 전체 목록으로 이동 확인, 이어서 뒤로가기 1회에 추억 노트 상세가 아니라 수신자 홈으로 나감(popUpTo 백스택 처리 의도대로). develop 대조는 코드로 확정: MemorialReceivedDetailScreen 의 onNavigateToFullList 가 `= {}` no-op 이고 ReceiverNavGraph 가 넘기지 않아 «버튼은 있는데 무반응» 이었다. 덤으로 수신자 목록에 BUSINESS 3건이 정상 표시돼 #1160 수신자측 매핑도 확인.", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 35, + "size": "1080x2400", + "density": 420 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "191f4baa8d78b069a960534834f4fa343ba0842fe9e7a957044dffd9fe98bbfe", + "changed_files": [ + "app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt", + "app/src/androidTest/java/com/afternote/afternote_fe/ProcessingMethodListAndroidTest.kt", + "app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt", + "app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodList.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodListSection.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodListState.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/model/ProcessingMethodSection.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/home/AfternoteHomeEntry.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/home/AfternoteHomeScreen.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/home/HomeHeaderSection.kt", + "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/body/infinite/InfiniteListBody.kt", + "feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/home/HomeHeaderSectionScreenshotTest.kt", + "feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodListStateTest.kt", + "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteHomeEntry.kt", + "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreen.kt", + "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailRoute.kt", + "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraph.kt", + "feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTest.kt" + ] + } +} diff --git a/docs/qa/evidence/ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json b/docs/qa/evidence/ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json new file mode 100644 index 0000000000..bf029612a3 --- /dev/null +++ b/docs/qa/evidence/ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json @@ -0,0 +1,48 @@ +{ + "schema": 1, + "source": { + "original_file": "ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "device.avd", + "device.serial" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62", + "head_ref": "fix/1126", + "base_sha": "817578e8d2d1bc80fba63906ecce08eae9ede04d", + "base_ref": "develop", + "recorded_at": "2026-08-26T08:02:35.327027+00:00", + "result": "pass", + "scenario": "PR #1194(#1126) 2차 실기 — 경합 창의 결정 실험. 1차(같은 대장 16591cb2 항목)에서 develop 6회 반복에도 경합이 재현되지 않아 미확정으로 남겼던 것을, 세션 정리 창을 인위적으로 넓혀 결정적으로 재현했다. 실험 방법: AuthRepositoryImpl.clearSession() 맨 앞에 Thread.sleep(800) 을 넣은 APK 를 두 벌 만들었다 — (A) develop 프로덕션 코드(817578e8 의 TokenReissuer·TokenAuthenticator·AuthInterceptor)+지연, (B) 이 브랜치 코드+같은 지연. 지연 패치는 커밋하지 않았고 빌드 후 워크트리를 원복했다(status clean 확인). \n\n같은 기기·같은 절차(유효 토큰으로 홈 도달 → force-stop → 토큰만 무효 세트로 교체 → 콜드 스타트)로 3회씩:\n- (A) develop+지연: 401 5건 → 재발급 HTTP **5회**, '리이슈 인증 거절: 세션 만료 처리' 5건. 3회 모두 동일. 즉 정리가 락 밖에 있으면 창이 열린 동안 **동시 진입 전원이 각자 재발급을 친다**. \n- (B) fix+지연: 401 5건 → 재발급 HTTP **1회**, '세션 만료 확정(정리 완료), 요청 중단' 5건. 3회 모두 동일. \n\n결정적 증거는 (B) 의 타임스탬프다: 17:00:14.397 에 재발급 400 응답을 받고, 지연 800ms 뒤인 17:00:15.207 에 대기자 5건이 한꺼번에 처리됐다 — 락이 세션 정리 완료까지 대기자를 붙들었다는 직접 증거. (A) 는 16:58:20.646 첫 재발급 이후 21.080·21.098·21.114·21.131 로 14~16ms 간격 재발급이 줄줄이 나갔다. \n\n이슈 원본 증상('세션 유지, 요청 중단' 3건)이 이번엔 안 나온 이유: 창이 800ms 라 5건 모두 정리 전에 락을 잡아 rotate→400→거절이 됐다. 원본은 창이 3ms 라 일부만 정리 전에 잡고 나머지는 정리 후에 잡아 refresh 부재로 UnexpectedFailure(=세션 유지)가 된 것 — 같은 뿌리(실패 결과 미공유)의 다른 표현이다. \n\n남은 미확정: 400 본문 파싱 실패 경로는 실서버가 code=1107 을 정확히 실어 주므로 실기로 만들 수 없다. ReissueFailureClassificationTest 가 실제 ApiErrorInterceptor 를 태워 가드하며, develop 코드에 대고 돌리면 '빈 본문 -> UnexpectedFailure' 로 실패하는 것을 확인했다.", + "not_covered": [], + "device": { + "is_emulator": true, + "api": 35 + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "70ad99bc6f06b5ccda8325ceebde5ca59e23e4f0f5c0d6f633c0d88ecd216f55", + "changed_files": [ + "core/network/src/main/kotlin/com/afternote/core/network/interceptor/AuthInterceptor.kt", + "core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt", + "core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt", + "core/network/src/test/kotlin/com/afternote/core/network/FakeAuthRepository.kt", + "core/network/src/test/kotlin/com/afternote/core/network/FakeInterceptorChain.kt", + "core/network/src/test/kotlin/com/afternote/core/network/interceptor/AuthInterceptorTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/token/ReissueFailureClassificationTest.kt", + "core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt" + ] + } +} diff --git a/docs/qa/evidence/ef21fe76c5ee04036029109b0a82a25629dd79b7.json b/docs/qa/evidence/ef21fe76c5ee04036029109b0a82a25629dd79b7.json new file mode 100644 index 0000000000..af33643a68 --- /dev/null +++ b/docs/qa/evidence/ef21fe76c5ee04036029109b0a82a25629dd79b7.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "source": { + "original_file": "ef21fe76c5ee04036029109b0a82a25629dd79b7.json", + "schema": 1, + "field_mapping": { + "result": "result", + "scenario": "scenario", + "not_covered": null, + "recorded_at": "recorded_at" + }, + "redactions": [ + "details.qa_account", + "device.avd", + "device.serial", + "text.qa_account", + "text.qa_credential" + ] + }, + "repository": "Afternote/Afternote-FE", + "head_sha": "ef21fe76c5ee04036029109b0a82a25629dd79b7", + "head_ref": "fix/590-recorded-days-nondiary", + "base_sha": "6123a68af7295622222a98a4e09b9f4570e419ff", + "base_ref": "develop", + "recorded_at": "2026-08-23T12:36:22.380848+00:00", + "result": "pass", + "scenario": "PR #946(#590) 리뷰 QA: 실서버 상태를 '깊은 생각 1건만'으로 재구성(기존 QA 잔여 DQ 답변 24 삭제 → POST /deep-thought/categories '[QA_ACCOUNT_REDACTED]임시' → POST /deep-thought, 실측 week=[{day:23,type:DEEP_THOUGHT,emotion:null}], daily-question=[], amounts 0/0/1). 수정 전·후 같은 데이터 실측: 946 미포함 빌드(#937 head)='0일'(결함 재현), 946 빌드='1일의 마음을 기록하셨네요'(수정 확인). 달력 23일 칸 점 없는 숫자 — isDiary 표시 규칙 유지 확인. 스크린샷 [QA_CREDENTIAL_REDACTED].png. 상단 카운트 '데일리 질문 0/일기 0'+1일 조합은 깊은생각 열 부재(시안 3열) 잔여 갈래로 리뷰 [normal] 기록. 테스트 데이터 삭제 완료(week=[], amounts 0/0/0).", + "not_covered": [], + "device": { + "is_emulator": true + }, + "details": { + "dynamic": true, + "app": { + "package": "com.afternote.afternote_fe" + }, + "diff_sha256": "0aa9b98a488639d800cb26753d854b7a5059ce69b8ee336acd26ddbbb668bcd6", + "changed_files": [ + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt", + "feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt" + ] + } +} diff --git a/docs/qa/evidence/manifest.json b/docs/qa/evidence/manifest.json new file mode 100644 index 0000000000..4ccec7a959 --- /dev/null +++ b/docs/qa/evidence/manifest.json @@ -0,0 +1,212 @@ +{ + "schema": 1, + "initial_migration_count": 34, + "record_count": 34, + "last_updated_on": "2026-08-27", + "records": [ + { + "file": "05b5a49170952a4c6f9dea6e1aa4145ba097ad54.json", + "source_file": "05b5a49170952a4c6f9dea6e1aa4145ba097ad54.json", + "head_sha": "05b5a49170952a4c6f9dea6e1aa4145ba097ad54", + "sha256": "570660e524c5e6b4285613a24269a8fff0c586d53b123420055ef00a992d3202" + }, + { + "file": "0c7b63b2c0c5e1d3c602c15e79283496f8a6553a.json", + "source_file": "0c7b63b2c0c5e1d3c602c15e79283496f8a6553a.json", + "head_sha": "0c7b63b2c0c5e1d3c602c15e79283496f8a6553a", + "sha256": "cfdee1289a62d9e0cf90ccdb29d6bcc268de093fea5482d9b32b1a747262a2c8" + }, + { + "file": "16591cb21ffcb519ad03933f419a6586782a6e5d.json", + "source_file": "16591cb21ffcb519ad03933f419a6586782a6e5d.json", + "head_sha": "16591cb21ffcb519ad03933f419a6586782a6e5d", + "sha256": "96afb7df0c2ee996c46559c855fa7f2bc457e04f09ca06d2cc4550bbc00b3ce9" + }, + { + "file": "18e57a142a69be5fd3581bae67a459cf09c1b4a7.json", + "source_file": "18e57a142a69be5fd3581bae67a459cf09c1b4a7.json", + "head_sha": "18e57a142a69be5fd3581bae67a459cf09c1b4a7", + "sha256": "674efec4c42955396364ab5f529981651145dda1aa2e13ad444b8af44451e924" + }, + { + "file": "280f937d102a3e81d5c30be76c85aa7971c20268.json", + "source_file": "280f937d102a3e81d5c30be76c85aa7971c20268.json", + "head_sha": "280f937d102a3e81d5c30be76c85aa7971c20268", + "sha256": "4004dea7de0d583475a32d3cab5668efe2da29ebf932be25a31f07860c623481" + }, + { + "file": "3445d93228ce184e4e4634361d27486f6576035c.json", + "source_file": "3445d93228ce184e4e4634361d27486f6576035c.json", + "head_sha": "3445d93228ce184e4e4634361d27486f6576035c", + "sha256": "d99405d8618ab80564fd9d5a30d52c3bbf817c79b18b2cf73353dbae290c1135" + }, + { + "file": "3767e3d574e7efa91a0222509f50952cdb1d25c4.json", + "source_file": "3767e3d574e7efa91a0222509f50952cdb1d25c4.json", + "head_sha": "3767e3d574e7efa91a0222509f50952cdb1d25c4", + "sha256": "d2646eaf88377896c0552ea0b905500b24aaef00a637e37d0fd49b9ce71dcf59" + }, + { + "file": "428022ff3f56a78ba1d4a4cf341e581292fc560e.json", + "source_file": "428022ff3f56a78ba1d4a4cf341e581292fc560e.json", + "head_sha": "428022ff3f56a78ba1d4a4cf341e581292fc560e", + "sha256": "06dff6402006db96e0b211bcdcad5e3de300f72893cc20d4703f22f311cab55a" + }, + { + "file": "4a74833b65a7023ae5c4ff16c71b6dc0fc085706.json", + "source_file": "4a74833b65a7023ae5c4ff16c71b6dc0fc085706.json", + "head_sha": "4a74833b65a7023ae5c4ff16c71b6dc0fc085706", + "sha256": "8830b4a0da1f495f5d4dc0d8dfa37b950d84908fddd71c2fac5de7a301354657" + }, + { + "file": "53a2f7bc43c4922c96f00914052621eb95b6d75e.json", + "source_file": "53a2f7bc43c4922c96f00914052621eb95b6d75e.json", + "head_sha": "53a2f7bc43c4922c96f00914052621eb95b6d75e", + "sha256": "9ea4be5f0521ea0117a2507b0fd93bfe267af23cd8b70e2c1734e561f2d6d693" + }, + { + "file": "549349852a1bc004faf7660174c745835cabbc93.json", + "source_file": "549349852a1bc004faf7660174c745835cabbc93.json", + "head_sha": "549349852a1bc004faf7660174c745835cabbc93", + "sha256": "f4fb6d548092c7e851513b7e0a0ec047b1c46eaa44f46e7db2ae76e604af8a1d" + }, + { + "file": "5e3c6bd344606397b5cd7167c7a54de2e8338361.json", + "source_file": "5e3c6bd344606397b5cd7167c7a54de2e8338361.json", + "head_sha": "5e3c6bd344606397b5cd7167c7a54de2e8338361", + "sha256": "f2b249b552a34e8a9b547d715e80dfae83842aaf5cb4b342328d4954db7385dc" + }, + { + "file": "64fb893434b158eec050c7f71d35d2e207a2e824.json", + "source_file": "64fb893434b158eec050c7f71d35d2e207a2e824.json", + "head_sha": "64fb893434b158eec050c7f71d35d2e207a2e824", + "sha256": "30de4043483b536f18a2afd6ee3f618137963ce34f6bddd401d3fcbb9e8e43e3" + }, + { + "file": "77730cef8a2c36b4ed605e31ef22ef3904d4df75.json", + "source_file": "77730cef8a2c36b4ed605e31ef22ef3904d4df75.json", + "head_sha": "77730cef8a2c36b4ed605e31ef22ef3904d4df75", + "sha256": "88aa2f2d53fc4a8ec1a6850f0e2f37aad53b01b7cf54f1908e0b3c1bfb0f2cb6" + }, + { + "file": "7c13af45330a6afff6c73128e57863e72407cfef.json", + "source_file": "7c13af45330a6afff6c73128e57863e72407cfef.json", + "head_sha": "7c13af45330a6afff6c73128e57863e72407cfef", + "sha256": "bc1c53da7b70d98c829c2eefed0f655db96ca593299cec6c4de32bba8e533846" + }, + { + "file": "8325a703ab3275330b30713b0f4d542c975a2024.json", + "source_file": "8325a703ab3275330b30713b0f4d542c975a2024.json", + "head_sha": "8325a703ab3275330b30713b0f4d542c975a2024", + "sha256": "1d3a6e1c33d3a40c9023fb7b20e8b8f9e992bc939c1df543446f0857c2ed3595" + }, + { + "file": "867175c4916d01f1bd69a5879f72990664e58659.json", + "source_file": "867175c4916d01f1bd69a5879f72990664e58659.json", + "head_sha": "867175c4916d01f1bd69a5879f72990664e58659", + "sha256": "9afb65b0834d5d8a50f115a2548c34912b98471ab94006483939dcb466852eb6" + }, + { + "file": "8fd9bb4b17b762df47492fd98a910e1cc5d8d202.json", + "source_file": "8fd9bb4b17b762df47492fd98a910e1cc5d8d202.json", + "head_sha": "8fd9bb4b17b762df47492fd98a910e1cc5d8d202", + "sha256": "3deb9d3a9c8f5a7f3bd7f13b0e0b68216ea4df466ae47dea93901ae1ee14cff5" + }, + { + "file": "90913b4a0a1a887dbcb3b0b2795d6dd681082b6c.json", + "source_file": "90913b4a0a1a887dbcb3b0b2795d6dd681082b6c.json", + "head_sha": "90913b4a0a1a887dbcb3b0b2795d6dd681082b6c", + "sha256": "d01ad647d4091843e4659241b947375b74f533bbf925df68fa681d8200207d5e" + }, + { + "file": "92dea962c84de74092fd6160a7f8d8feb4d7f962.json", + "source_file": "92dea962c84de74092fd6160a7f8d8feb4d7f962.json", + "head_sha": "92dea962c84de74092fd6160a7f8d8feb4d7f962", + "sha256": "e74b1d5215ccd957d5b8ed205541619f27ec8a43f5aab0710ed7e5f256fc91a6" + }, + { + "file": "9434cff751927d36117b27f8d58c6bd986d801f4.json", + "source_file": "9434cff751927d36117b27f8d58c6bd986d801f4.json", + "head_sha": "9434cff751927d36117b27f8d58c6bd986d801f4", + "sha256": "ac201d973a4d3fb77c825713b6c341b6ab5008eaf0ab421cc9ae6ceffbff7892" + }, + { + "file": "98ac361f0a690681dd7c430a44fe5daf37f46f2b.json", + "source_file": "98ac361f0a690681dd7c430a44fe5daf37f46f2b.json", + "head_sha": "98ac361f0a690681dd7c430a44fe5daf37f46f2b", + "sha256": "bcf893cc1076d10ab7294d3e903171dccbee8fef548d8cabc528918e3faf28f9" + }, + { + "file": "a1e25a1e1ca5a732500deedad75234fe066e20e6.json", + "source_file": "a1e25a1e1ca5a732500deedad75234fe066e20e6.json", + "head_sha": "a1e25a1e1ca5a732500deedad75234fe066e20e6", + "sha256": "6e38965b7f97394cf4097f7166525b89bb5715597ac8d2f23729b7720e528bd4" + }, + { + "file": "a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json", + "source_file": "a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db.json", + "head_sha": "a3b3b1f24ce280f88cd3bdc0adeddbdba9d7b9db", + "sha256": "a5d16dc8e188e809a3b1976bc67c8a52b77368c1ef491de947778b38f18f57d6" + }, + { + "file": "b765ddb642b55e341a8d213f5787cf05019200c5.json", + "source_file": "b765ddb642b55e341a8d213f5787cf05019200c5.json", + "head_sha": "b765ddb642b55e341a8d213f5787cf05019200c5", + "sha256": "a43d3cb63e2cad7c85ba7dd2dd9f94cae2f18541f36f42b2f4046ab4521d3a2e" + }, + { + "file": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json", + "source_file": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5.json", + "head_sha": "b834c1100bc5d5dcf7f34a6a28e1d108e4404ec5", + "sha256": "8e3e4b265a924ff9f32cf583a4cd87a05f175807ae7bb9d6ab110c2836f0667b" + }, + { + "file": "c1e78fd6d13caedfbf6b1292ab07bbd545b07262.json", + "source_file": "c1e78fd6d13caedfbf6b1292ab07bbd545b07262.json", + "head_sha": "c1e78fd6d13caedfbf6b1292ab07bbd545b07262", + "sha256": "c0677499050f88424b35d176e7600eb11fc240f4c7caeaad84b4ec23707159a8" + }, + { + "file": "c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5.json", + "source_file": "c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5.json", + "head_sha": "c2c4b8717a1f2eb588b8a85805e0c823e1e0dea5", + "sha256": "12a7d19bfdc05f162e82b1fe56eab52bdb485f589a3af702d6ae433c5139454d" + }, + { + "file": "cfb25be52498551cac20774f17e768180d3942b8.json", + "source_file": "cfb25be52498551cac20774f17e768180d3942b8.json", + "head_sha": "cfb25be52498551cac20774f17e768180d3942b8", + "sha256": "c4f217345b9bb5f5f1f78cf9a5c77afd046f29b284714bc99d81afedb7aa6827" + }, + { + "file": "d459deb5c867fa4d7ab4123bb2cb65b9b44925ce.json", + "source_file": "d459deb5-pr967.json", + "head_sha": "d459deb5c867fa4d7ab4123bb2cb65b9b44925ce", + "sha256": "4ac4babd6b08be6df7cd9a326ce4346f1ad3bc5e4c7bcec2ffc0dcf11e8a781b" + }, + { + "file": "d6123c67ccd95f8a6cc2524564acec480c02c801.json", + "source_file": "d6123c67ccd95f8a6cc2524564acec480c02c801.json", + "head_sha": "d6123c67ccd95f8a6cc2524564acec480c02c801", + "sha256": "8ba6ffc4241ee6b861cc0910d174e8f62b639c84b9a47308e13e8721eeae7d7e" + }, + { + "file": "db14d2322ef5383e2b1995f0134f414187c0f047.json", + "source_file": "db14d2322ef5383e2b1995f0134f414187c0f047.json", + "head_sha": "db14d2322ef5383e2b1995f0134f414187c0f047", + "sha256": "9b586557806a62a9bdc0e969918d18fd4a99ce2e51880cf856e42650e6790bf3" + }, + { + "file": "ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json", + "source_file": "ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62.json", + "head_sha": "ec5fe3d2717693f1a93c73e1aa3b6ecc0cd87d62", + "sha256": "2c810f1e5c141d1f7024186df3d3a6cb9d2d417bcfc61432c288939ee3a555ca" + }, + { + "file": "ef21fe76c5ee04036029109b0a82a25629dd79b7.json", + "source_file": "ef21fe76c5ee04036029109b0a82a25629dd79b7.json", + "head_sha": "ef21fe76c5ee04036029109b0a82a25629dd79b7", + "sha256": "8528710e738722316f25631deeee354536ec5f82f2f2935db2a70e227be9f2f7" + } + ] +} diff --git a/docs/qa/status.md b/docs/qa/status.md index d1c224970b..95b5512ec4 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -1,17 +1,18 @@ -# QA 현황 정리 — 2026-08-25 +# QA 현황 정리 — 2026-08-27 > **QA 문서 위치** — 회차 기록은 [1차](round-1.md) · [2차](round-2.md), 시안에 없어 판단한 것은 -> [assumptions.md](assumptions.md), 런타임 증거는 `.codex/qa-evidence/emulator/.json`(로컬 전용). +> [assumptions.md](assumptions.md), 런타임 증거는 [커밋별 증거 대장](evidence/README.md)에 남긴다. -회차 QA 와 PR 단위 검증이 따로 쌓이면서 "어디까지 했는지" 가 흐려져, 2026-08-25 기준으로 실측해 한자리에 모은다. +회차 QA 와 PR 단위 검증이 따로 쌓이면서 "어디까지 했는지" 가 흐려져, 2026-08-25 실측을 +한자리에 모았다. 런타임 증거 34건을 저장소로 옮긴 2026-08-27 상태도 함께 반영한다. ## 한 줄 판정 -**QA 는 7/28 이후로도 계속 돌아갔다. 멈춘 것은 "회차 QA" 라는 형식과, 그것을 기록하는 수단이다.** +**QA 는 7/28 이후로도 계속 돌아갔고, 8/25에는 3차 QA를 시작해 예비·핵심 8개 패스를 수행했다. 전 기능 순회는 아직 진행 중이다.** -- **회차 QA**(전 기능 순회)는 2차(7/28)가 마지막이고 3차는 미착수. +- **3차 QA** 예비·핵심 실측은 8/25 8개 패스까지 진행했다. 증거에는 `findings` 50건과 `not_covered` 6건이 남아 있어 전체 완료로 보지는 않는다. - 그 사이에도 상시 QA 는 활발했다 — 실측 흔적이 담긴 이슈만 7/28~8/24 에 **180건**. -- 다만 **런타임 증거 대장은 이걸 거의 못 담았다.** 기록기·게이트가 8/20 에야 도입돼서, 그 이전 3주치 QA 는 증거 파일이 남지 않았다. +- 다만 **런타임 증거 대장은 이걸 거의 못 담았다.** 기록기·게이트가 8/20 에야 도입돼서, 그 이전 3주치 QA 대부분은 증거 파일이 남지 않았다. ## 1. 실제 QA 타임라인 (7/28 ~ 8/24) @@ -41,24 +42,24 @@ | 8/20 | — | — | — | **QA 기록기·게이트 도입** (`record-emulator-qa.py`) | | 8/21 | 7 | — | — | | | 8/22 | 1 | — | 7 | | -| 8/23 | 20 | 22 | 12 | | +| 8/23 | 20 | 22 | 10 | | | 8/24 | 42 | 40 | 1 | | -| **합계** | **180** | **79** | **23** | | +| **합계** | **180** | **79** | **22** | 8/24까지 `recorded_at` 재집계 | -**8/10~8/20 이 비어 보이는 이유**는 그 기간에 CI·테스트 인프라 작업(#835~#852 androidTest·릴리스 파이프라인)을 했기 때문이고, QA 를 안 한 게 아니다. +**8/10~8/20 이 비어 보이는 이유**는 그 기간에 CI·테스트 인프라 작업(#835~#852 androidTest·릴리스 파이프라인)을 했기 때문이고, QA 를 안 한 게 아니다. 표의 증거 파일은 `recorded_at` 기준 **8/24까지 22건**이다. 여기에 8/25 6건과 8/26 6건을 더해, 8/27 저장소로 이관한 현재 대장은 **22 + 6 + 6 = 34건**이다. ## 2. 증거 대장을 얼마나 믿을 수 있나 -`.codex/qa-evidence/emulator/.json` 은 **부분 기록이다.** 총량 판단의 근거로 쓰면 안 된다. +[`docs/qa/evidence/.json`](evidence/README.md)은 **부분 기록이다.** 총량 판단의 근거로 쓰면 안 된다. | 한계 | 실태 | | --- | --- | -| 도입 시점 | 기록기·게이트 파일이 **8/20** 생성. 23건 중 **19건이 8/22 이후**에 몰린 이유가 이것 | -| 저장 위치 | `.gitignore` 52행에 `.codex/` — **git 에 안 올라간다.** 이 체크아웃에만 존재하고, 워크트리 55개 전부 0건 | -| 유실 위험 | 로컬 전용이라 체크아웃을 지우면 증거도 사라진다 | -| 기록 방식 | 자동 15 · 수동 8. 스키마는 동일 | +| 도입 시점 | 기록기·게이트 파일이 **8/20** 생성. 8/24까지 22건 중 **18건이 8/22 이후**에 몰린 이유가 이것 | +| 저장 위치 | 8/25 조사 당시에는 `.codex/` 아래 로컬 전용이었으나, 8/27 공개 가능한 34건을 `docs/qa/evidence/`로 이관 | +| 유실 위험 | 저장소 추적으로 해소. 다만 이관 전 기록되지 않은 QA는 사후 복원할 수 없음 | +| 원본 스키마 | 현재 34건은 schema 1 32건 · schema 2 1건 · legacy 1건. 공개 대장은 기존 결과를 정제해 보존 | -**결론: "런타임 실측 23건" 은 "8/20 이후 규약대로 남긴 것이 23건" 으로 읽어야 한다.** +**결론: 8/24까지의 런타임 증거는 22건이고, 현재 추적하는 공개 대장은 34건이다. 어느 숫자도 전체 QA 총량을 뜻하지 않는다.** ## 3. 회차 QA 이력 @@ -66,9 +67,9 @@ | --- | --- | --- | --- | | 1차 | 2026-07-19 | 이준혁 | 결함 19건 + 서버 보안 1건 + 기획 확인 6건 | | 2차 | 2026-07-28 | 정일혁 | 결함 42건 (+ 회원 탈퇴 추가 회차 7건) | -| 3차 | 순회 2026-08-25 | 정일혁 | 7회차(오프라인·실서버·admin 승인) — 증거 45건 · 신규 3건 · 재현 다수 · 열람 흐름 규명 | +| 3차 | 2026-08-25 착수(진행 중) | 정일혁 | 예비·핵심 8개 패스 — `findings` 50건 · `not_covered` 6건, 전 기능 순회 미완료 | -**2026-08-25 예비 순회 결과** — dev 서버 폐쇄 시간대(04:20~04:40 KST)를 이용해 *실패 경로* 에 집중한 오프라인 순회를 돌렸다. 증거는 `.codex/qa-evidence/emulator/8325a703….json`. +**2026-08-25 예비 순회 결과** — dev 서버 폐쇄 시간대(04:20~04:40 KST)를 이용해 *실패 경로* 에 집중한 오프라인 순회를 돌렸다. 증거는 [`docs/qa/evidence/8325a703ab3275330b30713b0f4d542c975a2024.json`](evidence/8325a703ab3275330b30713b0f4d542c975a2024.json). - **수정이 실동작함** — 2차 QA #21(통신 오류 강제 로그아웃) · #25(작성 중 이탈 가드) · #26(플레이리스트 유령 곡수) · 1차 QA #3(소셜네트워크 수신자 UI) · #9(TODAY'S QUESTION 날짜) - **재현 확인** — #708(설정) · #714(타임레터) · #716(마음의 기록, 서버·내부 IP 노출 포함) · 요청 중복 @@ -83,7 +84,7 @@ - **신규 등재** — #1126 동시 요청이 각자 재발급을 시도하는 경합. 이번엔 "인증 거절" 이 먼저 반영돼 결과가 맞았지만 순서가 바뀌면 무효 refresh 가 세션에 남아 401 이 반복될 수 있다. - **#701 에 축 추가** — 오류 화면인 채로 백그라운드에 있던 앱이 서버 복구 후 포그라운드로 돌아와도 자동 재조회가 없다. -**로그인 상태 실측 (16:58~17:06 KST)** — QA823 재로그인 후 설정 전 구간을 돌렸다. **2차 QA 잔여 설정 4건이 전부 그대로 남아 있다.** +**로그인 상태 실측 (16:58~17:06 KST)** — QA용 계정으로 다시 로그인한 뒤 설정 전 구간을 돌렸다. **2차 QA 잔여 설정 4건이 전부 그대로 남아 있다.** | 이슈 | 결과 | | --- | --- | @@ -93,7 +94,7 @@ | #564 고객지원 6종 | 재현 — **공지사항만 동작**. 나머지 5종은 화면 미구현(#785·#786) | | #555 패스키 목록 | 재현 — 자리표시자 1건('이름'·'생성일시'·애플 아이콘)이 항목처럼 노출 | -**해소 확인** — 1차 QA #9(TODAY'S QUESTION 고정값) · #17/2차 #5(placeholder `Text Field`) · 2차 #2 부분(상단 바로가기 3종 중 공지사항·수신자 목록 연결). 새벽의 미확정 관찰이던 홈 인사말 "…님" 은 **결함이 아니었다** — 클린 설치 + 서버 무응답이라 이름을 받을 경로가 없던 정상 폴백이고, 로그인 상태에서는 "QA823님" 으로 나온다. +**해소 확인** — 1차 QA #9(TODAY'S QUESTION 고정값) · #17/2차 #5(placeholder `Text Field`) · 2차 #2 부분(상단 바로가기 3종 중 공지사항·수신자 목록 연결). 새벽의 미확정 관찰이던 홈 인사말 "…님" 은 **결함이 아니었다** — 클린 설치 + 서버 무응답이라 이름을 받을 경로가 없던 정상 폴백이고, 로그인 상태에서는 계정 이름이 포함된 인사말로 나온다. **성공 경로 실측 (17:14~17:26 KST)** — 에뮬레이터에 화면잠금·지문을 등록해 애프터노트 인증 게이트를 통과한 뒤 돌렸다. @@ -102,7 +103,7 @@ - **#706 재발** — CLOSED 이슈인데 **같은 검증 에러가 연속되면 안내가 발행되지 않는다.** 에러 값이 바뀌면 뜨고 같으면 안 뜬다. 후속 #1127 등록. - **관찰** — 상세 응답이 계정 비밀번호를 평문으로 내려준다. 애프터노트 조회 게이트가 앱 재시작마다 지문을 요구하고, 화면 제목이 '지문 로그인' 인데 실제로는 조회 인증이라 라벨이 어긋난다. -**수신자 열람 흐름 — admin 승인으로 무대를 세워 끝까지 검증 (18:34 KST)** +**수신자 열람 흐름 — admin 승인으로 무대를 세워 FE 차단 지점 확인 (18:34 KST)** 2차 QA 이래 '관리자 승인 수단 부재' 로 못 보던 구간을, admin 계정 승인 API 로 열었다. curl 로 서류 제출·승인까지 무대를 세우고 그 뒤를 앱으로 검증하는 방식(무대는 우회, 대상은 앱). @@ -112,12 +113,12 @@ 이 세 개가 겹쳐 **admin 이 승인해도 수신자는 앱에서 기록을 못 본다.** record-boxes 연동(#607) 하나로 대부분 풀린다. -**수신자 열람을 끝까지 뚫어 대조 (18:5x KST)** — 발신자 토큰으로 전달 조건까지 설정(무대), 수신자 열람을 열어 작성자 원본과 대조했다. +**수신자 열람 API를 직접 열어 응답 대조 (18:5x KST)** — 발신자 토큰으로 전달 조건을 설정하고 관리자 API로 승인한 뒤, 수신자 API 응답을 작성자 원본과 대조했다. #607 때문에 앱의 승인 후 화면으로는 진입하지 못했으므로 FE 종단 간 검증은 아니다. -- **BE #98 회귀 발견** — 수신자가 받는 SOCIAL 애프터노트 상세에 **credentials(계정 ID·비번)가 없다.** 작성자는 저장했는데(qa935_dummy_id/pw) 수신자 응답 8개 키에 credentials 가 빠진다. `actions:['delete account']` 는 오는데 대상 계정이 없어 사후 계정 인계가 성립하지 않는다. BE #98(2026-07-30 '완료' 로 닫힘)과 동일 증상 → 회귀. BE 레포에 재현 코멘트로 재오픈 요청(담당 sooooa16). **FE 결함 아님.** +- **BE #98 회귀 발견** — 수신자가 받는 SOCIAL 애프터노트 상세에 **credentials(계정 ID·비번)가 없다.** 작성자는 테스트용 ID·비밀번호를 저장했지만 수신자 응답 8개 키에 credentials 가 빠진다. `actions:['delete account']` 는 오는데 대상 계정이 없어 사후 계정 인계가 성립하지 않는다. BE #98(2026-07-30 '완료' 로 닫힘)과 동일 증상 → 회귀. BE 레포에 재현 코멘트로 재오픈 요청(담당 sooooa16). **FE 결함 아님.** - **전달 조건 + 승인 경로 실동작** — AFTERNOTE=RECEIVER_REQUEST 설정 → 서류 재제출 → admin 승인 → 403→200 열림. fulfillByReceiverRequest 가 승인 시 조건을 충족시키는 설계 확인. -**3차 QA 에 정해진 기한은 없다.** 팀 캘린더에 8/23 자 "QA마무리?" Task 가 있지만 담당자·본문 없이 "시작 전" 상태로 남아 있는 미정 메모다. 착수 시점은 아래 진척(4절)과 커버리지 갭(5절)을 보고 정한다. +**3차 QA는 8/25 예비·핵심 8개 패스까지 수행했다.** 전체 기능 순회는 완료되지 않았으며, 아래 진척(4절)과 커버리지 갭(5절)을 기준으로 남은 범위를 보강한다. ## 4. 2차 QA 결함의 현재 진척 @@ -167,19 +168,23 @@ | 무엇을 했나 | 어디에 남기나 | | --- | --- | -| 회차 QA (전 기능 순회) | 노션 QA DB 에 새 회차 페이지 | +| 회차 QA (전 기능 순회) | `docs/qa/round-<회차>.md` | | PR 리뷰에서 지적별 실측 | 해당 PR 리뷰 본문의 검증 섹션 | -| 에뮬레이터·실서버 실측 | `.codex/qa-evidence/emulator/<검증한 HEAD sha>.json` | +| 에뮬레이터·실서버 실측 | `docs/qa/evidence/.json` | | 시안·명세에 없어 판단으로 정한 것 | `docs/qa/assumptions.md` | -**지금 이 규약의 구멍 두 개:** +**정본은 Git으로 추적하는 저장소 문서다.** 런타임 증거의 필드·민감정보 제거 규칙은 +[증거 대장 안내](evidence/README.md)를 따른다. +신규·갱신 기록은 저장소 문서에만 남긴다. -1. **증거 대장이 `.gitignore` 라 로컬에만 있다.** 팀이 공유할 수도, 유실을 막을 수도 없다. 추적 대상으로 올리든지(`.gitignore` 예외), 결과를 이슈·PR 본문으로 옮기든지 정해야 한다. -2. **상시 QA 는 발견된 결함(이슈)만 남고, "무엇을 봤는데 멀쩡했나" 는 안 남는다.** 그래서 되짚으면 공백처럼 보인다. 회차 QA 문서의 "정상 확인" 절이 이걸 메우는 유일한 수단이다. +**지금 남은 기록상의 구멍:** + +1. **증거 규약 도입 전 QA 상당수는 파일이 없다.** 이관으로 새 유실은 막았지만 기록되지 않은 과거 QA는 복원되지 않는다. +2. **상시 QA 는 발견된 결함(이슈)만 남고, "무엇을 봤는데 멀쩡했나" 는 안 남는 경우가 있다.** 회차 QA 문서와 런타임 증거의 `result`·`not_covered`가 이 공백을 메워야 한다. **원칙: "누가 했나" 는 기록하지 않는다.** 증거 대장은 도구·사람 무관 공용이고, 남아야 하는 것은 *어느 리비전에서 무엇이 검증됐나* 뿐이다. -## 7. 3차 QA 계획 +## 7. 3차 QA 남은 우선순위 **우선순위** — 5절의 구멍이 겹치는 순서. @@ -190,7 +195,7 @@ **미확인으로 남아 있는 것** (2차 QA 에서 이월) -- 수신자 열람 승인 이후 화면 — 관리자 승인 수단이 없어 dev 환경에서 도달 불가 +- 수신자 승인 이후 FE 화면 — 관리자 승인·전달 조건과 수신자 API 응답은 확인했지만, #607로 앱 화면에 도달하지 못해 종단 간 검증이 남음 - 비즈니스·추억 노트 등록 완주 — 수신자 추가 진입점 문제가 선행 - 구독 화면 — 미구현("구독 후 기록하기" 는 팝업만 닫힘) diff --git a/scripts/qa-device-profile.sh b/scripts/qa-device-profile.sh index a2dfa46778..a8842664fd 100755 --- a/scripts/qa-device-profile.sh +++ b/scripts/qa-device-profile.sh @@ -2,13 +2,14 @@ # 에뮬레이터 QA 기기의 화면 프로파일을 조회·전환한다. # -# 이 레포의 QA 증거 대장(.codex/qa-evidence/emulator/.json)은 오랫동안 AVD 이름만 적어 +# 이 레포의 QA 증거 대장(docs/qa/evidence/.json)은 오랫동안 AVD 이름만 적어 # 왔는데, 이름만으로는 어떤 화면에서 검증했는지 단정할 수 없다. `wm size`/`wm density` # override 는 재부팅으로도 풀리지 않고 세션 사이에 조용히 남기 때문이다. 실제로 2026-08-25 -# 감사에서 Pixel_7_Claude_QA 에 1080x2340 @387dpi override 가 남아 있어, 표준 411x914dp 가 +# 감사에서 당시 QA AVD에 1080x2340 @387dpi override 가 남아 있어, 표준 411x914dp 가 # 아니라 446x967dp 에서 QA 가 돌고 있었다. # -# 그래서 실측 전에는 status 로 실효 dp 를 확인하고, --json 출력을 증거에 그대로 넣는다. +# 그래서 실측 전에는 status 로 실효 dp 를 확인한다. --json 출력은 공개 증거에 넣을 수 있도록 +# 로컬 serial과 AVD 이름을 제외한다. set -uo pipefail @@ -25,7 +26,7 @@ Usage: qa-device-profile.sh [--serial ] [--jso compact 720x1600 @320dpi(=360x800dp) 로 전환한다 — 국내 보급형 기준 --serial 대상 기기 지정 (생략하면 연결된 기기가 하나일 때만 자동 선택) - --json QA 증거(.codex/qa-evidence/emulator/.json)의 device 블록을 출력한다 + --json 공개 QA 증거용 device 블록을 출력한다 (serial·AVD 이름 제외) USAGE exit 2 } @@ -184,8 +185,6 @@ main() { if [ "$json_output" = "true" ]; then printf '{\n' - printf ' "serial": "%s",\n' "$target" - printf ' "avd": "%s",\n' "${avd:-null}" printf ' "is_emulator": %s,\n' "$([ -n "$avd" ] && echo true || echo false)" printf ' "api_level": %s,\n' "${api_level:-null}" printf ' "screen": {\n' diff --git a/scripts/test-qa-device-profile.sh b/scripts/test-qa-device-profile.sh index 7d0eca58fb..ab59ff897d 100755 --- a/scripts/test-qa-device-profile.sh +++ b/scripts/test-qa-device-profile.sh @@ -65,7 +65,7 @@ run_profile() { STUB_PHYSICAL_DENSITY="${STUB_PHYSICAL_DENSITY:-420}" \ STUB_OVERRIDE_SIZE="${STUB_OVERRIDE_SIZE:-}" \ STUB_OVERRIDE_DENSITY="${STUB_OVERRIDE_DENSITY:-}" \ - STUB_AVD="${STUB_AVD:-Pixel_7_Claude_QA}" \ + STUB_AVD="${STUB_AVD:-QA_Test_AVD}" \ STUB_API="${STUB_API:-35}" \ "$profile_script" "$@" } @@ -90,6 +90,20 @@ assert_contains() { fi } +assert_not_contains() { + local description=$1 + local haystack=$2 + local needle=$3 + + if [[ "$haystack" != *"$needle"* ]]; then + echo " ok: $description" + else + echo " FAIL: $description" >&2 + echo " 공개 출력에 포함되면 안 됨: $needle" >&2 + failures=$((failures + 1)) + fi +} + assert_status() { local description=$1 local expected=$2 @@ -136,7 +150,8 @@ echo "증거용 JSON" reset_fixtures STUB_OVERRIDE_SIZE="1080x2340" STUB_OVERRIDE_DENSITY="387" output=$(run_profile status --json) -assert_contains "avd 를 담는다" "$output" '"avd": "Pixel_7_Claude_QA"' +assert_not_contains "로컬 serial 을 제외한다" "$output" '"serial"' +assert_not_contains "로컬 AVD 이름을 제외한다" "$output" '"avd"' assert_contains "api_level 을 담는다" "$output" '"api_level": 35' assert_contains "override 를 담는다" "$output" '"override": "1080x2340"' assert_contains "실효 dp 를 담는다" "$output" '"effective_dp": "446x967"' From b63a0577e2d507a98c7c77433945eac0d0e9f710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 27 Aug 2026 20:42:00 +0900 Subject: [PATCH 11/12] =?UTF-8?q?docs:=20QA=EC=99=80=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EA=B0=80=EC=9D=B4=EB=93=9C=EB=A5=BC=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 251 +++-------------------------------- docs/qa/status.md | 16 +-- docs/release/distribution.md | 183 +++++++++++++++++++++++++ docs/testing/screenshot.md | 62 +++++++++ 4 files changed, 270 insertions(+), 242 deletions(-) create mode 100644 docs/release/distribution.md create mode 100644 docs/testing/screenshot.md diff --git a/README.md b/README.md index 3d365b1f6f..af9ec52119 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,27 @@ # Afternote-FE -## QA 문서 +애프터노트 안드로이드 앱 — Kotlin · Jetpack Compose · Hilt 멀티모듈. + +``` +app/ 진입점 · 네비게이션 · androidTest +core/ common · data · datastore · domain · model · network · ui +feature/ afternote · mindrecord · onboarding · receiver · setting · timeletter + (각 data · domain · presentation) + home/presentation · timeletter/res +konsist/ 아키텍처 규칙 테스트 +``` + +## 문서 | 문서 | 내용 | |---|---| +| [docs/release/distribution.md](docs/release/distribution.md) | 비개발자 APK 배포 (Firebase App Distribution) | +| [docs/testing/screenshot.md](docs/testing/screenshot.md) | Compose Preview 스크린샷 baseline (Docker) | | [docs/qa/status.md](docs/qa/status.md) | QA 현황 · 회차 기록 · 커버리지 | +| [docs/qa/assumptions.md](docs/qa/assumptions.md) | 시안 · 명세에 없어 판단으로 정한 것 | | [docs/qa/evidence/README.md](docs/qa/evidence/README.md) | 커밋별 런타임 QA 증거 · 기록 규약 | | [docs/qa/device-baseline.md](docs/qa/device-baseline.md) | 에뮬레이터 화면 프로파일 · 기기 기준 | -| [docs/qa/assumptions.md](docs/qa/assumptions.md) | 시안 · 명세에 없어 판단으로 정한 것 | + +--- # 🚀 신규 팀원 빌드 셋업 @@ -99,238 +113,7 @@ debug 빌드는 기본적으로 머신마다 다른 `~/.android/debug.keystore` keytool -exportcert -alias afternote-debug-shared -keystore ~/afternote-debug-shared.jks | openssl sha1 -binary | openssl base64 ``` -# 📦 비개발자 APK 배포 (Firebase App Distribution) - -디자이너·PM·QA·외부 베타테스터에게 release APK 를 자동 배포하는 흐름. Firebase 프로젝트 `afternote-android` + 테스터 그룹 `afternote` 사용. - -## 운영 자격 인계 (로컬 fallback 담당자만) - -일반 배포는 GitHub Actions가 담당하므로 모든 개발자가 이 설정을 가질 필요는 없다. CI 장애 때 로컬 fallback을 수행할 배포 권한 보유자만 다음을 준비한다. - -1. **기존 release keystore와 자격 값 인계** — 승인된 보안 저장소에서 기존 서명 파일과 네 값을 받는다. 새 keystore를 임의로 생성하거나 교체하지 않는다. 기존 앱과 다른 키로 서명하면 업데이트할 수 없으므로, 자격을 찾지 못하면 배포를 중단하고 복구·공식 키 교체 절차를 먼저 진행한다. - -2. **`local.properties` 끝에 4개 키 추가** (signing config 가 읽음) - - ```properties - RELEASE_STORE_FILE=/Users//afternote-release.jks - RELEASE_STORE_PASSWORD= - RELEASE_KEY_ALIAS=afternote-release - RELEASE_KEY_PASSWORD= - ``` - -3. **`google-services.json` 배치** — Firebase Console → 프로젝트 설정 → 일반 → Android 앱 `com.afternote.afternote_fe` 카드에서 다운로드 → `app/google-services.json` - -4. **Firebase CLI 설치 + 인증** (로컬 fallback 업로드용) - - ```bash - npm install -g firebase-tools - firebase login - ``` - -5. **기존 서명 ID 확인** — `./gradlew :app:signingReport`의 release SHA-1과 `keytool`로 구한 카카오 키 해시가 각 콘솔에 이미 등록된 값과 같은지 확인한다. 다른 값이면 새로 등록해 우회하지 말고 인계받은 keystore가 맞는지부터 확인한다. - -## 배포 (매 회) - -모든 배포의 릴리스 노트에는 `포함 이슈`와 `QA 포인트`가 필요하다. 둘 중 하나라도 비어 있거나 포함 이슈에 `#123` 형식의 번호가 없으면 Firebase 업로드 전에 실패한다. - -### 배포 판단 기준 - -배포 시점은 일 단위 주기가 아니라 `develop`에 머지된 변경 묶음의 크기와 위험도로 정한다. - -- 인증·온보딩·데이터 손실·API 계약·빌드/서명처럼 영향이 큰 변경은 다른 변경을 기다리지 않고 단독 배포한다. -- 작은 변경은 하나의 QA 세션에서 회귀 원인을 구분할 수 있는 범위까지만 묶는다. 서로 다른 사용자 흐름을 한꺼번에 확인해야 하거나 함께 롤백하기 어려워지는 시점이 배포 경계다. -- 수정 결과를 테스터가 확인해야 하는 결함이 머지되면, 묶음 크기와 관계없이 확인 가능한 빌드를 배포한다. -- 현재 묶음의 모든 QA 포인트가 통과한 뒤 `develop`을 `main`으로 승격한다. - -### 릴리스 PR 범위 자동 산출 - -배포 시점은 위 기준에 따라 사람이 정한다 — `develop` → `main` 릴리스 PR을 여는 것이 곧 배포 결정이다. - -릴리스 PR이 열리거나 head가 갱신되면 [`release-scope.yml`](.github/workflows/release-scope.yml)이 마지막 성공 배포 이후 `develop`에 머지된 PR과 `Closes`·`Fixes`·`Resolves`로 완료 처리할 이슈를 모아 PR 본문의 `## 포함 이슈`를 채운다. head가 움직일 때마다 다시 채우므로 머지 직전에 목록을 손으로 대조할 필요가 없다. - -`## QA 포인트`는 비어 있을 때만 구성 PR 본문에서 모은 초안으로 채우고, 사람이 쓴 문장이 있으면 건드리지 않는다. 두 섹션은 main push 시 그대로 릴리스 노트가 되므로 배포 전에 테스터가 실행할 문장으로 다듬는다. - -별도 API나 유료 AI를 호출하지 않으며 기존 GitHub Actions 실행량만 사용한다. Actions의 **Collect Release Scope**에서 릴리스 PR 번호를 입력해 다시 산출할 수도 있다. - -### PR별 구조화 QA 원천 - -모든 PR은 `QA Metadata` 섹션의 JSON 객체를 채운다. `app-runtime`·`release-only`는 `precondition`·`action`·`expected`·`risk`·`evidence`가 필요하다. `ci-only`·`covered-by-ci`는 빈 QA 문구 대신 `exclusionReason`과 동일 입력·경계·관찰 결과를 적은 `ci` 또는 `test` evidence가 필요하다. 누락과 `#123 관련 동작을 재현...` 형태의 generic 문구는 Unit Test workflow에서 실패한다. 게이트 도입(`QA_METADATA_GATE_CUTOFF`) 전에 생성된 PR은 섹션이 없으면 검증을 건너뛰므로, 리베이스로 이 workflow를 받아도 소급 차단되지 않는다. 섹션을 채우면 생성 시각과 무관하게 검증한다. - -```json -{ - "scope": "app-runtime", - "precondition": "삭제할 애프터노트가 목록에 있는 로그인 상태", - "action": "삭제 확인에서 확인을 눌러 DELETE 요청을 보낸다", - "expected": "성공 시 목록에서 제거되고 실패 시 기존 항목과 오류 안내가 유지된다", - "risk": "실패한 삭제가 성공처럼 보이거나 기존 항목이 유실될 수 있다", - "evidence": [ - { - "kind": "issue", - "ref": "#550", - "assertion": "삭제 성공·실패의 관찰 결과를 정의한다" - } - ] -} -``` - -앱 QA 제외 원천은 다음처럼 같은 경계를 검증하는 CI 근거를 구조화한다. - -```json -{ - "scope": "ci-only", - "exclusionReason": "GitHub Actions 제어 변경으로 APK 사용자 흐름이 존재하지 않는다", - "evidence": [ - { - "kind": "ci", - "ref": "Unit Test / Run deployment script tests", - "assertion": "같은 스크립트 입력과 종료 상태를 CI에서 검증한다", - "input": "배포 판단 context fixture", - "boundary": "구조화 메타데이터 파싱부터 최종 JSON 검증까지", - "observation": "node test가 제외·병합·generic 0건을 단언한다" - } - ] -} -``` - -### 일반 배포 — `main` → Firebase App Distribution (자동) - -여기서 배포는 검증할 `main` 빌드를 Firebase 테스터에게 전달하는 단계이며, Play Store 프로덕션 릴리스를 뜻하지 않는다. 일반 배포의 자동 진입점은 `main` push 하나다. - -기존 `release-distribution.yml`의 `develop` 수동 배포(`workflow_dispatch`)는 #1029에서 제거했다. 도착지와 산출물 버전이 main 경로와 같아 실익이 PR 생성 한 단계뿐이었던 반면, release keystore와 service account를 임의 ref에 노출하는 표면이었다. - -예외적으로 [`firebase-wif-canary.yml`](.github/workflows/firebase-wif-canary.yml)은 `develop` 또는 `main`에서 보호 Environment 승인과 명시적 확인을 받은 뒤 실제 APK를 올리는 **수동 인증 호환성 검증 경로**다. 일반 릴리스나 임의 브랜치 배포 수단으로 사용하지 않으며, production JSON 자격을 WIF로 교체하기 전 판정 절차는 [WIF canary runbook](docs/release/firebase-wif-canary.md)을 따른다. - -`develop` → `main` 릴리스 PR 본문에 다음 섹션을 채운다. - -```markdown -## 포함 이슈 -- #716 -- #723 - -## QA 포인트 -- 오프라인에서 오류 안내와 재시도 수단이 표시되는지 확인 -- 주차를 변경한 뒤 최신 리포트가 표시되는지 확인 -``` - -PR이 `main`에 머지되면 워크플로가 두 섹션을 릴리스 노트로 사용한다. 연결된 PR이나 필수 섹션을 찾지 못하면 배포하지 않는다. `## QA 포인트`에 사전조건·행동·기대 결과가 없는 generic fallback 문구가 있으면 릴리스 노트 렌더 단계에서 실패한다. - -CI 가 사용하는 GitHub Secrets (Settings → Secrets and variables → Actions): - -| 키 | 용도 | -|---|---| -| `RELEASE_STORE_FILE_B64` | release keystore 파일 (`~/afternote-release.jks`) 의 base64 인코딩 | -| `RELEASE_STORE_PASSWORD` | keystore 비밀번호 | -| `RELEASE_KEY_ALIAS` | key alias (`afternote-release`) | -| `RELEASE_KEY_PASSWORD` | key 비밀번호 | -| `FIREBASE_SERVICE_ACCOUNT_JSON` | App Distribution Admin 권한 부여된 service account JSON 원문 | -| `KAKAO_NATIVE_APP_KEY` · `GOOGLE_WEB_CLIENT_ID` · `GOOGLE_SERVICES_JSON_B64` | release distribution·WIF canary·dependency audit가 쓰는 실서비스 앱 설정 | -| `GCP_WORKLOAD_IDENTITY_PROVIDER` · `GCP_FIREBASE_SERVICE_ACCOUNT` | 수동 WIF canary 전용 OIDC 설정 ([runbook](docs/release/firebase-wif-canary.md)) | - -> base64 인코딩: `base64 -i ~/afternote-release.jks | pbcopy` (macOS) - -PR 검증용 lint·unit-test·screenshot은 repository secret 대신 -`.github/actions/setup-ci-config`가 만드는 결정적 CI 전용 placeholder를 사용한다. 이 fixture는 -배포에 사용할 수 없으며, production `release-distribution.yml`은 계속 승인된 환경의 JSON 자격을 사용한다. WIF canary 성공만으로 이를 제거하지 않는다. - -### 배포 provenance — 이 APK 가 어느 commit·run 에서 나왔는지 (#851) - -배포 워크플로는 signing 이 끝난 그 APK 하나를 subject 로 [GitHub artifact attestation](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations)을 발급하고, 업로드 전에 스스로 검증한다. 서명·저장소·signer workflow·source commit·GitHub-hosted 러너 중 하나라도 어긋나거나 attestation subject digest 가 빌드 직후 digest 와 다르면 Firebase 업로드까지 가지 않는다. - -성공한 run 의 summary 에 남는 값은 넷이다 — source commit SHA, `sha256:` artifact digest, attestation URL, run URL. APK·AAB 와 R8 mapping 자체는 여기서도 public Actions artifact 로 게시하지 않는다. - -받은 APK 가 정말 그 배포 경로에서 나왔는지는 손에 든 파일로 직접 확인할 수 있다. - -```bash -gh attestation verify ~/Downloads/afternote-release.apk --repo Afternote/Afternote-FE --signer-workflow Afternote/Afternote-FE/.github/workflows/release-distribution.yml --source-ref refs/heads/main --deny-self-hosted-runners -``` - -특정 릴리스 commit 으로 좁히려면 `--source-digest ` 를 더한다. 파일이 1비트라도 다르면 digest 가 달라져 검증이 실패한다. - -> 아래 로컬 fallback 으로 올린 빌드에는 attestation 이 없다. 그 경로로 배포한 APK 는 위 명령이 실패하는 게 정상이며, 그래서 CI 장애 때만 쓴다. - -### 로컬 — 배포 자격 보유자 (fallback / 긴급 시) - -`origin/main`의 배포할 commit을 정확히 checkout하고 위 운영 자격을 준비한 뒤 실행한다. - -```bash -EVENT_NAME=workflow_dispatch \ -ISSUE_NUMBERS="#716, #723" \ -QA_POINTS="오프라인 오류 안내 확인;주차 변경 후 재시도 확인" \ -SOURCE_REF=main \ -SOURCE_SHA="$(git rev-parse HEAD)" \ -bash .github/scripts/render-distribution-release-notes.sh /tmp/afternote-release-notes.txt - -./gradlew assembleRelease appDistributionUploadRelease \ - --releaseNotesFile=/tmp/afternote-release-notes.txt -``` - -→ 동일하게 APK 빌드 + Firebase 업로드. CI 장애 시에만 사용한다. - -> 일반 로컬·Firebase 빌드는 기본 `versionCode` 1을 유지한다. Play용 다른 값을 빌드할 때는 `AFTERNOTE_VERSION_CODE` 환경변수로 주입할 수 있지만 현재 값을 자동 산출·주입하거나 Play에 배포하는 workflow는 없다. `app/build.gradle.kts`를 직접 수정하지 말고, 업로드 전 이전 Play 산출물보다 큰 값을 확인하는 절차는 [Play release runbook](docs/play-release.md)을 따른다. - -## 테스터 관리 - -- 추가/제거: Firebase Console → App Distribution → 테스터 및 그룹 → `afternote` 그룹 편집 -- 신규 테스터는 첫 초대 이메일에서 **App Tester** 앱 설치 안내를 받음 → 이후 빌드는 자동 알림 - -# 📸 Compose Preview Screenshot Testing (docker baseline) - -`Compose Preview Screenshot Testing` 의 anti-aliasing / font hinting / scale 등 host 환경 의존 렌더링 차이로 CI rendered PNG 를 baseline 으로 교체하는 ping-pong 이 발생해 왔다 (PR [#302](https://github.com/Afternote/Afternote-FE/pull/302) / [#322](https://github.com/Afternote/Afternote-FE/pull/322)). 본 리포의 `Dockerfile.screenshot`을 공통 환경으로 사용해 [PR 검증](.github/workflows/screenshot.yml)은 baseline을 검증하고, [baseline 생성 workflow](.github/workflows/screenshot-baseline-generate.yml)는 같은 환경에서 생성 후 재검증한다. - -## 로컬 fallback 사전 준비 - -- Docker 호환 runtime 설치 (macOS 의 Colima/Docker Desktop 또는 Linux Docker) - -## Actions 에서 baseline 갱신 (기본 경로) - -1. 갱신할 PR 에 `screenshot-baseline` 라벨을 붙인다. -2. 읽기 전용 **Generate Screenshot Baselines** job 이 PR 의 정확한 head SHA 를 CI 표준 Docker 이미지에서 렌더하고 검증한다. -3. 생성 job 은 라벨을 붙인 `pull_request` 권한 경계에서 실행되므로 PR 코드가 default branch cache 를 오염시키지 않는다. -4. 별도 **Apply Screenshot Baselines** job 이 결과가 PNG baseline 경로만 바꾸는지와 PR head 가 - 그대로인지 checkout 없이 재검증한 뒤 PR 브랜치에 커밋하고 필수 검사를 다시 요청한다. 성공하면 라벨도 제거된다. - -무엇을 캡처할지는 Action 이 화면을 탐색해서 추측하지 않는다. 각 모듈의 -`src/screenshotTest/kotlin/**/*ScreenshotTest.kt` 가 Preview 함수, 상태와 device spec 을 선언하며, -Action 은 그 테스트 전체를 실행한다. 새 화면·새 상태를 추가하려면 먼저 screenshot test 를 추가한다. -생성된 이미지는 PR 의 PNG diff 에서 눈으로 최종 확인한다. - -## 로컬 baseline 갱신 (Actions 장애 시 fallback) - -```bash -docker build --platform linux/amd64 -t afternote-screenshot:latest -f Dockerfile.screenshot . -docker run --rm --platform linux/amd64 -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ - ./gradlew :core:ui:updateScreenshotTest \ - :feature:home:presentation:updateScreenshotTest \ - :feature:receiver:presentation:updateScreenshotTest \ - :feature:onboarding:presentation:updateScreenshotTest \ - :feature:afternote:presentation:updateScreenshotTest \ - :feature:mindrecord:presentation:updateScreenshotTest \ - --rerun -``` - -→ 변경된 PNG 가 각 모듈 `src/screenshotTestDebug/reference/...` 에 갱신. `git add` 후 commit. - -> 실패한 모듈만 갱신하려면 그 모듈 태스크만 지정한다 — 예: `./gradlew :feature:home:presentation:updateScreenshotTest` -> -> 대상 모듈 목록은 [검증](.github/workflows/screenshot.yml)·[생성](.github/workflows/screenshot-baseline-generate.yml) workflow와 [적용 workflow의 허용 경로](.github/workflows/screenshot-baseline-apply.yml)에 함께 들어 있다. 모듈을 추가·이전했다면 세 workflow와 `Dockerfile.screenshot`의 예시, 이 문서를 함께 갱신한다. - -## 로컬 baseline 검증 (CI 실패 재현) - -```bash -docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ - ./gradlew :core:ui:validateScreenshotTest \ - :feature:home:presentation:validateScreenshotTest \ - :feature:receiver:presentation:validateScreenshotTest \ - :feature:onboarding:presentation:validateScreenshotTest \ - :feature:afternote:presentation:validateScreenshotTest \ - :feature:mindrecord:presentation:validateScreenshotTest -``` - -→ baseline 과 docker 환경에서 새로 그린 PNG 비교. 실패 시 `build/outputs/screenshotTest-results/preview/debug/diffs/` 에서 diff PNG 확인. - -## 호스트 직접 실행은 사용하지 않음 - -`./gradlew ::updateScreenshotTest` 를 host 에서 직접 실행하면 macOS / Linux / JDK 마이너 버전 / 폰트 캐시 차이로 CI 와 baseline 이 어긋난다. docker 환경 통일이 root fix. +--- # 💻 코딩 및 패키지 컨벤션 diff --git a/docs/qa/status.md b/docs/qa/status.md index 95b5512ec4..6e0822c21b 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -14,9 +14,9 @@ - 그 사이에도 상시 QA 는 활발했다 — 실측 흔적이 담긴 이슈만 7/28~8/24 에 **180건**. - 다만 **런타임 증거 대장은 이걸 거의 못 담았다.** 기록기·게이트가 8/20 에야 도입돼서, 그 이전 3주치 QA 대부분은 증거 파일이 남지 않았다. -## 1. 실제 QA 타임라인 (7/28 ~ 8/24) +## 1. 3차 QA 착수 전 타임라인 (7/28~8/24) -세 지표를 겹쳐 놓으면 QA 가 실제로 언제 돌았는지 보인다. **증거 파일 열만 보면 공백처럼 보이지만, 그건 기록 수단의 공백이지 QA 의 공백이 아니다.** +아래 표는 3차 QA가 시작되기 전날까지의 스냅샷이다. 세 지표를 겹쳐 놓으면 QA가 실제로 언제 돌았는지 보인다. **증거 파일 열만 보면 공백처럼 보이지만, 그건 기록 수단의 공백이지 QA의 공백이 아니다.** 8/25에 시작한 3차 QA 결과는 3절에 별도로 정리한다. | 날짜 | 실측 흔적 이슈 | 리뷰 실측 | 증거 파일 | 비고 | | --- | --- | --- | --- | --- | @@ -67,9 +67,9 @@ | --- | --- | --- | --- | | 1차 | 2026-07-19 | 이준혁 | 결함 19건 + 서버 보안 1건 + 기획 확인 6건 | | 2차 | 2026-07-28 | 정일혁 | 결함 42건 (+ 회원 탈퇴 추가 회차 7건) | -| 3차 | 2026-08-25 착수(진행 중) | 정일혁 | 예비·핵심 8개 패스 — `findings` 50건 · `not_covered` 6건, 전 기능 순회 미완료 | +| 3차 | 2026-08-25 착수 (진행 중) | 정일혁 | 예비·핵심 8개 패스 — `findings` 50건 · `not_covered` 6건, 전 기능 순회 미완료 | -**2026-08-25 예비 순회 결과** — dev 서버 폐쇄 시간대(04:20~04:40 KST)를 이용해 *실패 경로* 에 집중한 오프라인 순회를 돌렸다. 증거는 [`docs/qa/evidence/8325a703ab3275330b30713b0f4d542c975a2024.json`](evidence/8325a703ab3275330b30713b0f4d542c975a2024.json). +**2026-08-25 3차 QA 첫 패스** — dev 서버 폐쇄 시간대(04:20~04:40 KST)를 이용해 *실패 경로*에 집중한 오프라인 순회로 시작했다. 증거는 [`docs/qa/evidence/8325a703ab3275330b30713b0f4d542c975a2024.json`](evidence/8325a703ab3275330b30713b0f4d542c975a2024.json). - **수정이 실동작함** — 2차 QA #21(통신 오류 강제 로그아웃) · #25(작성 중 이탈 가드) · #26(플레이리스트 유령 곡수) · 1차 QA #3(소셜네트워크 수신자 UI) · #9(TODAY'S QUESTION 날짜) - **재현 확인** — #708(설정) · #714(타임레터) · #716(마음의 기록, 서버·내부 IP 노출 포함) · 요청 중복 @@ -118,7 +118,7 @@ - **BE #98 회귀 발견** — 수신자가 받는 SOCIAL 애프터노트 상세에 **credentials(계정 ID·비번)가 없다.** 작성자는 테스트용 ID·비밀번호를 저장했지만 수신자 응답 8개 키에 credentials 가 빠진다. `actions:['delete account']` 는 오는데 대상 계정이 없어 사후 계정 인계가 성립하지 않는다. BE #98(2026-07-30 '완료' 로 닫힘)과 동일 증상 → 회귀. BE 레포에 재현 코멘트로 재오픈 요청(담당 sooooa16). **FE 결함 아님.** - **전달 조건 + 승인 경로 실동작** — AFTERNOTE=RECEIVER_REQUEST 설정 → 서류 재제출 → admin 승인 → 403→200 열림. fulfillByReceiverRequest 가 승인 시 조건을 충족시키는 설계 확인. -**3차 QA는 8/25 예비·핵심 8개 패스까지 수행했다.** 전체 기능 순회는 완료되지 않았으며, 아래 진척(4절)과 커버리지 갭(5절)을 기준으로 남은 범위를 보강한다. +**3차 QA는 8/25 예비·핵심 8개 패스까지 수행했고 현재 진행 중이다.** 전체 기능 순회는 완료되지 않았으며, 아래 진척(4절)과 커버리지 갭(5절)을 기준으로 남은 범위를 7절의 향후 계획에서 보강한다. ## 4. 2차 QA 결함의 현재 진척 @@ -184,16 +184,16 @@ **원칙: "누가 했나" 는 기록하지 않는다.** 증거 대장은 도구·사람 무관 공용이고, 남아야 하는 것은 *어느 리비전에서 무엇이 검증됐나* 뿐이다. -## 7. 3차 QA 남은 우선순위 +## 7. 3차 QA 잔여 계획 (향후) -**우선순위** — 5절의 구멍이 겹치는 순서. +3차 QA는 이미 8/25에 시작됐다. 아래 항목은 착수 전 계획이 아니라 **8개 패스 이후에도 남은 후속 검증**이며, 5절의 구멍이 겹치는 순서로 진행한다. 1. **애프터노트** (잔여 14 · 머지 60건 · 증거 3건) — 최우선 2. **설정** (잔여 9 · 스크린샷 0 · 증거 1건 차단) — 화면 17개 전수 순회 3. **타임레터** (잔여 6 · 스크린샷 0장) 4. **마음의 기록·온보딩·수신자** — 회귀 확인 수준 -**미확인으로 남아 있는 것** (2차 QA 에서 이월) +**향후 확인할 것** - 수신자 승인 이후 FE 화면 — 관리자 승인·전달 조건과 수신자 API 응답은 확인했지만, #607로 앱 화면에 도달하지 못해 종단 간 검증이 남음 - 비즈니스·추억 노트 등록 완주 — 수신자 추가 진입점 문제가 선행 diff --git a/docs/release/distribution.md b/docs/release/distribution.md new file mode 100644 index 0000000000..93af38355a --- /dev/null +++ b/docs/release/distribution.md @@ -0,0 +1,183 @@ +# 비개발자 APK 배포 (Firebase App Distribution) + +디자이너·PM·QA·외부 베타테스터에게 release APK 를 자동 배포하는 흐름. Firebase 프로젝트 `afternote-android` + 테스터 그룹 `afternote` 사용. + +## 운영 자격 인계 (로컬 fallback 담당자만) + +일반 배포는 GitHub Actions가 담당하므로 모든 개발자가 이 설정을 가질 필요는 없다. CI 장애 때 로컬 fallback을 수행할 **배포 권한 보유자**만 다음을 준비한다. + +1. **기존 release keystore와 자격 값 인계** — 승인된 보안 저장소에서 기존 서명 파일과 네 값을 받는다. 새 keystore를 임의로 생성하거나 교체하지 않는다. 기존 앱과 다른 키로 서명하면 업데이트할 수 없으므로, 자격을 찾지 못하면 배포를 중단하고 복구·공식 키 교체 절차를 먼저 진행한다. + +2. **`local.properties` 끝에 4개 키 추가** (signing config 가 읽음) + + ```properties + RELEASE_STORE_FILE=/Users//afternote-release.jks + RELEASE_STORE_PASSWORD= + RELEASE_KEY_ALIAS=afternote-release + RELEASE_KEY_PASSWORD= + ``` + +3. **`google-services.json` 배치** — Firebase Console → 프로젝트 설정 → 일반 → Android 앱 `com.afternote.afternote_fe` 카드에서 다운로드 → `app/google-services.json` + +4. **Firebase CLI 설치 + 인증** (로컬 fallback 업로드용) + + ```bash + npm install -g firebase-tools + firebase login + ``` + +5. **기존 서명 ID 확인** — `./gradlew :app:signingReport`의 release SHA-1과 `keytool`로 구한 카카오 키 해시가 각 콘솔에 이미 등록된 값과 같은지 확인한다. 다른 값이면 새로 등록해 우회하지 말고 인계받은 keystore가 맞는지부터 확인한다. + +## 배포 (매 회) + +모든 배포의 릴리스 노트에는 `포함 이슈`와 `QA 포인트`가 필요하다. 둘 중 하나라도 비어 있거나 포함 이슈에 `#123` 형식의 번호가 없으면 Firebase 업로드 전에 실패한다. + +### 배포 판단 기준 + +배포 시점은 일 단위 주기가 아니라 `develop`에 머지된 변경 묶음의 크기와 위험도로 정한다. + +- 인증·온보딩·데이터 손실·API 계약·빌드/서명처럼 영향이 큰 변경은 다른 변경을 기다리지 않고 단독 배포한다. +- 작은 변경은 하나의 QA 세션에서 회귀 원인을 구분할 수 있는 범위까지만 묶는다. 서로 다른 사용자 흐름을 한꺼번에 확인해야 하거나 함께 롤백하기 어려워지는 시점이 배포 경계다. +- 수정 결과를 테스터가 확인해야 하는 결함이 머지되면, 묶음 크기와 관계없이 확인 가능한 빌드를 배포한다. +- 현재 묶음의 모든 QA 포인트가 통과한 뒤 `develop`을 `main`으로 승격한다. + +### 릴리스 PR 범위 자동 산출 + +배포 시점은 위 기준에 따라 사람이 정한다 — `develop` → `main` 릴리스 PR을 여는 것이 곧 배포 결정이다. + +릴리스 PR이 열리거나 head가 갱신되면 [`release-scope.yml`](../../.github/workflows/release-scope.yml)이 마지막 성공 배포 이후 `develop`에 머지된 PR과 그 연결 이슈를 모아 PR 본문의 `## 포함 이슈`를 채운다. head가 움직일 때마다 다시 채우므로 머지 직전에 목록을 손으로 대조할 필요가 없다. + +`## QA 포인트`는 비어 있을 때만 구성 PR 본문에서 모은 초안으로 채우고, 사람이 쓴 문장이 있으면 건드리지 않는다. 두 섹션은 main push 시 그대로 릴리스 노트가 되므로 배포 전에 테스터가 실행할 문장으로 다듬는다. + +별도 API나 유료 AI를 호출하지 않으며 기존 GitHub Actions 실행량만 사용한다. Actions의 **Collect Release Scope**에서 릴리스 PR 번호를 입력해 다시 산출할 수도 있다. + +### PR별 구조화 QA 원천 + +모든 PR은 `QA Metadata` 섹션의 JSON 객체를 채운다. `app-runtime`·`release-only`는 `precondition`·`action`·`expected`·`risk`·`evidence`가 필요하다. `ci-only`·`covered-by-ci`는 빈 QA 문구 대신 `exclusionReason`과 동일 입력·경계·관찰 결과를 적은 `ci` 또는 `test` evidence가 필요하다. 누락과 `#123 관련 동작을 재현...` 형태의 generic 문구는 Unit Test workflow에서 실패한다. 게이트 도입(`QA_METADATA_GATE_CUTOFF`) 전에 생성된 PR은 섹션이 없으면 검증을 건너뛰므로, 리베이스로 이 workflow를 받아도 소급 차단되지 않는다. 섹션을 채우면 생성 시각과 무관하게 검증한다. + +```json +{ + "scope": "app-runtime", + "precondition": "삭제할 애프터노트가 목록에 있는 로그인 상태", + "action": "삭제 확인에서 확인을 눌러 DELETE 요청을 보낸다", + "expected": "성공 시 목록에서 제거되고 실패 시 기존 항목과 오류 안내가 유지된다", + "risk": "실패한 삭제가 성공처럼 보이거나 기존 항목이 유실될 수 있다", + "evidence": [ + { + "kind": "issue", + "ref": "#550", + "assertion": "삭제 성공·실패의 관찰 결과를 정의한다" + } + ] +} +``` + +앱 QA 제외 원천은 다음처럼 같은 경계를 검증하는 CI 근거를 구조화한다. + +```json +{ + "scope": "ci-only", + "exclusionReason": "GitHub Actions 제어 변경으로 APK 사용자 흐름이 존재하지 않는다", + "evidence": [ + { + "kind": "ci", + "ref": "Unit Test / Run deployment script tests", + "assertion": "같은 스크립트 입력과 종료 상태를 CI에서 검증한다", + "input": "배포 판단 context fixture", + "boundary": "구조화 메타데이터 파싱부터 최종 JSON 검증까지", + "observation": "node test가 제외·병합·generic 0건을 단언한다" + } + ] +} +``` + +### 일반 배포 — `main` → Firebase App Distribution (자동) + +여기서 배포는 검증할 `main` 빌드를 Firebase 테스터에게 전달하는 단계이며, Play Store 프로덕션 릴리스를 뜻하지 않는다. 일반 배포의 자동 진입점은 `main` push 하나다. + +기존 `release-distribution.yml`의 `develop` 수동 배포(`workflow_dispatch`)는 #1029에서 제거했다. 도착지와 산출물 버전이 main 경로와 같아 실익이 PR 생성 한 단계뿐이었던 반면, release keystore와 service account를 임의 ref에 노출하는 표면이었다. + +예외적으로 [`firebase-wif-canary.yml`](../../.github/workflows/firebase-wif-canary.yml)은 `develop` 또는 `main`에서 보호 Environment 승인과 명시적 확인을 받은 뒤 실제 APK를 올리는 **수동 인증 호환성 검증 경로**다. 일반 릴리스나 임의 브랜치 배포 수단으로 사용하지 않으며, production JSON 자격을 WIF로 교체하기 전 판정 절차는 [WIF canary runbook](firebase-wif-canary.md)을 따른다. + +`develop` → `main` 릴리스 PR 본문에 다음 섹션을 채운다. + +```markdown +## 포함 이슈 +- #716 +- #723 + +## QA 포인트 +- 오프라인에서 오류 안내와 재시도 수단이 표시되는지 확인 +- 주차를 변경한 뒤 최신 리포트가 표시되는지 확인 +``` + +PR이 `main`에 머지되면 워크플로가 두 섹션을 릴리스 노트로 사용한다. 연결된 PR이나 필수 섹션을 찾지 못하면 배포하지 않는다. `## QA 포인트`에 사전조건·행동·기대 결과가 없는 generic fallback 문구가 있으면 릴리스 노트 렌더 단계에서 실패한다. + +보호된 `release-distribution` Environment의 관리자가 등록하는 GitHub Secrets: + +| 키 | 용도 | +|---|---| +| `RELEASE_STORE_FILE_B64` | release keystore 파일 (`~/afternote-release.jks`) 의 base64 인코딩 | +| `RELEASE_STORE_PASSWORD` | keystore 비밀번호 | +| `RELEASE_KEY_ALIAS` | key alias (`afternote-release`) | +| `RELEASE_KEY_PASSWORD` | key 비밀번호 | +| `FIREBASE_SERVICE_ACCOUNT_JSON` | App Distribution Admin 권한 부여된 service account JSON 원문 | +| `KAKAO_NATIVE_APP_KEY` · `GOOGLE_WEB_CLIENT_ID` · `GOOGLE_SERVICES_JSON_B64` | release distribution·WIF canary·dependency audit가 쓰는 실서비스 앱 설정 | +| `GCP_WORKLOAD_IDENTITY_PROVIDER` · `GCP_FIREBASE_SERVICE_ACCOUNT` | 수동 WIF canary 전용 OIDC 설정 ([runbook](firebase-wif-canary.md)) | + +> base64 인코딩: `base64 -i ~/afternote-release.jks | pbcopy` (macOS) + +PR 검증용 lint·unit-test·screenshot은 repository secret 대신 +`.github/actions/setup-ci-config`가 만드는 결정적 CI 전용 placeholder를 사용한다. 이 fixture는 +배포에 사용할 수 없으며, production `release-distribution.yml`은 계속 승인된 환경의 JSON 자격을 사용한다. WIF canary 성공만으로 이를 제거하지 않는다. + +### 배포 provenance — 이 APK 가 어느 commit·run 에서 나왔는지 (#851) + +배포 워크플로는 signing 이 끝난 그 APK 하나를 subject 로 [GitHub artifact attestation](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations)을 발급하고, 업로드 전에 스스로 검증한다. 서명·저장소·signer workflow·source commit·GitHub-hosted 러너 중 하나라도 어긋나거나 attestation subject digest 가 빌드 직후 digest 와 다르면 Firebase 업로드까지 가지 않는다. + +성공한 run 의 summary 에 남는 값은 넷이다 — source commit SHA, `sha256:` artifact digest, attestation URL, run URL. APK·AAB 와 R8 mapping 자체는 여기서도 public Actions artifact 로 게시하지 않는다. + +받은 APK 가 정말 그 배포 경로에서 나왔는지는 손에 든 파일로 직접 확인할 수 있다. + +```bash +gh attestation verify ~/Downloads/afternote-release.apk --repo Afternote/Afternote-FE --signer-workflow Afternote/Afternote-FE/.github/workflows/release-distribution.yml --source-ref refs/heads/main --deny-self-hosted-runners +``` + +특정 릴리스 commit 으로 좁히려면 `--source-digest ` 를 더한다. 파일이 1비트라도 다르면 digest 가 달라져 검증이 실패한다. + +> 아래 로컬 fallback 으로 올린 빌드에는 attestation 이 없다. 그 경로로 배포한 APK 는 위 명령이 실패하는 게 정상이며, 그래서 CI 장애 때만 쓴다. + +### 로컬 — 배포 자격 보유자 (fallback / 긴급 시) + +먼저 원격을 갱신하고 배포할 commit이 정확히 `origin/main`이며 작업 트리가 깨끗한지 확인한다. 다른 브랜치나 임의 SHA에서는 이 경로를 실행하지 않는다. + +```bash +git fetch origin main +git switch --detach origin/main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +test -z "$(git status --porcelain)" +``` + +위 운영 자격을 준비한 같은 checkout에서 릴리스 노트를 만들고 업로드한다. + +```bash +EVENT_NAME=workflow_dispatch \ +ISSUE_NUMBERS="#716, #723" \ +QA_POINTS="오프라인 오류 안내 확인;주차 변경 후 재시도 확인" \ +SOURCE_REF=main \ +SOURCE_SHA="$(git rev-parse origin/main)" \ +bash .github/scripts/render-distribution-release-notes.sh /tmp/afternote-release-notes.txt + +./gradlew assembleRelease appDistributionUploadRelease \ + --releaseNotesFile=/tmp/afternote-release-notes.txt \ + --no-daemon +``` + +→ 동일하게 APK 빌드 + Firebase 업로드. CI 장애 시에만 사용한다. + +> 일반 로컬·Firebase 빌드는 기본 `versionCode` 1을 유지한다. Play용 다른 값을 빌드할 때는 `AFTERNOTE_VERSION_CODE` 환경변수로 주입할 수 있지만 현재 값을 자동 산출·주입하거나 Play에 배포하는 workflow는 없다. `app/build.gradle.kts`를 직접 수정하지 말고, 업로드 전 이전 Play 산출물보다 큰 값을 확인하는 절차는 [Play release runbook](../play-release.md)을 따른다. + +## 테스터 관리 + +- 추가/제거: Firebase Console → App Distribution → 테스터 및 그룹 → `afternote` 그룹 편집 +- 신규 테스터는 첫 초대 이메일에서 **App Tester** 앱 설치 안내를 받음 → 이후 빌드는 자동 알림 diff --git a/docs/testing/screenshot.md b/docs/testing/screenshot.md new file mode 100644 index 0000000000..77ab0f3f55 --- /dev/null +++ b/docs/testing/screenshot.md @@ -0,0 +1,62 @@ +# Compose Preview Screenshot Testing (docker baseline) + +`Compose Preview Screenshot Testing`의 anti-aliasing·font hinting·scale 등 host 환경 의존 렌더링 차이로 CI rendered PNG를 baseline으로 교체하는 ping-pong이 발생해 왔다 (PR [#302](https://github.com/Afternote/Afternote-FE/pull/302) / [#322](https://github.com/Afternote/Afternote-FE/pull/322)). 본 저장소는 `Dockerfile.screenshot`을 공통 환경으로 사용하고 역할을 세 workflow로 나눈다. + +- [Compose Preview Screenshot Test](../../.github/workflows/screenshot.yml): PR과 `develop` 합성 결과의 기존 baseline 검증 +- [Generate Screenshot Baselines](../../.github/workflows/screenshot-baseline-generate.yml): 라벨이 붙은 PR의 정확한 head에서 baseline 생성·재검증 후 artifact 발행 +- [Apply Screenshot Baselines](../../.github/workflows/screenshot-baseline-apply.yml): artifact와 head를 다시 검증한 뒤 허용된 PNG만 PR 브랜치에 적용 + +## 로컬 fallback 사전 준비 + +- Docker 호환 runtime 설치 (macOS 의 Colima/Docker Desktop 또는 Linux Docker) + +## Actions 에서 baseline 갱신 (기본 경로) + +1. 갱신할 PR에 `screenshot-baseline` 라벨을 붙인다. +2. 읽기 전용 [Generate Screenshot Baselines](../../.github/workflows/screenshot-baseline-generate.yml)가 PR의 정확한 head SHA를 CI 표준 Docker 이미지에서 렌더하고 재검증한다. +3. 생성 workflow는 라벨을 붙인 `pull_request` 권한 경계에서 실행되므로 PR 코드가 default branch cache를 오염시키지 않는다. +4. 별도 [Apply Screenshot Baselines](../../.github/workflows/screenshot-baseline-apply.yml)가 artifact에 PNG baseline 이외 변경이 없는지, 생성 대상과 현재 PR head가 같은지 checkout 없이 재검증한다. +5. 검증된 PNG만 PR 브랜치에 커밋하고 필수 검사를 다시 요청한다. 성공하면 라벨도 제거된다. + +무엇을 캡처할지는 Action 이 화면을 탐색해서 추측하지 않는다. 각 모듈의 +`src/screenshotTest/kotlin/**/*ScreenshotTest.kt` 가 Preview 함수, 상태와 device spec 을 선언하며, +Action 은 그 테스트 전체를 실행한다. 새 화면·새 상태를 추가하려면 먼저 screenshot test 를 추가한다. +생성된 이미지는 PR 의 PNG diff 에서 눈으로 최종 확인한다. + +## 로컬 baseline 갱신 (Actions 장애 시 fallback) + +```bash +docker build --platform linux/amd64 -t afternote-screenshot:latest -f Dockerfile.screenshot . +docker run --rm --platform linux/amd64 -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ + ./gradlew :core:ui:updateScreenshotTest \ + :feature:home:presentation:updateScreenshotTest \ + :feature:receiver:presentation:updateScreenshotTest \ + :feature:onboarding:presentation:updateScreenshotTest \ + :feature:afternote:presentation:updateScreenshotTest \ + :feature:mindrecord:presentation:updateScreenshotTest \ + --rerun +``` + +→ 변경된 PNG 가 각 모듈 `src/screenshotTestDebug/reference/...` 에 갱신. `git add` 후 commit. + +> 실패한 모듈만 갱신하려면 그 모듈 태스크만 지정한다 — 예: `./gradlew :feature:home:presentation:updateScreenshotTest` +> +> 대상 모듈 목록은 [검증](../../.github/workflows/screenshot.yml)·[생성](../../.github/workflows/screenshot-baseline-generate.yml) workflow와 [적용 workflow의 허용 경로](../../.github/workflows/screenshot-baseline-apply.yml)에 함께 들어 있다. 모듈을 추가·이전했다면 세 workflow와 `Dockerfile.screenshot`, 이 문서를 함께 갱신한다. + +## 로컬 baseline 검증 (CI 실패 재현) + +```bash +docker run --rm --platform linux/amd64 -v "$PWD":/workspace -w /workspace afternote-screenshot:latest \ + ./gradlew :core:ui:validateScreenshotTest \ + :feature:home:presentation:validateScreenshotTest \ + :feature:receiver:presentation:validateScreenshotTest \ + :feature:onboarding:presentation:validateScreenshotTest \ + :feature:afternote:presentation:validateScreenshotTest \ + :feature:mindrecord:presentation:validateScreenshotTest +``` + +→ baseline 과 docker 환경에서 새로 그린 PNG 비교. 실패 시 `build/outputs/screenshotTest-results/preview/debug/diffs/` 에서 diff PNG 확인. + +## 호스트 직접 실행은 사용하지 않음 + +`./gradlew ::updateScreenshotTest` 를 host 에서 직접 실행하면 macOS / Linux / JDK 마이너 버전 / 폰트 캐시 차이로 CI 와 baseline 이 어긋난다. docker 환경 통일이 root fix. From 31201803de8beea0000ed4bffe4da965530f05a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 27 Aug 2026 20:47:52 +0900 Subject: [PATCH 12/12] docs(release): clarify issue and secret scopes --- docs/release/distribution.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/release/distribution.md b/docs/release/distribution.md index 93af38355a..32810fcc68 100644 --- a/docs/release/distribution.md +++ b/docs/release/distribution.md @@ -45,7 +45,7 @@ 배포 시점은 위 기준에 따라 사람이 정한다 — `develop` → `main` 릴리스 PR을 여는 것이 곧 배포 결정이다. -릴리스 PR이 열리거나 head가 갱신되면 [`release-scope.yml`](../../.github/workflows/release-scope.yml)이 마지막 성공 배포 이후 `develop`에 머지된 PR과 그 연결 이슈를 모아 PR 본문의 `## 포함 이슈`를 채운다. head가 움직일 때마다 다시 채우므로 머지 직전에 목록을 손으로 대조할 필요가 없다. +릴리스 PR이 열리거나 head가 갱신되면 [`release-scope.yml`](../../.github/workflows/release-scope.yml)이 마지막 성공 배포 이후 `develop`에 머지된 PR과 `Closes`·`Fixes`·`Resolves`로 완료 처리할 이슈를 모아 PR 본문의 `## 포함 이슈`를 채운다. head가 움직일 때마다 다시 채우므로 머지 직전에 목록을 손으로 대조할 필요가 없다. `## QA 포인트`는 비어 있을 때만 구성 PR 본문에서 모은 초안으로 채우고, 사람이 쓴 문장이 있으면 건드리지 않는다. 두 섹션은 main push 시 그대로 릴리스 노트가 되므로 배포 전에 테스터가 실행할 문장으로 다듬는다. @@ -113,7 +113,10 @@ PR이 `main`에 머지되면 워크플로가 두 섹션을 릴리스 노트로 사용한다. 연결된 PR이나 필수 섹션을 찾지 못하면 배포하지 않는다. `## QA 포인트`에 사전조건·행동·기대 결과가 없는 generic fallback 문구가 있으면 릴리스 노트 렌더 단계에서 실패한다. -보호된 `release-distribution` Environment의 관리자가 등록하는 GitHub Secrets: +CI가 사용하는 GitHub Secrets (Settings → Secrets and variables → Actions): + +- `KAKAO_NATIVE_APP_KEY`·`GOOGLE_WEB_CLIENT_ID`·`GOOGLE_SERVICES_JSON_B64`는 Environment를 사용하지 않는 dependency audit에서도 필요하므로 repository-level secret으로 둔다. +- release keystore·Firebase 자격·WIF 설정은 보호된 `release-distribution` Environment의 secret으로 둔다. | 키 | 용도 | |---|---|