diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index 143cb15297..80ad179e49 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -10,14 +10,21 @@ import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.testing.FakeUserProfileCacheRepository import com.afternote.feature.mindrecord.data.di.MindRecordRepositoryModule -import com.afternote.feature.mindrecord.data.repositoryimpl.MindRecordReceiverRepositoryImpl import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository +import com.afternote.feature.timeletter.data.di.TimeLetterModule +import com.afternote.feature.timeletter.data.repositoryImpl.FileMetadataRepositoryImpl +import com.afternote.feature.timeletter.domain.repository.FileMetadataRepository +import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository +import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository +import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent @@ -83,7 +90,7 @@ object TestMindRecordRepositoryModule { @Provides @Singleton - fun provideMindRecordReceiverRepository(impl: MindRecordReceiverRepositoryImpl): MindRecordReceiverRepository = impl + fun provideMindRecordReceiverRepository(): MindRecordReceiverRepository = FakeMindRecordReceiverRepository() /** * 주간 리포트도 fake 로 격리한다. @@ -110,6 +117,45 @@ object TestMindRecordRepositoryModule { fun provideWeeklyReportRepository(): WeeklyReportRepository = FakeWeeklyReportRepository(fallback = Result.success(emptyWeeklyReport())) } +/** + * 타임레터 탭이 dev 서버로 나가는 것을 막는다. + * + * [NotificationNavigationAndroidTest] 의 두 테스트가 하단 탭에서 타임레터를 연다. 그 화면은 + * `TimeletterScreen` 이 `repeatOnLifecycle(STARTED)` 안에서 `TimeletterViewModel.load()` 를 부르고, + * 그 안에서 `TimeLetterRepository.getTimeLetters()` 가 실제 `GET /api/v1/time-letters` 를 보낸다. + * api30 GMD 실측에서 요청 3건이 확인됐다 — 탭 진입에 1건, 그리고 Activity 를 `CREATED` 로 내렸다 + * 올리는 테스트가 `STARTED` 재진입으로 1건을 더 만들어 2건. 그런데도 36개 테스트가 전부 통과했다. + * 실패로 드러나지 않으니 dev 서버가 닫히는 03~12시(KST)에만 증상이 나오고, 그때는 원인이 코드로 + * 보이지 않는다. + * + * `FileMetadataRepository` 는 `ContentResolver` 만 쓰는 로컬 구현이라 그대로 둔다 — 네트워크가 + * 아닌 것까지 갈아끼우면 계측이 프로덕션과 달라지는 자리만 늘어난다. + * + * 두 fake 는 일부러 `strict()` 를 쓰지 않는다. 앱 전체 계측에서 관심 밖 탭이 합성돼도 무인자 + * 기본값의 빈 목록 성공으로 닫기 위해서다. 기본 생성자 정책이 strict 로 바뀌면 이 바인딩도 + * 함께 재검토해야 한다. + * + * api service 두 개를 다시 제공하지 않는 이유는 [TimeLetterModule] 밖에 소비자가 없어서다. + */ +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [TimeLetterModule::class], +) +object TestTimeLetterModule { + @Provides + @Singleton + fun provideTimeLetterRepository(): TimeLetterRepository = FakeTimeLetterRepository() + + @Provides + @Singleton + fun provideReceiverTimeLetterRepository(): ReceiverTimeLetterRepository = FakeReceiverTimeLetterRepository() + + @Provides + @Singleton + fun provideFileMetadataRepository(impl: FileMetadataRepositoryImpl): FileMetadataRepository = impl +} + @Module @TestInstallIn( components = [SingletonComponent::class], diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestRealBackendCallGuardModule.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestRealBackendCallGuardModule.kt new file mode 100644 index 0000000000..4e0feb1b88 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestRealBackendCallGuardModule.kt @@ -0,0 +1,60 @@ +package com.afternote.afternote_fe.test + +import android.util.Log +import com.afternote.core.network.interceptor.OptionalDebugNetworkInterceptor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import okhttp3.Interceptor +import java.io.IOException +import javax.inject.Singleton + +/** + * 계측 그래프가 실서버로 나가는 것을 막고, 나간 시도를 logcat 에 남긴다. + * + * 왜 있나 — `core/network` 의 `BASE_URL` 은 buildType 과 무관하게 dev 서버 하나로 못박혀 있고, + * CI 의 `AFTERNOTE_CI_CONFIG_MODE=stub` 도 이 값은 건드리지 않는다. 그래서 `app` androidTest 의 + * Hilt 그래프에 fake 로 대체되지 않은 Retrofit 저장소가 남아 있고 어떤 테스트가 그 화면에 닿으면, + * CI 가 조용히 dev 서버로 실제 HTTP 를 보낸다. dev 서버는 03~12시(KST) 닫히므로 증상이 시각에 + * 따라 달라져, 실패가 나도 원인이 코드로 보이지 않는다. 같은 사고가 두 번 났다(#1288, #1618). + * + * 왜 «검출» 이 아니라 «차단» 인가 — 이 파일을 넣기 전 api30 실측에서 타임레터 탭이 + * `GET /api/v1/time-letters` 를 3번 보냈는데도 36개 테스트가 전부 초록이었다. 누수는 테스트를 + * 깨지 않으므로 검출 장치만으로는 아무도 알아채지 못한다. 반대로 여기서 끊으면 누수가 남아 있어도 + * 실서버로 나가지 못해, 시각에 따라 달라지는 실패 자체가 성립하지 않는다. + * + * 무엇을 하나 — `MainClient` 를 타는 모든 요청을 네트워크로 나가기 전에 끊고 [IOException] 을 + * 던진다. 던지는 자리가 인터셉터라 앱에는 "네트워크 실패" 로 보인다. 저장소들은 이미 그 경로를 + * 다루므로 프로세스가 죽지 않는다 — `FakeX.strict()` 로 닫았을 때처럼 호출자에서 터지지 않는다 + * (#1288 이 그 사고다). 이 파일을 넣은 뒤에도 36개 테스트가 그대로 전부 통과하는 것으로 확인했다. + * + * 어디까지 보나 — `Retrofit` 은 `@Named("MainClient")` 하나로 만들어지고 이 묶음은 그 클라이언트에만 + * 붙는다. 즉 서버 계약을 타는 모든 api service 호출이 여기를 지난다. 이미지 로딩은 지나지 않는다 + * (계측에서는 `GlobalApplication` 이 `HiltTestApplication` 으로 대체돼 Coil 이 `@Named("CoilImage")` + * 클라이언트조차 쓰지 않고 자체 기본 로더로 간다). S3 업로드도 `@Named("S3Upload")` 라 지나지 않는다. + * 둘 다 서버 계약이 아닌 외부 호스트를 향하고, S3 는 presigned URL 을 받는 선행 호출이 여기 걸리며, + * 이미지 URL 은 응답 본문에서 오는데 그 응답 자체가 여기서 끊긴다. 계약 누수 판정에는 구멍이 없다. + * + * 새 누수가 생기면 — [TAG] 로 URL 이 logcat 에 남는다. GMD 산출물 + * `app/build/outputs/androidTest-results/managedDevice//logcat-*.txt` 를 이 태그로 훑으면 + * 어느 테스트가 무엇을 불렀는지 그대로 나온다. 처방은 그 저장소를 바인딩하는 모듈을 + * [TestHiltModules] 에서 `@TestInstallIn` 으로 대체하는 것이다. + */ +@Module +@InstallIn(SingletonComponent::class) +object TestRealBackendCallGuardModule { + private const val TAG = "RealBackendCallGuard" + + @Provides + @Singleton + @IntoSet + @OptionalDebugNetworkInterceptor + fun provideRealBackendCallGuardInterceptor(): Interceptor = + Interceptor { chain -> + val url = chain.request().url.toString() + Log.e(TAG, "계측 그래프가 실서버로 나갔다: $url") + throw IOException("계측 테스트는 실서버로 나갈 수 없다: $url") + } +}