From b5a544aa972142eea051c404544f7c0198ad4007 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 2 Jun 2026 10:41:41 +0900 Subject: [PATCH] chore: archive G06 market repository task files and add client market feature --- .../code_review_cloud_G06_0.log | 231 +++++++++++++++ .../03+01_market_repository/complete.log | 44 +++ .../plan_cloud_G06_0.log} | 0 .../CODE_REVIEW-cloud-G06.md | 81 ------ .../market/data/market_repository.dart | 104 +++++++ .../market/market_repository_test.dart | 275 ++++++++++++++++++ 6 files changed, 654 insertions(+), 81 deletions(-) create mode 100644 agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/code_review_cloud_G06_0.log create mode 100644 agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/complete.log rename agent-task/{m-operator-client-api-core-integration/03+01_market_repository/PLAN-cloud-G06.md => archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/plan_cloud_G06_0.log} (100%) delete mode 100644 agent-task/m-operator-client-api-core-integration/03+01_market_repository/CODE_REVIEW-cloud-G06.md create mode 100644 apps/client/lib/src/features/market/data/market_repository.dart create mode 100644 apps/client/test/features/market/market_repository_test.dart diff --git a/agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/code_review_cloud_G06_0.log b/agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/code_review_cloud_G06_0.log new file mode 100644 index 0000000..39155f6 --- /dev/null +++ b/agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/code_review_cloud_G06_0.log @@ -0,0 +1,231 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> Fill implementation-owned sections, then stop with active files in place and report ready for review. Finalization is review-agent-only. + +## 개요 + +date=2026-06-02 +task=m-operator-client-api-core-integration/03+01_market_repository, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operator-surface/milestones/operator-client-api-core-integration.md` +- Task ids: + - `market-call`: market data status/list 기본 API 호출을 client core에서 수행할 수 있다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 종결 절차는 리뷰 에이전트 전용이다. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Market repository 추가 | [x] | + +## 구현 체크리스트 + +- [x] market repository/provider boundary를 추가하고 `listInstruments`, `listBars`를 `AltApiResult`로 반환한다. 검증: `cd apps/client && flutter test test/features/market/market_repository_test.dart` +- [x] fake client로 request payload와 parsed result/status key를 확인한다. 검증: `cd apps/client && flutter test test/features/market/market_repository_test.dart` +- [x] typed `ErrorInfo`가 `AltApiError`로 보존되는 test를 추가한다. +- [x] `cd apps/client && flutter test`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +- [x] PASS/WARN/FAIL 판정을 append한다. +- [x] active files를 log로 archive한다. +- [x] PASS이면 `complete.log` 작성 후 archive 이동한다. +- [x] roadmap은 직접 수정하지 않는다. + +## 계획 대비 변경 사항 + +계획상의 명세와 일치하게 구현하였으며, 특이사항 및 계획 대비 변경 사항은 없습니다. Sibling subtask인 `01_contract_mapping` 완료 상태와 결과 형태(`AltApiResult`, `AltApiError`)에 정렬하여 구현 및 검증을 완료하였습니다. + +## 주요 설계 결정 + +1. **`MarketRepository`의 Socket wrapping 구조**: `AltSocketClient`를 wrapping하여 raw socket message 응답을 안전하게 `AltApiResult` 형태로 전환하고, server가 응답으로 반환한 `ErrorInfo`를 `AltApiError`로 매핑하여 presentation layer에 일관되게 전달합니다. +2. **연결 안정성 검증**: 소켓 클라이언트의 `isAlive` 상태를 먼저 확인하여 연결이 유실되었거나 활성화되지 않은 상황에서는 사전에 `AltTransportKind.disconnected`를 반환하도록 설계하였습니다. +3. **Transport Error 세분화**: 통신 중 발생할 수 있는 `TimeoutException`, response type mismatch (`StateError`), socket disconnected 상황과 기타 미확인 예외들을 적절한 `AltTransportKind`로 세분화하여 예외 상황의 원인을 식별하기 용이하게 구성하였습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- predecessor `01_contract_mapping` completion을 확인했는가 +- repository가 raw protobuf error를 success처럼 반환하지 않는가 +- request payload와 parsed result key가 test에 고정됐는가 + +## 검증 결과 + +### API-1 중간 검증 +```text +$ cd apps/client && flutter test test/features/market/market_repository_test.dart +00:00 +0: .../alt/apps/client/test/features/market/market_repository_test.dart +00:01 +0: .../alt/apps/client/test/features/market/market_repository_test.dart +00:01 +0: MarketRepository tests listInstruments success flows parsed results +00:01 +1: MarketRepository tests listInstruments success flows parsed results +00:01 +1: MarketRepository tests listBars success flows historical data +00:01 +2: MarketRepository tests listBars success flows historical data +00:01 +2: MarketRepository ... typed ErrorInfo maps correctly to AltApiFailure +00:01 +3: MarketRepository ... typed ErrorInfo maps correctly to AltApiFailure +00:01 +3: ... tests timeout maps correctly to AltTransportKind.timeout +00:01 +4: ... tests timeout maps correctly to AltTransportKind.timeout +00:01 +4: ... type mismatch maps correctly to AltTransportKind.decodeFailure +00:01 +5: ... type mismatch maps correctly to AltTransportKind.decodeFailure +00:01 +5: ... tests client not alive returns AltTransportKind.disconnected +00:01 +6: ... tests client not alive returns AltTransportKind.disconnected +00:01 +6: All tests passed! +``` + +### 최종 검증 +```text +$ cd apps/client && flutter test +00:00 +0: ... /config/workspace/alt/apps/client/test/app/bootstrap_test.dart +00:01 +0: ... /config/workspace/alt/apps/client/test/app/bootstrap_test.dart +00:02 +0: ... /config/workspace/alt/apps/client/test/app/bootstrap_test.dart +00:02 +0: ... does not call Firebase or Mattermost and returns null +00:02 +1: ... does not call Firebase or Mattermost and returns null +00:02 +1: ... bootstrapAltClient initializes Firebase and Mattermost push host +00:02 +2: ... bootstrapAltClient initializes Firebase and Mattermost push host +00:02 +2: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +3: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +4: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +5: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +6: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +7: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +8: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +9: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +10: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +11: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +12: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +13: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +14: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +15: ... runAltClient renders AltClientApp shell without Firebase/push +00:02 +16: ... MarketRepository tests listBars success flows historical data +00:02 +17: ... MarketRepository tests listBars success flows historical data +00:02 +17: ... tests typed ErrorInfo maps correctly to AltApiFailure +00:02 +18: ... tests typed ErrorInfo maps correctly to AltApiFailure +00:02 +18: ... tests timeout maps correctly to AltTransportKind.timeout +00:02 +19: ... tests timeout maps correctly to AltTransportKind.timeout +00:02 +19: ... type mismatch maps correctly to AltTransportKind.decodeFailure +00:02 +20: ... type mismatch maps correctly to AltTransportKind.decodeFailure +00:02 +20: ... tests client not alive returns AltTransportKind.disconnected +00:02 +21: ... tests client not alive returns AltTransportKind.disconnected +00:03 +21: ... tests client not alive returns AltTransportKind.disconnected +00:03 +21: loading /config/workspace/alt/apps/client/test/widget_test.dart +00:03 +21: ... ALT dashboard shell with default disconnected socket state +00:03 +22: ... ALT dashboard shell with default disconnected socket state +00:03 +22: /config/workspace/alt/apps/client/test/integrations/mattermost_push_host_integration_test.dart: auto-login failure does not block initialize +[MattermostHost] Mattermost auto-login failed: Bad state: credentials missing +00:03 +23: ... ALT dashboard shell with default disconnected socket state +00:03 +24: ... ALT dashboard shell with default disconnected socket state +00:03 +25: ... ALT dashboard shell with default disconnected socket state +00:03 +26: ... ALT dashboard shell with default disconnected socket state +00:03 +27: ... ALT dashboard shell with default disconnected socket state +00:03 +28: ... ALT dashboard shell with default disconnected socket state +00:03 +29: ... ALT dashboard shell with default disconnected socket state +00:03 +30: ... ALT dashboard shell with default disconnected socket state +00:03 +31: ... ALT dashboard shell with default disconnected socket state +00:03 +32: ... ALT dashboard shell with default disconnected socket state +00:03 +33: ... ALT dashboard shell with default disconnected socket state +00:03 +34: ... ALT dashboard shell with default disconnected socket state +00:03 +35: ... ALT dashboard shell with default disconnected socket state +00:03 +36: ... ALT dashboard shell with default disconnected socket state +00:03 +37: ... tests listBars market query request-response loop +00:03 +38: ... shows ALT dashboard with socket state Connecting +00:03 +39: ... shows ALT dashboard with socket state Connecting +00:03 +40: ... shows ALT dashboard with socket state Connecting +00:03 +40: ... shows ALT dashboard with socket state Connected +00:03 +41: ... shows ALT dashboard with socket state Connected +00:03 +41: ... shows ALT dashboard with socket state Error +00:03 +42: ... shows ALT dashboard with socket state Error +00:03 +42: ... opens ALT agent dock from the right rail +00:03 +43: ... opens ALT agent dock from the right rail +00:04 +43: ... opens ALT agent dock from the right rail +00:04 +44: ... opens ALT agent dock from the right rail +00:04 +45: ... opens ALT agent dock from the right rail +00:04 +46: ... opens ALT agent dock from the right rail +00:04 +47: ... opens ALT agent dock from the right rail +00:04 +48: ... opens ALT agent dock from the right rail +00:04 +49: ... opens ALT agent dock from the right rail +00:04 +50: ... opens ALT agent dock from the right rail +00:04 +51: ... opens ALT agent dock from the right rail +00:04 +51: ... switches ALT workbench center content from the right rail +00:04 +52: ... switches ALT workbench center content from the right rail +00:04 +52: All tests passed! +``` + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 active plan/review를 log로 archive하고 `complete.log` 작성 후 task archive 이동을 수행한다. + +### 리뷰어 검증 + +```text +$ cd apps/client && flutter test test/features/market/market_repository_test.dart +00:00 +0: loading /config/workspace/alt/apps/client/test/features/market/market_repository_test.dart +00:00 +0: MarketRepository tests listInstruments success flows parsed results +00:00 +1: MarketRepository tests listBars success flows historical data +00:00 +2: MarketRepository tests typed ErrorInfo maps correctly to AltApiFailure +00:00 +3: MarketRepository tests timeout maps correctly to AltTransportKind.timeout +00:00 +4: MarketRepository tests type mismatch maps correctly to AltTransportKind.decodeFailure +00:00 +5: MarketRepository tests client not alive returns AltTransportKind.disconnected +00:00 +6: All tests passed! +``` + +```text +$ cd apps/client && flutter test +00:00 +0: loading /config/workspace/alt/apps/client/test/app/bootstrap_test.dart +00:00 +1: /config/workspace/alt/apps/client/test/app/bootstrap_test.dart: AltClientBootstrap Tests opt-in bootstrapAltClient initializes Firebase and Mattermost push host +00:00 +15: /config/workspace/alt/apps/client/test/features/market/market_repository_test.dart: MarketRepository tests listInstruments success flows parsed results +00:00 +20: /config/workspace/alt/apps/client/test/features/market/market_repository_test.dart: MarketRepository tests client not alive returns AltTransportKind.disconnected +00:01 +43: /config/workspace/alt/apps/client/test/integrations/socket/socket_connection_controller_test.dart: SocketConnectionController tests successful connection and handshake +00:01 +52: All tests passed! +``` + +```text +$ cd apps/client && dart format --set-exit-if-changed lib/src/features/market/data/market_repository.dart test/features/market/market_repository_test.dart +Formatted test/features/market/market_repository_test.dart +Formatted 2 files (1 changed) in 0.02 seconds. +``` + +```text +$ cd apps/client && flutter analyze lib/src/features/market/data/market_repository.dart test/features/market/market_repository_test.dart +Analyzing 2 items... +No issues found! (ran in 2.6s) +``` + +```text +$ cd apps/client && flutter test test/features/market/market_repository_test.dart +00:00 +0: loading /config/workspace/alt/apps/client/test/features/market/market_repository_test.dart +00:00 +0: MarketRepository tests listInstruments success flows parsed results +00:00 +1: MarketRepository tests listBars success flows historical data +00:00 +2: MarketRepository tests typed ErrorInfo maps correctly to AltApiFailure +00:00 +3: MarketRepository tests timeout maps correctly to AltTransportKind.timeout +00:00 +4: MarketRepository tests type mismatch maps correctly to AltTransportKind.decodeFailure +00:00 +5: MarketRepository tests client not alive returns AltTransportKind.disconnected +00:00 +6: All tests passed! +``` diff --git a/agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/complete.log b/agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/complete.log new file mode 100644 index 0000000..1f102b0 --- /dev/null +++ b/agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/complete.log @@ -0,0 +1,44 @@ +# Complete - m-operator-client-api-core-integration/03+01_market_repository + +## 완료 일시 + +2026-06-02 + +## 요약 + +Market repository calls completed in 1 review loop with final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | PASS | `MarketRepository` wraps market socket calls in `AltApiResult`, preserves typed `ErrorInfo`, and verifies request payload/result mapping. | + +## 구현/정리 내용 + +- Added `apps/client/lib/src/features/market/data/market_repository.dart` with `listInstruments` and `listBars` repository methods returning `AltApiResult`. +- Added `apps/client/test/features/market/market_repository_test.dart` covering success payload mapping, request payload assertions, typed API errors, timeout, decode failure, and disconnected client behavior. +- Confirmed predecessor `01_contract_mapping` PASS completion evidence before accepting the shared `AltApiResult` dependency. + +## 최종 검증 + +- `cd apps/client && flutter test test/features/market/market_repository_test.dart` - PASS; 6 tests passed. +- `cd apps/client && flutter test` - PASS; 52 tests passed. +- `cd apps/client && dart format --set-exit-if-changed lib/src/features/market/data/market_repository.dart test/features/market/market_repository_test.dart` - FORMAT APPLIED; `market_repository_test.dart` was formatted. +- `cd apps/client && flutter analyze lib/src/features/market/data/market_repository.dart test/features/market/market_repository_test.dart` - PASS; No issues found. +- `cd apps/client && flutter test test/features/market/market_repository_test.dart` - PASS after formatting; 6 tests passed. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/operator-surface/milestones/operator-client-api-core-integration.md` +- Completed task ids: + - `market-call`: PASS; evidence=`plan_cloud_G06_0.log`, `code_review_cloud_G06_0.log`; verification=`cd apps/client && flutter test test/features/market/market_repository_test.dart`, `cd apps/client && flutter test`, `cd apps/client && flutter analyze lib/src/features/market/data/market_repository.dart test/features/market/market_repository_test.dart` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-operator-client-api-core-integration/03+01_market_repository/PLAN-cloud-G06.md b/agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/plan_cloud_G06_0.log similarity index 100% rename from agent-task/m-operator-client-api-core-integration/03+01_market_repository/PLAN-cloud-G06.md rename to agent-task/archive/2026/06/m-operator-client-api-core-integration/03+01_market_repository/plan_cloud_G06_0.log diff --git a/agent-task/m-operator-client-api-core-integration/03+01_market_repository/CODE_REVIEW-cloud-G06.md b/agent-task/m-operator-client-api-core-integration/03+01_market_repository/CODE_REVIEW-cloud-G06.md deleted file mode 100644 index eb769d6..0000000 --- a/agent-task/m-operator-client-api-core-integration/03+01_market_repository/CODE_REVIEW-cloud-G06.md +++ /dev/null @@ -1,81 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> Fill implementation-owned sections, then stop with active files in place and report ready for review. Finalization is review-agent-only. - -## 개요 - -date=2026-06-02 -task=m-operator-client-api-core-integration/03+01_market_repository, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/operator-surface/milestones/operator-client-api-core-integration.md` -- Task ids: - - `market-call`: market data status/list 기본 API 호출을 client core에서 수행할 수 있다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 종결 절차는 리뷰 에이전트 전용이다. - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Market repository 추가 | [ ] | - -## 구현 체크리스트 - -- [ ] market repository/provider boundary를 추가하고 `listInstruments`, `listBars`를 `AltApiResult`로 반환한다. 검증: `cd apps/client && flutter test test/features/market/market_repository_test.dart` -- [ ] fake client로 request payload와 parsed result/status key를 확인한다. 검증: `cd apps/client && flutter test test/features/market/market_repository_test.dart` -- [ ] typed `ErrorInfo`가 `AltApiError`로 보존되는 test를 추가한다. -- [ ] `cd apps/client && flutter test`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] PASS/WARN/FAIL 판정을 append한다. -- [ ] active files를 log로 archive한다. -- [ ] PASS이면 `complete.log` 작성 후 archive 이동한다. -- [ ] roadmap은 직접 수정하지 않는다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- predecessor `01_contract_mapping` completion을 확인했는가 -- repository가 raw protobuf error를 success처럼 반환하지 않는가 -- request payload와 parsed result key가 test에 고정됐는가 - -## 검증 결과 - -### API-1 중간 검증 -```text -$ cd apps/client && flutter test test/features/market/market_repository_test.dart -(output) -``` - -### 최종 검증 -```text -$ cd apps/client && flutter test -(output) -``` diff --git a/apps/client/lib/src/features/market/data/market_repository.dart b/apps/client/lib/src/features/market/data/market_repository.dart new file mode 100644 index 0000000..f6444ff --- /dev/null +++ b/apps/client/lib/src/features/market/data/market_repository.dart @@ -0,0 +1,104 @@ +import 'dart:async'; +import 'package:alt_client/src/contracts/alt_api_result.dart'; +import 'package:alt_client/src/integrations/socket/alt_socket_client.dart'; +import 'package:alt_client/src/generated/alt/v1/market.pb.dart'; + +/// Repository responsible for handling market-related data operations. +/// +/// Wraps [AltSocketClient] calls for listing instruments and historical bars, +/// transforming raw socket responses or failures into structured [AltApiResult]s. +class MarketRepository { + final AltSocketClient _client; + + MarketRepository(this._client); + + /// Lists market instruments matching the given [request]. + Future> listInstruments( + ListInstrumentsRequest request, { + Duration timeout = const Duration(seconds: 2), + }) async { + if (!_client.isAlive) { + return const AltApiResult.transportFailure( + AltTransportKind.disconnected, + detail: 'client is not alive', + ); + } + try { + final response = await _client.listInstruments(request, timeout: timeout); + final errorInfo = altResponseError(response); + if (errorInfo != null) { + return AltApiResult.apiError(AltApiError.fromErrorInfo(errorInfo)); + } + return AltApiResult.success(response); + } on TimeoutException catch (e) { + return AltApiResult.transportFailure( + AltTransportKind.timeout, + detail: e.toString(), + ); + } on StateError catch (e) { + return AltApiResult.transportFailure( + AltTransportKind.decodeFailure, + detail: e.toString(), + ); + } catch (e) { + final errStr = e.toString().toLowerCase(); + if (errStr.contains('disconnect') || + errStr.contains('closed') || + errStr.contains('not connected')) { + return AltApiResult.transportFailure( + AltTransportKind.disconnected, + detail: e.toString(), + ); + } + return AltApiResult.transportFailure( + AltTransportKind.unknown, + detail: e.toString(), + ); + } + } + + /// Lists bar (historical) data matching the given [request]. + Future> listBars( + ListBarsRequest request, { + Duration timeout = const Duration(seconds: 2), + }) async { + if (!_client.isAlive) { + return const AltApiResult.transportFailure( + AltTransportKind.disconnected, + detail: 'client is not alive', + ); + } + try { + final response = await _client.listBars(request, timeout: timeout); + final errorInfo = altResponseError(response); + if (errorInfo != null) { + return AltApiResult.apiError(AltApiError.fromErrorInfo(errorInfo)); + } + return AltApiResult.success(response); + } on TimeoutException catch (e) { + return AltApiResult.transportFailure( + AltTransportKind.timeout, + detail: e.toString(), + ); + } on StateError catch (e) { + return AltApiResult.transportFailure( + AltTransportKind.decodeFailure, + detail: e.toString(), + ); + } catch (e) { + final errStr = e.toString().toLowerCase(); + if (errStr.contains('disconnect') || + errStr.contains('closed') || + errStr.contains('not connected')) { + return AltApiResult.transportFailure( + AltTransportKind.disconnected, + detail: e.toString(), + ); + } + return AltApiResult.transportFailure( + AltTransportKind.unknown, + detail: e.toString(), + ); + } + } +} diff --git a/apps/client/test/features/market/market_repository_test.dart b/apps/client/test/features/market/market_repository_test.dart new file mode 100644 index 0000000..6cb6410 --- /dev/null +++ b/apps/client/test/features/market/market_repository_test.dart @@ -0,0 +1,275 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:proto_socket/proto_socket.dart'; + +import 'package:alt_client/src/contracts/alt_api_result.dart'; +import 'package:alt_client/src/generated/alt/v1/common.pb.dart'; +import 'package:alt_client/src/generated/alt/v1/market.pb.dart'; +import 'package:alt_client/src/integrations/socket/alt_socket_client.dart'; +import 'package:alt_client/src/features/market/data/market_repository.dart'; + +class FakeWebSocket extends Stream implements WebSocket { + final StreamController _controller = StreamController(); + final List> sentBytes = []; + bool isClosed = false; + + @override + void add(dynamic data) { + if (data is List) { + sentBytes.add(data); + } + } + + @override + Future close([int? code, String? reason]) async { + isClosed = true; + await _controller.close(); + } + + void feedFromServer(List data) { + _controller.add(data); + } + + @override + StreamSubscription listen( + void Function(dynamic event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('MarketRepository tests', () { + late FakeWebSocket fakeWs; + late AltSocketClient client; + late MarketRepository repository; + + setUp(() { + fakeWs = FakeWebSocket(); + client = AltSocketClient( + fakeWs, + heartbeatIntervalTime: 10, + heartbeatWaitTime: 10, + ); + repository = MarketRepository(client); + }); + + tearDown(() async { + await client.close(); + }); + + Future roundTrip({ + required Future Function() trigger, + required void Function(PacketBase sentPacket) onSent, + required String responseTypeName, + required List responseData, + }) async { + final sentCountBefore = fakeWs.sentBytes.length; + unawaited(trigger()); + + await Future.delayed(Duration.zero); + + expect(fakeWs.sentBytes.length, greaterThan(sentCountBefore)); + final sentPacket = PacketBase.fromBuffer(fakeWs.sentBytes.last); + onSent(sentPacket); + + final responsePacket = PacketBase() + ..typeName = responseTypeName + ..nonce = 200 + ..responseNonce = sentPacket.nonce + ..data = responseData; + fakeWs.feedFromServer(responsePacket.writeToBuffer()); + + return sentPacket; + } + + test('listInstruments success flows parsed results', () async { + final request = ListInstrumentsRequest() + ..market = Market.MARKET_US + ..provider = 'kis'; + + final expectedResponse = ListInstrumentsResponse() + ..instruments.add( + Instrument() + ..id = 'instrument-1' + ..market = Market.MARKET_US + ..symbol = 'AAPL', + ); + + final future = repository.listInstruments( + request, + timeout: const Duration(seconds: 1), + ); + + await roundTrip( + trigger: () => future, + onSent: (sentPacket) { + expect( + sentPacket.typeName, + equals( + ListInstrumentsRequest.getDefault().info_.qualifiedMessageName, + ), + ); + final sentRequest = ListInstrumentsRequest.fromBuffer( + sentPacket.data, + ); + expect(sentRequest.market, equals(Market.MARKET_US)); + expect(sentRequest.provider, equals('kis')); + }, + responseTypeName: + ListInstrumentsResponse.getDefault().info_.qualifiedMessageName, + responseData: expectedResponse.writeToBuffer(), + ); + + final result = await future; + expect(result, isA>()); + final successVal = + (result as AltApiSuccess).value; + expect(successVal.instruments, hasLength(1)); + expect(successVal.instruments.first.id, equals('instrument-1')); + expect(successVal.instruments.first.symbol, equals('AAPL')); + }); + + test('listBars success flows historical data', () async { + final request = ListBarsRequest() + ..instrumentId = 'instrument-2' + ..timeframe = Timeframe.TIMEFRAME_DAILY + ..fromUnixMs = Int64(1000) + ..toUnixMs = Int64(2000); + + final expectedResponse = ListBarsResponse() + ..bars.add( + Bar() + ..instrumentId = 'instrument-2' + ..timeframe = Timeframe.TIMEFRAME_DAILY + ..timestampUnixMs = Int64(1500) + ..close = Price(amount: Decimal(value: '150.0')), + ); + + final future = repository.listBars( + request, + timeout: const Duration(seconds: 1), + ); + + await roundTrip( + trigger: () => future, + onSent: (sentPacket) { + expect( + sentPacket.typeName, + equals(ListBarsRequest.getDefault().info_.qualifiedMessageName), + ); + final sentRequest = ListBarsRequest.fromBuffer(sentPacket.data); + expect(sentRequest.instrumentId, equals('instrument-2')); + expect(sentRequest.timeframe, equals(Timeframe.TIMEFRAME_DAILY)); + expect(sentRequest.fromUnixMs, equals(Int64(1000))); + expect(sentRequest.toUnixMs, equals(Int64(2000))); + }, + responseTypeName: + ListBarsResponse.getDefault().info_.qualifiedMessageName, + responseData: expectedResponse.writeToBuffer(), + ); + + final result = await future; + expect(result, isA>()); + final successVal = (result as AltApiSuccess).value; + expect(successVal.bars, hasLength(1)); + expect(successVal.bars.first.instrumentId, equals('instrument-2')); + expect(successVal.bars.first.close.amount.value, equals('150.0')); + }); + + test('typed ErrorInfo maps correctly to AltApiFailure', () async { + final request = ListInstrumentsRequest() + ..market = Market.MARKET_KR + ..provider = 'kis'; + + final expectedResponse = ListInstrumentsResponse() + ..error = ErrorInfo( + code: 'PROVIDER_LIMIT_EXCEEDED', + message: 'kis daily quota reached', + ); + + final future = repository.listInstruments( + request, + timeout: const Duration(seconds: 1), + ); + + await roundTrip( + trigger: () => future, + onSent: (sentPacket) {}, + responseTypeName: + ListInstrumentsResponse.getDefault().info_.qualifiedMessageName, + responseData: expectedResponse.writeToBuffer(), + ); + + final result = await future; + expect(result, isA>()); + final failure = result as AltApiFailure; + expect(failure.error.code, equals('PROVIDER_LIMIT_EXCEEDED')); + expect(failure.error.message, equals('kis daily quota reached')); + }); + + test('timeout maps correctly to AltTransportKind.timeout', () async { + final request = ListInstrumentsRequest(); + + // We do not feed any response packet so it will time out. + final result = await repository.listInstruments( + request, + timeout: const Duration(milliseconds: 10), + ); + + expect(result, isA>()); + final failure = result as AltTransportFailure; + expect(failure.kind, equals(AltTransportKind.timeout)); + }); + + test( + 'type mismatch maps correctly to AltTransportKind.decodeFailure', + () async { + final request = ListInstrumentsRequest(); + + final future = repository.listInstruments( + request, + timeout: const Duration(seconds: 1), + ); + + // Feed a response with a type mismatch (e.g. HelloResponse instead of ListInstrumentsResponse) + await roundTrip( + trigger: () => future, + onSent: (sentPacket) {}, + responseTypeName: + HelloResponse.getDefault().info_.qualifiedMessageName, + responseData: HelloResponse().writeToBuffer(), + ); + + final result = await future; + expect(result, isA>()); + final failure = result as AltTransportFailure; + expect(failure.kind, equals(AltTransportKind.decodeFailure)); + expect(failure.detail, contains('Bad state')); + }, + ); + + test('client not alive returns AltTransportKind.disconnected', () async { + await client.close(); + + final result = await repository.listInstruments(ListInstrumentsRequest()); + expect(result, isA>()); + final failure = result as AltTransportFailure; + expect(failure.kind, equals(AltTransportKind.disconnected)); + expect(failure.detail, contains('client is not alive')); + }); + }); +}