From 4f1d705ca19fbf240e8b8676484c92c9c18b1b94 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 30 Jun 2026 12:36:41 -0600 Subject: [PATCH 01/28] docs: add convertible-bond market-data design spec --- docs/convertible-bond-conversion.md | 49 +++++ ...-30-convertible-bond-market-data-design.md | 199 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 docs/convertible-bond-conversion.md create mode 100644 docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md diff --git a/docs/convertible-bond-conversion.md b/docs/convertible-bond-conversion.md new file mode 100644 index 0000000..b7481a5 --- /dev/null +++ b/docs/convertible-bond-conversion.md @@ -0,0 +1,49 @@ +# Convertible Bond — Conversion Value Reference + +Quick reference for the core convertible-bond (可轉債) conversion math, with the +Chinese terms and their English equivalents. + +## Formulas + +| Chinese | English | Formula | +|---|---|---| +| 轉換股數 | Conversion shares | `發行面額 / 轉換價` (Par value ÷ Conversion price) | +| 轉換總值 | Conversion value (parity) | `轉換股數 × 標的現價` (Conversion shares × Stock price) | +| 套利 / 價內 | In-the-money condition | `轉換總值 > 公司債市價` (Conversion value > Bond market price) | + +### 1. Conversion shares (轉換股數) + +``` +轉換股數 = 發行面額 / 轉換價 +Conversion shares = Par value / Conversion price +``` + +How many shares the bond converts into. +Example: NT$100,000 par ÷ NT$50 conversion price = **2,000 shares**. + +### 2. Conversion value / parity (轉換總值) + +``` +轉換總值 = 轉換股數 × 標的現價 +Conversion value = Conversion shares × Stock (underlying) price +``` + +The value of the converted shares at the current stock price. +Example: 2,000 shares × NT$60 = **NT$120,000**. + +### 3. In-the-money / arbitrage condition + +``` +轉換總值 > 公司債市價 → 價內 (in-the-money) +Conversion value > Bond market price → profitable to convert +``` + +When the conversion value exceeds the bond's current market price, it is +profitable to **buy the bond, convert into shares, and sell the shares**. + +## Terminology note + +- **標的 (underlying)** = the **stock**, not the bond. Use it only in formula #2. +- The comparison in #3 should be against the **bond's market price (公司債市價)**, + *not* "標的現價". Watch for this — labeling the bond price as 標的現價 is a common + wording mistake. diff --git a/docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md b/docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md new file mode 100644 index 0000000..e3dbfbb --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md @@ -0,0 +1,199 @@ +# Convertible Bond Market Data — Design + +**Date:** 2026-06-30 +**Status:** Approved (pending spec review) +**Related:** [docs/convertible-bond-conversion.md](../../convertible-bond-conversion.md) + +## 1. Purpose + +Let the app pull Taiwan convertible-bond (可轉換公司債) data from free public +open-data sources and compute the conversion valuation documented in +[convertible-bond-conversion.md](../../convertible-bond-conversion.md): + +``` +ConversionShares = ParValue / ConversionPrice +ConversionValue = ConversionShares * UnderlyingStockPrice +InTheMoney = ConversionValue > BondMarketPrice +``` + +Data is pulled over HTTP from structured open-data APIs (no web scraping, no +Python), cached into the existing SQLite database, and exposed to the UI through +Application use cases. + +## 2. Scope + +**In scope (v1):** +- Three open-data API clients: CB issuance terms, CB daily quote, stock quote. +- A `MarketDataProvider` gateway that composes the clients and maps raw API + shapes to Domain entities. +- Domain entities + a pure conversion-math value object. +- EF Core persistence of convertible bonds (local cache) with a manual-seed + fallback. +- Two use cases: refresh-from-network, and read-and-valuate. +- Unit tests across Domain, Infrastructure, and Application. + +**Out of scope (v1):** +- Real-time / intraday data (free feeds are end-of-day; the abstraction allows a + future real-time provider without touching Domain/Application/UI). +- UI screens (this slice ends at the Application DTO; UI is separate work). +- Anti-dilution recalculation of conversion price (we store what the feed + reports). + +## 3. Why not LEAN / Scrapling + +- **LEAN** is a backtesting/live-trading engine, not a data client; it does not + carry Taiwan CB data and will not fit inside a MAUI client. Rejected. +- **Scrapling** is Python + Playwright. This is a .NET MAUI app — bundling a + Python runtime and headless browser is unviable, and would force a separate + sidecar service to solve a problem we do not have (the data is structured open + data, not HTML that needs scraping). Rejected. If scraping were ever required, + the in-codebase choice is a C# library (HtmlAgilityPack / AngleSharp). + +## 4. Data sources (free, public) + +| Need | Source | Notes | +|---|---|---| +| Conversion **terms** (conversion price, par value, dates) | TPEx CB issuance dataset — [data.gov.tw #11406](https://data.gov.tw/dataset/11406) via TPEx OpenAPI | Static per bond; rarely changes | +| Bond **daily market price** | [TPEx CB daily query](https://www.tpex.org.tw/web/bond/bonds_info/cbq/NewCB_day_qry.php) | End-of-day | +| Underlying **stock price** | [TWSE OpenAPI](https://openapi.twse.com.tw/) / TPEx stock quotes | End-of-day | + +The TPEx **web** server returns `403` to naive automated user-agents; the +**open-data API** endpoints are for programmatic use. The HTTP pipeline sets a +normal `User-Agent` header to avoid being blocked. + +## 5. Architecture + +Follows the existing Clean Architecture layering and the SoleCore "Clients" +pattern (one folder per external source; a raw `IXxxApiClient` wrapped by a +domain-facing gateway). Folders are named by **technical role** to stay +consistent with the existing `Persistence/` convention. + +### 5.1 Domain (`AppName.Domain`) — concepts + math, zero I/O + +``` +Entities/ + ConvertibleBond.cs // Symbol, Name, ParValue, ConversionPrice, UnderlyingSymbol + BondQuote.cs // bond market price + as-of date + StockQuote.cs // underlying stock price + as-of date +ValueObjects/ + ConversionValuation.cs // ConversionShares, ConversionValue, IsInTheMoney (pure) +Abstractions/ + IMarketDataProvider.cs // GetIssuanceTermsAsync, GetBondQuoteAsync, GetStockQuoteAsync + IConvertibleBondRepository.cs // GetAllAsync, GetBySymbolAsync, UpsertAsync +``` + +- All money/price fields use `decimal`. +- `ConversionValuation` is computed purely from a `ConvertibleBond` + bond price + + stock price. No network, fully unit-testable. +- Per project CLAUDE.md: every property gets `/// ` + `[DisplayName(...)]`; + interfaces are fully documented; implementations use `/// `. + +### 5.2 Infrastructure (`AppName.Infrastructure`) + +``` +Clients/ + Tpex/ + TpexHttpPipeline.cs // AddTpexHttpClients() — typed HttpClient + Polly + User-Agent + TpexBondIssuanceApiClient.cs // ITpexBondIssuanceApiClient — conversion terms (#11406) + TpexBondQuoteApiClient.cs // ITpexBondQuoteApiClient — daily bond prices + TpexApiOptions.cs // BaseUrl, UserAgent + Models/ // raw JSON DTOs for TPEx responses + Twse/ + TwseHttpPipeline.cs // AddTwseHttpClients() + TwseStockQuoteApiClient.cs // ITwseStockQuoteApiClient — underlying stock prices + TwseApiOptions.cs + Models/ +Gateways/ + MarketDataProvider.cs // : IMarketDataProvider — composes the 3 clients, maps raw -> Domain +Persistence/ + AppDbContext.cs // + DbSet, entity config in OnModelCreating + ConvertibleBondRepository.cs // : IConvertibleBondRepository (uses IDbContextFactory) +DependencyInjection.cs // AddInfrastructure(): register clients, gateway, repository +``` + +- Each `ApiClient` is a thin primary-ctor class: public methods are try/catch + wrappers (rethrow `OperationCanceledException`; log + rethrow otherwise); a + private `SendAndDeserializeAsync` does GET → status check → + `EnsureSuccessStatusCode` → `JsonSerializer.Deserialize`. +- `HttpPipeline` registers a **typed `HttpClient`** via + `AddHttpClient` with a **Polly resilience handler** (retry, + exponential backoff + jitter; handles 5xx / 429 / 408 / transient exceptions), + and sets `BaseAddress` + `User-Agent` from `IOptions`. +- Clients return **raw API models** only. Mapping to Domain happens in + `MarketDataProvider`, keeping messy JSON shapes out of Domain. +- Persistence reuses the existing `AddDbContextFactory` + + `IDbContextFactory` pattern (consistent with `UserRepository`). + +### 5.3 Application (`AppName.Application`) — orchestration + +``` +UseCases/Bonds/ + RefreshBondDataUseCase.cs // pull via IMarketDataProvider -> UpsertAsync into local DB + GetConversionValuationUseCase.cs // read DB -> run Domain math -> ConversionValuationDto +Dtos/ + ConversionValuationDto.cs // record: symbol, shares, value, bond price, isInTheMoney, asOf +DependencyInjection.cs // register the two use cases (AddTransient) +``` + +## 6. Data flow + +``` +UI (read) -> GetConversionValuationUseCase -> IConvertibleBondRepository (SQLite) + ^ seeded by v +UI (refresh) -> RefreshBondDataUseCase -> IMarketDataProvider -> Tpex/Twse ApiClients -> HTTP open data +``` + +- **Refresh** is an explicit action: pull terms + bond price + stock price, + upsert the `ConvertibleBond` (+ latest prices) into SQLite. +- **Read/valuate** runs the pure Domain math over whatever is cached locally, so + the app works offline after a refresh. +- **Manual seed** is a fallback path on the repository for bonds the feed misses. + +## 7. Error handling + +- Network/transient faults: Polly retry in the HTTP pipeline; exhausted retries + surface as a logged exception from the client. +- Non-success HTTP: logged warning + `EnsureSuccessStatusCode` throws; the + gateway decides whether a missing source is fatal or skippable per bond. +- Deserialization failure: throw `InvalidOperationException` (cannot map → cannot + trust data). +- A refresh that fails for one bond must not abort the whole batch — log and + continue, report a per-bond result summary. +- Division-by-zero guard: `ConversionValuation` rejects a zero/negative + conversion price. + +## 8. Testing + +- **Domain.Tests** — `ConversionValuation` math exhaustively (incl. zero/negative + conversion price, in-the-money boundary). Pure, no network. +- **Infrastructure.Tests** — each `ApiClient` against canned JSON via a mocked + `HttpMessageHandler`; `ConvertibleBondRepository` against SQLite; mapping in + `MarketDataProvider` with fake clients. +- **Application.Tests** — both use cases with a fake `IMarketDataProvider` and a + fake repository. + +## 9. New dependencies + +Add to `Directory.Packages.props`: +- `Microsoft.Extensions.Http` (typed `HttpClient` / `AddHttpClient`) +- `Microsoft.Extensions.Http.Resilience` **or** `Polly` (resilience handler — + match the SoleCore reference, which uses Polly's `AddResilienceHandler`) +- `Microsoft.Extensions.Options` (Options pattern) + +## 10. Implementation subtasks (for decomposition) + +1. Domain: entities (`ConvertibleBond`, `BondQuote`, `StockQuote`) + + `ConversionValuation` value object + Domain.Tests. +2. Domain: `IMarketDataProvider` + `IConvertibleBondRepository` ports. +3. Infra: TPEx issuance client (pipeline + client + options + models + + Infra.Tests). +4. Infra: TPEx bond-quote client. +5. Infra: TWSE stock-quote client. +6. Infra: `MarketDataProvider` gateway (raw → Domain mapping). +7. Infra: EF `ConvertibleBond` config + repository + migration. +8. Application: `RefreshBondDataUseCase` + `GetConversionValuationUseCase` + + `ConversionValuationDto` + App.Tests. +9. Wiring: DI registration across layers + add NuGet packages. + +Each subtask has a clear interface boundary and its own tests, suitable for +independent (sub)agent implementation. From a78fae23c36b8f82a01e1225d6343cad5fbb2f88 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 30 Jun 2026 12:39:57 -0600 Subject: [PATCH 02/28] chore: stop tracking /docs (add to .gitignore) --- .gitignore | 3 + CLAUDE.md | 15 + docs/convertible-bond-conversion.md | 49 - .../2026-06-29-maui-react-hybrid-scaffold.md | 1253 ----------------- ...-react-hybrid-clean-architecture-design.md | 137 -- ...-30-convertible-bond-market-data-design.md | 199 --- 6 files changed, 18 insertions(+), 1638 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 docs/convertible-bond-conversion.md delete mode 100644 docs/superpowers/plans/2026-06-29-maui-react-hybrid-scaffold.md delete mode 100644 docs/superpowers/specs/2026-06-29-maui-react-hybrid-clean-architecture-design.md delete mode 100644 docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md diff --git a/.gitignore b/.gitignore index 3064557..1e0142c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,8 @@ node_modules/ dist/ src/AppName.Maui/Resources/Raw/web/ +# Docs (not tracked) +/docs/ + # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e4e3f21 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,15 @@ +# Project conventions + +## C# documentation & display naming + +These rules apply **only to the C# project** (not the React/web code). + +### XML documentation + +- **Interfaces**: every interface and its members (methods, properties, events) MUST have a concise `/// ` describing the contract. +- **Model properties**: every property on a model/DTO MUST have a `/// `. Also document `enum` types and their values. +- **Implementations**: classes that implement an interface (or override/inherit a documented member) MUST use `/// ` on the implementing members instead of repeating the summary. Only write a fresh `/// ` when the implementation adds behavior the interface doc doesn't cover. + +### Display naming + +- **Model properties**: every property on a model MUST carry a `[DisplayName("Human Readable Name")]` attribute so it renders correctly in UI, reports, exports, and filters. diff --git a/docs/convertible-bond-conversion.md b/docs/convertible-bond-conversion.md deleted file mode 100644 index b7481a5..0000000 --- a/docs/convertible-bond-conversion.md +++ /dev/null @@ -1,49 +0,0 @@ -# Convertible Bond — Conversion Value Reference - -Quick reference for the core convertible-bond (可轉債) conversion math, with the -Chinese terms and their English equivalents. - -## Formulas - -| Chinese | English | Formula | -|---|---|---| -| 轉換股數 | Conversion shares | `發行面額 / 轉換價` (Par value ÷ Conversion price) | -| 轉換總值 | Conversion value (parity) | `轉換股數 × 標的現價` (Conversion shares × Stock price) | -| 套利 / 價內 | In-the-money condition | `轉換總值 > 公司債市價` (Conversion value > Bond market price) | - -### 1. Conversion shares (轉換股數) - -``` -轉換股數 = 發行面額 / 轉換價 -Conversion shares = Par value / Conversion price -``` - -How many shares the bond converts into. -Example: NT$100,000 par ÷ NT$50 conversion price = **2,000 shares**. - -### 2. Conversion value / parity (轉換總值) - -``` -轉換總值 = 轉換股數 × 標的現價 -Conversion value = Conversion shares × Stock (underlying) price -``` - -The value of the converted shares at the current stock price. -Example: 2,000 shares × NT$60 = **NT$120,000**. - -### 3. In-the-money / arbitrage condition - -``` -轉換總值 > 公司債市價 → 價內 (in-the-money) -Conversion value > Bond market price → profitable to convert -``` - -When the conversion value exceeds the bond's current market price, it is -profitable to **buy the bond, convert into shares, and sell the shares**. - -## Terminology note - -- **標的 (underlying)** = the **stock**, not the bond. Use it only in formula #2. -- The comparison in #3 should be against the **bond's market price (公司債市價)**, - *not* "標的現價". Watch for this — labeling the bond price as 標的現價 is a common - wording mistake. diff --git a/docs/superpowers/plans/2026-06-29-maui-react-hybrid-scaffold.md b/docs/superpowers/plans/2026-06-29-maui-react-hybrid-scaffold.md deleted file mode 100644 index fac1880..0000000 --- a/docs/superpowers/plans/2026-06-29-maui-react-hybrid-scaffold.md +++ /dev/null @@ -1,1253 +0,0 @@ -# MAUI + React HybridWebView Clean-Architecture Scaffold — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Scaffold a cross-platform .NET MAUI app that hosts a React (Vite + TypeScript) UI through the native `HybridWebView`, organized with Domain / Application / Infrastructure / Host clean-architecture layering and an end-to-end JS↔C# bridge demonstrated by one working vertical slice. - -**Architecture:** Four C# projects with strictly inward dependencies (`Domain ← Application ← Infrastructure ← Maui`). Application defines ports (interfaces); Infrastructure implements them (EF Core/SQLite, HTTP, auth, native). The Maui project is the composition root and hosts the `HybridWebView`. React lives in `src/web`, builds with Vite into the Maui project's `Resources/Raw/web`, and talks to C# through a thin typed bridge. - -**Tech Stack:** .NET 10, .NET MAUI, EF Core + SQLite, xUnit, React 18, Vite, TypeScript, Vitest + React Testing Library. - -## Global Constraints - -- .NET SDK: 10.x (pin via `global.json`; installed: 10.0.109). One line per constraint, values verbatim. -- Node: v24.16.0 (installed). -- MAUI workload required: `dotnet workload install maui` before building the Maui project. -- App name placeholder: `AppName` (used in every namespace, project name, and path below). -- Dependency rule: no inner layer references an outer one. Domain references nothing. Application references Domain only. Infrastructure references Application (+ Domain). Maui references Application + Infrastructure. -- Target frameworks for `AppName.Maui`: `net10.0-android;net10.0-ios;net10.0-maccatalyst;net10.0-windows10.0.19041.0` (Windows TFM only built on Windows). -- Central NuGet versions via `Directory.Packages.props` (no inline `Version=` on `PackageReference`). -- **Host-platform note:** This dev machine is Linux **with no JDK and no Android SDK installed**, and no macOS/Windows host. Consequence: the platform-neutral projects (Domain, Application, Infrastructure + their tests) and the React app build and test fully here, but **no MAUI target framework can compile to completion on this machine** (android needs JDK + Android SDK; iOS/Mac Catalyst need macOS; the Windows target needs Windows). Per user decision, MAUI target builds are deferred to a configured machine. -- **MAUI-task verification protocol (Tasks 5, 6, 8, 9):** Do NOT expect a successful MAUI target build here. Verify instead by: (1) `dotnet workload install maui` succeeds; (2) `dotnet restore src/AppName.Maui/AppName.Maui.csproj` succeeds (proves project + package + project references resolve); (3) a build attempt `dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android` is allowed to FAIL, but ONLY at the missing-Android-SDK/JDK step — it must NOT fail with C# compile errors (`CS####`), missing-type, or unresolved-reference errors. If you see `CS####` or reference errors, that is a real defect to fix; failures mentioning Android SDK / `JAVA_HOME` / `aapt` / `Xamarin.Android` tooling are the expected environmental stop and count as "C# verified." Report the exact failure reason so the reviewer can confirm it is environmental. -- TypeScript bridge DTOs mirror Application DTOs by hand; keep them in sync. - ---- - -### Task 1: Repository foundation (solution + shared build config) - -**Files:** -- Create: `AppName.sln` -- Create: `global.json` -- Create: `Directory.Build.props` -- Create: `Directory.Packages.props` -- Create: `.editorconfig` -- Create: `.gitignore` - -**Interfaces:** -- Consumes: nothing. -- Produces: a solution file later tasks add projects to; central package management (other tasks add `` entries here, not inline versions); shared C# language settings (`Nullable=enable`, `ImplicitUsings=enable`, `LangVersion=latest`). - -- [ ] **Step 1: Pin the SDK** - -Create `global.json`: - -```json -{ - "sdk": { - "version": "10.0.100", - "rollForward": "latestFeature" - } -} -``` - -- [ ] **Step 2: Shared MSBuild props** - -Create `Directory.Build.props`: - -```xml - - - enable - enable - latest - false - true - - -``` - -- [ ] **Step 3: Central package versions (seed file)** - -Create `Directory.Packages.props`: - -```xml - - - - - - -``` - -- [ ] **Step 4: .editorconfig and .gitignore** - -Create `.editorconfig`: - -```ini -root = true - -[*.cs] -indent_style = space -indent_size = 4 -dotnet_sort_system_directives_first = true - -[*.{ts,tsx,js,jsx,json}] -indent_style = space -indent_size = 2 -``` - -Create `.gitignore` (covers .NET, MAUI, Node): - -```gitignore -# .NET -bin/ -obj/ -*.user -.vs/ - -# MAUI / mobile -*.binlog - -# Node / Vite -node_modules/ -dist/ -src/AppName.Maui/Resources/Raw/web/ - -# OS -.DS_Store -``` - -- [ ] **Step 5: Create the empty solution** - -Run: `dotnet new sln -n AppName` -Expected: `AppName.sln` created. - -- [ ] **Step 6: Verify and commit** - -Run: `dotnet sln list` -Expected: "No projects found in the solution." (prints header without error) - -```bash -git add global.json Directory.Build.props Directory.Packages.props .editorconfig .gitignore AppName.sln -git commit -m "chore: scaffold solution and shared build config" -``` - ---- - -### Task 2: Domain project + sample entity - -**Files:** -- Create: `src/AppName.Domain/AppName.Domain.csproj` -- Create: `src/AppName.Domain/Entities/User.cs` -- Create: `src/AppName.Domain/Abstractions/IUserRepository.cs` -- Test: `tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj` -- Test: `tests/AppName.Domain.Tests/UserTests.cs` - -**Interfaces:** -- Consumes: shared props from Task 1. -- Produces: - - `AppName.Domain.Entities.User` with constructor `User(Guid id, string name, string email)`; properties `Guid Id`, `string Name`, `string Email`; method `void Rename(string newName)` (throws `ArgumentException` on null/whitespace). - - `AppName.Domain.Abstractions.IUserRepository` with `Task> GetAllAsync(CancellationToken ct = default)` and `Task AddAsync(User user, CancellationToken ct = default)`. - -- [ ] **Step 1: Create the Domain project (no dependencies)** - -Run: -```bash -dotnet new classlib -n AppName.Domain -o src/AppName.Domain -f net10.0 -dotnet sln add src/AppName.Domain/AppName.Domain.csproj -rm src/AppName.Domain/Class1.cs -``` -Expected: project created and added to solution. - -- [ ] **Step 2: Create the test project and add xUnit versions** - -Run: -```bash -dotnet new xunit -n AppName.Domain.Tests -o tests/AppName.Domain.Tests -f net10.0 -dotnet sln add tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj -dotnet add tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj reference src/AppName.Domain/AppName.Domain.csproj -``` - -Ensure xUnit packages use central versions. In `Directory.Packages.props`, add inside ``: - -```xml - - - - -``` - -Then strip inline versions from `tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj` (remove `Version="..."` from each `PackageReference`). - -- [ ] **Step 3: Write the failing test** - -Create `tests/AppName.Domain.Tests/UserTests.cs`: - -```csharp -using AppName.Domain.Entities; - -namespace AppName.Domain.Tests; - -public class UserTests -{ - [Fact] - public void Rename_WithValidName_UpdatesName() - { - var user = new User(Guid.NewGuid(), "Old Name", "a@b.com"); - - user.Rename("New Name"); - - Assert.Equal("New Name", user.Name); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Rename_WithBlankName_Throws(string blank) - { - var user = new User(Guid.NewGuid(), "Old Name", "a@b.com"); - - Assert.Throws(() => user.Rename(blank)); - } -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `dotnet test tests/AppName.Domain.Tests` -Expected: FAIL — `User` type does not exist (compile error). - -- [ ] **Step 5: Implement the entity and the repository port** - -Create `src/AppName.Domain/Entities/User.cs`: - -```csharp -namespace AppName.Domain.Entities; - -public sealed class User -{ - public User(Guid id, string name, string email) - { - Id = id; - Name = name; - Email = email; - } - - public Guid Id { get; } - public string Name { get; private set; } - public string Email { get; } - - public void Rename(string newName) - { - if (string.IsNullOrWhiteSpace(newName)) - throw new ArgumentException("Name cannot be blank.", nameof(newName)); - Name = newName; - } -} -``` - -Create `src/AppName.Domain/Abstractions/IUserRepository.cs`: - -```csharp -using AppName.Domain.Entities; - -namespace AppName.Domain.Abstractions; - -public interface IUserRepository -{ - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(User user, CancellationToken ct = default); -} -``` - -- [ ] **Step 6: Run test to verify it passes** - -Run: `dotnet test tests/AppName.Domain.Tests` -Expected: PASS (3 tests). - -- [ ] **Step 7: Commit** - -```bash -git add src/AppName.Domain tests/AppName.Domain.Tests Directory.Packages.props AppName.sln -git commit -m "feat(domain): add User entity and IUserRepository port" -``` - ---- - -### Task 3: Application project + use case, DTO, port - -**Files:** -- Create: `src/AppName.Application/AppName.Application.csproj` -- Create: `src/AppName.Application/Dtos/UserDto.cs` -- Create: `src/AppName.Application/UseCases/Users/GetUsersUseCase.cs` -- Create: `src/AppName.Application/UseCases/Users/AddUserUseCase.cs` -- Create: `src/AppName.Application/DependencyInjection.cs` -- Test: `tests/AppName.Application.Tests/AppName.Application.Tests.csproj` -- Test: `tests/AppName.Application.Tests/GetUsersUseCaseTests.cs` - -**Interfaces:** -- Consumes: `AppName.Domain.Entities.User`, `AppName.Domain.Abstractions.IUserRepository`. -- Produces: - - `AppName.Application.Dtos.UserDto` record: `record UserDto(Guid Id, string Name, string Email)`. - - `AppName.Application.UseCases.Users.GetUsersUseCase` with ctor `(IUserRepository repo)` and `Task> ExecuteAsync(CancellationToken ct = default)`. - - `AppName.Application.UseCases.Users.AddUserUseCase` with ctor `(IUserRepository repo)` and `Task ExecuteAsync(string name, string email, CancellationToken ct = default)`. - - `AppName.Application.DependencyInjection.AddApplication(this IServiceCollection services)` registering both use cases as transient. - -- [ ] **Step 1: Create the Application project referencing Domain + DI abstractions** - -Run: -```bash -dotnet new classlib -n AppName.Application -o src/AppName.Application -f net10.0 -dotnet sln add src/AppName.Application/AppName.Application.csproj -rm src/AppName.Application/Class1.cs -dotnet add src/AppName.Application/AppName.Application.csproj reference src/AppName.Domain/AppName.Domain.csproj -dotnet add src/AppName.Application/AppName.Application.csproj package Microsoft.Extensions.DependencyInjection.Abstractions -``` - -Add to `Directory.Packages.props`: - -```xml - -``` -Then remove the inline `Version=` the `dotnet add package` wrote into the csproj. - -- [ ] **Step 2: Create the test project** - -Run: -```bash -dotnet new xunit -n AppName.Application.Tests -o tests/AppName.Application.Tests -f net10.0 -dotnet sln add tests/AppName.Application.Tests/AppName.Application.Tests.csproj -dotnet add tests/AppName.Application.Tests/AppName.Application.Tests.csproj reference src/AppName.Application/AppName.Application.csproj -dotnet add tests/AppName.Application.Tests/AppName.Application.Tests.csproj reference src/AppName.Domain/AppName.Domain.csproj -``` -Remove inline xUnit `Version=` entries (they resolve from central versions added in Task 2). - -- [ ] **Step 3: Write the failing test (hand-rolled fake repo, no mocking lib)** - -Create `tests/AppName.Application.Tests/GetUsersUseCaseTests.cs`: - -```csharp -using AppName.Application.UseCases.Users; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; - -namespace AppName.Application.Tests; - -public class GetUsersUseCaseTests -{ - private sealed class FakeUserRepository : IUserRepository - { - private readonly List _users = new(); - public Task AddAsync(User user, CancellationToken ct = default) - { - _users.Add(user); - return Task.CompletedTask; - } - public Task> GetAllAsync(CancellationToken ct = default) - => Task.FromResult>(_users); - } - - [Fact] - public async Task ExecuteAsync_MapsEntitiesToDtos() - { - var repo = new FakeUserRepository(); - await repo.AddAsync(new User(Guid.NewGuid(), "Ada", "ada@x.com")); - var sut = new GetUsersUseCase(repo); - - var result = await sut.ExecuteAsync(); - - var dto = Assert.Single(result); - Assert.Equal("Ada", dto.Name); - Assert.Equal("ada@x.com", dto.Email); - } -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `dotnet test tests/AppName.Application.Tests` -Expected: FAIL — `GetUsersUseCase` / `UserDto` do not exist. - -- [ ] **Step 5: Implement DTO and use cases** - -Create `src/AppName.Application/Dtos/UserDto.cs`: - -```csharp -namespace AppName.Application.Dtos; - -public record UserDto(Guid Id, string Name, string Email); -``` - -Create `src/AppName.Application/UseCases/Users/GetUsersUseCase.cs`: - -```csharp -using AppName.Application.Dtos; -using AppName.Domain.Abstractions; - -namespace AppName.Application.UseCases.Users; - -public sealed class GetUsersUseCase -{ - private readonly IUserRepository _repo; - public GetUsersUseCase(IUserRepository repo) => _repo = repo; - - public async Task> ExecuteAsync(CancellationToken ct = default) - { - var users = await _repo.GetAllAsync(ct); - return users.Select(u => new UserDto(u.Id, u.Name, u.Email)).ToList(); - } -} -``` - -Create `src/AppName.Application/UseCases/Users/AddUserUseCase.cs`: - -```csharp -using AppName.Application.Dtos; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; - -namespace AppName.Application.UseCases.Users; - -public sealed class AddUserUseCase -{ - private readonly IUserRepository _repo; - public AddUserUseCase(IUserRepository repo) => _repo = repo; - - public async Task ExecuteAsync(string name, string email, CancellationToken ct = default) - { - var user = new User(Guid.NewGuid(), name, email); - await _repo.AddAsync(user, ct); - return new UserDto(user.Id, user.Name, user.Email); - } -} -``` - -Create `src/AppName.Application/DependencyInjection.cs`: - -```csharp -using AppName.Application.UseCases.Users; -using Microsoft.Extensions.DependencyInjection; - -namespace AppName.Application; - -public static class DependencyInjection -{ - public static IServiceCollection AddApplication(this IServiceCollection services) - { - services.AddTransient(); - services.AddTransient(); - return services; - } -} -``` - -- [ ] **Step 6: Run test to verify it passes** - -Run: `dotnet test tests/AppName.Application.Tests` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/AppName.Application tests/AppName.Application.Tests Directory.Packages.props AppName.sln -git commit -m "feat(application): add user use cases, DTO, and DI registration" -``` - ---- - -### Task 4: Infrastructure project (EF Core/SQLite repo + DI) - -**Files:** -- Create: `src/AppName.Infrastructure/AppName.Infrastructure.csproj` -- Create: `src/AppName.Infrastructure/Persistence/AppDbContext.cs` -- Create: `src/AppName.Infrastructure/Persistence/UserRepository.cs` -- Create: `src/AppName.Infrastructure/DependencyInjection.cs` -- Test: `tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj` -- Test: `tests/AppName.Infrastructure.Tests/UserRepositoryTests.cs` - -**Interfaces:** -- Consumes: `IUserRepository`, `User` (Domain); `AddApplication` (Application). -- Produces: - - `AppName.Infrastructure.Persistence.AppDbContext : DbContext` with `DbSet Users`. - - `AppName.Infrastructure.Persistence.UserRepository : IUserRepository`. - - `AppName.Infrastructure.DependencyInjection.AddInfrastructure(this IServiceCollection services, string dbPath)` — registers `AppDbContext` (SQLite at `dbPath`) and `IUserRepository → UserRepository` scoped. - -- [ ] **Step 1: Create the Infrastructure project** - -Run: -```bash -dotnet new classlib -n AppName.Infrastructure -o src/AppName.Infrastructure -f net10.0 -dotnet sln add src/AppName.Infrastructure/AppName.Infrastructure.csproj -rm src/AppName.Infrastructure/Class1.cs -dotnet add src/AppName.Infrastructure/AppName.Infrastructure.csproj reference src/AppName.Application/AppName.Application.csproj -dotnet add src/AppName.Infrastructure/AppName.Infrastructure.csproj reference src/AppName.Domain/AppName.Domain.csproj -``` - -Add EF Core SQLite. In `Directory.Packages.props` add: - -```xml - -``` -Then reference it (no inline version): - -```bash -dotnet add src/AppName.Infrastructure/AppName.Infrastructure.csproj package Microsoft.EntityFrameworkCore.Sqlite -``` -Remove the inline `Version=` it adds. - -- [ ] **Step 2: Create the test project** - -Run: -```bash -dotnet new xunit -n AppName.Infrastructure.Tests -o tests/AppName.Infrastructure.Tests -f net10.0 -dotnet sln add tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj -dotnet add tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj reference src/AppName.Infrastructure/AppName.Infrastructure.csproj -dotnet add tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj reference src/AppName.Domain/AppName.Domain.csproj -``` -Remove inline xUnit versions. - -- [ ] **Step 3: Write the failing test (SQLite file in temp dir, real round-trip)** - -Create `tests/AppName.Infrastructure.Tests/UserRepositoryTests.cs`: - -```csharp -using AppName.Domain.Entities; -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace AppName.Infrastructure.Tests; - -public class UserRepositoryTests -{ - private static AppDbContext NewContext(string dbPath) - { - var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source={dbPath}") - .Options; - var ctx = new AppDbContext(options); - ctx.Database.EnsureCreated(); - return ctx; - } - - [Fact] - public async Task AddAsync_ThenGetAllAsync_ReturnsPersistedUser() - { - var dbPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.db"); - try - { - await using var ctx = NewContext(dbPath); - var repo = new UserRepository(ctx); - - await repo.AddAsync(new User(Guid.NewGuid(), "Ada", "ada@x.com")); - var all = await repo.GetAllAsync(); - - var user = Assert.Single(all); - Assert.Equal("Ada", user.Name); - } - finally - { - if (File.Exists(dbPath)) File.Delete(dbPath); - } - } -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `dotnet test tests/AppName.Infrastructure.Tests` -Expected: FAIL — `AppDbContext` / `UserRepository` do not exist. - -- [ ] **Step 5: Implement DbContext, repository, and DI** - -Create `src/AppName.Infrastructure/Persistence/AppDbContext.cs`: - -```csharp -using AppName.Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace AppName.Infrastructure.Persistence; - -public sealed class AppDbContext : DbContext -{ - public AppDbContext(DbContextOptions options) : base(options) { } - - public DbSet Users => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - var user = modelBuilder.Entity(); - user.HasKey(u => u.Id); - user.Property(u => u.Name).IsRequired(); - user.Property(u => u.Email).IsRequired(); - } -} -``` - -Create `src/AppName.Infrastructure/Persistence/UserRepository.cs`: - -```csharp -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace AppName.Infrastructure.Persistence; - -public sealed class UserRepository : IUserRepository -{ - private readonly AppDbContext _db; - public UserRepository(AppDbContext db) => _db = db; - - public async Task AddAsync(User user, CancellationToken ct = default) - { - _db.Users.Add(user); - await _db.SaveChangesAsync(ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - => await _db.Users.AsNoTracking().ToListAsync(ct); -} -``` - -Create `src/AppName.Infrastructure/DependencyInjection.cs`: - -```csharp -using AppName.Domain.Abstractions; -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace AppName.Infrastructure; - -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure(this IServiceCollection services, string dbPath) - { - services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}")); - services.AddScoped(); - return services; - } -} -``` - -- [ ] **Step 6: Run test to verify it passes** - -Run: `dotnet test tests/AppName.Infrastructure.Tests` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/AppName.Infrastructure tests/AppName.Infrastructure.Tests Directory.Packages.props AppName.sln -git commit -m "feat(infrastructure): add EF Core SQLite UserRepository and DI" -``` - ---- - -### Task 5: MAUI host project with HybridWebView - -**Files:** -- Create: `src/AppName.Maui/AppName.Maui.csproj` (via template, then edited) -- Modify: `src/AppName.Maui/MauiProgram.cs` -- Create: `src/AppName.Maui/MainPage.xaml` -- Create: `src/AppName.Maui/MainPage.xaml.cs` -- Create: `src/AppName.Maui/Resources/Raw/web/index.html` (placeholder until React build) - -**Interfaces:** -- Consumes: `AddApplication()` (Application), `AddInfrastructure(dbPath)` (Infrastructure). -- Produces: a running MAUI app whose `MainPage` contains a `HybridWebView` with `HybridRoot="web"` and `DefaultFile="index.html"`, serving files from `Resources/Raw/web`. DI configured in `MauiProgram`. - -- [ ] **Step 1: Install the MAUI workload (one-time, host machine)** - -Run: `dotnet workload install maui` -Expected: workload installs (or "already installed"). If offline/unavailable, stop and surface to the user — the rest of this task depends on it. - -- [ ] **Step 2: Create the MAUI app from template** - -Run: -```bash -dotnet new maui -n AppName.Maui -o src/AppName.Maui -dotnet sln add src/AppName.Maui/AppName.Maui.csproj -dotnet add src/AppName.Maui/AppName.Maui.csproj reference src/AppName.Application/AppName.Application.csproj -dotnet add src/AppName.Maui/AppName.Maui.csproj reference src/AppName.Infrastructure/AppName.Infrastructure.csproj -``` -Then delete template sample files not used by this scaffold: -```bash -rm -f src/AppName.Maui/MainPage.xaml src/AppName.Maui/MainPage.xaml.cs -``` -(We recreate `MainPage` below with the HybridWebView.) - -- [ ] **Step 3: Replace MainPage with a HybridWebView host** - -Create `src/AppName.Maui/MainPage.xaml`: - -```xml - - - - -``` - -Create `src/AppName.Maui/MainPage.xaml.cs`: - -```csharp -namespace AppName.Maui; - -public partial class MainPage : ContentPage -{ - public MainPage() - { - InitializeComponent(); - } -} -``` - -- [ ] **Step 4: Wire DI in MauiProgram and register the HybridWebView control** - -Replace `src/AppName.Maui/MauiProgram.cs` with: - -```csharp -using AppName.Application; -using AppName.Infrastructure; -using Microsoft.Extensions.Logging; - -namespace AppName.Maui; - -public static class MauiProgram -{ - public static MauiApp CreateMauiApp() - { - var builder = MauiApp.CreateBuilder(); - builder - .UseMauiApp() - .ConfigureFonts(fonts => - { - fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); - }); - - var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db"); - builder.Services.AddApplication(); - builder.Services.AddInfrastructure(dbPath); - - builder.Services.AddTransient(); - -#if DEBUG - builder.Logging.AddDebug(); -#endif - return builder.Build(); - } -} -``` - -Ensure `App.xaml.cs` resolves `MainPage` from DI. Replace its `MainPage` assignment so the constructor takes `MainPage`: - -```csharp -namespace AppName.Maui; - -public partial class App : Application -{ - public App(MainPage mainPage) - { - InitializeComponent(); - MainPage = mainPage; - } -} -``` -And register `App` is already handled by `UseMauiApp()`; `AppName.Maui` template wires `App` via DI automatically. - -- [ ] **Step 5: Add a placeholder web asset (until React build exists)** - -Create `src/AppName.Maui/Resources/Raw/web/index.html`: - -```html - - - AppName -

HybridWebView placeholder — React build replaces this.

- -``` - -Note: `.gitignore` ignores `Resources/Raw/web/`. Force-add this placeholder so the dir exists pre-build: -```bash -git add -f src/AppName.Maui/Resources/Raw/web/index.html -``` - -- [ ] **Step 6: Verify per the MAUI-task verification protocol (see Global Constraints)** - -Run: `dotnet restore src/AppName.Maui/AppName.Maui.csproj` -Expected: restore succeeds (project, package, and project references resolve). - -Then run: `dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android` -Expected: build attempt fails ONLY at the missing Android SDK / JDK step (e.g. message about Android SDK, `JAVA_HOME`, or Xamarin.Android tooling). It must NOT fail with `CS####` compile errors or unresolved references — those indicate real defects. Capture and report the exact failure reason. - -- [ ] **Step 7: Commit** - -```bash -git add src/AppName.Maui AppName.sln -git add -f src/AppName.Maui/Resources/Raw/web/index.html -git commit -m "feat(maui): add MAUI host with HybridWebView and DI wiring" -``` - ---- - -### Task 6: C# bridge endpoint (HybridWebView → Application) - -**Files:** -- Create: `src/AppName.Maui/Bridge/UsersBridge.cs` -- Modify: `src/AppName.Maui/MainPage.xaml.cs` - -**Interfaces:** -- Consumes: `GetUsersUseCase`, `AddUserUseCase`, `UserDto` (Application); `HybridWebView` named `HybridView`. -- Produces: a JS-invokable C# object registered on the HybridWebView. Methods callable from JS: - - `Task> GetUsers()` - - `Task AddUser(string name, string email)` - -- [ ] **Step 1: Create the bridge object (thin; delegates to use cases)** - -Create `src/AppName.Maui/Bridge/UsersBridge.cs`: - -```csharp -using AppName.Application.Dtos; -using AppName.Application.UseCases.Users; - -namespace AppName.Maui.Bridge; - -// Methods on this object are invoked from JavaScript via HybridWebView. -public sealed class UsersBridge -{ - private readonly GetUsersUseCase _getUsers; - private readonly AddUserUseCase _addUser; - - public UsersBridge(GetUsersUseCase getUsers, AddUserUseCase addUser) - { - _getUsers = getUsers; - _addUser = addUser; - } - - public Task> GetUsers() => _getUsers.ExecuteAsync(); - - public Task AddUser(string name, string email) => _addUser.ExecuteAsync(name, email); -} -``` - -- [ ] **Step 2: Register the bridge with the HybridWebView** - -Replace `src/AppName.Maui/MainPage.xaml.cs`: - -```csharp -using AppName.Maui.Bridge; - -namespace AppName.Maui; - -public partial class MainPage : ContentPage -{ - public MainPage(UsersBridge usersBridge) - { - InitializeComponent(); - HybridView.SetInvokeJavaScriptTarget(usersBridge); - } -} -``` - -- [ ] **Step 3: Register the bridge in DI** - -In `src/AppName.Maui/MauiProgram.cs`, add after `AddInfrastructure(...)`: - -```csharp -builder.Services.AddTransient(); -``` - -- [ ] **Step 4: Verify the bridge compiles (MAUI-task verification protocol)** - -Run: `dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android` -Expected: per the protocol in Global Constraints — failure ONLY at the missing Android SDK / JDK step, with NO `CS####` compile errors (which would mean the bridge code is wrong). Report the exact failure reason. - -- [ ] **Step 5: Commit** - -```bash -git add src/AppName.Maui -git commit -m "feat(maui): add UsersBridge JS<->C# endpoint" -``` - ---- - -### Task 7: React app (Vite + TypeScript) with typed bridge client - -**Files:** -- Create: `src/web/package.json`, `src/web/vite.config.ts`, `src/web/tsconfig.json`, `src/web/tsconfig.node.json`, `src/web/index.html` -- Create: `src/web/src/main.tsx`, `src/web/src/App.tsx` -- Create: `src/web/src/bridge/types.ts`, `src/web/src/bridge/client.ts` -- Create: `src/web/src/features/users/UsersList.tsx` -- Create: `src/web/src/bridge/client.test.ts` -- Create: `src/web/vitest.config.ts`, `src/web/src/test/setup.ts` - -**Interfaces:** -- Consumes: the C# bridge object exposed as `window.HybridWebView.InvokeDotNet(...)` (TS wraps this). -- Produces: - - `bridge/types.ts`: `export interface UserDto { id: string; name: string; email: string; }` (mirrors C# `UserDto`). - - `bridge/client.ts`: `export const bridge = { users: { getAll(): Promise, add(name: string, email: string): Promise } }`. - - Vite builds to `../AppName.Maui/Resources/Raw/web`. - -- [ ] **Step 1: Scaffold the Vite React-TS app** - -Run: -```bash -cd src/web && npm create vite@latest . -- --template react-ts -``` -(If `src/web` already has the placeholder dir, run in an empty `src/web`; accept overwrite of `index.html`.) Then: -```bash -cd src/web && npm install -``` - -- [ ] **Step 2: Point Vite build output at the MAUI Raw assets and set relative base** - -Replace `src/web/vite.config.ts`: - -```ts -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -// HybridWebView serves from a virtual root; assets must use relative paths. -export default defineConfig({ - plugins: [react()], - base: "./", - build: { - outDir: "../AppName.Maui/Resources/Raw/web", - emptyOutDir: true, - }, -}); -``` - -- [ ] **Step 3: Add Vitest + Testing Library** - -Run: -```bash -cd src/web && npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom -``` - -Create `src/web/vitest.config.ts`: - -```ts -import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - test: { - environment: "jsdom", - globals: true, - setupFiles: ["./src/test/setup.ts"], - }, -}); -``` - -Create `src/web/src/test/setup.ts`: - -```ts -import "@testing-library/jest-dom"; -``` - -Add scripts to `src/web/package.json` (`scripts` section): - -```json -"test": "vitest run", -"test:watch": "vitest" -``` - -- [ ] **Step 4: Write the failing bridge-client test** - -Create `src/web/src/bridge/client.test.ts`: - -```ts -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { bridge } from "./client"; -import type { UserDto } from "./types"; - -declare global { - // eslint-disable-next-line no-var - var HybridWebView: { InvokeDotNet: (name: string, args?: unknown[]) => Promise }; -} - -describe("bridge.users", () => { - beforeEach(() => { - globalThis.HybridWebView = { InvokeDotNet: vi.fn() }; - }); - - it("getAll calls GetUsers and returns the result", async () => { - const fake: UserDto[] = [{ id: "1", name: "Ada", email: "ada@x.com" }]; - (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue(fake); - - const result = await bridge.users.getAll(); - - expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("GetUsers"); - expect(result).toEqual(fake); - }); - - it("add calls AddUser with name and email", async () => { - const created: UserDto = { id: "2", name: "Bob", email: "bob@x.com" }; - (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue(created); - - const result = await bridge.users.add("Bob", "bob@x.com"); - - expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("AddUser", ["Bob", "bob@x.com"]); - expect(result).toEqual(created); - }); -}); -``` - -- [ ] **Step 5: Run test to verify it fails** - -Run: `cd src/web && npm test` -Expected: FAIL — `./client` and `./types` not found. - -- [ ] **Step 6: Implement the typed bridge types and client** - -Create `src/web/src/bridge/types.ts`: - -```ts -// Mirrors AppName.Application.Dtos.UserDto — keep in sync by hand. -export interface UserDto { - id: string; - name: string; - email: string; -} -``` - -Create `src/web/src/bridge/client.ts`: - -```ts -import type { UserDto } from "./types"; - -// HybridWebView injects this global at runtime. -declare const HybridWebView: { - InvokeDotNet: (methodName: string, args?: unknown[]) => Promise; -}; - -function invoke(method: string, args?: unknown[]): Promise { - return HybridWebView.InvokeDotNet(method, args) as Promise; -} - -export const bridge = { - users: { - getAll: (): Promise => invoke("GetUsers"), - add: (name: string, email: string): Promise => - invoke("AddUser", [name, email]), - }, -}; -``` - -- [ ] **Step 7: Run test to verify it passes** - -Run: `cd src/web && npm test` -Expected: PASS (2 tests). - -- [ ] **Step 8: Build a sample feature that uses the bridge** - -Create `src/web/src/features/users/UsersList.tsx`: - -```tsx -import { useEffect, useState } from "react"; -import { bridge } from "../../bridge/client"; -import type { UserDto } from "../../bridge/types"; - -export function UsersList() { - const [users, setUsers] = useState([]); - const [error, setError] = useState(null); - - useEffect(() => { - bridge.users - .getAll() - .then(setUsers) - .catch((e: unknown) => setError(String(e))); - }, []); - - if (error) return

Bridge error: {error}

; - return ( -
    - {users.map((u) => ( -
  • - {u.name} — {u.email} -
  • - ))} -
- ); -} -``` - -Replace `src/web/src/App.tsx`: - -```tsx -import { UsersList } from "./features/users/UsersList"; - -export default function App() { - return ( -
-

AppName

- -
- ); -} -``` - -- [ ] **Step 9: Build the React app into the MAUI Raw folder** - -Run: `cd src/web && npm run build` -Expected: build succeeds; output appears in `src/AppName.Maui/Resources/Raw/web/` (`index.html` + `assets/`). - -- [ ] **Step 10: Commit (source only; build output is gitignored)** - -```bash -git add src/web -git commit -m "feat(web): add Vite React TS app with typed bridge client" -``` - ---- - -### Task 8: Build integration (React build runs before MAUI build) - -**Files:** -- Modify: `src/AppName.Maui/AppName.Maui.csproj` - -**Interfaces:** -- Consumes: `src/web` npm scripts (`build`). -- Produces: an MSBuild target `BuildReactApp` that runs `npm install` + `npm run build` in `src/web` before `Build`, gated by a `SkipReactBuild` property so devs can iterate against the Vite dev server. - -- [ ] **Step 1: Add the MSBuild target** - -In `src/AppName.Maui/AppName.Maui.csproj`, add before ``: - -```xml - - - false - $(MSBuildProjectDirectory)/../web - - - - - - - -``` - -- [ ] **Step 2: Verify the target runs on build (MAUI-task verification protocol)** - -Run: `dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android` -Expected: log shows "Building React app in .../web" and the npm build runs (proving the target fires before the build), then the build reaches the expected environmental stop at the missing Android SDK / JDK step (no `CS####` errors). Confirm `src/AppName.Maui/Resources/Raw/web/index.html` was (re)generated by the React build. Report the exact failure reason. - -- [ ] **Step 3: Verify the skip switch works** - -Run: `dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android -p:SkipReactBuild=true` -Expected: log does NOT show "Building React app" (the npm build is skipped); the build then reaches the same expected environmental Android-SDK/JDK stop. Report the exact failure reason. - -- [ ] **Step 4: Commit** - -```bash -git add src/AppName.Maui/AppName.Maui.csproj -git commit -m "build(maui): run Vite build before MAUI build (toggleable)" -``` - ---- - -### Task 9: README with dev/run workflow and final solution verification - -**Files:** -- Create: `README.md` - -**Interfaces:** -- Consumes: everything above. -- Produces: documented dev loops (bundled vs Vite dev server) and a top-to-bottom verification. - -- [ ] **Step 1: Write the README** - -Create `README.md`: - -````markdown -# AppName - -.NET MAUI app hosting a React (Vite + TypeScript) UI via `HybridWebView`, -structured with clean architecture. - -## Layout - -- `src/AppName.Domain` — entities, value objects, domain interfaces (no deps) -- `src/AppName.Application` — use cases, DTOs, ports (depends on Domain) -- `src/AppName.Infrastructure` — EF Core/SQLite, HTTP, auth, native (implements ports) -- `src/AppName.Maui` — MAUI host, HybridWebView, JS↔C# bridge, DI composition root -- `src/web` — React + Vite + TypeScript UI (builds into `AppName.Maui/Resources/Raw/web`) -- `tests/*` — xUnit tests per C# layer - -## Prerequisites - -- .NET 10 SDK -- `dotnet workload install maui` -- Node 24+ -- Platform SDKs for the targets you build (Android SDK; macOS for iOS/Mac Catalyst; Windows for the Windows target) - -## Run (bundled assets — production-like) - -```bash -dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android -# then deploy/run via your IDE or: dotnet build -t:Run -f net10.0-android -``` - -The MAUI build runs the Vite build automatically (see `BuildReactApp` target). - -## Develop the UI with hot reload (Vite dev server) - -```bash -cd src/web && npm run dev # serves at http://localhost:5173 -``` -Build MAUI with `-p:SkipReactBuild=true` and point the HybridWebView source at -the dev server URL during development (see comment in `MainPage`). - -## Test - -```bash -dotnet test # all C# layers -cd src/web && npm test # React + bridge client -``` - -## The JS↔C# bridge - -C# methods on `UsersBridge` are invoked from JS via the typed client in -`src/web/src/bridge/client.ts`. C# DTOs in `AppName.Application/Dtos` are -mirrored in `src/web/src/bridge/types.ts` — keep them in sync. -```` - -- [ ] **Step 2: Full verification — all C# tests** - -Run: `dotnet test` -Expected: all test projects PASS. - -- [ ] **Step 3: Full verification — React tests + build** - -Run: `cd src/web && npm test && npm run build` -Expected: tests PASS; build emits to `src/AppName.Maui/Resources/Raw/web`. - -- [ ] **Step 4: Full verification — MAUI restore + C# compile check (MAUI-task verification protocol)** - -Run: `dotnet restore src/AppName.Maui/AppName.Maui.csproj` -Expected: restore succeeds. - -Then run: `dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android` -Expected: per protocol — failure ONLY at the missing Android SDK / JDK step, no `CS####` errors. This confirms all C# across the MAUI host is consistent. Note in the README/report that completing an actual MAUI target build requires a configured Android/macOS/Windows host. - -- [ ] **Step 5: Commit** - -```bash -git add README.md -git commit -m "docs: add README with layout, dev loop, and bridge notes" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** §1 solution/layout → Tasks 1–5; §2 layer responsibilities → Tasks 2 (Domain), 3 (Application), 4 (Infrastructure), 5 (Maui); §3 bridge → Tasks 6 (C#) + 7 (TS client/types); §4 React structure → Task 7; §5 build integration → Task 8; §6 testing → tests in Tasks 2/3/4/7 + verification in Task 9. Auth/HTTP/native infrastructure folders are represented by the persistence vertical slice as the proven pattern; additional ports (`IRemoteApi`, `ISecureStore`, `IFileService`) follow the same Domain-port → Infrastructure-impl shape and are noted for follow-up rather than fully built, to keep this scaffold to one working slice (YAGNI). -- **Placeholder scan:** no TBD/TODO; every code step contains full code. -- **Type consistency:** `UserDto(Guid Id, string Name, string Email)` ↔ TS `UserDto { id, name, email }`; bridge methods `GetUsers`/`AddUser` match C# `UsersBridge` method names and TS `invoke` call strings. -- **Scope note:** This is one cohesive scaffold producing a runnable app; not decomposed further. diff --git a/docs/superpowers/specs/2026-06-29-maui-react-hybrid-clean-architecture-design.md b/docs/superpowers/specs/2026-06-29-maui-react-hybrid-clean-architecture-design.md deleted file mode 100644 index 6d96af7..0000000 --- a/docs/superpowers/specs/2026-06-29-maui-react-hybrid-clean-architecture-design.md +++ /dev/null @@ -1,137 +0,0 @@ -# MAUI + React HybridWebView with Clean Architecture — Design - -**Date:** 2026-06-29 -**Status:** Approved (pending spec review) - -## Goal - -Scaffold a cross-platform .NET MAUI application that hosts a React UI via MAUI's -native `HybridWebView` control, organized with clean-architecture separation -(Domain / Application / Infrastructure / Host) on the C# side and a structured -Vite + TypeScript React app on the web side. - -## Key Decisions - -| Decision | Choice | -| --- | --- | -| Web framework | React (Vite + TypeScript) | -| MAUI web host | Native `HybridWebView` (not Blazor `BlazorWebView`) | -| Logic split | Balanced — full C# layering **and** a structured React app | -| Infrastructure scope | Local DB (SQLite/EF Core), Remote API (HTTP), Native device features, Auth + secure storage | -| Target platforms | All MAUI targets: Android, iOS, Windows, macOS (Mac Catalyst) | -| Repo layout | Single repo, `src/` layout | -| App name | Placeholder `AppName` (rename later) | -| Toolchain present | .NET 10 SDK (10.0.109), Node v24.16.0. MAUI workload **not yet installed**. | - -## 1. Solution & Folder Structure - -``` -AppName/ -├─ AppName.sln -├─ Directory.Build.props # shared C# props (nullable, langversion, etc.) -├─ Directory.Packages.props # central NuGet version management -├─ global.json # pin .NET SDK -├─ .editorconfig -├─ src/ -│ ├─ AppName.Domain/ # entities, value objects, domain interfaces — ZERO deps -│ ├─ AppName.Application/ # use cases, DTOs, ports (interfaces) — depends on Domain only -│ ├─ AppName.Infrastructure/ # EF Core/SQLite, HTTP clients, auth, native adapters -│ ├─ AppName.Maui/ # MAUI host: HybridWebView, DI wiring, JS bridge, platform code -│ │ └─ Resources/Raw/web/ # ← Vite build output lands here (served to HybridWebView) -│ └─ web/ # React + Vite + TS source (own package.json) -└─ tests/ - ├─ AppName.Domain.Tests/ - ├─ AppName.Application.Tests/ - └─ AppName.Infrastructure.Tests/ -``` - -**Dependency rule:** `Domain ← Application ← Infrastructure ← Maui`. -- Domain depends on nothing. -- Application depends only on Domain and defines *ports* (interfaces). -- Infrastructure implements those ports. -- Maui is the composition root: wires DI and hosts the web view. -- Dependencies only point inward; no inner layer references an outer one. - -## 2. C# Layer Responsibilities - -### Domain (`AppName.Domain`) -- `Entities/`, `ValueObjects/`, `Enums/`, `Exceptions/` -- Repository/service **interfaces** expressing domain needs -- No EF, no HTTP, no MAUI references - -### Application (`AppName.Application`) -- `UseCases/` (or `Features/` vertical slices) -- `Dtos/` — data contracts (mirrored in TS on the web side) -- `Ports/` — interfaces Infrastructure implements: `IUserRepository`, - `IRemoteApi`, `ISecureStore`, `IFileService`, etc. -- `Behaviors/` — cross-cutting concerns -- Depends only on Domain; framework-agnostic - -### Infrastructure (`AppName.Infrastructure`) -- `Persistence/` — EF Core `DbContext`, SQLite, migrations, repository impls -- `Http/` — typed `HttpClient` clients implementing remote ports -- `Auth/` — token flow + `SecureStorage` -- `Native/` — MAUI Essentials adapters (files, notifications, sensors) -- `DependencyInjection.cs` — extension method registering all the above - -### Maui (`AppName.Maui`) -- `MauiProgram.cs` — composition root (registers Application + Infrastructure) -- `Bridge/` — the JS↔C# contract (thin; no business logic) -- `Platforms/` — Android / iOS / Windows / MacCatalyst -- `MainPage` — hosts the `HybridWebView` - -## 3. JS ↔ C# Bridge - -- **C# side:** `Bridge/` folder exposes invokable methods to JS via - `HybridWebView` (`InvokeJavaScriptAsync` + `RawMessageReceived` / JS-invokes-C#). - Each bridge method is thin: deserialize request → call an **Application use case** - → serialize result. No business logic in the bridge. -- **Contract:** C# DTOs live in Application; TypeScript mirror types live in - `web/src/bridge/types.ts`. Manual mirroring initially (kept small); can - auto-generate later if it grows. -- **TS side:** `web/src/bridge/` wraps raw `window.HybridWebView` calls in a - clean typed client (e.g. `bridge.users.getAll(): Promise`), so React - components never touch the raw bridge. - -## 4. React App Structure (`src/web/`) - -``` -web/ -├─ package.json, vite.config.ts, tsconfig.json -├─ index.html -└─ src/ - ├─ main.tsx, App.tsx - ├─ bridge/ # typed C# bridge client + mirror types - ├─ features/ # feature-based folders (components + hooks + state per feature) - ├─ components/ # shared/presentational components - ├─ hooks/ # shared hooks - ├─ lib/ # utils, formatting - └─ styles/ -``` - -Vite `build.outDir` → `../AppName.Maui/Resources/Raw/web/`. MAUI serves those -static assets to the `HybridWebView`. - -## 5. Build Integration - -- Maui `.csproj` gets an MSBuild target that runs `npm install` + `npm run build` - in `src/web/` before the MAUI build, so the latest React bundle is always - bundled. Toggleable (so Vite can run separately while iterating). -- Dev loop: run the Vite dev server and point the `HybridWebView` at `localhost` - during development for hot reload; switch to bundled assets for release. Both - documented. - -## 6. Testing - -- **C#:** xUnit per layer. Domain + Application are pure and easy to test. - Infrastructure tested with SQLite in-memory and mocked HTTP. -- **React:** Vitest + React Testing Library (Vite-native). -- **Bridge seam:** test use cases directly in C#; test the TS bridge client - against a mocked `window.HybridWebView`. - -## Prerequisites / Notes - -- MAUI workload must be installed: `dotnet workload install maui`. -- All-target builds require platform SDKs (Android SDK; iOS/macOS builds require - a Mac). Windows builds run on Windows. The scaffold will compile cross-platform - per the usual MAUI constraints. diff --git a/docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md b/docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md deleted file mode 100644 index e3dbfbb..0000000 --- a/docs/superpowers/specs/2026-06-30-convertible-bond-market-data-design.md +++ /dev/null @@ -1,199 +0,0 @@ -# Convertible Bond Market Data — Design - -**Date:** 2026-06-30 -**Status:** Approved (pending spec review) -**Related:** [docs/convertible-bond-conversion.md](../../convertible-bond-conversion.md) - -## 1. Purpose - -Let the app pull Taiwan convertible-bond (可轉換公司債) data from free public -open-data sources and compute the conversion valuation documented in -[convertible-bond-conversion.md](../../convertible-bond-conversion.md): - -``` -ConversionShares = ParValue / ConversionPrice -ConversionValue = ConversionShares * UnderlyingStockPrice -InTheMoney = ConversionValue > BondMarketPrice -``` - -Data is pulled over HTTP from structured open-data APIs (no web scraping, no -Python), cached into the existing SQLite database, and exposed to the UI through -Application use cases. - -## 2. Scope - -**In scope (v1):** -- Three open-data API clients: CB issuance terms, CB daily quote, stock quote. -- A `MarketDataProvider` gateway that composes the clients and maps raw API - shapes to Domain entities. -- Domain entities + a pure conversion-math value object. -- EF Core persistence of convertible bonds (local cache) with a manual-seed - fallback. -- Two use cases: refresh-from-network, and read-and-valuate. -- Unit tests across Domain, Infrastructure, and Application. - -**Out of scope (v1):** -- Real-time / intraday data (free feeds are end-of-day; the abstraction allows a - future real-time provider without touching Domain/Application/UI). -- UI screens (this slice ends at the Application DTO; UI is separate work). -- Anti-dilution recalculation of conversion price (we store what the feed - reports). - -## 3. Why not LEAN / Scrapling - -- **LEAN** is a backtesting/live-trading engine, not a data client; it does not - carry Taiwan CB data and will not fit inside a MAUI client. Rejected. -- **Scrapling** is Python + Playwright. This is a .NET MAUI app — bundling a - Python runtime and headless browser is unviable, and would force a separate - sidecar service to solve a problem we do not have (the data is structured open - data, not HTML that needs scraping). Rejected. If scraping were ever required, - the in-codebase choice is a C# library (HtmlAgilityPack / AngleSharp). - -## 4. Data sources (free, public) - -| Need | Source | Notes | -|---|---|---| -| Conversion **terms** (conversion price, par value, dates) | TPEx CB issuance dataset — [data.gov.tw #11406](https://data.gov.tw/dataset/11406) via TPEx OpenAPI | Static per bond; rarely changes | -| Bond **daily market price** | [TPEx CB daily query](https://www.tpex.org.tw/web/bond/bonds_info/cbq/NewCB_day_qry.php) | End-of-day | -| Underlying **stock price** | [TWSE OpenAPI](https://openapi.twse.com.tw/) / TPEx stock quotes | End-of-day | - -The TPEx **web** server returns `403` to naive automated user-agents; the -**open-data API** endpoints are for programmatic use. The HTTP pipeline sets a -normal `User-Agent` header to avoid being blocked. - -## 5. Architecture - -Follows the existing Clean Architecture layering and the SoleCore "Clients" -pattern (one folder per external source; a raw `IXxxApiClient` wrapped by a -domain-facing gateway). Folders are named by **technical role** to stay -consistent with the existing `Persistence/` convention. - -### 5.1 Domain (`AppName.Domain`) — concepts + math, zero I/O - -``` -Entities/ - ConvertibleBond.cs // Symbol, Name, ParValue, ConversionPrice, UnderlyingSymbol - BondQuote.cs // bond market price + as-of date - StockQuote.cs // underlying stock price + as-of date -ValueObjects/ - ConversionValuation.cs // ConversionShares, ConversionValue, IsInTheMoney (pure) -Abstractions/ - IMarketDataProvider.cs // GetIssuanceTermsAsync, GetBondQuoteAsync, GetStockQuoteAsync - IConvertibleBondRepository.cs // GetAllAsync, GetBySymbolAsync, UpsertAsync -``` - -- All money/price fields use `decimal`. -- `ConversionValuation` is computed purely from a `ConvertibleBond` + bond price - + stock price. No network, fully unit-testable. -- Per project CLAUDE.md: every property gets `/// ` + `[DisplayName(...)]`; - interfaces are fully documented; implementations use `/// `. - -### 5.2 Infrastructure (`AppName.Infrastructure`) - -``` -Clients/ - Tpex/ - TpexHttpPipeline.cs // AddTpexHttpClients() — typed HttpClient + Polly + User-Agent - TpexBondIssuanceApiClient.cs // ITpexBondIssuanceApiClient — conversion terms (#11406) - TpexBondQuoteApiClient.cs // ITpexBondQuoteApiClient — daily bond prices - TpexApiOptions.cs // BaseUrl, UserAgent - Models/ // raw JSON DTOs for TPEx responses - Twse/ - TwseHttpPipeline.cs // AddTwseHttpClients() - TwseStockQuoteApiClient.cs // ITwseStockQuoteApiClient — underlying stock prices - TwseApiOptions.cs - Models/ -Gateways/ - MarketDataProvider.cs // : IMarketDataProvider — composes the 3 clients, maps raw -> Domain -Persistence/ - AppDbContext.cs // + DbSet, entity config in OnModelCreating - ConvertibleBondRepository.cs // : IConvertibleBondRepository (uses IDbContextFactory) -DependencyInjection.cs // AddInfrastructure(): register clients, gateway, repository -``` - -- Each `ApiClient` is a thin primary-ctor class: public methods are try/catch - wrappers (rethrow `OperationCanceledException`; log + rethrow otherwise); a - private `SendAndDeserializeAsync` does GET → status check → - `EnsureSuccessStatusCode` → `JsonSerializer.Deserialize`. -- `HttpPipeline` registers a **typed `HttpClient`** via - `AddHttpClient` with a **Polly resilience handler** (retry, - exponential backoff + jitter; handles 5xx / 429 / 408 / transient exceptions), - and sets `BaseAddress` + `User-Agent` from `IOptions`. -- Clients return **raw API models** only. Mapping to Domain happens in - `MarketDataProvider`, keeping messy JSON shapes out of Domain. -- Persistence reuses the existing `AddDbContextFactory` + - `IDbContextFactory` pattern (consistent with `UserRepository`). - -### 5.3 Application (`AppName.Application`) — orchestration - -``` -UseCases/Bonds/ - RefreshBondDataUseCase.cs // pull via IMarketDataProvider -> UpsertAsync into local DB - GetConversionValuationUseCase.cs // read DB -> run Domain math -> ConversionValuationDto -Dtos/ - ConversionValuationDto.cs // record: symbol, shares, value, bond price, isInTheMoney, asOf -DependencyInjection.cs // register the two use cases (AddTransient) -``` - -## 6. Data flow - -``` -UI (read) -> GetConversionValuationUseCase -> IConvertibleBondRepository (SQLite) - ^ seeded by v -UI (refresh) -> RefreshBondDataUseCase -> IMarketDataProvider -> Tpex/Twse ApiClients -> HTTP open data -``` - -- **Refresh** is an explicit action: pull terms + bond price + stock price, - upsert the `ConvertibleBond` (+ latest prices) into SQLite. -- **Read/valuate** runs the pure Domain math over whatever is cached locally, so - the app works offline after a refresh. -- **Manual seed** is a fallback path on the repository for bonds the feed misses. - -## 7. Error handling - -- Network/transient faults: Polly retry in the HTTP pipeline; exhausted retries - surface as a logged exception from the client. -- Non-success HTTP: logged warning + `EnsureSuccessStatusCode` throws; the - gateway decides whether a missing source is fatal or skippable per bond. -- Deserialization failure: throw `InvalidOperationException` (cannot map → cannot - trust data). -- A refresh that fails for one bond must not abort the whole batch — log and - continue, report a per-bond result summary. -- Division-by-zero guard: `ConversionValuation` rejects a zero/negative - conversion price. - -## 8. Testing - -- **Domain.Tests** — `ConversionValuation` math exhaustively (incl. zero/negative - conversion price, in-the-money boundary). Pure, no network. -- **Infrastructure.Tests** — each `ApiClient` against canned JSON via a mocked - `HttpMessageHandler`; `ConvertibleBondRepository` against SQLite; mapping in - `MarketDataProvider` with fake clients. -- **Application.Tests** — both use cases with a fake `IMarketDataProvider` and a - fake repository. - -## 9. New dependencies - -Add to `Directory.Packages.props`: -- `Microsoft.Extensions.Http` (typed `HttpClient` / `AddHttpClient`) -- `Microsoft.Extensions.Http.Resilience` **or** `Polly` (resilience handler — - match the SoleCore reference, which uses Polly's `AddResilienceHandler`) -- `Microsoft.Extensions.Options` (Options pattern) - -## 10. Implementation subtasks (for decomposition) - -1. Domain: entities (`ConvertibleBond`, `BondQuote`, `StockQuote`) + - `ConversionValuation` value object + Domain.Tests. -2. Domain: `IMarketDataProvider` + `IConvertibleBondRepository` ports. -3. Infra: TPEx issuance client (pipeline + client + options + models + - Infra.Tests). -4. Infra: TPEx bond-quote client. -5. Infra: TWSE stock-quote client. -6. Infra: `MarketDataProvider` gateway (raw → Domain mapping). -7. Infra: EF `ConvertibleBond` config + repository + migration. -8. Application: `RefreshBondDataUseCase` + `GetConversionValuationUseCase` + - `ConversionValuationDto` + App.Tests. -9. Wiring: DI registration across layers + add NuGet packages. - -Each subtask has a clear interface boundary and its own tests, suitable for -independent (sub)agent implementation. From a790862d396fe87055c56ee8a0e4cc2805268803 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Wed, 1 Jul 2026 12:38:29 -0600 Subject: [PATCH 03/28] refactor: remove UserRepository and UsersBridge --- .devcontainer/devcontainer.json | 16 +++ .github/workflows/ci.yml | 45 ++++++ CLAUDE.md | 14 ++ Directory.Packages.props | 15 +- docker/README.md | 55 +++++++ docker/android.Dockerfile | 77 ++++++++++ justfile | 106 ++++++++++++++ .../DependencyInjection.cs | 6 +- .../Dtos/ConversionValuationDto.cs | 76 ++++++++++ src/AppName.Application/Dtos/UserDto.cs | 3 - .../Bonds/GetConversionValuationUseCase.cs | 34 +++++ .../UseCases/Bonds/RefreshBondDataUseCase.cs | 23 +++ .../UseCases/Users/AddUserUseCase.cs | 18 --- .../UseCases/Users/GetUsersUseCase.cs | 16 --- .../IConvertibleBondRepository.cs | 24 ++++ .../Abstractions/IMarketDataProvider.cs | 27 ++++ .../Abstractions/IUserRepository.cs | 9 -- src/AppName.Domain/Entities/BondQuote.cs | 30 ++++ .../Entities/ConvertibleBond.cs | 63 ++++++++ src/AppName.Domain/Entities/StockQuote.cs | 30 ++++ src/AppName.Domain/Entities/User.cs | 22 --- .../ValueObjects/ConversionValuation.cs | 39 +++++ .../AppName.Infrastructure.csproj | 4 + .../Tpex/ITpexBondIssuanceApiClient.cs | 14 ++ .../Clients/Tpex/ITpexStockQuoteApiClient.cs | 14 ++ .../Tpex/Models/TpexBondIssuanceRecord.cs | 38 +++++ .../Tpex/Models/TpexStockQuoteRecord.cs | 31 ++++ .../Clients/Tpex/TpexApiOptions.cs | 17 +++ .../Clients/Tpex/TpexBondIssuanceApiClient.cs | 26 ++++ .../Clients/Tpex/TpexHttpPipeline.cs | 33 +++++ .../Clients/Tpex/TpexStockQuoteApiClient.cs | 26 ++++ .../Clients/Twse/ITwseStockQuoteApiClient.cs | 14 ++ .../Twse/Models/TwseStockQuoteRecord.cs | 31 ++++ .../Clients/Twse/TwseApiOptions.cs | 17 +++ .../Clients/Twse/TwseHttpPipeline.cs | 30 ++++ .../Clients/Twse/TwseStockQuoteApiClient.cs | 26 ++++ .../DependencyInjection.cs | 12 +- .../Gateways/MarketDataProvider.cs | 88 ++++++++++++ .../20260630214959_InitialCreate.Designer.cs | 48 +++++++ .../20260630214959_InitialCreate.cs | 36 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 45 ++++++ .../Persistence/AppDbContext.cs | 17 +-- .../Persistence/AppDbContextFactory.cs | 23 +++ .../Persistence/ConvertibleBondRepository.cs | 41 ++++++ .../Persistence/UserRepository.cs | 24 ---- src/AppName.Maui/Bridge/UsersBridge.cs | 21 --- src/AppName.Maui/MainPage.xaml.cs | 5 +- src/AppName.Maui/MauiProgram.cs | 17 ++- src/web/src/App.tsx | 3 - src/web/src/bridge/client.test.ts | 31 ++-- src/web/src/bridge/client.ts | 12 +- src/web/src/bridge/types.ts | 6 - src/web/src/features/users/UsersList.tsx | 26 ---- .../AppName.Application.Tests.csproj | 2 + .../GetUsersUseCaseTests.cs | 51 ------- .../GetConversionValuationUseCaseTests.cs | 71 ++++++++++ .../Bonds/RefreshBondDataUseCaseTests.cs | 36 +++++ .../AppName.Domain.Tests.csproj | 2 + .../Entities/ConvertibleBondTests.cs | 32 +++++ tests/AppName.Domain.Tests/UserTests.cs | 28 ---- .../ValueObjects/ConversionValuationTests.cs | 29 ++++ .../AppName.Infrastructure.Tests.csproj | 6 + .../Clients/Tpex/Fixtures/bond-issuance.json | 4 + .../Tpex/Fixtures/otc-stock-quote.json | 4 + .../Tpex/TpexBondIssuanceApiClientTests.cs | 56 ++++++++ .../Tpex/TpexStockQuoteApiClientTests.cs | 52 +++++++ .../Clients/Twse/Fixtures/stock-quote.json | 4 + .../Twse/TwseStockQuoteApiClientTests.cs | 53 +++++++ .../DependencyInjectionTests.cs | 25 ++++ .../Gateways/MarketDataProviderTests.cs | 134 ++++++++++++++++++ .../ConvertibleBondRepositoryTests.cs | 60 ++++++++ .../Persistence/MigrationTests.cs | 43 ++++++ .../UserRepositoryTests.cs | 42 ------ 73 files changed, 1935 insertions(+), 323 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 docker/README.md create mode 100644 docker/android.Dockerfile create mode 100644 justfile create mode 100644 src/AppName.Application/Dtos/ConversionValuationDto.cs delete mode 100644 src/AppName.Application/Dtos/UserDto.cs create mode 100644 src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs create mode 100644 src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs delete mode 100644 src/AppName.Application/UseCases/Users/AddUserUseCase.cs delete mode 100644 src/AppName.Application/UseCases/Users/GetUsersUseCase.cs create mode 100644 src/AppName.Domain/Abstractions/IConvertibleBondRepository.cs create mode 100644 src/AppName.Domain/Abstractions/IMarketDataProvider.cs delete mode 100644 src/AppName.Domain/Abstractions/IUserRepository.cs create mode 100644 src/AppName.Domain/Entities/BondQuote.cs create mode 100644 src/AppName.Domain/Entities/ConvertibleBond.cs create mode 100644 src/AppName.Domain/Entities/StockQuote.cs delete mode 100644 src/AppName.Domain/Entities/User.cs create mode 100644 src/AppName.Domain/ValueObjects/ConversionValuation.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/ITpexBondIssuanceApiClient.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/ITpexStockQuoteApiClient.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/Models/TpexBondIssuanceRecord.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/Models/TpexStockQuoteRecord.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/TpexApiOptions.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/TpexBondIssuanceApiClient.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/TpexHttpPipeline.cs create mode 100644 src/AppName.Infrastructure/Clients/Tpex/TpexStockQuoteApiClient.cs create mode 100644 src/AppName.Infrastructure/Clients/Twse/ITwseStockQuoteApiClient.cs create mode 100644 src/AppName.Infrastructure/Clients/Twse/Models/TwseStockQuoteRecord.cs create mode 100644 src/AppName.Infrastructure/Clients/Twse/TwseApiOptions.cs create mode 100644 src/AppName.Infrastructure/Clients/Twse/TwseHttpPipeline.cs create mode 100644 src/AppName.Infrastructure/Clients/Twse/TwseStockQuoteApiClient.cs create mode 100644 src/AppName.Infrastructure/Gateways/MarketDataProvider.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.Designer.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.cs create mode 100644 src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs create mode 100644 src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs create mode 100644 src/AppName.Infrastructure/Persistence/ConvertibleBondRepository.cs delete mode 100644 src/AppName.Infrastructure/Persistence/UserRepository.cs delete mode 100644 src/AppName.Maui/Bridge/UsersBridge.cs delete mode 100644 src/web/src/bridge/types.ts delete mode 100644 src/web/src/features/users/UsersList.tsx delete mode 100644 tests/AppName.Application.Tests/GetUsersUseCaseTests.cs create mode 100644 tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs create mode 100644 tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs create mode 100644 tests/AppName.Domain.Tests/Entities/ConvertibleBondTests.cs delete mode 100644 tests/AppName.Domain.Tests/UserTests.cs create mode 100644 tests/AppName.Domain.Tests/ValueObjects/ConversionValuationTests.cs create mode 100644 tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/bond-issuance.json create mode 100644 tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/otc-stock-quote.json create mode 100644 tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexBondIssuanceApiClientTests.cs create mode 100644 tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexStockQuoteApiClientTests.cs create mode 100644 tests/AppName.Infrastructure.Tests/Clients/Twse/Fixtures/stock-quote.json create mode 100644 tests/AppName.Infrastructure.Tests/Clients/Twse/TwseStockQuoteApiClientTests.cs create mode 100644 tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs create mode 100644 tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs create mode 100644 tests/AppName.Infrastructure.Tests/Persistence/ConvertibleBondRepositoryTests.cs create mode 100644 tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs delete mode 100644 tests/AppName.Infrastructure.Tests/UserRepositoryTests.cs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..0eae1ea --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +// .devcontainer/devcontainer.json +// +// Dev container for building the AppName.Maui Android head without an Android +// SDK / Java / MAUI workload on the host. Builds docker/android.Dockerfile, +// which carries the JDK, Android cmdline-tools, Node.js, and the +// `maui-android` workload. +{ + "name": "AppName MAUI (Android, dotnet 10)", + "build": { + "dockerfile": "../docker/android.Dockerfile", + "context": ".." + }, + "workspaceFolder": "/src", + "workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind", + "remoteUser": "root" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0a58e8..a7725e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,48 @@ jobs: - name: Build run: npm run build + + # Builds the MAUI host (src/AppName.Maui) per target platform. Each platform's + # SDK is only available on certain OSes: iOS and MacCatalyst require Xcode on a + # macOS runner, Windows (WinUI) requires the Windows SDK on a windows runner, and + # Android can build anywhere but is included here too. This is why these builds + # can't run on the single ubuntu runner used by dotnet-tests above. + maui-build: + name: maui-build + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + tfm: net10.0-android + workload: maui-android + - os: windows-latest + tfm: net10.0-windows10.0.19041.0 + workload: maui-windows + - os: macos-latest + tfm: net10.0-ios + workload: maui-ios + - os: macos-latest + tfm: net10.0-maccatalyst + workload: maui-maccatalyst + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: src/web/package-lock.json + + - name: Install MAUI workload (${{ matrix.workload }}) + run: dotnet workload install ${{ matrix.workload }} + + - name: Build MAUI head (${{ matrix.tfm }}) + run: dotnet build src/AppName.Maui/AppName.Maui.csproj -f ${{ matrix.tfm }} -c Release diff --git a/CLAUDE.md b/CLAUDE.md index e4e3f21..f6c67d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,16 @@ These rules apply **only to the C# project** (not the React/web code). ### XML documentation +- **Comment shape**: write every `` (and other XML doc tags) in the multi-line form — the opening tag, the text, and the closing tag each on their own `///` line. Do NOT collapse a summary onto a single line. + + ```csharp + /// + /// Number of shares the bond converts into (par value / conversion price). + /// + [DisplayName("Conversion Shares")] + public decimal ConversionShares { get; } + ``` + - **Interfaces**: every interface and its members (methods, properties, events) MUST have a concise `/// ` describing the contract. - **Model properties**: every property on a model/DTO MUST have a `/// `. Also document `enum` types and their values. - **Implementations**: classes that implement an interface (or override/inherit a documented member) MUST use `/// ` on the implementing members instead of repeating the summary. Only write a fresh `/// ` when the implementation adds behavior the interface doc doesn't cover. @@ -13,3 +23,7 @@ These rules apply **only to the C# project** (not the React/web code). ### Display naming - **Model properties**: every property on a model MUST carry a `[DisplayName("Human Readable Name")]` attribute so it renders correctly in UI, reports, exports, and filters. + +## C# unit testing + +- When writing, reviewing, or refactoring **C# unit tests**, follow [docs/TestingConventionGuidance.md](docs/TestingConventionGuidance.md) — it is the authoritative guidance for this solution's xUnit + Moq conventions (the nested `Fixture` pattern, when to mock vs. use a real/hand-written double, test naming, and file placement). Read it before adding or changing tests. diff --git a/Directory.Packages.props b/Directory.Packages.props index 82be5c6..f637ca4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,14 +1,17 @@ - - + + + + + + + + + - - - - diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..35357a0 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,55 @@ +# Docker Android build + +Build the `AppName.Maui` Android head without installing the Android SDK, Java, +or the MAUI workload on your host machine. Everything needed lives in +`docker/android.Dockerfile`; the repo is bind-mounted into the container so +NuGet restores and build outputs land directly in your working tree. + +## Quick start + +```bash +# 1. Build the image (slow the first time: downloads the .NET SDK base image, +# JDK, Node.js, and the Android cmdline-tools + platform/build-tools, then +# runs `dotnet workload install maui-android`). +just docker-android-image + +# 2. Build the Android head. +just docker-build-android + +# 3. Or just get a shell with the SDK/workload available. +just docker-shell +``` + +These map to plain `docker build` / `docker run` commands — see the +`# ---- Docker (Android) ----` section of the root `justfile` for the exact +commands if you want to run them without `just`. + +## Where the APK lands + +``` +src/AppName.Maui/bin/Release/net10.0-android/ +``` + +(Because the repo is bind-mounted at `/src` inside the container, this is the +same path on your host once the build finishes.) + +## Caveats + +- **Image size**: the image is several GB (dotnet SDK + JDK + Android SDK + components + Node.js). The first `docker-android-image` build will take a + while and use real disk space. +- **No emulator support**: this image does not set up an Android emulator. + Running one inside Docker needs `--device /dev/kvm` (hardware + virtualization passthrough) plus an AVD/system-image setup that isn't + included here. The supported workflow is to build the APK in the container + and deploy it to a **physical device** via `adb install` (or `adb` over + USB/network from the host, since `adb` itself isn't bundled in this image), + or to use `just docker-shell` to poke around interactively. +- **Android only**: this image and devcontainer cover the Android head only. + iOS, Mac Catalyst, and Windows heads need their native OS toolchains and are + not buildable in this Linux container — see the CI matrix + (`.github/workflows/ci.yml`) for how those are/aren't covered. +- **Versions may drift**: the Android cmdline-tools build number and + platform/build-tools versions pinned in `android.Dockerfile` will go stale + over time. See the comments at the top of the `ENV ANDROID_HOME=...` block + in the Dockerfile for how to find current values. diff --git a/docker/android.Dockerfile b/docker/android.Dockerfile new file mode 100644 index 0000000..b3c385a --- /dev/null +++ b/docker/android.Dockerfile @@ -0,0 +1,77 @@ +# docker/android.Dockerfile +# +# Builds the AppName.Maui Android head WITHOUT installing the Android SDK, +# Java, or the MAUI workload on the host machine. Everything lives in this +# image; the repo is bind-mounted in at build/run time (see justfile recipes +# docker-android-image / docker-build-android / docker-shell). +# +# Usage (from repo root): +# just docker-android-image # build this image (slow the first time, several GB) +# just docker-build-android # build the Android head into the mounted repo +# just docker-shell # interactive shell with the SDK/workload available +# +# See docker/README.md for details and caveats (no emulator support here). + +FROM mcr.microsoft.com/dotnet/sdk:10.0 + +# ---- OS packages ------------------------------------------------------------- +# openjdk-17-jdk: required by the Android SDK build-tools / d8 / aapt2 toolchain. +# unzip/curl: needed to fetch and extract the Android cmdline-tools archive. +# Other bits (git, ca-certificates) are common build-time needs. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + openjdk-17-jdk \ + unzip \ + curl \ + ca-certificates \ + git \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 +ENV PATH="${JAVA_HOME}/bin:${PATH}" + +# ---- Node.js 24 (NodeSource) -------------------------------------------------- +# Matches the Node version used in CI (.github/workflows/ci.yml) and is needed +# because AppName.Maui.csproj's BuildReactApp MSBuild target runs `npm install` +# + `npm run build` in ../web as part of a normal (non-SkipReactBuild) build. +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# ---- Android SDK (command-line tools only; no Android Studio) ---------------- +# NOTE: cmdline-tools version/build number changes periodically. The value +# below ("11076708", commandlinetools-linux package) was current as of late +# 2024/2025 cmdline-tools releases targeting compileSdk 35. If the download +# 404s, check https://developer.android.com/studio#command-line-tools-only +# for the latest "commandlinetools-linux-XXXXXXXX_latest.zip" build number +# and bump it here. +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=${ANDROID_HOME} +ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708 +ARG ANDROID_PLATFORM=android-35 +ARG ANDROID_BUILD_TOOLS=35.0.0 + +RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \ + && curl -fsSL -o /tmp/cmdline-tools.zip \ + "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \ + && unzip -q /tmp/cmdline-tools.zip -d ${ANDROID_HOME}/cmdline-tools \ + && mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \ + && rm /tmp/cmdline-tools.zip + +ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}" + +# Accept licenses, then install only what's needed to build net10.0-android +# (compileSdk 35, min API 21 per SupportedOSPlatformVersion in the csproj). +RUN yes | sdkmanager --licenses >/dev/null \ + && sdkmanager \ + "platform-tools" \ + "platforms;${ANDROID_PLATFORM}" \ + "build-tools;${ANDROID_BUILD_TOOLS}" + +# ---- .NET MAUI Android workload ----------------------------------------------- +# This is the step that lets the container build the Android head without any +# host-side Android SDK install: the workload (incl. the Java/Android bindings) +# is installed straight into this image's dotnet install. +RUN dotnet workload install maui-android + +WORKDIR /src diff --git a/justfile b/justfile new file mode 100644 index 0000000..49fec67 --- /dev/null +++ b/justfile @@ -0,0 +1,106 @@ +# justfile — reusable dev commands for the MAUI + React solution. +# Install `just`: `cargo install just`, or grab a binary from https://github.com/casey/just/releases +# Run `just` (or `just --list`) to see all recipes. + +set shell := ["bash", "-uc"] + +# Host has memory pressure: dotnet must run single-proc with the MSBuild server/node-reuse off. +export DOTNET_CLI_USE_MSBUILD_SERVER := "0" +export MSBUILDDISABLENODEREUSE := "1" + +# MAUI target framework to build/run. On Linux only android is available; +# on Windows/Mac override, e.g. `just tfm=net10.0-maccatalyst build-maui`. +tfm := "net10.0-android" + +# Default: list recipes. +default: + @just --list + +# ---- Tests ------------------------------------------------------------------- + +# Run all tests (C# three projects + the React/vitest suite). +test: test-cs test-web + +# Run the three C# test projects single-proc. +test-cs: + dotnet test tests/AppName.Domain.Tests -m:1 + dotnet test tests/AppName.Infrastructure.Tests -m:1 + dotnet test tests/AppName.Application.Tests -m:1 + +# Run the React unit tests (vitest). +test-web: + cd src/web && npm test + +# ---- React web app ----------------------------------------------------------- + +# Build the React bundle into src/AppName.Maui/Resources/Raw/web (what the MAUI build runs automatically). +web-build: + cd src/web && npm install && npm run build + +# Start the Vite dev server (use with `build-maui-noweb` + the dev URL while iterating on UI). +web-dev: + cd src/web && npm run dev + +# ---- MAUI app ---------------------------------------------------------------- + +# Build the MAUI head (builds the React bundle first via the BuildReactApp MSBuild target). +# Requires the Android SDK + MAUI workload: `dotnet workload install maui-android`. +build-maui: + dotnet build src/AppName.Maui/AppName.Maui.csproj -f {{tfm}} -m:1 + +# Build the MAUI head WITHOUT rebuilding the React bundle (faster; use with `web-dev`). +build-maui-noweb: + dotnet build src/AppName.Maui/AppName.Maui.csproj -f {{tfm}} -m:1 -p:SkipReactBuild=true + +# Build + deploy + run the MAUI app on a connected device/emulator. +run-maui: + dotnet build src/AppName.Maui/AppName.Maui.csproj -t:Run -f {{tfm}} -m:1 + +# ---- EF Core migrations ------------------------------------------------------ + +# Add a migration: `just migrate-add AddSomething`. +migrate-add name: + dotnet ef migrations add {{name}} \ + --project src/AppName.Infrastructure \ + --startup-project src/AppName.Infrastructure \ + --output-dir Migrations + +# Apply migrations to a local SQLite file (design-time; the app itself migrates on startup). +migrate-update: + dotnet ef database update \ + --project src/AppName.Infrastructure \ + --startup-project src/AppName.Infrastructure + +# List migrations. +migrate-list: + dotnet ef migrations list --project src/AppName.Infrastructure --startup-project src/AppName.Infrastructure + +# ---- Docker (Android) --------------------------------------------------------- +# Builds the Android head inside a container that carries the Android SDK + +# `maui-android` workload, so the host never needs the Android SDK/Java +# installed. The repo is bind-mounted in so build outputs (and the APK) land +# in the normal working tree. See docker/README.md for details/caveats. + +# Build the docker image (slow first time; several GB). Re-run after editing docker/android.Dockerfile. +docker-android-image: + docker build -t appname-maui-android -f docker/android.Dockerfile . + +# Build the Android head inside the container; output lands in src/AppName.Maui/bin/Release/net10.0-android/. +docker-build-android: + docker run --rm -v "$PWD":/src -w /src appname-maui-android dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android -c Release + +# Open an interactive shell in the container (SDK + Android tooling + workload available). +docker-shell: + docker run --rm -it -v "$PWD":/src -w /src appname-maui-android bash + +# ---- Housekeeping ------------------------------------------------------------ + +# Restore NuGet + npm dependencies. +restore: + dotnet restore -m:1 + cd src/web && npm install + +# Remove build outputs. +clean: + dotnet clean -m:1 + rm -rf src/web/node_modules/.vite src/AppName.Maui/Resources/Raw/web diff --git a/src/AppName.Application/DependencyInjection.cs b/src/AppName.Application/DependencyInjection.cs index 9e173e3..c595849 100644 --- a/src/AppName.Application/DependencyInjection.cs +++ b/src/AppName.Application/DependencyInjection.cs @@ -1,4 +1,4 @@ -using AppName.Application.UseCases.Users; +using AppName.Application.UseCases.Bonds; using Microsoft.Extensions.DependencyInjection; namespace AppName.Application; @@ -7,8 +7,8 @@ public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { - services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); return services; } } diff --git a/src/AppName.Application/Dtos/ConversionValuationDto.cs b/src/AppName.Application/Dtos/ConversionValuationDto.cs new file mode 100644 index 0000000..03ce2d4 --- /dev/null +++ b/src/AppName.Application/Dtos/ConversionValuationDto.cs @@ -0,0 +1,76 @@ +using System.ComponentModel; + +namespace AppName.Application.Dtos; + +/// +/// Conversion valuation result for display. and +/// are null until the CB market-price source is +/// implemented (deferred — see spec §2). +/// +public record ConversionValuationDto +{ + /// + /// Creates a conversion valuation result. + /// + public ConversionValuationDto( + string Symbol, + decimal ConversionShares, + decimal ConversionValue, + decimal StockPrice, + DateOnly AsOf, + decimal? BondPrice, + bool? IsInTheMoney) + { + this.Symbol = Symbol; + this.ConversionShares = ConversionShares; + this.ConversionValue = ConversionValue; + this.StockPrice = StockPrice; + this.AsOf = AsOf; + this.BondPrice = BondPrice; + this.IsInTheMoney = IsInTheMoney; + } + + /// + /// Exchange ticker of the convertible bond. + /// + [DisplayName("Bond Symbol")] + public string Symbol { get; init; } + + /// + /// Number of shares the bond converts into (par value / conversion price). + /// + [DisplayName("Conversion Shares")] + public decimal ConversionShares { get; init; } + + /// + /// Market value of the converted shares (shares × stock price). + /// + [DisplayName("Conversion Value")] + public decimal ConversionValue { get; init; } + + /// + /// Underlying stock's price used to compute this valuation. + /// + [DisplayName("Stock Price")] + public decimal StockPrice { get; init; } + + /// + /// Trading date the underlying stock price applies to. + /// + [DisplayName("As Of")] + public DateOnly AsOf { get; init; } + + /// + /// Market price of the bond itself. Null in v1: the CB market price + /// source is deferred (see spec §2). + /// + [DisplayName("Bond Price")] + public decimal? BondPrice { get; init; } + + /// + /// Whether the bond is currently in the money. Null in v1: depends on + /// , which is deferred (see spec §2). + /// + [DisplayName("In The Money")] + public bool? IsInTheMoney { get; init; } +} diff --git a/src/AppName.Application/Dtos/UserDto.cs b/src/AppName.Application/Dtos/UserDto.cs deleted file mode 100644 index 13fba65..0000000 --- a/src/AppName.Application/Dtos/UserDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace AppName.Application.Dtos; - -public record UserDto(Guid Id, string Name, string Email); diff --git a/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs new file mode 100644 index 0000000..14326a2 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs @@ -0,0 +1,34 @@ +using AppName.Application.Dtos; +using AppName.Domain.Abstractions; +using AppName.Domain.ValueObjects; + +namespace AppName.Application.UseCases.Bonds; + +/// +/// Reads a cached bond plus a live underlying stock price and computes its +/// conversion valuation. +/// +public sealed class GetConversionValuationUseCase(IConvertibleBondRepository repo, IMarketDataProvider provider) +{ + /// + /// Returns the valuation DTO, or null when the bond is not cached or + /// its stock quote is unavailable. BondPrice/IsInTheMoney are left + /// null: the CB market price source is deferred (see spec §2). + /// + public async Task ExecuteAsync(string bondSymbol, CancellationToken ct = default) + { + var bond = await repo.GetBySymbolAsync(bondSymbol, ct); + + if (bond is null) return null; + + var stockQuote = await provider.GetStockQuoteAsync(bond.UnderlyingSymbol, ct); + + if (stockQuote is null) return null; + + var v = ConversionValuation.Calculate(bond.ParValue, bond.ConversionPrice, stockQuote.Price); + + return new ConversionValuationDto( + bond.Symbol, v.ConversionShares, v.ConversionValue, stockQuote.Price, stockQuote.AsOf, + BondPrice: null, IsInTheMoney: null); + } +} diff --git a/src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs b/src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs new file mode 100644 index 0000000..e37888d --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs @@ -0,0 +1,23 @@ +using AppName.Domain.Abstractions; + +namespace AppName.Application.UseCases.Bonds; + +/// +/// Pulls a bond's terms from the market-data provider and caches them +/// locally. +/// +public sealed class RefreshBondDataUseCase(IMarketDataProvider provider, IConvertibleBondRepository repo) +{ + /// + /// Fetches issuance terms for the bond and upserts them. No-op if the + /// provider has no record. + /// + public async Task ExecuteAsync(string bondSymbol, CancellationToken ct = default) + { + var terms = await provider.GetIssuanceTermsAsync(bondSymbol, ct); + + if (terms is null) return; + + await repo.UpsertAsync(terms, ct); + } +} diff --git a/src/AppName.Application/UseCases/Users/AddUserUseCase.cs b/src/AppName.Application/UseCases/Users/AddUserUseCase.cs deleted file mode 100644 index 1aad139..0000000 --- a/src/AppName.Application/UseCases/Users/AddUserUseCase.cs +++ /dev/null @@ -1,18 +0,0 @@ -using AppName.Application.Dtos; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; - -namespace AppName.Application.UseCases.Users; - -public sealed class AddUserUseCase -{ - private readonly IUserRepository _repo; - public AddUserUseCase(IUserRepository repo) => _repo = repo; - - public async Task ExecuteAsync(string name, string email, CancellationToken ct = default) - { - var user = new User(Guid.NewGuid(), name, email); - await _repo.AddAsync(user, ct); - return new UserDto(user.Id, user.Name, user.Email); - } -} diff --git a/src/AppName.Application/UseCases/Users/GetUsersUseCase.cs b/src/AppName.Application/UseCases/Users/GetUsersUseCase.cs deleted file mode 100644 index dcb2594..0000000 --- a/src/AppName.Application/UseCases/Users/GetUsersUseCase.cs +++ /dev/null @@ -1,16 +0,0 @@ -using AppName.Application.Dtos; -using AppName.Domain.Abstractions; - -namespace AppName.Application.UseCases.Users; - -public sealed class GetUsersUseCase -{ - private readonly IUserRepository _repo; - public GetUsersUseCase(IUserRepository repo) => _repo = repo; - - public async Task> ExecuteAsync(CancellationToken ct = default) - { - var users = await _repo.GetAllAsync(ct); - return users.Select(u => new UserDto(u.Id, u.Name, u.Email)).ToList(); - } -} diff --git a/src/AppName.Domain/Abstractions/IConvertibleBondRepository.cs b/src/AppName.Domain/Abstractions/IConvertibleBondRepository.cs new file mode 100644 index 0000000..aaf22ad --- /dev/null +++ b/src/AppName.Domain/Abstractions/IConvertibleBondRepository.cs @@ -0,0 +1,24 @@ +using AppName.Domain.Entities; + +namespace AppName.Domain.Abstractions; + +/// +/// Local persistence of convertible bonds and their terms. +/// +public interface IConvertibleBondRepository +{ + /// + /// Returns all cached convertible bonds. + /// + Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Returns a single bond by symbol, or null if absent. + /// + Task GetBySymbolAsync(string symbol, CancellationToken ct = default); + + /// + /// Inserts the bond, or updates its terms if it already exists. + /// + Task UpsertAsync(ConvertibleBond bond, CancellationToken ct = default); +} diff --git a/src/AppName.Domain/Abstractions/IMarketDataProvider.cs b/src/AppName.Domain/Abstractions/IMarketDataProvider.cs new file mode 100644 index 0000000..3bd1a13 --- /dev/null +++ b/src/AppName.Domain/Abstractions/IMarketDataProvider.cs @@ -0,0 +1,27 @@ +using AppName.Domain.Entities; + +namespace AppName.Domain.Abstractions; + +/// +/// Pulls convertible-bond reference data and daily prices from an external market-data source. +/// +public interface IMarketDataProvider +{ + /// + /// Gets the static conversion terms for a bond, or null if not found. + /// + Task GetIssuanceTermsAsync(string bondSymbol, CancellationToken ct = default); + + /// + /// Gets the latest daily quote for an underlying stock, or null if not found. + /// + Task GetStockQuoteAsync(string stockSymbol, CancellationToken ct = default); + + /// + /// Gets the latest daily market quote for a convertible bond. + /// NOT IMPLEMENTED in v1: the per-bond CB market price is not available from any free JSON API + /// (only a JS-gated TPEx web page). Implementations throw . + /// See spec §2 (deferred scope). + /// + Task GetBondQuoteAsync(string bondSymbol, CancellationToken ct = default); +} diff --git a/src/AppName.Domain/Abstractions/IUserRepository.cs b/src/AppName.Domain/Abstractions/IUserRepository.cs deleted file mode 100644 index 830520e..0000000 --- a/src/AppName.Domain/Abstractions/IUserRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using AppName.Domain.Entities; - -namespace AppName.Domain.Abstractions; - -public interface IUserRepository -{ - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(User user, CancellationToken ct = default); -} diff --git a/src/AppName.Domain/Entities/BondQuote.cs b/src/AppName.Domain/Entities/BondQuote.cs new file mode 100644 index 0000000..24df22f --- /dev/null +++ b/src/AppName.Domain/Entities/BondQuote.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; + +namespace AppName.Domain.Entities; + +/// +/// A convertible bond's market price on a given trading day (deferred data source). +/// +/// +/// Creates a bond quote. +/// +public sealed class BondQuote(string bondSymbol, decimal price, DateOnly asOf) +{ + /// + /// Ticker of the convertible bond. + /// + [DisplayName("Bond Symbol")] + public string BondSymbol { get; } = bondSymbol; + + /// + /// Closing market price of the bond. + /// + [DisplayName("Bond Price")] + public decimal Price { get; } = price; + + /// + /// Trading date this quote applies to. + /// + [DisplayName("As Of")] + public DateOnly AsOf { get; } = asOf; +} diff --git a/src/AppName.Domain/Entities/ConvertibleBond.cs b/src/AppName.Domain/Entities/ConvertibleBond.cs new file mode 100644 index 0000000..cb53b50 --- /dev/null +++ b/src/AppName.Domain/Entities/ConvertibleBond.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; + +namespace AppName.Domain.Entities; + +/// +/// A convertible corporate bond and its static conversion terms. +/// +public sealed class ConvertibleBond +{ + /// + /// Creates a convertible bond. Throws when the symbol is blank. + /// + public ConvertibleBond(string symbol, string name, decimal parValue, decimal conversionPrice, string underlyingSymbol) + { + if (string.IsNullOrWhiteSpace(symbol)) + throw new ArgumentException("Symbol cannot be blank.", nameof(symbol)); + + Symbol = symbol; + Name = name; + ParValue = parValue; + ConversionPrice = conversionPrice; + UnderlyingSymbol = underlyingSymbol; + } + + /// + /// Exchange ticker of the convertible bond (e.g. "11011"). + /// + [DisplayName("Bond Symbol")] + public string Symbol { get; } + + /// + /// Display name of the bond. + /// + [DisplayName("Bond Name")] + public string Name { get; } + + /// + /// Face/issue value per bond unit (發行面額); NT$100,000 by convention. + /// + [DisplayName("Par Value")] + public decimal ParValue { get; private set; } + + /// + /// Price at which the bond converts into shares (轉換價). + /// + [DisplayName("Conversion Price")] + public decimal ConversionPrice { get; private set; } + + /// + /// Ticker of the underlying stock the bond converts into (the issuer's stock). + /// + [DisplayName("Underlying Symbol")] + public string UnderlyingSymbol { get; } + + /// + /// Replaces the static terms (e.g. after an anti-dilution adjustment). + /// + public void UpdateTerms(decimal parValue, decimal conversionPrice) + { + ParValue = parValue; + ConversionPrice = conversionPrice; + } +} diff --git a/src/AppName.Domain/Entities/StockQuote.cs b/src/AppName.Domain/Entities/StockQuote.cs new file mode 100644 index 0000000..c62d11c --- /dev/null +++ b/src/AppName.Domain/Entities/StockQuote.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; + +namespace AppName.Domain.Entities; + +/// +/// An underlying stock's market price on a given trading day. +/// +/// +/// Creates a stock quote. +/// +public sealed class StockQuote(string stockSymbol, decimal price, DateOnly asOf) +{ + /// + /// Ticker of the underlying stock. + /// + [DisplayName("Stock Symbol")] + public string StockSymbol { get; } = stockSymbol; + + /// + /// Closing market price of the stock. + /// + [DisplayName("Stock Price")] + public decimal Price { get; } = price; + + /// + /// Trading date this quote applies to. + /// + [DisplayName("As Of")] + public DateOnly AsOf { get; } = asOf; +} diff --git a/src/AppName.Domain/Entities/User.cs b/src/AppName.Domain/Entities/User.cs deleted file mode 100644 index b74d05a..0000000 --- a/src/AppName.Domain/Entities/User.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace AppName.Domain.Entities; - -public sealed class User -{ - public User(Guid id, string name, string email) - { - Id = id; - Name = name; - Email = email; - } - - public Guid Id { get; } - public string Name { get; private set; } - public string Email { get; } - - public void Rename(string newName) - { - if (string.IsNullOrWhiteSpace(newName)) - throw new ArgumentException("Name cannot be blank.", nameof(newName)); - Name = newName; - } -} diff --git a/src/AppName.Domain/ValueObjects/ConversionValuation.cs b/src/AppName.Domain/ValueObjects/ConversionValuation.cs new file mode 100644 index 0000000..6001377 --- /dev/null +++ b/src/AppName.Domain/ValueObjects/ConversionValuation.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; + +namespace AppName.Domain.ValueObjects; + +/// +/// Conversion economics of a convertible bond at a point in time (shares + value). +/// +public sealed class ConversionValuation +{ + private ConversionValuation(decimal shares, decimal value) + { + ConversionShares = shares; + ConversionValue = value; + } + + /// + /// Number of shares the bond converts into (par value / conversion price). + /// + [DisplayName("Conversion Shares")] + public decimal ConversionShares { get; } + + /// + /// Market value of the converted shares (shares × stock price). + /// + [DisplayName("Conversion Value")] + public decimal ConversionValue { get; } + + /// + /// Computes shares and value. Throws when conversion price is not positive. + /// + public static ConversionValuation Calculate(decimal parValue, decimal conversionPrice, decimal stockPrice) + { + if (conversionPrice <= 0) + throw new ArgumentOutOfRangeException(nameof(conversionPrice), "Conversion price must be positive."); + + var shares = parValue / conversionPrice; + return new ConversionValuation(shares, shares * stockPrice); + } +} diff --git a/src/AppName.Infrastructure/AppName.Infrastructure.csproj b/src/AppName.Infrastructure/AppName.Infrastructure.csproj index 84e47d2..3010d20 100644 --- a/src/AppName.Infrastructure/AppName.Infrastructure.csproj +++ b/src/AppName.Infrastructure/AppName.Infrastructure.csproj @@ -11,6 +11,10 @@ + + + + diff --git a/src/AppName.Infrastructure/Clients/Tpex/ITpexBondIssuanceApiClient.cs b/src/AppName.Infrastructure/Clients/Tpex/ITpexBondIssuanceApiClient.cs new file mode 100644 index 0000000..8e8eccb --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/ITpexBondIssuanceApiClient.cs @@ -0,0 +1,14 @@ +using AppName.Infrastructure.Clients.Tpex.Models; + +namespace AppName.Infrastructure.Clients.Tpex; + +/// +/// Raw HTTP access to the TPEx convertible-bond issuance dataset. +/// +public interface ITpexBondIssuanceApiClient +{ + /// + /// Fetches all issuance records (raw shapes, no domain mapping). + /// + Task> GetAllAsync(CancellationToken ct = default); +} diff --git a/src/AppName.Infrastructure/Clients/Tpex/ITpexStockQuoteApiClient.cs b/src/AppName.Infrastructure/Clients/Tpex/ITpexStockQuoteApiClient.cs new file mode 100644 index 0000000..47ebea2 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/ITpexStockQuoteApiClient.cs @@ -0,0 +1,14 @@ +using AppName.Infrastructure.Clients.Tpex.Models; + +namespace AppName.Infrastructure.Clients.Tpex; + +/// +/// Raw HTTP access to TPEx OTC daily stock closing prices. +/// +public interface ITpexStockQuoteApiClient +{ + /// + /// Fetches all OTC daily stock quotes (raw shapes, no domain mapping). + /// + Task> GetAllAsync(CancellationToken ct = default); +} diff --git a/src/AppName.Infrastructure/Clients/Tpex/Models/TpexBondIssuanceRecord.cs b/src/AppName.Infrastructure/Clients/Tpex/Models/TpexBondIssuanceRecord.cs new file mode 100644 index 0000000..4e730fd --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/Models/TpexBondIssuanceRecord.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace AppName.Infrastructure.Clients.Tpex.Models; + +/// +/// Raw TPEx issuance record (bond_ISSBD5_data) for one convertible bond. +/// +public sealed class TpexBondIssuanceRecord +{ + /// + /// Convertible bond trading symbol. + /// + [DisplayName("Bond Code")] + [JsonPropertyName("BondCode")] + public string BondCode { get; set; } = ""; + + /// + /// Short display name of the bond. + /// + [DisplayName("Short Name")] + [JsonPropertyName("ShortName")] + public string ShortName { get; set; } = ""; + + /// + /// Underlying stock code (the issuer's stock). + /// + [DisplayName("Issuer Code")] + [JsonPropertyName("IssuerCode")] + public string IssuerCode { get; set; } = ""; + + /// + /// Conversion price at issuance, as the raw string the API returns. + /// + [DisplayName("Conversion Price at Issuance")] + [JsonPropertyName("Conversion/ExchangePriceAtIssuance")] + public string ConversionPriceAtIssuance { get; set; } = ""; +} diff --git a/src/AppName.Infrastructure/Clients/Tpex/Models/TpexStockQuoteRecord.cs b/src/AppName.Infrastructure/Clients/Tpex/Models/TpexStockQuoteRecord.cs new file mode 100644 index 0000000..fc716d8 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/Models/TpexStockQuoteRecord.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace AppName.Infrastructure.Clients.Tpex.Models; + +/// +/// Raw TPEx OTC daily closing-price record (tpex_mainboard_quotes) for one stock. +/// +public sealed class TpexStockQuoteRecord +{ + /// + /// OTC stock ticker code. + /// + [DisplayName("Stock Code")] + [JsonPropertyName("SecuritiesCompanyCode")] + public string SecuritiesCompanyCode { get; set; } = ""; + + /// + /// Closing price as the raw string the API returns. + /// + [DisplayName("Closing Price")] + [JsonPropertyName("Close")] + public string Close { get; set; } = ""; + + /// + /// Trading date in ROC format (e.g. "1150630"). + /// + [DisplayName("Trading Date")] + [JsonPropertyName("Date")] + public string Date { get; set; } = ""; +} diff --git a/src/AppName.Infrastructure/Clients/Tpex/TpexApiOptions.cs b/src/AppName.Infrastructure/Clients/Tpex/TpexApiOptions.cs new file mode 100644 index 0000000..0b111b5 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/TpexApiOptions.cs @@ -0,0 +1,17 @@ +namespace AppName.Infrastructure.Clients.Tpex; + +/// +/// Configuration for the TPEx open-data HTTP clients. +/// +public sealed class TpexApiOptions +{ + /// + /// Base address of the TPEx OpenAPI v1 host. + /// + public string BaseUrl { get; set; } = "https://www.tpex.org.tw/openapi/v1/"; + + /// + /// User-Agent sent with requests (TPEx blocks empty/automated agents). + /// + public string UserAgent { get; set; } = "Mozilla/5.0 (compatible; AppName/1.0)"; +} diff --git a/src/AppName.Infrastructure/Clients/Tpex/TpexBondIssuanceApiClient.cs b/src/AppName.Infrastructure/Clients/Tpex/TpexBondIssuanceApiClient.cs new file mode 100644 index 0000000..b895171 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/TpexBondIssuanceApiClient.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using AppName.Infrastructure.Clients.Tpex.Models; + +namespace AppName.Infrastructure.Clients.Tpex; + +/// +public sealed class TpexBondIssuanceApiClient(HttpClient httpClient) : ITpexBondIssuanceApiClient +{ + private const string Path = "bond_ISSBD5_data"; + + /// + public async Task> GetAllAsync(CancellationToken ct = default) + { + try + { + using var response = await httpClient.GetAsync(Path, ct); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(ct); + + return await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct) + ?? throw new InvalidOperationException("Failed to deserialize TPEx issuance response."); + } + catch (OperationCanceledException) { throw; } + } +} diff --git a/src/AppName.Infrastructure/Clients/Tpex/TpexHttpPipeline.cs b/src/AppName.Infrastructure/Clients/Tpex/TpexHttpPipeline.cs new file mode 100644 index 0000000..27b2a1a --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/TpexHttpPipeline.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace AppName.Infrastructure.Clients.Tpex; + +/// +/// Registers the TPEx typed HTTP clients with resilience. +/// +public static class TpexHttpPipeline +{ + /// + /// Adds the TPEx bond-issuance and OTC stock-quote typed HTTP clients to DI. + /// + public static IServiceCollection AddTpexHttpClients(this IServiceCollection services) + { + services.AddHttpClient(Configure) + .AddStandardResilienceHandler(); + + services.AddHttpClient(Configure) + .AddStandardResilienceHandler(); + + return services; + } + + internal static void Configure(IServiceProvider sp, HttpClient client) + { + var o = sp.GetRequiredService>().Value; + + client.BaseAddress = new Uri(o.BaseUrl.EndsWith('/') ? o.BaseUrl : o.BaseUrl + "/"); + + client.DefaultRequestHeaders.UserAgent.ParseAdd(o.UserAgent); + } +} diff --git a/src/AppName.Infrastructure/Clients/Tpex/TpexStockQuoteApiClient.cs b/src/AppName.Infrastructure/Clients/Tpex/TpexStockQuoteApiClient.cs new file mode 100644 index 0000000..3bff7be --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Tpex/TpexStockQuoteApiClient.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using AppName.Infrastructure.Clients.Tpex.Models; + +namespace AppName.Infrastructure.Clients.Tpex; + +/// +public sealed class TpexStockQuoteApiClient(HttpClient httpClient) : ITpexStockQuoteApiClient +{ + private const string Path = "tpex_mainboard_quotes"; + + /// + public async Task> GetAllAsync(CancellationToken ct = default) + { + try + { + using var response = await httpClient.GetAsync(Path, ct); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(ct); + + return await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct) + ?? throw new InvalidOperationException("Failed to deserialize TPEx OTC stock-quote response."); + } + catch (OperationCanceledException) { throw; } + } +} diff --git a/src/AppName.Infrastructure/Clients/Twse/ITwseStockQuoteApiClient.cs b/src/AppName.Infrastructure/Clients/Twse/ITwseStockQuoteApiClient.cs new file mode 100644 index 0000000..c5889a3 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Twse/ITwseStockQuoteApiClient.cs @@ -0,0 +1,14 @@ +using AppName.Infrastructure.Clients.Twse.Models; + +namespace AppName.Infrastructure.Clients.Twse; + +/// +/// Raw HTTP access to TWSE daily stock closing prices. +/// +public interface ITwseStockQuoteApiClient +{ + /// + /// Fetches all daily stock quotes (raw shapes, no domain mapping). + /// + Task> GetAllAsync(CancellationToken ct = default); +} diff --git a/src/AppName.Infrastructure/Clients/Twse/Models/TwseStockQuoteRecord.cs b/src/AppName.Infrastructure/Clients/Twse/Models/TwseStockQuoteRecord.cs new file mode 100644 index 0000000..267504e --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Twse/Models/TwseStockQuoteRecord.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace AppName.Infrastructure.Clients.Twse.Models; + +/// +/// Raw TWSE daily closing-price record (STOCK_DAY_ALL) for one stock. +/// +public sealed class TwseStockQuoteRecord +{ + /// + /// Stock ticker symbol. + /// + [DisplayName("Stock Code")] + [JsonPropertyName("Code")] + public string Code { get; set; } = ""; + + /// + /// Closing price as the raw string the API returns. + /// + [DisplayName("Closing Price")] + [JsonPropertyName("ClosingPrice")] + public string ClosingPrice { get; set; } = ""; + + /// + /// Trading date in ROC format (e.g. "1150629"). + /// + [DisplayName("Trading Date")] + [JsonPropertyName("Date")] + public string Date { get; set; } = ""; +} diff --git a/src/AppName.Infrastructure/Clients/Twse/TwseApiOptions.cs b/src/AppName.Infrastructure/Clients/Twse/TwseApiOptions.cs new file mode 100644 index 0000000..dc9a121 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Twse/TwseApiOptions.cs @@ -0,0 +1,17 @@ +namespace AppName.Infrastructure.Clients.Twse; + +/// +/// Configuration for the TWSE open-data HTTP client. +/// +public sealed class TwseApiOptions +{ + /// + /// Base address of the TWSE OpenAPI v1 host. + /// + public string BaseUrl { get; set; } = "https://openapi.twse.com.tw/v1/"; + + /// + /// User-Agent sent with requests. + /// + public string UserAgent { get; set; } = "Mozilla/5.0 (compatible; AppName/1.0)"; +} diff --git a/src/AppName.Infrastructure/Clients/Twse/TwseHttpPipeline.cs b/src/AppName.Infrastructure/Clients/Twse/TwseHttpPipeline.cs new file mode 100644 index 0000000..7a3cc91 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Twse/TwseHttpPipeline.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace AppName.Infrastructure.Clients.Twse; + +/// +/// Registers the TWSE typed HTTP client with resilience. +/// +public static class TwseHttpPipeline +{ + /// + /// Adds the TWSE stock-quote typed HTTP client to DI. + /// + public static IServiceCollection AddTwseHttpClients(this IServiceCollection services) + { + services.AddHttpClient(Configure) + .AddStandardResilienceHandler(); + + return services; + } + + internal static void Configure(IServiceProvider sp, HttpClient client) + { + var o = sp.GetRequiredService>().Value; + + client.BaseAddress = new Uri(o.BaseUrl.EndsWith('/') ? o.BaseUrl : o.BaseUrl + "/"); + + client.DefaultRequestHeaders.UserAgent.ParseAdd(o.UserAgent); + } +} diff --git a/src/AppName.Infrastructure/Clients/Twse/TwseStockQuoteApiClient.cs b/src/AppName.Infrastructure/Clients/Twse/TwseStockQuoteApiClient.cs new file mode 100644 index 0000000..64a03f6 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/Twse/TwseStockQuoteApiClient.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using AppName.Infrastructure.Clients.Twse.Models; + +namespace AppName.Infrastructure.Clients.Twse; + +/// +public sealed class TwseStockQuoteApiClient(HttpClient httpClient) : ITwseStockQuoteApiClient +{ + private const string Path = "exchangeReport/STOCK_DAY_ALL"; + + /// + public async Task> GetAllAsync(CancellationToken ct = default) + { + try + { + using var response = await httpClient.GetAsync(Path, ct); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(ct); + + return await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct) + ?? throw new InvalidOperationException("Failed to deserialize TWSE stock-quote response."); + } + catch (OperationCanceledException) { throw; } + } +} diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index 6fb4536..9355810 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -1,4 +1,7 @@ using AppName.Domain.Abstractions; +using AppName.Infrastructure.Clients.Tpex; +using AppName.Infrastructure.Clients.Twse; +using AppName.Infrastructure.Gateways; using AppName.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -10,7 +13,14 @@ public static class DependencyInjection public static IServiceCollection AddInfrastructure(this IServiceCollection services, string dbPath) { services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); - services.AddScoped(); + + services.AddOptions(); + services.AddOptions(); + services.AddTpexHttpClients(); + services.AddTwseHttpClients(); + + services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs new file mode 100644 index 0000000..f8d3d68 --- /dev/null +++ b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +using AppName.Infrastructure.Clients.Tpex; +using AppName.Infrastructure.Clients.Twse; + +namespace AppName.Infrastructure.Gateways; + +/// +public sealed class MarketDataProvider( + ITpexBondIssuanceApiClient issuance, + ITwseStockQuoteApiClient twseStocks, + ITpexStockQuoteApiClient tpexStocks) : IMarketDataProvider +{ + private const decimal ParValueConvention = 100_000m; + + /// + public async Task GetIssuanceTermsAsync(string bondSymbol, CancellationToken ct = default) + { + var records = await issuance.GetAllAsync(ct); + + var r = records.FirstOrDefault(x => x.BondCode == bondSymbol); + + if (r is null) + { + return null; + } + + var conversionPrice = ParseDecimal(r.ConversionPriceAtIssuance); + + if (conversionPrice <= 0) + { + return null; // unparseable/halted upstream row → no usable record + } + + return new ConvertibleBond(r.BondCode, r.ShortName, ParValueConvention, conversionPrice, r.IssuerCode); + } + + /// + public async Task GetStockQuoteAsync(string stockSymbol, CancellationToken ct = default) + { + var twse = await twseStocks.GetAllAsync(ct); + + var t = twse.FirstOrDefault(x => x.Code == stockSymbol); + + if (t is not null && ParseRocDate(t.Date) is { } twseAsOf) + { + return new StockQuote(t.Code, ParseDecimal(t.ClosingPrice), twseAsOf); + } + + var otc = await tpexStocks.GetAllAsync(ct); + + var o = otc.FirstOrDefault(x => x.SecuritiesCompanyCode == stockSymbol); + + if (o is not null && ParseRocDate(o.Date) is { } otcAsOf) + { + return new StockQuote(o.SecuritiesCompanyCode, ParseDecimal(o.Close), otcAsOf); + } + + return null; + } + + /// + public Task GetBondQuoteAsync(string bondSymbol, CancellationToken ct = default) + => throw new NotImplementedException( + "CB market price is not available from any free JSON API; it requires scraping the JS-gated TPEx " + + "page (NewCB_day_qry.php). Deferred — see spec §2."); + + private static decimal ParseDecimal(string raw) => + decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0m; + + /// + /// Converts a Taiwan ROC date string ("1150629") to a Gregorian DateOnly (year = ROC + 1911). + /// Returns null when the input is not a valid 7-char ROC date. + /// + internal static DateOnly? ParseRocDate(string raw) + { + if (raw.Length == 7 && + int.TryParse(raw.AsSpan(0, 3), out var yyy) && + int.TryParse(raw.AsSpan(3, 2), out var mm) && + int.TryParse(raw.AsSpan(5, 2), out var dd)) + { + return new DateOnly(yyy + 1911, mm, dd); + } + + return null; + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.Designer.cs b/src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.Designer.cs new file mode 100644 index 0000000..2892611 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.Designer.cs @@ -0,0 +1,48 @@ +// +using AppName.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260630214959_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("TEXT"); + + b.Property("ConversionPrice") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParValue") + .HasColumnType("TEXT"); + + b.Property("UnderlyingSymbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Symbol"); + + b.ToTable("ConvertibleBonds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.cs b/src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.cs new file mode 100644 index 0000000..1c8e1e7 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260630214959_InitialCreate.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ConvertibleBonds", + columns: table => new + { + Symbol = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + ParValue = table.Column(type: "TEXT", nullable: false), + ConversionPrice = table.Column(type: "TEXT", nullable: false), + UnderlyingSymbol = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ConvertibleBonds", x => x.Symbol); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ConvertibleBonds"); + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..13e29a2 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,45 @@ +// +using AppName.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("TEXT"); + + b.Property("ConversionPrice") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParValue") + .HasColumnType("TEXT"); + + b.Property("UnderlyingSymbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Symbol"); + + b.ToTable("ConvertibleBonds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AppName.Infrastructure/Persistence/AppDbContext.cs b/src/AppName.Infrastructure/Persistence/AppDbContext.cs index ce91ca3..fc7073b 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContext.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContext.cs @@ -3,17 +3,18 @@ namespace AppName.Infrastructure.Persistence; -public sealed class AppDbContext : DbContext +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { - public AppDbContext(DbContextOptions options) : base(options) { } - - public DbSet Users => Set(); + public DbSet ConvertibleBonds => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - var user = modelBuilder.Entity(); - user.HasKey(u => u.Id); - user.Property(u => u.Name).IsRequired(); - user.Property(u => u.Email).IsRequired(); + var bond = modelBuilder.Entity(); + bond.HasKey(b => b.Symbol); + bond.Property(b => b.Symbol).IsRequired(); + bond.Property(b => b.Name).IsRequired(); + bond.Property(b => b.ParValue); + bond.Property(b => b.ConversionPrice); + bond.Property(b => b.UnderlyingSymbol).IsRequired(); } } diff --git a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs new file mode 100644 index 0000000..88e4b31 --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace AppName.Infrastructure.Persistence; + +/// +/// Creates an at design time so the EF Core CLI can generate and apply +/// migrations without the MAUI head project (which cannot host the EF tooling). +/// +public sealed class AppDbContextFactory : IDesignTimeDbContextFactory +{ + /// + public AppDbContext CreateDbContext(string[] args) + { + // Design-time only (used by `dotnet ef`); the running app uses AddInfrastructure's + // app-data dbPath (FileSystem.AppDataDirectory) instead of this local file. + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=app.db") + .Options; + + return new AppDbContext(options); + } +} diff --git a/src/AppName.Infrastructure/Persistence/ConvertibleBondRepository.cs b/src/AppName.Infrastructure/Persistence/ConvertibleBondRepository.cs new file mode 100644 index 0000000..9bf8575 --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/ConvertibleBondRepository.cs @@ -0,0 +1,41 @@ +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Persistence; + +/// +public sealed class ConvertibleBondRepository(IDbContextFactory factory) : IConvertibleBondRepository +{ + /// + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + return await db.ConvertibleBonds.AsNoTracking().ToListAsync(ct); + } + + /// + public async Task GetBySymbolAsync(string symbol, CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + return await db.ConvertibleBonds.AsNoTracking().FirstOrDefaultAsync(b => b.Symbol == symbol, ct); + } + + /// + public async Task UpsertAsync(ConvertibleBond bond, CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + var existing = await db.ConvertibleBonds.FirstOrDefaultAsync(b => b.Symbol == bond.Symbol, ct); + + if (existing is null) + { + db.ConvertibleBonds.Add(bond); + } + else + { + existing.UpdateTerms(bond.ParValue, bond.ConversionPrice); + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/src/AppName.Infrastructure/Persistence/UserRepository.cs b/src/AppName.Infrastructure/Persistence/UserRepository.cs deleted file mode 100644 index 2624e10..0000000 --- a/src/AppName.Infrastructure/Persistence/UserRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace AppName.Infrastructure.Persistence; - -public sealed class UserRepository : IUserRepository -{ - private readonly IDbContextFactory _factory; - public UserRepository(IDbContextFactory factory) => _factory = factory; - - public async Task AddAsync(User user, CancellationToken ct = default) - { - await using var db = _factory.CreateDbContext(); - db.Users.Add(user); - await db.SaveChangesAsync(ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - await using var db = _factory.CreateDbContext(); - return await db.Users.AsNoTracking().ToListAsync(ct); - } -} diff --git a/src/AppName.Maui/Bridge/UsersBridge.cs b/src/AppName.Maui/Bridge/UsersBridge.cs deleted file mode 100644 index 68e38e1..0000000 --- a/src/AppName.Maui/Bridge/UsersBridge.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AppName.Application.Dtos; -using AppName.Application.UseCases.Users; - -namespace AppName.Maui.Bridge; - -// Methods on this object are invoked from JavaScript via HybridWebView. -public sealed class UsersBridge -{ - private readonly GetUsersUseCase _getUsers; - private readonly AddUserUseCase _addUser; - - public UsersBridge(GetUsersUseCase getUsers, AddUserUseCase addUser) - { - _getUsers = getUsers; - _addUser = addUser; - } - - public Task> GetUsers() => _getUsers.ExecuteAsync(); - - public Task AddUser(string name, string email) => _addUser.ExecuteAsync(name, email); -} diff --git a/src/AppName.Maui/MainPage.xaml.cs b/src/AppName.Maui/MainPage.xaml.cs index bd38dfc..c109100 100644 --- a/src/AppName.Maui/MainPage.xaml.cs +++ b/src/AppName.Maui/MainPage.xaml.cs @@ -1,12 +1,9 @@ -using AppName.Maui.Bridge; - namespace AppName.Maui; public partial class MainPage : ContentPage { - public MainPage(UsersBridge usersBridge) + public MainPage() { InitializeComponent(); - HybridView.SetInvokeJavaScriptTarget(usersBridge); } } diff --git a/src/AppName.Maui/MauiProgram.cs b/src/AppName.Maui/MauiProgram.cs index a17ce32..ce771e2 100644 --- a/src/AppName.Maui/MauiProgram.cs +++ b/src/AppName.Maui/MauiProgram.cs @@ -1,5 +1,8 @@ using AppName.Application; using AppName.Infrastructure; +using AppName.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace AppName.Maui; @@ -9,6 +12,7 @@ public static class MauiProgram public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); + builder .UseMauiApp() .ConfigureFonts(fonts => @@ -17,15 +21,24 @@ public static MauiApp CreateMauiApp() }); var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db"); + builder.Services.AddApplication(); + builder.Services.AddInfrastructure(dbPath); - builder.Services.AddTransient(); builder.Services.AddTransient(); #if DEBUG builder.Logging.AddDebug(); #endif - return builder.Build(); + + var app = builder.Build(); + + using (var db = app.Services.GetRequiredService>().CreateDbContext()) + { + db.Database.Migrate(); + } + + return app; } } diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index f3a76d9..1bfaa41 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -1,10 +1,7 @@ -import { UsersList } from "./features/users/UsersList"; - export default function App() { return (

AppName

-
); } diff --git a/src/web/src/bridge/client.test.ts b/src/web/src/bridge/client.test.ts index 415339e..e1340aa 100644 --- a/src/web/src/bridge/client.test.ts +++ b/src/web/src/bridge/client.test.ts @@ -1,34 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { bridge } from "./client"; -import type { UserDto } from "./types"; +import { invoke } from "./client"; declare global { // eslint-disable-next-line no-var var HybridWebView: { InvokeDotNet: (name: string, args?: unknown[]) => Promise }; } -describe("bridge.users", () => { +describe("invoke", () => { beforeEach(() => { globalThis.HybridWebView = { InvokeDotNet: vi.fn() }; }); - it("getAll calls GetUsers and returns the result", async () => { - const fake: UserDto[] = [{ id: "1", name: "Ada", email: "ada@x.com" }]; - (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue(fake); - - const result = await bridge.users.getAll(); - - expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("GetUsers"); - expect(result).toEqual(fake); + it("calls InvokeDotNet with only the method name when no args are given", async () => { + (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue("ok"); + const result = await invoke("Ping"); + expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("Ping"); + expect(result).toBe("ok"); }); - it("add calls AddUser with name and email", async () => { - const created: UserDto = { id: "2", name: "Bob", email: "bob@x.com" }; - (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue(created); - - const result = await bridge.users.add("Bob", "bob@x.com"); - - expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("AddUser", ["Bob", "bob@x.com"]); - expect(result).toEqual(created); + it("forwards args to InvokeDotNet when provided", async () => { + (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue(42); + const result = await invoke("Add", [1, 2]); + expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("Add", [1, 2]); + expect(result).toBe(42); }); }); diff --git a/src/web/src/bridge/client.ts b/src/web/src/bridge/client.ts index 43122e4..f9af381 100644 --- a/src/web/src/bridge/client.ts +++ b/src/web/src/bridge/client.ts @@ -1,20 +1,10 @@ -import type { UserDto } from "./types"; - // HybridWebView injects this global at runtime. declare const HybridWebView: { InvokeDotNet: (methodName: string, args?: unknown[]) => Promise; }; -function invoke(method: string, args?: unknown[]): Promise { +export function invoke(method: string, args?: unknown[]): Promise { return (args !== undefined ? HybridWebView.InvokeDotNet(method, args) : HybridWebView.InvokeDotNet(method)) as Promise; } - -export const bridge = { - users: { - getAll: (): Promise => invoke("GetUsers"), - add: (name: string, email: string): Promise => - invoke("AddUser", [name, email]), - }, -}; diff --git a/src/web/src/bridge/types.ts b/src/web/src/bridge/types.ts deleted file mode 100644 index 1bdbf0f..0000000 --- a/src/web/src/bridge/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Mirrors AppName.Application.Dtos.UserDto — keep in sync by hand. -export interface UserDto { - id: string; - name: string; - email: string; -} diff --git a/src/web/src/features/users/UsersList.tsx b/src/web/src/features/users/UsersList.tsx deleted file mode 100644 index bb9dabc..0000000 --- a/src/web/src/features/users/UsersList.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useState } from "react"; -import { bridge } from "../../bridge/client"; -import type { UserDto } from "../../bridge/types"; - -export function UsersList() { - const [users, setUsers] = useState([]); - const [error, setError] = useState(null); - - useEffect(() => { - bridge.users - .getAll() - .then(setUsers) - .catch((e: unknown) => setError(String(e))); - }, []); - - if (error) return

Bridge error: {error}

; - return ( -
    - {users.map((u) => ( -
  • - {u.name} — {u.email} -
  • - ))} -
- ); -} diff --git a/tests/AppName.Application.Tests/AppName.Application.Tests.csproj b/tests/AppName.Application.Tests/AppName.Application.Tests.csproj index 1eb6285..d9d34a7 100644 --- a/tests/AppName.Application.Tests/AppName.Application.Tests.csproj +++ b/tests/AppName.Application.Tests/AppName.Application.Tests.csproj @@ -15,10 +15,12 @@ + + diff --git a/tests/AppName.Application.Tests/GetUsersUseCaseTests.cs b/tests/AppName.Application.Tests/GetUsersUseCaseTests.cs deleted file mode 100644 index 97435ee..0000000 --- a/tests/AppName.Application.Tests/GetUsersUseCaseTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using AppName.Application.UseCases.Users; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; - -namespace AppName.Application.Tests; - -public class GetUsersUseCaseTests -{ - private sealed class FakeUserRepository : IUserRepository - { - private readonly List _users = new(); - public Task AddAsync(User user, CancellationToken ct = default) - { - _users.Add(user); - return Task.CompletedTask; - } - public Task> GetAllAsync(CancellationToken ct = default) - => Task.FromResult>(_users); - } - - [Fact] - public async Task ExecuteAsync_MapsEntitiesToDtos() - { - var repo = new FakeUserRepository(); - await repo.AddAsync(new User(Guid.NewGuid(), "Ada", "ada@x.com")); - var sut = new GetUsersUseCase(repo); - - var result = await sut.ExecuteAsync(); - - var dto = Assert.Single(result); - Assert.Equal("Ada", dto.Name); - Assert.Equal("ada@x.com", dto.Email); - } - - [Fact] - public async Task AddUserUseCase_AddsUserAndReturnsMappedDto() - { - var repo = new FakeUserRepository(); - var sut = new AddUserUseCase(repo); - - var dto = await sut.ExecuteAsync("Bob", "bob@x.com"); - - Assert.Equal("Bob", dto.Name); - Assert.Equal("bob@x.com", dto.Email); - Assert.NotEqual(Guid.Empty, dto.Id); - - var all = await repo.GetAllAsync(); - var saved = Assert.Single(all); - Assert.Equal(dto.Id, saved.Id); - } -} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs new file mode 100644 index 0000000..bcd7da6 --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs @@ -0,0 +1,71 @@ +using AppName.Application.UseCases.Bonds; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; + +namespace AppName.Application.Tests.UseCases.Bonds; + +public class GetConversionValuationUseCaseTests +{ + private sealed class Fixture + { + public Mock Repo { get; } = new(); + + public Mock Provider { get; } = new(); + + public GetConversionValuationUseCase Build() => new(Repo.Object, Provider.Object); + + public Fixture WithCachedBond(ConvertibleBond bond) + { + Repo.Setup(r => r.GetBySymbolAsync(bond.Symbol, It.IsAny())) + .ReturnsAsync(bond); + return this; + } + + public Fixture WithStockQuote(StockQuote quote) + { + Provider.Setup(p => p.GetStockQuoteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(quote); + return this; + } + } + + [Fact] + public async Task ExecuteAsync_ComputesValuation_AndLeavesBondPriceNull() + { + var bond = new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101"); + var fx = new Fixture() + .WithCachedBond(bond) + .WithStockQuote(new StockQuote("1101", 60m, new DateOnly(2026, 6, 29))); + + var dto = await fx.Build().ExecuteAsync("11011"); + + Assert.NotNull(dto); + Assert.Equal(2_000m, dto!.ConversionShares); + Assert.Equal(120_000m, dto.ConversionValue); + Assert.Equal(60m, dto.StockPrice); + Assert.Null(dto.BondPrice); + Assert.Null(dto.IsInTheMoney); + } + + [Fact] + public async Task ExecuteAsync_ReturnsNull_WhenBondNotCached() + { + var fx = new Fixture(); + + var dto = await fx.Build().ExecuteAsync("9999"); + + Assert.Null(dto); + } + + [Fact] + public async Task ExecuteAsync_ReturnsNull_WhenNoStockQuote() + { + var bond = new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101"); + var fx = new Fixture() + .WithCachedBond(bond); + + var dto = await fx.Build().ExecuteAsync("11011"); + + Assert.Null(dto); + } +} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs new file mode 100644 index 0000000..d539bd4 --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs @@ -0,0 +1,36 @@ +using AppName.Application.UseCases.Bonds; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; + +namespace AppName.Application.Tests.UseCases.Bonds; + +public class RefreshBondDataUseCaseTests +{ + private sealed class Fixture + { + public Mock Provider { get; } = new(); + + public Mock Repo { get; } = new(); + + public RefreshBondDataUseCase Build() => new(Provider.Object, Repo.Object); + + public Fixture WithIssuanceTerms(ConvertibleBond terms) + { + Provider.Setup(p => p.GetIssuanceTermsAsync(terms.Symbol, It.IsAny())) + .ReturnsAsync(terms); + return this; + } + } + + [Fact] + public async Task ExecuteAsync_UpsertsTermsFromProvider() + { + var terms = new ConvertibleBond("11011", "台泥一永", 100_000m, 36.5m, "1101"); + var fx = new Fixture() + .WithIssuanceTerms(terms); + + await fx.Build().ExecuteAsync("11011"); + + fx.Repo.Verify(r => r.UpsertAsync(It.Is(b => b.Symbol == "11011"), It.IsAny()), Times.Once); + } +} diff --git a/tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj b/tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj index 27e69b5..b3c9e39 100644 --- a/tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj +++ b/tests/AppName.Domain.Tests/AppName.Domain.Tests.csproj @@ -10,10 +10,12 @@ + + diff --git a/tests/AppName.Domain.Tests/Entities/ConvertibleBondTests.cs b/tests/AppName.Domain.Tests/Entities/ConvertibleBondTests.cs new file mode 100644 index 0000000..b036c58 --- /dev/null +++ b/tests/AppName.Domain.Tests/Entities/ConvertibleBondTests.cs @@ -0,0 +1,32 @@ +using AppName.Domain.Entities; + +namespace AppName.Domain.Tests.Entities; + +public class ConvertibleBondTests +{ + [Fact] + public void Constructor_SetsAllTerms() + { + var b = new ConvertibleBond("11011", "台泥一永", 100_000m, 36.5m, "1101"); + Assert.Equal("11011", b.Symbol); + Assert.Equal("台泥一永", b.Name); + Assert.Equal(100_000m, b.ParValue); + Assert.Equal(36.5m, b.ConversionPrice); + Assert.Equal("1101", b.UnderlyingSymbol); + } + + [Fact] + public void UpdateTerms_OverwritesParAndConversionPrice() + { + var b = new ConvertibleBond("11011", "台泥一永", 100_000m, 36.5m, "1101"); + b.UpdateTerms(100_000m, 34.0m); + Assert.Equal(100_000m, b.ParValue); + Assert.Equal(34.0m, b.ConversionPrice); + } + + [Fact] + public void Constructor_Throws_WhenSymbolBlank() + { + Assert.Throws(() => new ConvertibleBond(" ", "x", 100_000m, 36.5m, "1101")); + } +} diff --git a/tests/AppName.Domain.Tests/UserTests.cs b/tests/AppName.Domain.Tests/UserTests.cs deleted file mode 100644 index b65d7a6..0000000 --- a/tests/AppName.Domain.Tests/UserTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using AppName.Domain.Entities; - -namespace AppName.Domain.Tests; - -public class UserTests -{ - [Fact] - public void Rename_WithValidName_UpdatesName() - { - var user = new User(Guid.NewGuid(), "Old Name", "a@b.com"); - - user.Rename("New Name"); - - Assert.Equal("New Name", user.Name); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void Rename_WithBlankName_Throws(string? blank) - { - var user = new User(Guid.NewGuid(), "Old Name", "a@b.com"); - - // blank! — deliberately passing null/blank to verify the guard rejects it - Assert.Throws(() => user.Rename(blank!)); - } -} diff --git a/tests/AppName.Domain.Tests/ValueObjects/ConversionValuationTests.cs b/tests/AppName.Domain.Tests/ValueObjects/ConversionValuationTests.cs new file mode 100644 index 0000000..40fc84d --- /dev/null +++ b/tests/AppName.Domain.Tests/ValueObjects/ConversionValuationTests.cs @@ -0,0 +1,29 @@ +using AppName.Domain.ValueObjects; + +namespace AppName.Domain.Tests.ValueObjects; + +public class ConversionValuationTests +{ + [Fact] + public void Calculate_DividesParByConversionPrice_ForShares() + { + var v = ConversionValuation.Calculate(parValue: 100_000m, conversionPrice: 50m, stockPrice: 60m); + Assert.Equal(2_000m, v.ConversionShares); + } + + [Fact] + public void Calculate_MultipliesSharesByStockPrice_ForValue() + { + var v = ConversionValuation.Calculate(100_000m, 50m, 60m); + Assert.Equal(120_000m, v.ConversionValue); + } + + [Theory] + [InlineData(0)] + [InlineData(-50)] + public void Calculate_Throws_WhenConversionPriceNotPositive(decimal badPrice) + { + Assert.Throws( + () => ConversionValuation.Calculate(100_000m, badPrice, 60m)); + } +} diff --git a/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj b/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj index 5a0f504..3f77c63 100644 --- a/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj +++ b/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj @@ -10,6 +10,7 @@ + @@ -19,6 +20,11 @@ + + + + + diff --git a/tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/bond-issuance.json b/tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/bond-issuance.json new file mode 100644 index 0000000..89a5a0d --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/bond-issuance.json @@ -0,0 +1,4 @@ +[ + { "BondCode": "11011", "ShortName": "台泥一永", "IssuerCode": "1101", "Conversion/ExchangePriceAtIssuance": "36.5000" }, + { "BondCode": "12561", "ShortName": "鮮活果汁一KY", "IssuerCode": "1256", "Conversion/ExchangePriceAtIssuance": "190.0000" } +] diff --git a/tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/otc-stock-quote.json b/tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/otc-stock-quote.json new file mode 100644 index 0000000..abf059f --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/Tpex/Fixtures/otc-stock-quote.json @@ -0,0 +1,4 @@ +[ + { "Date": "1150630", "SecuritiesCompanyCode": "6488", "CompanyName": "環球晶", "Close": "500.00" }, + { "Date": "1150630", "SecuritiesCompanyCode": "5483", "CompanyName": "中美晶", "Close": "210.50" } +] diff --git a/tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexBondIssuanceApiClientTests.cs b/tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexBondIssuanceApiClientTests.cs new file mode 100644 index 0000000..5fce796 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexBondIssuanceApiClientTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Text; +using AppName.Infrastructure.Clients.Tpex; + +namespace AppName.Infrastructure.Tests.Clients.Tpex; + +public class TpexBondIssuanceApiClientTests +{ + private sealed class StubHandler(string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + + private sealed class Fixture : IDisposable + { + private HttpClient? _http; + + public string ResponseBody { get; private set; } = "[]"; + + public Fixture WithResponse(string body) + { + ResponseBody = body; + return this; + } + + public TpexBondIssuanceApiClient Build() + { + _http = new HttpClient(new StubHandler(ResponseBody)) { BaseAddress = new Uri("https://example.test/") }; + return new TpexBondIssuanceApiClient(_http); + } + + public void Dispose() => _http?.Dispose(); + } + + [Fact] + public async Task GetAllAsync_DeserializesRecords_IncludingSlashKey() + { + var json = await File.ReadAllTextAsync("Clients/Tpex/Fixtures/bond-issuance.json"); + using var fx = new Fixture().WithResponse(json); + + var records = await fx.Build().GetAllAsync(); + + var taicement = Assert.Single(records, r => r.BondCode == "11011"); + Assert.Equal("1101", taicement.IssuerCode); + Assert.Equal("36.5000", taicement.ConversionPriceAtIssuance); + Assert.Equal("台泥一永", taicement.ShortName); + + Assert.Equal(2, records.Count); + var freshJuice = Assert.Single(records, r => r.BondCode == "12561"); + Assert.Equal("鮮活果汁一KY", freshJuice.ShortName); + Assert.Equal("1256", freshJuice.IssuerCode); + Assert.Equal("190.0000", freshJuice.ConversionPriceAtIssuance); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexStockQuoteApiClientTests.cs b/tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexStockQuoteApiClientTests.cs new file mode 100644 index 0000000..d3af3f6 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/Tpex/TpexStockQuoteApiClientTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Text; +using AppName.Infrastructure.Clients.Tpex; + +namespace AppName.Infrastructure.Tests.Clients.Tpex; + +public class TpexStockQuoteApiClientTests +{ + private sealed class StubHandler(string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + + private sealed class Fixture : IDisposable + { + private HttpClient? _http; + + public string ResponseBody { get; private set; } = "[]"; + + public Fixture WithResponse(string body) + { + ResponseBody = body; + return this; + } + + public TpexStockQuoteApiClient Build() + { + _http = new HttpClient(new StubHandler(ResponseBody)) { BaseAddress = new Uri("https://example.test/") }; + return new TpexStockQuoteApiClient(_http); + } + + public void Dispose() => _http?.Dispose(); + } + + [Fact] + public async Task GetAllAsync_DeserializesOtcQuotes() + { + var json = await File.ReadAllTextAsync("Clients/Tpex/Fixtures/otc-stock-quote.json"); + using var fx = new Fixture().WithResponse(json); + + var quotes = await fx.Build().GetAllAsync(); + + var gws = Assert.Single(quotes, q => q.SecuritiesCompanyCode == "6488"); + Assert.Equal("500.00", gws.Close); + + Assert.Equal(2, quotes.Count); + var sas = Assert.Single(quotes, q => q.SecuritiesCompanyCode == "5483"); + Assert.Equal("210.50", sas.Close); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Clients/Twse/Fixtures/stock-quote.json b/tests/AppName.Infrastructure.Tests/Clients/Twse/Fixtures/stock-quote.json new file mode 100644 index 0000000..44d7ddd --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/Twse/Fixtures/stock-quote.json @@ -0,0 +1,4 @@ +[ + { "Date": "1150629", "Code": "1101", "Name": "台泥", "ClosingPrice": "36.80" }, + { "Date": "1150629", "Code": "2330", "Name": "台積電", "ClosingPrice": "1085.00" } +] diff --git a/tests/AppName.Infrastructure.Tests/Clients/Twse/TwseStockQuoteApiClientTests.cs b/tests/AppName.Infrastructure.Tests/Clients/Twse/TwseStockQuoteApiClientTests.cs new file mode 100644 index 0000000..b115670 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/Twse/TwseStockQuoteApiClientTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Text; +using AppName.Infrastructure.Clients.Twse; + +namespace AppName.Infrastructure.Tests.Clients.Twse; + +public class TwseStockQuoteApiClientTests +{ + private sealed class StubHandler(string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + + private sealed class Fixture : IDisposable + { + private HttpClient? _http; + + public string ResponseBody { get; private set; } = "[]"; + + public Fixture WithResponse(string body) + { + ResponseBody = body; + return this; + } + + public TwseStockQuoteApiClient Build() + { + _http = new HttpClient(new StubHandler(ResponseBody)) { BaseAddress = new Uri("https://example.test/") }; + return new TwseStockQuoteApiClient(_http); + } + + public void Dispose() => _http?.Dispose(); + } + + [Fact] + public async Task GetAllAsync_DeserializesQuotes() + { + var json = await File.ReadAllTextAsync("Clients/Twse/Fixtures/stock-quote.json"); + using var fx = new Fixture().WithResponse(json); + + var quotes = await fx.Build().GetAllAsync(); + + var cement = Assert.Single(quotes, q => q.Code == "1101"); + Assert.Equal("36.80", cement.ClosingPrice); + Assert.Equal("1150629", cement.Date); + + Assert.Equal(2, quotes.Count); + var tsmc = Assert.Single(quotes, q => q.Code == "2330"); + Assert.Equal("1085.00", tsmc.ClosingPrice); + } +} diff --git a/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..614d928 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs @@ -0,0 +1,25 @@ +using AppName.Domain.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace AppName.Infrastructure.Tests; + +public class DependencyInjectionTests +{ + [Fact] + public void AddInfrastructure_Resolves_MarketDataProvider() + { + var services = new ServiceCollection(); + services.AddInfrastructure(":memory:"); + using var sp = services.BuildServiceProvider(); + Assert.NotNull(sp.GetService()); + } + + [Fact] + public void AddInfrastructure_Resolves_BondRepository() + { + var services = new ServiceCollection(); + services.AddInfrastructure(":memory:"); + using var sp = services.BuildServiceProvider(); + Assert.NotNull(sp.GetService()); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs new file mode 100644 index 0000000..5c890e7 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs @@ -0,0 +1,134 @@ +using AppName.Infrastructure.Clients.Tpex; +using AppName.Infrastructure.Clients.Tpex.Models; +using AppName.Infrastructure.Clients.Twse; +using AppName.Infrastructure.Clients.Twse.Models; +using AppName.Infrastructure.Gateways; + +namespace AppName.Infrastructure.Tests.Gateways; + +public class MarketDataProviderTests +{ + private sealed class Fixture + { + public Mock Issuance { get; } = new(); + + public Mock Twse { get; } = new(); + + public Mock TpexStock { get; } = new(); + + public Fixture() + { + // Default baseline: every source returns an empty list (the "nothing found" case). + Issuance.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + Twse.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + TpexStock.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + } + + public Fixture WithIssuance(params TpexBondIssuanceRecord[] records) + { + Issuance.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(records); + return this; + } + + public Fixture WithTwse(params TwseStockQuoteRecord[] records) + { + Twse.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(records); + return this; + } + + public Fixture WithTpexOtc(params TpexStockQuoteRecord[] records) + { + TpexStock.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(records); + return this; + } + + public MarketDataProvider Build() => new(Issuance.Object, Twse.Object, TpexStock.Object); + } + + [Fact] + public async Task GetIssuanceTermsAsync_MapsRawToDomain_WithParConventionAndUnderlying() + { + var fx = new Fixture().WithIssuance(new TpexBondIssuanceRecord + { BondCode = "11011", ShortName = "台泥一永", IssuerCode = "1101", ConversionPriceAtIssuance = "36.5000" }); + + var bond = await fx.Build().GetIssuanceTermsAsync("11011"); + + Assert.NotNull(bond); + Assert.Equal(36.5m, bond!.ConversionPrice); + Assert.Equal("1101", bond.UnderlyingSymbol); + Assert.Equal(100_000m, bond.ParValue); + } + + [Fact] + public async Task GetIssuanceTermsAsync_ReturnsNull_WhenMissing() + => Assert.Null(await new Fixture().Build().GetIssuanceTermsAsync("9999")); + + [Fact] + public async Task GetIssuanceTermsAsync_ReturnsNull_WhenConversionPriceUnparseable() + { + var fx = new Fixture().WithIssuance(new TpexBondIssuanceRecord + { BondCode = "11011", ShortName = "台泥一永", IssuerCode = "1101", ConversionPriceAtIssuance = "-" }); + + var bond = await fx.Build().GetIssuanceTermsAsync("11011"); + + Assert.Null(bond); + } + + [Fact] + public async Task GetStockQuoteAsync_PrefersTwse_AndParsesRocDate() + { + var fx = new Fixture().WithTwse(new TwseStockQuoteRecord { Code = "1101", ClosingPrice = "36.80", Date = "1150629" }); + + var q = await fx.Build().GetStockQuoteAsync("1101"); + + Assert.Equal(36.80m, q!.Price); + Assert.Equal(new DateOnly(2026, 6, 29), q.AsOf); + } + + [Fact] + public async Task GetStockQuoteAsync_FallsBackToTpex_WhenNotOnTwse() + { + var fx = new Fixture().WithTpexOtc(new TpexStockQuoteRecord { SecuritiesCompanyCode = "6488", Close = "500.00", Date = "1150630" }); + + var q = await fx.Build().GetStockQuoteAsync("6488"); + + Assert.Equal(500.00m, q!.Price); + Assert.Equal(new DateOnly(2026, 6, 30), q.AsOf); + } + + [Fact] + public async Task GetStockQuoteAsync_ReturnsNull_WhenOnNeitherSource() + => Assert.Null(await new Fixture().Build().GetStockQuoteAsync("9999")); + + [Theory] + [InlineData("")] + [InlineData("bad")] + public async Task GetStockQuoteAsync_FallsBackToTpex_WhenTwseDateMalformed(string malformedDate) + { + var fx = new Fixture() + .WithTwse(new TwseStockQuoteRecord { Code = "6488", ClosingPrice = "36.80", Date = malformedDate }) + .WithTpexOtc(new TpexStockQuoteRecord { SecuritiesCompanyCode = "6488", Close = "500.00", Date = "1150630" }); + + var q = await fx.Build().GetStockQuoteAsync("6488"); + + Assert.Equal(500.00m, q!.Price); + Assert.Equal(new DateOnly(2026, 6, 30), q.AsOf); + } + + [Theory] + [InlineData("")] + [InlineData("bad")] + public async Task GetStockQuoteAsync_ReturnsNull_WhenOnlySourceHasMalformedDate(string malformedDate) + { + var fx = new Fixture() + .WithTwse(new TwseStockQuoteRecord { Code = "1101", ClosingPrice = "36.80", Date = malformedDate }); + + var q = await fx.Build().GetStockQuoteAsync("1101"); + + Assert.Null(q); + } + + [Fact] + public async Task GetBondQuoteAsync_Throws_NotImplemented() + => await Assert.ThrowsAsync(() => new Fixture().Build().GetBondQuoteAsync("11011")); +} diff --git a/tests/AppName.Infrastructure.Tests/Persistence/ConvertibleBondRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/ConvertibleBondRepositoryTests.cs new file mode 100644 index 0000000..5dbcffb --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Persistence/ConvertibleBondRepositoryTests.cs @@ -0,0 +1,60 @@ +using AppName.Domain.Entities; +using AppName.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Tests.Persistence; + +public class ConvertibleBondRepositoryTests +{ + private sealed class Fixture : IDisposable + { + private sealed class SingleContextFactory(DbContextOptions options) : IDbContextFactory + { + public AppDbContext CreateDbContext() => new(options); + } + + private readonly SqliteConnection _connection; + + public ConvertibleBondRepository Repo { get; } + + public Fixture() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + using (var ctx = new AppDbContext(options)) + ctx.Database.EnsureCreated(); + + Repo = new ConvertibleBondRepository(new SingleContextFactory(options)); + } + + public void Dispose() => _connection.Dispose(); + } + + [Fact] + public async Task UpsertAsync_ThenGetBySymbol_RoundTrips() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertAsync(new ConvertibleBond("11011", "台泥一永", 100_000m, 36.5m, "1101")); + var found = await fx.Repo.GetBySymbolAsync("11011"); + + Assert.NotNull(found); + Assert.Equal(36.5m, found!.ConversionPrice); + } + + [Fact] + public async Task UpsertAsync_Existing_UpdatesTerms() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertAsync(new ConvertibleBond("11011", "台泥一永", 100_000m, 36.5m, "1101")); + await fx.Repo.UpsertAsync(new ConvertibleBond("11011", "台泥一永", 100_000m, 34.0m, "1101")); + var all = await fx.Repo.GetAllAsync(); + + var bond = Assert.Single(all); + Assert.Equal(34.0m, bond.ConversionPrice); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs new file mode 100644 index 0000000..fcec5f9 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs @@ -0,0 +1,43 @@ +using AppName.Domain.Entities; +using AppName.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Tests.Persistence; + +public class MigrationTests +{ + private sealed class Fixture : IDisposable + { + private readonly SqliteConnection _conn; + + public AppDbContext Db { get; } + + public Fixture() + { + _conn = new SqliteConnection("Data Source=:memory:"); + _conn.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_conn).Options; + Db = new AppDbContext(options); + Db.Database.Migrate(); + } + + public void Dispose() + { + Db.Dispose(); + _conn.Dispose(); + } + } + + [Fact] + public void Migrate_CreatesConvertibleBondsSchema_AndRoundTripsABond() + { + using var fx = new Fixture(); + + fx.Db.ConvertibleBonds.Add(new ConvertibleBond("11011", "台泥一永", 100_000m, 36.5m, "1101")); + fx.Db.SaveChanges(); + + Assert.Equal(36.5m, fx.Db.ConvertibleBonds.Single().ConversionPrice); + } +} diff --git a/tests/AppName.Infrastructure.Tests/UserRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/UserRepositoryTests.cs deleted file mode 100644 index 5fbf5f7..0000000 --- a/tests/AppName.Infrastructure.Tests/UserRepositoryTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -using AppName.Domain.Entities; -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace AppName.Infrastructure.Tests; - -public class UserRepositoryTests -{ - private sealed class TestDbContextFactory : IDbContextFactory - { - private readonly DbContextOptions _options; - public TestDbContextFactory(string dbPath) - => _options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source={dbPath}").Options; - public AppDbContext CreateDbContext() => new AppDbContext(_options); - } - - [Fact] - public async Task AddAsync_ThenGetAllAsync_ReturnsPersistedUser() - { - var dbPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.db"); - try - { - var factory = new TestDbContextFactory(dbPath); - - using (var ctx = factory.CreateDbContext()) - ctx.Database.EnsureCreated(); - - var repo = new UserRepository(factory); - - await repo.AddAsync(new User(Guid.NewGuid(), "Ada", "ada@x.com")); - var all = await repo.GetAllAsync(); - - var user = Assert.Single(all); - Assert.Equal("Ada", user.Name); - } - finally - { - if (File.Exists(dbPath)) File.Delete(dbPath); - } - } -} From 037e21623d78158001d26831800ff92b067c4a56 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Thu, 2 Jul 2026 14:22:10 -0600 Subject: [PATCH 04/28] feat: implement TWSE MIS bond quote client and integrate into market data provider --- .gitignore | 2 + .../Dtos/ConversionValuationDto.cs | 12 +-- .../Bonds/GetConversionValuationUseCase.cs | 28 +++++-- .../Abstractions/IMarketDataProvider.cs | 7 +- src/AppName.Domain/Entities/BondQuote.cs | 2 +- .../TwseMis/ITwseMisBondQuoteApiClient.cs | 16 ++++ .../TwseMis/Models/TwseMisQuoteRecord.cs | 53 +++++++++++++ .../Clients/TwseMis/Models/TwseMisResponse.cs | 31 ++++++++ .../Clients/TwseMis/TwseMisApiOptions.cs | 22 ++++++ .../TwseMis/TwseMisBondQuoteApiClient.cs | 46 +++++++++++ .../Clients/TwseMis/TwseMisHttpPipeline.cs | 31 ++++++++ .../DependencyInjection.cs | 3 + .../Gateways/MarketDataProvider.cs | 42 ++++++++-- .../GetConversionValuationUseCaseTests.cs | 66 +++++++++++++++- .../TwseMis/Fixtures/mis-cb-quote.json | 8 ++ .../TwseMis/TwseMisBondQuoteApiClientTests.cs | 79 +++++++++++++++++++ .../Gateways/MarketDataProviderTests.cs | 71 ++++++++++++++++- 17 files changed, 491 insertions(+), 28 deletions(-) create mode 100644 src/AppName.Infrastructure/Clients/TwseMis/ITwseMisBondQuoteApiClient.cs create mode 100644 src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisQuoteRecord.cs create mode 100644 src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisResponse.cs create mode 100644 src/AppName.Infrastructure/Clients/TwseMis/TwseMisApiOptions.cs create mode 100644 src/AppName.Infrastructure/Clients/TwseMis/TwseMisBondQuoteApiClient.cs create mode 100644 src/AppName.Infrastructure/Clients/TwseMis/TwseMisHttpPipeline.cs create mode 100644 tests/AppName.Infrastructure.Tests/Clients/TwseMis/Fixtures/mis-cb-quote.json create mode 100644 tests/AppName.Infrastructure.Tests/Clients/TwseMis/TwseMisBondQuoteApiClientTests.cs diff --git a/.gitignore b/.gitignore index 1e0142c..55279e0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ src/AppName.Maui/Resources/Raw/web/ # OS .DS_Store + +.playwright-cli diff --git a/src/AppName.Application/Dtos/ConversionValuationDto.cs b/src/AppName.Application/Dtos/ConversionValuationDto.cs index 03ce2d4..db38214 100644 --- a/src/AppName.Application/Dtos/ConversionValuationDto.cs +++ b/src/AppName.Application/Dtos/ConversionValuationDto.cs @@ -4,8 +4,8 @@ namespace AppName.Application.Dtos; /// /// Conversion valuation result for display. and -/// are null until the CB market-price source is -/// implemented (deferred — see spec §2). +/// are populated from the live CB quote when +/// available, and null when no bond quote can be fetched. /// public record ConversionValuationDto { @@ -61,15 +61,15 @@ public ConversionValuationDto( public DateOnly AsOf { get; init; } /// - /// Market price of the bond itself. Null in v1: the CB market price - /// source is deferred (see spec §2). + /// Market price of the bond itself, populated from the live CB quote + /// when available, and null when no bond quote can be fetched. /// [DisplayName("Bond Price")] public decimal? BondPrice { get; init; } /// - /// Whether the bond is currently in the money. Null in v1: depends on - /// , which is deferred (see spec §2). + /// Whether the bond is currently in the money. Depends on + /// ; null when no bond quote can be fetched. /// [DisplayName("In The Money")] public bool? IsInTheMoney { get; init; } diff --git a/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs index 14326a2..80e75ae 100644 --- a/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs @@ -1,5 +1,6 @@ using AppName.Application.Dtos; using AppName.Domain.Abstractions; +using AppName.Domain.Entities; using AppName.Domain.ValueObjects; namespace AppName.Application.UseCases.Bonds; @@ -11,9 +12,10 @@ namespace AppName.Application.UseCases.Bonds; public sealed class GetConversionValuationUseCase(IConvertibleBondRepository repo, IMarketDataProvider provider) { /// - /// Returns the valuation DTO, or null when the bond is not cached or - /// its stock quote is unavailable. BondPrice/IsInTheMoney are left - /// null: the CB market price source is deferred (see spec §2). + /// Returns the valuation DTO, or null when the bond is not cached or its stock + /// quote is unavailable. BondPrice/IsInTheMoney are populated from the live CB + /// quote when available, and left null when no bond quote can be fetched or the + /// fetch fails. /// public async Task ExecuteAsync(string bondSymbol, CancellationToken ct = default) { @@ -26,9 +28,25 @@ public sealed class GetConversionValuationUseCase(IConvertibleBondRepository rep if (stockQuote is null) return null; var v = ConversionValuation.Calculate(bond.ParValue, bond.ConversionPrice, stockQuote.Price); - + + BondQuote? bondQuote; + + try + { + bondQuote = await provider.GetBondQuoteAsync(bondSymbol, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Bond price is a best-effort enrichment; a source outage must not break the + // core stock-based valuation. Degrade to no bond price (see spec §5.3). + bondQuote = null; + } + + decimal? bondPrice = bondQuote?.Price; + bool? isInTheMoney = bondPrice is { } bp ? v.ConversionValue > bp : null; + return new ConversionValuationDto( bond.Symbol, v.ConversionShares, v.ConversionValue, stockQuote.Price, stockQuote.AsOf, - BondPrice: null, IsInTheMoney: null); + BondPrice: bondPrice, IsInTheMoney: isInTheMoney); } } diff --git a/src/AppName.Domain/Abstractions/IMarketDataProvider.cs b/src/AppName.Domain/Abstractions/IMarketDataProvider.cs index 3bd1a13..32a1a3e 100644 --- a/src/AppName.Domain/Abstractions/IMarketDataProvider.cs +++ b/src/AppName.Domain/Abstractions/IMarketDataProvider.cs @@ -18,10 +18,9 @@ public interface IMarketDataProvider Task GetStockQuoteAsync(string stockSymbol, CancellationToken ct = default); /// - /// Gets the latest daily market quote for a convertible bond. - /// NOT IMPLEMENTED in v1: the per-bond CB market price is not available from any free JSON API - /// (only a JS-gated TPEx web page). Implementations throw . - /// See spec §2 (deferred scope). + /// Gets the latest market quote for a convertible bond from the TWSE MIS + /// service, or null when no usable price is available (bond not found, no + /// tradable price, or an unparseable quote). Returns the price in NT$. /// Task GetBondQuoteAsync(string bondSymbol, CancellationToken ct = default); } diff --git a/src/AppName.Domain/Entities/BondQuote.cs b/src/AppName.Domain/Entities/BondQuote.cs index 24df22f..85ae198 100644 --- a/src/AppName.Domain/Entities/BondQuote.cs +++ b/src/AppName.Domain/Entities/BondQuote.cs @@ -3,7 +3,7 @@ namespace AppName.Domain.Entities; /// -/// A convertible bond's market price on a given trading day (deferred data source). +/// A convertible bond's market price on a given trading day. /// /// /// Creates a bond quote. diff --git a/src/AppName.Infrastructure/Clients/TwseMis/ITwseMisBondQuoteApiClient.cs b/src/AppName.Infrastructure/Clients/TwseMis/ITwseMisBondQuoteApiClient.cs new file mode 100644 index 0000000..7c3c37f --- /dev/null +++ b/src/AppName.Infrastructure/Clients/TwseMis/ITwseMisBondQuoteApiClient.cs @@ -0,0 +1,16 @@ +using AppName.Infrastructure.Clients.TwseMis.Models; + +namespace AppName.Infrastructure.Clients.TwseMis; + +/// +/// Fetches live convertible-bond quotes from the TWSE MIS service (mis.twse.com.tw). +/// +public interface ITwseMisBondQuoteApiClient +{ + /// + /// Gets the current quote records for the given bond codes. Codes are queried + /// on the OTC channel and batched into as few requests as possible; the result + /// contains only records the service returned (missing codes are omitted). + /// + Task> GetQuotesAsync(IEnumerable bondCodes, CancellationToken ct = default); +} diff --git a/src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisQuoteRecord.cs b/src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisQuoteRecord.cs new file mode 100644 index 0000000..163025a --- /dev/null +++ b/src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisQuoteRecord.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace AppName.Infrastructure.Clients.TwseMis.Models; + +/// +/// Raw TWSE MIS quote record (one security) from getStockInfo.jsp. Values are the +/// raw strings the service returns; a last-price of "-" means no trade yet today. +/// +public sealed class TwseMisQuoteRecord +{ + /// + /// Security code (the convertible-bond ticker). + /// + [DisplayName("Security Code")] + [JsonPropertyName("c")] + public string Code { get; set; } = ""; + + /// + /// Latest trade price ("z"); "-" when the bond has not traded today. + /// + [DisplayName("Last Price")] + [JsonPropertyName("z")] + public string LastPrice { get; set; } = ""; + + /// + /// Previous trading day's closing price ("y"); always present. + /// + [DisplayName("Previous Close")] + [JsonPropertyName("y")] + public string PreviousClose { get; set; } = ""; + + /// + /// Trade date in Gregorian format "yyyyMMdd" (e.g. "20260702"). + /// + [DisplayName("Trade Date")] + [JsonPropertyName("d")] + public string Date { get; set; } = ""; + + /// + /// Short display name of the security. + /// + [DisplayName("Name")] + [JsonPropertyName("n")] + public string Name { get; set; } = ""; + + /// + /// Market channel the quote came from (e.g. "otc"). + /// + [DisplayName("Market")] + [JsonPropertyName("ex")] + public string Market { get; set; } = ""; +} diff --git a/src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisResponse.cs b/src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisResponse.cs new file mode 100644 index 0000000..b96535f --- /dev/null +++ b/src/AppName.Infrastructure/Clients/TwseMis/Models/TwseMisResponse.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace AppName.Infrastructure.Clients.TwseMis.Models; + +/// +/// Raw TWSE MIS getStockInfo.jsp envelope wrapping one or more quote records. +/// +public sealed class TwseMisResponse +{ + /// + /// Result code; "0000" means success. + /// + [DisplayName("Result Code")] + [JsonPropertyName("rtcode")] + public string RtCode { get; set; } = ""; + + /// + /// Human-readable result message. + /// + [DisplayName("Result Message")] + [JsonPropertyName("rtmessage")] + public string RtMessage { get; set; } = ""; + + /// + /// The per-security quote records; null or empty when nothing matched. + /// + [DisplayName("Quotes")] + [JsonPropertyName("msgArray")] + public List? MsgArray { get; set; } +} diff --git a/src/AppName.Infrastructure/Clients/TwseMis/TwseMisApiOptions.cs b/src/AppName.Infrastructure/Clients/TwseMis/TwseMisApiOptions.cs new file mode 100644 index 0000000..bd3fb23 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/TwseMis/TwseMisApiOptions.cs @@ -0,0 +1,22 @@ +namespace AppName.Infrastructure.Clients.TwseMis; + +/// +/// Configuration for the TWSE MIS quote HTTP client. +/// +public sealed class TwseMisApiOptions +{ + /// + /// Base address of the TWSE MIS stock-quote API host. + /// + public string BaseUrl { get; set; } = "https://mis.twse.com.tw/stock/api/"; + + /// + /// User-Agent sent with requests. + /// + public string UserAgent { get; set; } = "Mozilla/5.0 (compatible; AppName/1.0)"; + + /// + /// Referer header the MIS service requires to serve quote data. + /// + public string Referer { get; set; } = "https://mis.twse.com.tw/stock/index.jsp"; +} diff --git a/src/AppName.Infrastructure/Clients/TwseMis/TwseMisBondQuoteApiClient.cs b/src/AppName.Infrastructure/Clients/TwseMis/TwseMisBondQuoteApiClient.cs new file mode 100644 index 0000000..9ca16cd --- /dev/null +++ b/src/AppName.Infrastructure/Clients/TwseMis/TwseMisBondQuoteApiClient.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using AppName.Infrastructure.Clients.TwseMis.Models; + +namespace AppName.Infrastructure.Clients.TwseMis; + +/// +public sealed class TwseMisBondQuoteApiClient(HttpClient httpClient) : ITwseMisBondQuoteApiClient +{ + private const string Path = "getStockInfo.jsp"; + + private const int ChunkSize = 50; + + /// + public async Task> GetQuotesAsync(IEnumerable bondCodes, CancellationToken ct = default) + { + var codes = bondCodes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList(); + + var results = new List(); + + try + { + foreach (var chunk in codes.Chunk(ChunkSize)) + { + // "%7C" is the percent-encoded pipe MIS expects between channels. + var exCh = string.Join("%7C", chunk.Select(c => $"otc_{c}.tw")); + var url = $"{Path}?ex_ch={exCh}&json=1&delay=0"; + + using var response = await httpClient.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(ct); + + var payload = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct) + ?? throw new InvalidOperationException("Failed to deserialize TWSE MIS response."); + + if (payload.MsgArray is { Count: > 0 }) + { + results.AddRange(payload.MsgArray.Where(r => !string.IsNullOrEmpty(r.Code))); + } + } + + return results; + } + catch (OperationCanceledException) { throw; } + } +} diff --git a/src/AppName.Infrastructure/Clients/TwseMis/TwseMisHttpPipeline.cs b/src/AppName.Infrastructure/Clients/TwseMis/TwseMisHttpPipeline.cs new file mode 100644 index 0000000..b3910a9 --- /dev/null +++ b/src/AppName.Infrastructure/Clients/TwseMis/TwseMisHttpPipeline.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace AppName.Infrastructure.Clients.TwseMis; + +/// +/// Registers the TWSE MIS quote typed HTTP client with resilience. +/// +public static class TwseMisHttpPipeline +{ + /// + /// Adds the TWSE MIS bond-quote typed HTTP client to DI. + /// + public static IServiceCollection AddTwseMisHttpClients(this IServiceCollection services) + { + services.AddHttpClient(Configure) + .AddStandardResilienceHandler(); + + return services; + } + + internal static void Configure(IServiceProvider sp, HttpClient client) + { + var o = sp.GetRequiredService>().Value; + + client.BaseAddress = new Uri(o.BaseUrl.EndsWith('/') ? o.BaseUrl : o.BaseUrl + "/"); + + client.DefaultRequestHeaders.UserAgent.ParseAdd(o.UserAgent); + client.DefaultRequestHeaders.Referrer = new Uri(o.Referer); + } +} diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index 9355810..6e5dc26 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -1,6 +1,7 @@ using AppName.Domain.Abstractions; using AppName.Infrastructure.Clients.Tpex; using AppName.Infrastructure.Clients.Twse; +using AppName.Infrastructure.Clients.TwseMis; using AppName.Infrastructure.Gateways; using AppName.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -16,8 +17,10 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddOptions(); services.AddOptions(); + services.AddOptions(); services.AddTpexHttpClients(); services.AddTwseHttpClients(); + services.AddTwseMisHttpClients(); services.AddScoped(); services.AddScoped(); diff --git a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs index f8d3d68..71f7d48 100644 --- a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs +++ b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs @@ -3,6 +3,7 @@ using AppName.Domain.Entities; using AppName.Infrastructure.Clients.Tpex; using AppName.Infrastructure.Clients.Twse; +using AppName.Infrastructure.Clients.TwseMis; namespace AppName.Infrastructure.Gateways; @@ -10,7 +11,8 @@ namespace AppName.Infrastructure.Gateways; public sealed class MarketDataProvider( ITpexBondIssuanceApiClient issuance, ITwseStockQuoteApiClient twseStocks, - ITpexStockQuoteApiClient tpexStocks) : IMarketDataProvider + ITpexStockQuoteApiClient tpexStocks, + ITwseMisBondQuoteApiClient misBonds) : IMarketDataProvider { private const decimal ParValueConvention = 100_000m; @@ -61,14 +63,44 @@ public sealed class MarketDataProvider( } /// - public Task GetBondQuoteAsync(string bondSymbol, CancellationToken ct = default) - => throw new NotImplementedException( - "CB market price is not available from any free JSON API; it requires scraping the JS-gated TPEx " + - "page (NewCB_day_qry.php). Deferred — see spec §2."); + public async Task GetBondQuoteAsync(string bondSymbol, CancellationToken ct = default) + { + var quotes = await misBonds.GetQuotesAsync(new[] { bondSymbol }, ct); + + var q = quotes.FirstOrDefault(x => x.Code == bondSymbol); + + if (q is null) return null; + + // z (last trade) may be "-" for illiquid bonds; fall back to y (previous close). + var pointsRaw = IsNumeric(q.LastPrice) ? q.LastPrice : q.PreviousClose; + var points = ParseDecimal(pointsRaw); + + if (points <= 0) return null; + + if (ParseMisDate(q.Date) is not { } asOf) return null; + + // MIS quotes CB price as a percentage of par; scale to NT$ so it is + // comparable with ConversionValue. + var priceNt = points / 100m * ParValueConvention; + + return new BondQuote(bondSymbol, priceNt, asOf); + } private static decimal ParseDecimal(string raw) => decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0m; + private static bool IsNumeric(string raw) => + decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out _); + + /// + /// Converts a TWSE MIS Gregorian date string ("20260702") to a DateOnly. + /// Returns null when the input is not a valid 8-char yyyyMMdd date. + /// + internal static DateOnly? ParseMisDate(string raw) => + DateOnly.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) + ? d + : null; + /// /// Converts a Taiwan ROC date string ("1150629") to a Gregorian DateOnly (year = ROC + 1911). /// Returns null when the input is not a valid 7-char ROC date. diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs index bcd7da6..0134f7c 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs @@ -27,22 +27,80 @@ public Fixture WithStockQuote(StockQuote quote) .ReturnsAsync(quote); return this; } + + public Fixture WithBondQuote(BondQuote quote) + { + Provider.Setup(p => p.GetBondQuoteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(quote); + return this; + } } [Fact] - public async Task ExecuteAsync_ComputesValuation_AndLeavesBondPriceNull() + public async Task ExecuteAsync_ComputesInTheMoney_WhenConversionValueExceedsBondPrice() { var bond = new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101"); var fx = new Fixture() .WithCachedBond(bond) - .WithStockQuote(new StockQuote("1101", 60m, new DateOnly(2026, 6, 29))); + .WithStockQuote(new StockQuote("1101", 60m, new DateOnly(2026, 6, 29))) + .WithBondQuote(new BondQuote("11011", 100_000m, new DateOnly(2026, 6, 29))); var dto = await fx.Build().ExecuteAsync("11011"); Assert.NotNull(dto); Assert.Equal(2_000m, dto!.ConversionShares); - Assert.Equal(120_000m, dto.ConversionValue); - Assert.Equal(60m, dto.StockPrice); + Assert.Equal(120_000m, dto.ConversionValue); // 2000 * 60 + Assert.Equal(100_000m, dto.BondPrice); + Assert.True(dto.IsInTheMoney); // 120,000 > 100,000 + } + + [Fact] + public async Task ExecuteAsync_ComputesOutOfTheMoney_WhenBondPriceExceedsConversionValue() + { + var bond = new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101"); + var fx = new Fixture() + .WithCachedBond(bond) + .WithStockQuote(new StockQuote("1101", 40m, new DateOnly(2026, 6, 29))) + .WithBondQuote(new BondQuote("11011", 100_000m, new DateOnly(2026, 6, 29))); + + var dto = await fx.Build().ExecuteAsync("11011"); + + Assert.NotNull(dto); + Assert.Equal(80_000m, dto!.ConversionValue); // 2000 * 40 + Assert.Equal(100_000m, dto.BondPrice); + Assert.False(dto.IsInTheMoney); // 80,000 < 100,000 + } + + [Fact] + public async Task ExecuteAsync_ComputesOutOfTheMoney_WhenConversionValueEqualsBondPrice() + { + var bond = new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101"); + var fx = new Fixture() + .WithCachedBond(bond) + .WithStockQuote(new StockQuote("1101", 50m, new DateOnly(2026, 6, 29))) + .WithBondQuote(new BondQuote("11011", 100_000m, new DateOnly(2026, 6, 29))); + + var dto = await fx.Build().ExecuteAsync("11011"); + + Assert.NotNull(dto); + Assert.Equal(100_000m, dto!.ConversionValue); // 2000 * 50 + Assert.Equal(100_000m, dto.BondPrice); + Assert.False(dto.IsInTheMoney); // 100,000 == 100,000 → strict > is false + } + + [Fact] + public async Task ExecuteAsync_LeavesBondPriceAndInTheMoneyNull_WhenNoBondQuote() + { + var bond = new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101"); + var fx = new Fixture() + .WithCachedBond(bond) + .WithStockQuote(new StockQuote("1101", 60m, new DateOnly(2026, 6, 29))); + // No WithBondQuote → provider returns null by default. + + var dto = await fx.Build().ExecuteAsync("11011"); + + Assert.NotNull(dto); + Assert.Equal(120_000m, dto!.ConversionValue); Assert.Null(dto.BondPrice); Assert.Null(dto.IsInTheMoney); } diff --git a/tests/AppName.Infrastructure.Tests/Clients/TwseMis/Fixtures/mis-cb-quote.json b/tests/AppName.Infrastructure.Tests/Clients/TwseMis/Fixtures/mis-cb-quote.json new file mode 100644 index 0000000..548e7d9 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/TwseMis/Fixtures/mis-cb-quote.json @@ -0,0 +1,8 @@ +{ + "rtcode": "0000", + "rtmessage": "OK", + "msgArray": [ + { "c": "11011", "n": "台泥一永", "z": "-", "y": "100.0000", "o": "100.0000", "h": "100.2000", "l": "100.0000", "d": "20260702", "ex": "otc" }, + { "c": "12561", "n": "鮮活果汁一KY", "z": "103.5000", "y": "103.0000", "d": "20260702", "ex": "otc" } + ] +} diff --git a/tests/AppName.Infrastructure.Tests/Clients/TwseMis/TwseMisBondQuoteApiClientTests.cs b/tests/AppName.Infrastructure.Tests/Clients/TwseMis/TwseMisBondQuoteApiClientTests.cs new file mode 100644 index 0000000..e5811f2 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Clients/TwseMis/TwseMisBondQuoteApiClientTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text; +using AppName.Infrastructure.Clients.TwseMis; + +namespace AppName.Infrastructure.Tests.Clients.TwseMis; + +public class TwseMisBondQuoteApiClientTests +{ + private sealed class CapturingHandler(string body) : HttpMessageHandler + { + public List Requests { get; } = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(request.RequestUri!); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + } + + private sealed class Fixture : IDisposable + { + private HttpClient? _http; + + public string ResponseBody { get; private set; } = "{\"rtcode\":\"0000\",\"msgArray\":[]}"; + + public CapturingHandler? Handler { get; private set; } + + public Fixture WithResponse(string body) + { + ResponseBody = body; + return this; + } + + public TwseMisBondQuoteApiClient Build() + { + Handler = new CapturingHandler(ResponseBody); + _http = new HttpClient(Handler) { BaseAddress = new Uri("https://mis.example.test/stock/api/") }; + return new TwseMisBondQuoteApiClient(_http); + } + + public void Dispose() => _http?.Dispose(); + } + + [Fact] + public async Task GetQuotesAsync_DeserializesEnvelopeRecords() + { + var json = await File.ReadAllTextAsync("Clients/TwseMis/Fixtures/mis-cb-quote.json"); + using var fx = new Fixture().WithResponse(json); + + var quotes = await fx.Build().GetQuotesAsync(new[] { "11011", "12561" }); + + Assert.Equal(2, quotes.Count); + + var taicement = Assert.Single(quotes, q => q.Code == "11011"); + Assert.Equal("-", taicement.LastPrice); + Assert.Equal("100.0000", taicement.PreviousClose); + Assert.Equal("20260702", taicement.Date); + + var fruit = Assert.Single(quotes, q => q.Code == "12561"); + Assert.Equal("103.5000", fruit.LastPrice); + } + + [Fact] + public async Task GetQuotesAsync_BatchesCodesIntoOneOtcRequest() + { + using var fx = new Fixture(); + var client = fx.Build(); + + await client.GetQuotesAsync(new[] { "11011", "12561" }); + + var request = Assert.Single(fx.Handler!.Requests); + var url = request.ToString(); + Assert.Contains("getStockInfo.jsp", url); + Assert.Contains("otc_11011.tw", url); + Assert.Contains("otc_12561.tw", url); + Assert.Contains("json=1", url); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs index 5c890e7..4586700 100644 --- a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs +++ b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs @@ -2,6 +2,8 @@ using AppName.Infrastructure.Clients.Tpex.Models; using AppName.Infrastructure.Clients.Twse; using AppName.Infrastructure.Clients.Twse.Models; +using AppName.Infrastructure.Clients.TwseMis; +using AppName.Infrastructure.Clients.TwseMis.Models; using AppName.Infrastructure.Gateways; namespace AppName.Infrastructure.Tests.Gateways; @@ -16,12 +18,16 @@ private sealed class Fixture public Mock TpexStock { get; } = new(); + public Mock Mis { get; } = new(); + public Fixture() { // Default baseline: every source returns an empty list (the "nothing found" case). Issuance.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(Array.Empty()); Twse.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(Array.Empty()); TpexStock.Setup(c => c.GetAllAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + Mis.Setup(c => c.GetQuotesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(Array.Empty()); } public Fixture WithIssuance(params TpexBondIssuanceRecord[] records) @@ -42,7 +48,14 @@ public Fixture WithTpexOtc(params TpexStockQuoteRecord[] records) return this; } - public MarketDataProvider Build() => new(Issuance.Object, Twse.Object, TpexStock.Object); + public Fixture WithMisQuotes(params TwseMisQuoteRecord[] records) + { + Mis.Setup(c => c.GetQuotesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(records); + return this; + } + + public MarketDataProvider Build() => new(Issuance.Object, Twse.Object, TpexStock.Object, Mis.Object); } [Fact] @@ -129,6 +142,58 @@ public async Task GetStockQuoteAsync_ReturnsNull_WhenOnlySourceHasMalformedDate( } [Fact] - public async Task GetBondQuoteAsync_Throws_NotImplemented() - => await Assert.ThrowsAsync(() => new Fixture().Build().GetBondQuoteAsync("11011")); + public async Task GetBondQuoteAsync_UsesLastPrice_ScaledToNtDollars() + { + var fx = new Fixture().WithMisQuotes(new TwseMisQuoteRecord + { Code = "11011", LastPrice = "100.2000", PreviousClose = "100.0000", Date = "20260702" }); + + var q = await fx.Build().GetBondQuoteAsync("11011"); + + Assert.NotNull(q); + Assert.Equal(100_200m, q!.Price); // 100.20% of NT$100,000 par + Assert.Equal(new DateOnly(2026, 7, 2), q.AsOf); + } + + [Fact] + public async Task GetBondQuoteAsync_FallsBackToPreviousClose_WhenLastPriceIsDash() + { + var fx = new Fixture().WithMisQuotes(new TwseMisQuoteRecord + { Code = "11011", LastPrice = "-", PreviousClose = "100.0000", Date = "20260702" }); + + var q = await fx.Build().GetBondQuoteAsync("11011"); + + Assert.NotNull(q); + Assert.Equal(100_000m, q!.Price); // falls back to y = 100.00% of par + } + + [Fact] + public async Task GetBondQuoteAsync_ReturnsNull_WhenNoMatchingRecord() + => Assert.Null(await new Fixture().Build().GetBondQuoteAsync("9999")); + + [Fact] + public async Task GetBondQuoteAsync_ReturnsNull_WhenOnlyRecordIsForDifferentCode() + { + var fx = new Fixture().WithMisQuotes(new TwseMisQuoteRecord + { Code = "22222", LastPrice = "100.0000", PreviousClose = "100.0000", Date = "20260702" }); + + Assert.Null(await fx.Build().GetBondQuoteAsync("11011")); + } + + [Fact] + public async Task GetBondQuoteAsync_ReturnsNull_WhenNoUsablePrice() + { + var fx = new Fixture().WithMisQuotes(new TwseMisQuoteRecord + { Code = "11011", LastPrice = "-", PreviousClose = "-", Date = "20260702" }); + + Assert.Null(await fx.Build().GetBondQuoteAsync("11011")); + } + + [Fact] + public async Task GetBondQuoteAsync_ReturnsNull_WhenDateMalformed() + { + var fx = new Fixture().WithMisQuotes(new TwseMisQuoteRecord + { Code = "11011", LastPrice = "100.2000", PreviousClose = "100.0000", Date = "bad" }); + + Assert.Null(await fx.Build().GetBondQuoteAsync("11011")); + } } From f6e1d0dab0d105081f8252aca3c301d014b6160a Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Thu, 2 Jul 2026 16:25:40 -0600 Subject: [PATCH 05/28] feat: add methods to retrieve all issuance terms and stock/bond quotes in MarketDataProvider --- .../Abstractions/IMarketDataProvider.cs | 19 ++++ .../Gateways/MarketDataProvider.cs | 88 +++++++++++++++++++ .../Gateways/MarketDataProviderTests.cs | 50 +++++++++++ 3 files changed, 157 insertions(+) diff --git a/src/AppName.Domain/Abstractions/IMarketDataProvider.cs b/src/AppName.Domain/Abstractions/IMarketDataProvider.cs index 32a1a3e..89bdcb5 100644 --- a/src/AppName.Domain/Abstractions/IMarketDataProvider.cs +++ b/src/AppName.Domain/Abstractions/IMarketDataProvider.cs @@ -23,4 +23,23 @@ public interface IMarketDataProvider /// tradable price, or an unparseable quote). Returns the price in NT$. /// Task GetBondQuoteAsync(string bondSymbol, CancellationToken ct = default); + + /// + /// Gets the static conversion terms for every convertible bond in the TPEx + /// issuance dataset (rows with an unusable conversion price are skipped). + /// + Task> GetAllIssuanceTermsAsync(CancellationToken ct = default); + + /// + /// Gets the latest daily quote for each requested underlying stock, resolved + /// against a single TWSE snapshot then a single TPEx snapshot. Symbols with no + /// usable quote are omitted from the result. + /// + Task> GetStockQuotesAsync(IEnumerable stockSymbols, CancellationToken ct = default); + + /// + /// Gets the latest MIS market quote for each requested convertible bond, in NT$. + /// Bonds with no usable price are omitted from the result. + /// + Task> GetBondQuotesAsync(IEnumerable bondSymbols, CancellationToken ct = default); } diff --git a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs index 71f7d48..f1116be 100644 --- a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs +++ b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs @@ -86,6 +86,94 @@ public sealed class MarketDataProvider( return new BondQuote(bondSymbol, priceNt, asOf); } + /// + public async Task> GetAllIssuanceTermsAsync(CancellationToken ct = default) + { + var records = await issuance.GetAllAsync(ct); + + var bonds = new List(); + + foreach (var r in records) + { + var conversionPrice = ParseDecimal(r.ConversionPriceAtIssuance); + + if (conversionPrice <= 0) + { + continue; // unparseable/halted upstream row → skip + } + + bonds.Add(new ConvertibleBond(r.BondCode, r.ShortName, ParValueConvention, conversionPrice, r.IssuerCode)); + } + + return bonds; + } + + /// + public async Task> GetStockQuotesAsync(IEnumerable stockSymbols, CancellationToken ct = default) + { + var wanted = stockSymbols.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToHashSet(); + + var result = new Dictionary(); + + // One TWSE snapshot for the whole batch (preferred source). + var twse = await twseStocks.GetAllAsync(ct); + + foreach (var t in twse) + { + if (!wanted.Contains(t.Code) || result.ContainsKey(t.Code)) continue; + + if (ParseRocDate(t.Date) is { } asOf) + { + result[t.Code] = new StockQuote(t.Code, ParseDecimal(t.ClosingPrice), asOf); + } + } + + // One TPEx OTC snapshot for anything TWSE did not resolve. + var otc = await tpexStocks.GetAllAsync(ct); + + foreach (var o in otc) + { + if (!wanted.Contains(o.SecuritiesCompanyCode) || result.ContainsKey(o.SecuritiesCompanyCode)) continue; + + if (ParseRocDate(o.Date) is { } asOf) + { + result[o.SecuritiesCompanyCode] = new StockQuote(o.SecuritiesCompanyCode, ParseDecimal(o.Close), asOf); + } + } + + return result; + } + + /// + public async Task> GetBondQuotesAsync(IEnumerable bondSymbols, CancellationToken ct = default) + { + var codes = bondSymbols.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToList(); + + var quotes = await misBonds.GetQuotesAsync(codes, ct); + + var result = new Dictionary(); + + foreach (var q in quotes) + { + if (result.ContainsKey(q.Code)) continue; + + // z (last trade) may be "-" for illiquid bonds; fall back to y (previous close). + var pointsRaw = IsNumeric(q.LastPrice) ? q.LastPrice : q.PreviousClose; + var points = ParseDecimal(pointsRaw); + + if (points <= 0) continue; + + if (ParseMisDate(q.Date) is not { } asOf) continue; + + // MIS quotes CB price as a percentage of par; scale to NT$. + var priceNt = points / 100m * ParValueConvention; + + result[q.Code] = new BondQuote(q.Code, priceNt, asOf); + } + + return result; + } + private static decimal ParseDecimal(string raw) => decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0m; diff --git a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs index 4586700..a1d133d 100644 --- a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs +++ b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs @@ -196,4 +196,54 @@ public async Task GetBondQuoteAsync_ReturnsNull_WhenDateMalformed() Assert.Null(await fx.Build().GetBondQuoteAsync("11011")); } + + [Fact] + public async Task GetAllIssuanceTermsAsync_MapsAllValidRows_AndSkipsUnparseable() + { + var fx = new Fixture().WithIssuance( + new TpexBondIssuanceRecord { BondCode = "11011", ShortName = "台泥一永", IssuerCode = "1101", ConversionPriceAtIssuance = "36.5000" }, + new TpexBondIssuanceRecord { BondCode = "22222", ShortName = "壞資料", IssuerCode = "2222", ConversionPriceAtIssuance = "-" }, + new TpexBondIssuanceRecord { BondCode = "33033", ShortName = "某三", IssuerCode = "3303", ConversionPriceAtIssuance = "50.0000" }); + + var bonds = await fx.Build().GetAllIssuanceTermsAsync(); + + Assert.Equal(2, bonds.Count); + Assert.Contains(bonds, b => b.Symbol == "11011" && b.ConversionPrice == 36.5m && b.UnderlyingSymbol == "1101"); + Assert.Contains(bonds, b => b.Symbol == "33033" && b.ConversionPrice == 50.0m); + Assert.DoesNotContain(bonds, b => b.Symbol == "22222"); + } + + [Fact] + public async Task GetStockQuotesAsync_ResolvesAgainstSingleSnapshot_PreferringTwse() + { + var fx = new Fixture() + .WithTwse(new TwseStockQuoteRecord { Code = "1101", ClosingPrice = "36.80", Date = "1150629" }) + .WithTpexOtc(new TpexStockQuoteRecord { SecuritiesCompanyCode = "6488", Close = "500.00", Date = "1150630" }); + + var quotes = await fx.Build().GetStockQuotesAsync(new[] { "1101", "6488", "9999" }); + + Assert.Equal(2, quotes.Count); + Assert.Equal(36.80m, quotes["1101"].Price); + Assert.Equal(new DateOnly(2026, 6, 29), quotes["1101"].AsOf); + Assert.Equal(500.00m, quotes["6488"].Price); + Assert.False(quotes.ContainsKey("9999")); + // Each underlying snapshot is fetched exactly once regardless of how many symbols are requested. + fx.Twse.Verify(c => c.GetAllAsync(It.IsAny()), Times.Once); + fx.TpexStock.Verify(c => c.GetAllAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetBondQuotesAsync_MapsRecords_WithLastPriceFallbackAndParScaling() + { + var fx = new Fixture().WithMisQuotes( + new TwseMisQuoteRecord { Code = "11011", LastPrice = "100.2000", PreviousClose = "100.0000", Date = "20260702" }, + new TwseMisQuoteRecord { Code = "33033", LastPrice = "-", PreviousClose = "99.0000", Date = "20260702" }, + new TwseMisQuoteRecord { Code = "44044", LastPrice = "-", PreviousClose = "-", Date = "20260702" }); + + var quotes = await fx.Build().GetBondQuotesAsync(new[] { "11011", "33033", "44044" }); + + Assert.Equal(100_200m, quotes["11011"].Price); // 100.20% of par + Assert.Equal(99_000m, quotes["33033"].Price); // z is "-", falls back to y + Assert.False(quotes.ContainsKey("44044")); // no usable price → omitted + } } From 7905ba1aa2f3790182cff5163cd9f737a700fb75 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Thu, 2 Jul 2026 16:42:14 -0600 Subject: [PATCH 06/28] feat: add BondValuationSnapshot and BondValuationRefreshState entities with migration support --- .../Entities/BondValuationRefreshState.cs | 45 +++++++++ .../Entities/BondValuationSnapshot.cs | 94 +++++++++++++++++++ ...luationSnapshotAndRefreshState.Designer.cs | 93 ++++++++++++++++++ ...AddBondValuationSnapshotAndRefreshState.cs | 55 +++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 45 +++++++++ .../Persistence/AppDbContext.cs | 12 +++ .../Persistence/MigrationTests.cs | 18 ++++ 7 files changed, 362 insertions(+) create mode 100644 src/AppName.Domain/Entities/BondValuationRefreshState.cs create mode 100644 src/AppName.Domain/Entities/BondValuationSnapshot.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.Designer.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.cs diff --git a/src/AppName.Domain/Entities/BondValuationRefreshState.cs b/src/AppName.Domain/Entities/BondValuationRefreshState.cs new file mode 100644 index 0000000..8b9dd28 --- /dev/null +++ b/src/AppName.Domain/Entities/BondValuationRefreshState.cs @@ -0,0 +1,45 @@ +using System.ComponentModel; + +namespace AppName.Domain.Entities; + +/// +/// Single-row marker recording when bond data was last pulled, used to gate +/// refreshes to once per Taiwan trading day. +/// +/// +/// Creates a refresh-state marker. +/// +public sealed class BondValuationRefreshState(long id, DateOnly? lastAsOf, DateOnly? lastAttemptDate) +{ + /// + /// The fixed primary-key value of the single marker row. + /// + public const long SingletonId = 1; + + /// + /// Primary key; always . + /// + [DisplayName("Id")] + public long Id { get; } = id; + + /// + /// Max quote as-of date across the last successful pull; null before any pull. + /// + [DisplayName("Last As Of")] + public DateOnly? LastAsOf { get; private set; } = lastAsOf; + + /// + /// Taiwan calendar date of the last pull attempt; null before any pull. + /// + [DisplayName("Last Attempt Date")] + public DateOnly? LastAttemptDate { get; private set; } = lastAttemptDate; + + /// + /// Replaces the recorded markers after a pull attempt. + /// + public void Update(DateOnly? lastAsOf, DateOnly? lastAttemptDate) + { + LastAsOf = lastAsOf; + LastAttemptDate = lastAttemptDate; + } +} diff --git a/src/AppName.Domain/Entities/BondValuationSnapshot.cs b/src/AppName.Domain/Entities/BondValuationSnapshot.cs new file mode 100644 index 0000000..74ed144 --- /dev/null +++ b/src/AppName.Domain/Entities/BondValuationSnapshot.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; + +namespace AppName.Domain.Entities; + +/// +/// A cached conversion valuation for one convertible bond on a given trading day. +/// +public sealed class BondValuationSnapshot +{ + /// + /// Creates a valuation snapshot. Throws when the symbol is blank. + /// + public BondValuationSnapshot( + string symbol, + decimal conversionShares, + decimal conversionValue, + decimal stockPrice, + DateOnly asOf, + decimal? bondPrice, + bool? isInTheMoney) + { + if (string.IsNullOrWhiteSpace(symbol)) + throw new ArgumentException("Symbol cannot be blank.", nameof(symbol)); + + Symbol = symbol; + ConversionShares = conversionShares; + ConversionValue = conversionValue; + StockPrice = stockPrice; + AsOf = asOf; + BondPrice = bondPrice; + IsInTheMoney = isInTheMoney; + } + + /// + /// Exchange ticker of the convertible bond. + /// + [DisplayName("Bond Symbol")] + public string Symbol { get; } + + /// + /// Number of shares the bond converts into (par value / conversion price). + /// + [DisplayName("Conversion Shares")] + public decimal ConversionShares { get; private set; } + + /// + /// Market value of the converted shares (shares × stock price), in NT$. + /// + [DisplayName("Conversion Value")] + public decimal ConversionValue { get; private set; } + + /// + /// Underlying stock's price used to compute this valuation. + /// + [DisplayName("Stock Price")] + public decimal StockPrice { get; private set; } + + /// + /// Trading date this valuation applies to. + /// + [DisplayName("As Of")] + public DateOnly AsOf { get; private set; } + + /// + /// Market price of the bond in NT$, or null when no bond quote was available. + /// + [DisplayName("Bond Price")] + public decimal? BondPrice { get; private set; } + + /// + /// Whether the bond is in the money; null when is null. + /// + [DisplayName("In The Money")] + public bool? IsInTheMoney { get; private set; } + + /// + /// Replaces this snapshot's daily values with a fresh valuation. + /// + public void Update( + decimal conversionShares, + decimal conversionValue, + decimal stockPrice, + DateOnly asOf, + decimal? bondPrice, + bool? isInTheMoney) + { + ConversionShares = conversionShares; + ConversionValue = conversionValue; + StockPrice = stockPrice; + AsOf = asOf; + BondPrice = bondPrice; + IsInTheMoney = isInTheMoney; + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.Designer.cs b/src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.Designer.cs new file mode 100644 index 0000000..bb15ed3 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.Designer.cs @@ -0,0 +1,93 @@ +// +using System; +using AppName.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260702223401_AddBondValuationSnapshotAndRefreshState")] + partial class AddBondValuationSnapshotAndRefreshState + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("LastAsOf") + .HasColumnType("TEXT"); + + b.Property("LastAttemptDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BondValuationRefreshStates"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + { + b.Property("Symbol") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("BondPrice") + .HasColumnType("TEXT"); + + b.Property("ConversionShares") + .HasColumnType("TEXT"); + + b.Property("ConversionValue") + .HasColumnType("TEXT"); + + b.Property("IsInTheMoney") + .HasColumnType("INTEGER"); + + b.Property("StockPrice") + .HasColumnType("TEXT"); + + b.HasKey("Symbol"); + + b.ToTable("BondValuationSnapshots"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("TEXT"); + + b.Property("ConversionPrice") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParValue") + .HasColumnType("TEXT"); + + b.Property("UnderlyingSymbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Symbol"); + + b.ToTable("ConvertibleBonds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.cs b/src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.cs new file mode 100644 index 0000000..4ba8f93 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260702223401_AddBondValuationSnapshotAndRefreshState.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + /// + public partial class AddBondValuationSnapshotAndRefreshState : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BondValuationRefreshStates", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + LastAsOf = table.Column(type: "TEXT", nullable: true), + LastAttemptDate = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BondValuationRefreshStates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BondValuationSnapshots", + columns: table => new + { + Symbol = table.Column(type: "TEXT", nullable: false), + ConversionShares = table.Column(type: "TEXT", nullable: false), + ConversionValue = table.Column(type: "TEXT", nullable: false), + StockPrice = table.Column(type: "TEXT", nullable: false), + AsOf = table.Column(type: "TEXT", nullable: false), + BondPrice = table.Column(type: "TEXT", nullable: true), + IsInTheMoney = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BondValuationSnapshots", x => x.Symbol); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BondValuationRefreshStates"); + + migrationBuilder.DropTable( + name: "BondValuationSnapshots"); + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 13e29a2..b94c72e 100644 --- a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using AppName.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -16,6 +17,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("LastAsOf") + .HasColumnType("TEXT"); + + b.Property("LastAttemptDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BondValuationRefreshStates"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + { + b.Property("Symbol") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("BondPrice") + .HasColumnType("TEXT"); + + b.Property("ConversionShares") + .HasColumnType("TEXT"); + + b.Property("ConversionValue") + .HasColumnType("TEXT"); + + b.Property("IsInTheMoney") + .HasColumnType("INTEGER"); + + b.Property("StockPrice") + .HasColumnType("TEXT"); + + b.HasKey("Symbol"); + + b.ToTable("BondValuationSnapshots"); + }); + modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => { b.Property("Symbol") diff --git a/src/AppName.Infrastructure/Persistence/AppDbContext.cs b/src/AppName.Infrastructure/Persistence/AppDbContext.cs index fc7073b..d29ee55 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContext.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContext.cs @@ -7,6 +7,10 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon { public DbSet ConvertibleBonds => Set(); + public DbSet BondValuationSnapshots => Set(); + + public DbSet BondValuationRefreshStates => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { var bond = modelBuilder.Entity(); @@ -16,5 +20,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) bond.Property(b => b.ParValue); bond.Property(b => b.ConversionPrice); bond.Property(b => b.UnderlyingSymbol).IsRequired(); + + var snapshot = modelBuilder.Entity(); + snapshot.HasKey(s => s.Symbol); + snapshot.Property(s => s.Symbol).IsRequired(); + + var bondValuationRefreshState = modelBuilder.Entity(); + bondValuationRefreshState.HasKey(r => r.Id); + bondValuationRefreshState.Property(r => r.Id).ValueGeneratedNever(); } } diff --git a/tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs index fcec5f9..2784d8d 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/MigrationTests.cs @@ -40,4 +40,22 @@ public void Migrate_CreatesConvertibleBondsSchema_AndRoundTripsABond() Assert.Equal(36.5m, fx.Db.ConvertibleBonds.Single().ConversionPrice); } + + [Fact] + public void Migrate_CreatesSnapshotAndBondValuationRefreshStateSchema_AndRoundTrips() + { + using var fx = new Fixture(); + + fx.Db.BondValuationSnapshots.Add(new BondValuationSnapshot( + "11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 2), 100_000m, true)); + fx.Db.BondValuationRefreshStates.Add(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 7, 2), new DateOnly(2026, 7, 2))); + fx.Db.SaveChanges(); + + var snap = fx.Db.BondValuationSnapshots.Single(); + Assert.Equal(120_000m, snap.ConversionValue); + Assert.True(snap.IsInTheMoney); + + var state = fx.Db.BondValuationRefreshStates.Single(); + Assert.Equal(new DateOnly(2026, 7, 2), state.LastAttemptDate); + } } From d1a8b3d163f93b8c1ae8af205f41decae3680936 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 7 Jul 2026 23:34:34 -0600 Subject: [PATCH 07/28] feat: implement ValuationSnapshotRepository with upsert and retrieval methods --- .../IValuationSnapshotRepository.cs | 19 +++++ .../DependencyInjection.cs | 1 + .../ValuationSnapshotRepository.cs | 50 ++++++++++++ .../ValuationSnapshotRepositoryTests.cs | 77 +++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs create mode 100644 src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs create mode 100644 tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs diff --git a/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs b/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs new file mode 100644 index 0000000..852641f --- /dev/null +++ b/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs @@ -0,0 +1,19 @@ +using AppName.Domain.Entities; + +namespace AppName.Domain.Abstractions; + +/// +/// Local persistence of daily convertible-bond valuation snapshots. +/// +public interface IValuationSnapshotRepository +{ + /// + /// Returns all cached valuation snapshots. + /// + Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Inserts each snapshot, or overwrites the existing snapshot for that symbol. + /// + Task UpsertManyAsync(IEnumerable snapshots, CancellationToken ct = default); +} diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index 6e5dc26..1ee5f9e 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -24,6 +24,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs b/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs new file mode 100644 index 0000000..481f305 --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs @@ -0,0 +1,50 @@ +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Persistence; + +/// +public sealed class ValuationSnapshotRepository(IDbContextFactory factory) : IValuationSnapshotRepository +{ + /// + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + + return await db.BondValuationSnapshots.AsNoTracking().ToListAsync(ct); + } + + /// + public async Task UpsertManyAsync(IEnumerable snapshots, CancellationToken ct = default) + { + var incoming = snapshots.ToList(); + + if (incoming.Count == 0) + { + return; + } + + await using var db = factory.CreateDbContext(); + + var symbols = incoming.Select(s => s.Symbol).ToList(); + + var existing = await db.BondValuationSnapshots + .Where(s => symbols.Contains(s.Symbol)) + .ToDictionaryAsync(s => s.Symbol, ct); + + foreach (var snap in incoming) + { + if (existing.TryGetValue(snap.Symbol, out var row)) + { + row.Update(snap.ConversionShares, snap.ConversionValue, snap.StockPrice, snap.AsOf, snap.BondPrice, snap.IsInTheMoney); + } + else + { + db.BondValuationSnapshots.Add(snap); + } + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs new file mode 100644 index 0000000..e69e170 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs @@ -0,0 +1,77 @@ +using AppName.Domain.Entities; +using AppName.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Tests.Persistence; + +public class ValuationSnapshotRepositoryTests +{ + private sealed class Fixture : IDisposable + { + private sealed class SingleContextFactory(DbContextOptions options) : IDbContextFactory + { + public AppDbContext CreateDbContext() => new(options); + } + + private readonly SqliteConnection _connection; + + public ValuationSnapshotRepository Repo { get; } + + public Fixture() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + using (var ctx = new AppDbContext(options)) + ctx.Database.EnsureCreated(); + + Repo = new ValuationSnapshotRepository(new SingleContextFactory(options)); + } + + public void Dispose() => _connection.Dispose(); + } + + private static BondValuationSnapshot Snap(string symbol, decimal value, bool? itm) => + new(symbol, 2_000m, value, 60m, new DateOnly(2026, 7, 2), itm is null ? null : 100_000m, itm); + + [Fact] + public async Task UpsertManyAsync_ThenGetAll_RoundTripsAllRows() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 120_000m, true), Snap("33033", 80_000m, false) }); + var all = await fx.Repo.GetAllAsync(); + + Assert.Equal(2, all.Count); + Assert.Contains(all, s => s.Symbol == "11011" && s.IsInTheMoney == true); + Assert.Contains(all, s => s.Symbol == "33033" && s.IsInTheMoney == false); + } + + [Fact] + public async Task UpsertManyAsync_Existing_OverwritesInPlace_WithoutDuplicating() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 120_000m, true) }); + await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 90_000m, false) }); + var all = await fx.Repo.GetAllAsync(); + + var only = Assert.Single(all); + Assert.Equal(90_000m, only.ConversionValue); + Assert.False(only.IsInTheMoney); + } + + [Fact] + public async Task UpsertManyAsync_PreservesNullBondPrice() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 120_000m, null) }); + var only = Assert.Single(await fx.Repo.GetAllAsync()); + + Assert.Null(only.BondPrice); + Assert.Null(only.IsInTheMoney); + } +} From 427c1194de892219d93b2a62d48019371d1c690c Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 7 Jul 2026 23:42:27 -0600 Subject: [PATCH 08/28] feat: implement BondValuationRefreshStateRepository with GetAsync and SetAsync methods --- .../IBondValuationRefreshStateRepository.cs | 19 +++++ .../DependencyInjection.cs | 1 + .../BondValuationRefreshStateRepository.cs | 41 +++++++++++ ...ondValuationRefreshStateRepositoryTests.cs | 71 +++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs create mode 100644 src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs create mode 100644 tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs diff --git a/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs b/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs new file mode 100644 index 0000000..0e378a9 --- /dev/null +++ b/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs @@ -0,0 +1,19 @@ +using AppName.Domain.Entities; + +namespace AppName.Domain.Abstractions; + +/// +/// Local persistence of the single bond-data refresh marker. +/// +public interface IBondValuationRefreshStateRepository +{ + /// + /// Returns the refresh marker, or a default (both dates null) when never set. + /// + Task GetAsync(CancellationToken ct = default); + + /// + /// Inserts or updates the single refresh-marker row. + /// + Task SetAsync(BondValuationRefreshState state, CancellationToken ct = default); +} diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index 1ee5f9e..0241709 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -25,6 +25,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs b/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs new file mode 100644 index 0000000..9449456 --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs @@ -0,0 +1,41 @@ +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Persistence; + +/// +public sealed class BondValuationRefreshStateRepository(IDbContextFactory factory) : IBondValuationRefreshStateRepository +{ + /// + public async Task GetAsync(CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + + var row = await db.BondValuationRefreshStates + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == BondValuationRefreshState.SingletonId, ct); + + return row ?? new BondValuationRefreshState(BondValuationRefreshState.SingletonId, null, null); + } + + /// + public async Task SetAsync(BondValuationRefreshState state, CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + + var existing = await db.BondValuationRefreshStates + .FirstOrDefaultAsync(r => r.Id == BondValuationRefreshState.SingletonId, ct); + + if (existing is null) + { + db.BondValuationRefreshStates.Add(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, state.LastAsOf, state.LastAttemptDate)); + } + else + { + existing.Update(state.LastAsOf, state.LastAttemptDate); + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs new file mode 100644 index 0000000..d2f0d0b --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs @@ -0,0 +1,71 @@ +using AppName.Domain.Entities; +using AppName.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Tests.Persistence; + +public class BondValuationRefreshStateRepositoryTests +{ + private sealed class Fixture : IDisposable + { + private sealed class SingleContextFactory(DbContextOptions options) : IDbContextFactory + { + public AppDbContext CreateDbContext() => new(options); + } + + private readonly SqliteConnection _connection; + + public BondValuationRefreshStateRepository Repo { get; } + + public Fixture() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + using (var ctx = new AppDbContext(options)) + ctx.Database.EnsureCreated(); + + Repo = new BondValuationRefreshStateRepository(new SingleContextFactory(options)); + } + + public void Dispose() => _connection.Dispose(); + } + + [Fact] + public async Task GetAsync_ReturnsDefault_WhenNeverSet() + { + using var fx = new Fixture(); + + var state = await fx.Repo.GetAsync(); + + Assert.Equal(BondValuationRefreshState.SingletonId, state.Id); + Assert.Null(state.LastAttemptDate); + Assert.Null(state.LastAsOf); + } + + [Fact] + public async Task SetAsync_ThenGet_RoundTrips() + { + using var fx = new Fixture(); + + await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 7, 2), new DateOnly(2026, 7, 2))); + var state = await fx.Repo.GetAsync(); + + Assert.Equal(new DateOnly(2026, 7, 2), state.LastAttemptDate); + Assert.Equal(new DateOnly(2026, 7, 2), state.LastAsOf); + } + + [Fact] + public async Task SetAsync_Twice_UpdatesSingleRowInPlace() + { + using var fx = new Fixture(); + + await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 1))); + await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 7, 2), new DateOnly(2026, 7, 2))); + var state = await fx.Repo.GetAsync(); + + Assert.Equal(new DateOnly(2026, 7, 2), state.LastAttemptDate); + } +} From ebdff1304e4d5e151a80993b8aae93650ea8f4c5 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 7 Jul 2026 23:57:15 -0600 Subject: [PATCH 09/28] feat: implement RefreshAllBondsUseCase and related interfaces, add TaiwanClock for date resolution --- .../DependencyInjection.cs | 2 + src/AppName.Application/Time/TaiwanClock.cs | 15 ++ .../UseCases/Bonds/IRefreshAllBondsUseCase.cs | 14 ++ .../UseCases/Bonds/RefreshAllBondsUseCase.cs | 56 ++++++++ .../FixedTimeProvider.cs | 9 ++ .../Bonds/RefreshAllBondsUseCaseTests.cs | 135 ++++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 src/AppName.Application/Time/TaiwanClock.cs create mode 100644 src/AppName.Application/UseCases/Bonds/IRefreshAllBondsUseCase.cs create mode 100644 src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs create mode 100644 tests/AppName.Application.Tests/FixedTimeProvider.cs create mode 100644 tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs diff --git a/src/AppName.Application/DependencyInjection.cs b/src/AppName.Application/DependencyInjection.cs index c595849..9e94904 100644 --- a/src/AppName.Application/DependencyInjection.cs +++ b/src/AppName.Application/DependencyInjection.cs @@ -7,8 +7,10 @@ public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { + services.AddSingleton(TimeProvider.System); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services; } } diff --git a/src/AppName.Application/Time/TaiwanClock.cs b/src/AppName.Application/Time/TaiwanClock.cs new file mode 100644 index 0000000..e306141 --- /dev/null +++ b/src/AppName.Application/Time/TaiwanClock.cs @@ -0,0 +1,15 @@ +namespace AppName.Application.Time; + +/// +/// Resolves the current Taiwan trading-calendar date from a . +/// +public static class TaiwanClock +{ + private static readonly TimeZoneInfo Taipei = TimeZoneInfo.FindSystemTimeZoneById("Asia/Taipei"); + + /// + /// Returns today's date in the Asia/Taipei time zone. + /// + public static DateOnly Today(TimeProvider timeProvider) => + DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), Taipei).DateTime); +} diff --git a/src/AppName.Application/UseCases/Bonds/IRefreshAllBondsUseCase.cs b/src/AppName.Application/UseCases/Bonds/IRefreshAllBondsUseCase.cs new file mode 100644 index 0000000..67faff6 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/IRefreshAllBondsUseCase.cs @@ -0,0 +1,14 @@ +namespace AppName.Application.UseCases.Bonds; + +/// +/// Discovers all active convertible bonds, pulls their daily quotes, and caches +/// a valuation snapshot for each, stamping the refresh marker. +/// +public interface IRefreshAllBondsUseCase +{ + /// + /// Runs a full whole-market refresh. Best-effort per bond: a bond without a + /// usable stock quote is skipped rather than failing the whole refresh. + /// + Task ExecuteAsync(CancellationToken ct = default); +} diff --git a/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs b/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs new file mode 100644 index 0000000..5c01409 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs @@ -0,0 +1,56 @@ +using AppName.Application.Time; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +using AppName.Domain.ValueObjects; + +namespace AppName.Application.UseCases.Bonds; + +/// +public sealed class RefreshAllBondsUseCase( + IMarketDataProvider provider, + IConvertibleBondRepository bondRepo, + IValuationSnapshotRepository snapshotRepo, + IBondValuationRefreshStateRepository refreshStateRepo, + TimeProvider timeProvider) : IRefreshAllBondsUseCase +{ + /// + public async Task ExecuteAsync(CancellationToken ct = default) + { + var bonds = await provider.GetAllIssuanceTermsAsync(ct); + + foreach (var bond in bonds) + { + await bondRepo.UpsertAsync(bond, ct); + } + + var symbols = bonds.Select(b => b.Symbol).ToList(); + var underlyings = bonds.Select(b => b.UnderlyingSymbol).ToList(); + + var bondQuotes = await provider.GetBondQuotesAsync(symbols, ct); + var stockQuotes = await provider.GetStockQuotesAsync(underlyings, ct); + + var snapshots = new List(); + + foreach (var bond in bonds) + { + if (!stockQuotes.TryGetValue(bond.UnderlyingSymbol, out var stock)) + { + continue; // best-effort: no stock quote → skip this bond + } + + var v = ConversionValuation.Calculate(bond.ParValue, bond.ConversionPrice, stock.Price); + + decimal? bondPrice = bondQuotes.TryGetValue(bond.Symbol, out var bq) ? bq.Price : null; + bool? isInTheMoney = bondPrice is { } bp ? v.ConversionValue > bp : null; + + snapshots.Add(new BondValuationSnapshot( + bond.Symbol, v.ConversionShares, v.ConversionValue, stock.Price, stock.AsOf, bondPrice, isInTheMoney)); + } + + await snapshotRepo.UpsertManyAsync(snapshots, ct); + + DateOnly? maxAsOf = snapshots.Count > 0 ? snapshots.Max(s => s.AsOf) : null; + await refreshStateRepo.SetAsync( + new BondValuationRefreshState(BondValuationRefreshState.SingletonId, maxAsOf, TaiwanClock.Today(timeProvider)), ct); + } +} diff --git a/tests/AppName.Application.Tests/FixedTimeProvider.cs b/tests/AppName.Application.Tests/FixedTimeProvider.cs new file mode 100644 index 0000000..50e13ab --- /dev/null +++ b/tests/AppName.Application.Tests/FixedTimeProvider.cs @@ -0,0 +1,9 @@ +namespace AppName.Application.Tests; + +/// +/// Deterministic returning a fixed UTC instant. +/// +public sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => utcNow; +} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs new file mode 100644 index 0000000..7887623 --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs @@ -0,0 +1,135 @@ +using AppName.Application.UseCases.Bonds; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; + +namespace AppName.Application.Tests.UseCases.Bonds; + +public class RefreshAllBondsUseCaseTests +{ + // TW is UTC+8, so 04:00Z on 2026-07-02 is 12:00 in Taipei on 2026-07-02. + private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); + private static readonly DateOnly TwToday = new(2026, 7, 2); + private static readonly DateOnly QuoteDate = new(2026, 6, 30); + + private sealed class Fixture + { + public Mock Provider { get; } = new(); + public Mock Bonds { get; } = new(); + public Mock Snapshots { get; } = new(); + public Mock RefreshState { get; } = new(); + + public List Saved { get; } = new(); + public BondValuationRefreshState? SavedState { get; private set; } + + public Fixture() + { + Provider.Setup(p => p.GetAllIssuanceTermsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + Provider.Setup(p => p.GetBondQuotesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + Provider.Setup(p => p.GetStockQuotesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + Snapshots.Setup(s => s.UpsertManyAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((s, _) => Saved.AddRange(s)) + .Returns(Task.CompletedTask); + RefreshState.Setup(r => r.SetAsync(It.IsAny(), It.IsAny())) + .Callback((s, _) => SavedState = s) + .Returns(Task.CompletedTask); + } + + public Fixture WithBonds(params ConvertibleBond[] bonds) + { + Provider.Setup(p => p.GetAllIssuanceTermsAsync(It.IsAny())).ReturnsAsync(bonds); + return this; + } + + public Fixture WithStockQuotes(params StockQuote[] quotes) + { + Provider.Setup(p => p.GetStockQuotesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(quotes.ToDictionary(q => q.StockSymbol)); + return this; + } + + public Fixture WithBondQuotes(params BondQuote[] quotes) + { + Provider.Setup(p => p.GetBondQuotesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(quotes.ToDictionary(q => q.BondSymbol)); + return this; + } + + public RefreshAllBondsUseCase Build() => + new(Provider.Object, Bonds.Object, Snapshots.Object, RefreshState.Object, new FixedTimeProvider(FixedUtc)); + } + + [Fact] + public async Task ExecuteAsync_UpsertsTermsForEveryDiscoveredBond() + { + var fx = new Fixture().WithBonds( + new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101"), + new ConvertibleBond("33033", "某三", 100_000m, 50m, "3303")); + + await fx.Build().ExecuteAsync(); + + fx.Bonds.Verify(r => r.UpsertAsync(It.Is(b => b.Symbol == "11011"), It.IsAny()), Times.Once); + fx.Bonds.Verify(r => r.UpsertAsync(It.Is(b => b.Symbol == "33033"), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ComputesSnapshot_WithInTheMoneyFlag() + { + var fx = new Fixture() + .WithBonds(new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101")) + .WithStockQuotes(new StockQuote("1101", 60m, QuoteDate)) + .WithBondQuotes(new BondQuote("11011", 100_000m, QuoteDate)); + + await fx.Build().ExecuteAsync(); + + var snap = Assert.Single(fx.Saved); + Assert.Equal("11011", snap.Symbol); + Assert.Equal(2_000m, snap.ConversionShares); + Assert.Equal(120_000m, snap.ConversionValue); // 2000 * 60 + Assert.Equal(100_000m, snap.BondPrice); + Assert.True(snap.IsInTheMoney); // 120,000 > 100,000 + } + + [Fact] + public async Task ExecuteAsync_LeavesBondPriceNull_WhenNoBondQuote() + { + var fx = new Fixture() + .WithBonds(new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101")) + .WithStockQuotes(new StockQuote("1101", 60m, QuoteDate)); + // no bond quote + + await fx.Build().ExecuteAsync(); + + var snap = Assert.Single(fx.Saved); + Assert.Null(snap.BondPrice); + Assert.Null(snap.IsInTheMoney); + } + + [Fact] + public async Task ExecuteAsync_SkipsBond_WhenNoStockQuote() + { + var fx = new Fixture() + .WithBonds(new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101")); + // no stock quote for 1101 + + await fx.Build().ExecuteAsync(); + + Assert.Empty(fx.Saved); + } + + [Fact] + public async Task ExecuteAsync_StampsRefreshState_WithTwTodayAndMaxAsOf() + { + var fx = new Fixture() + .WithBonds(new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101")) + .WithStockQuotes(new StockQuote("1101", 60m, QuoteDate)); + + await fx.Build().ExecuteAsync(); + + Assert.NotNull(fx.SavedState); + Assert.Equal(TwToday, fx.SavedState!.LastAttemptDate); + Assert.Equal(QuoteDate, fx.SavedState.LastAsOf); + } +} From 4b6a55e7c558cc8001d65498e8500e917fbf8bb6 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Wed, 8 Jul 2026 00:05:17 -0600 Subject: [PATCH 10/28] feat: add GetValuationsUseCase and corresponding tests for conversion valuation retrieval --- .../DependencyInjection.cs | 1 + .../UseCases/Bonds/GetValuationsUseCase.cs | 46 ++++++++ .../Bonds/GetValuationsUseCaseTests.cs | 100 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs create mode 100644 tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs diff --git a/src/AppName.Application/DependencyInjection.cs b/src/AppName.Application/DependencyInjection.cs index 9e94904..44931e9 100644 --- a/src/AppName.Application/DependencyInjection.cs +++ b/src/AppName.Application/DependencyInjection.cs @@ -11,6 +11,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services; } } diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs new file mode 100644 index 0000000..025b9c7 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs @@ -0,0 +1,46 @@ +using AppName.Application.Dtos; +using AppName.Application.Time; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; + +namespace AppName.Application.UseCases.Bonds; + +/// +/// Returns today's conversion valuation for every cached convertible bond, +/// refreshing the cache once per Taiwan trading day. Refresh is an internal, +/// lazy step — never surfaced as an error to the caller. +/// +public sealed class GetValuationsUseCase( + IBondValuationRefreshStateRepository refreshStateRepo, + IValuationSnapshotRepository snapshotRepo, + IRefreshAllBondsUseCase refreshAllBonds, + TimeProvider timeProvider) +{ + /// + /// Ensures freshness (best-effort) then returns all cached valuations. + /// + public async Task> ExecuteAsync(CancellationToken ct = default) + { + var state = await refreshStateRepo.GetAsync(ct); + var today = TaiwanClock.Today(timeProvider); + + if (state.LastAttemptDate != today) + { + try + { + await refreshAllBonds.ExecuteAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Freshness is best-effort; a provider outage must serve stale cache, + // not fail the caller. Snapshots below are whatever we already have. + } + } + + var snapshots = await snapshotRepo.GetAllAsync(ct); + return snapshots.Select(ToDto).ToList(); + } + + private static ConversionValuationDto ToDto(BondValuationSnapshot s) => + new(s.Symbol, s.ConversionShares, s.ConversionValue, s.StockPrice, s.AsOf, s.BondPrice, s.IsInTheMoney); +} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs new file mode 100644 index 0000000..e3c92a3 --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs @@ -0,0 +1,100 @@ +using AppName.Application.UseCases.Bonds; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; + +namespace AppName.Application.Tests.UseCases.Bonds; + +public class GetValuationsUseCaseTests +{ + private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); // TW 2026-07-02 + private static readonly DateOnly TwToday = new(2026, 7, 2); + + private sealed class Fixture + { + public Mock RefreshState { get; } = new(); + public Mock Snapshots { get; } = new(); + public Mock Refresh { get; } = new(); + + public Fixture() + { + RefreshState.Setup(r => r.GetAsync(It.IsAny())) + .ReturnsAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, null, null)); + Snapshots.Setup(s => s.GetAllAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + Refresh.Setup(r => r.ExecuteAsync(It.IsAny())).Returns(Task.CompletedTask); + } + + public Fixture WithLastAttempt(DateOnly date) + { + RefreshState.Setup(r => r.GetAsync(It.IsAny())) + .ReturnsAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, date, date)); + return this; + } + + public Fixture WithSnapshots(params BondValuationSnapshot[] snaps) + { + Snapshots.Setup(s => s.GetAllAsync(It.IsAny())).ReturnsAsync(snaps); + return this; + } + + public Fixture WithFailingRefresh() + { + Refresh.Setup(r => r.ExecuteAsync(It.IsAny())) + .ThrowsAsync(new HttpRequestException("network down")); + return this; + } + + public GetValuationsUseCase Build() => + new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc)); + } + + [Fact] + public async Task ExecuteAsync_Refreshes_WhenLastAttemptIsNotToday() + { + var fx = new Fixture().WithLastAttempt(new DateOnly(2026, 7, 1)); + + await fx.Build().ExecuteAsync(); + + fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ServesCache_WhenLastAttemptIsToday() + { + var fx = new Fixture().WithLastAttempt(TwToday); + + await fx.Build().ExecuteAsync(); + + fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ExecuteAsync_ReturnsSnapshotsMappedToDtos() + { + var fx = new Fixture() + .WithLastAttempt(TwToday) + .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 2), 100_000m, true)); + + var dtos = await fx.Build().ExecuteAsync(); + + var dto = Assert.Single(dtos); + Assert.Equal("11011", dto.Symbol); + Assert.Equal(120_000m, dto.ConversionValue); + Assert.Equal(100_000m, dto.BondPrice); + Assert.True(dto.IsInTheMoney); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() + { + var fx = new Fixture() + .WithLastAttempt(new DateOnly(2026, 7, 1)) + .WithFailingRefresh() + .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 1), null, null)); + + var dtos = await fx.Build().ExecuteAsync(); + + var dto = Assert.Single(dtos); // no throw; stale cache served + Assert.Null(dto.BondPrice); + } +} From 84e29b32277aa65801fb497ce36effe19a352fc2 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 11:46:08 -0600 Subject: [PATCH 11/28] feat: enhance bond valuation features with new bridge and data source implementations, add dependency injection, and improve tests --- Directory.Packages.props | 1 + .../UseCases/Bonds/GetValuationsUseCase.cs | 4 +- .../Gateways/MarketDataProvider.cs | 2 +- src/AppName.Maui/AppName.Maui.csproj | 1 + src/AppName.Maui/Bridge/AppBridge.cs | 17 +++++ src/AppName.Maui/Bridge/BondsBridge.cs | 15 ++++ src/AppName.Maui/Bridge/IWebBridge.cs | 10 +++ src/AppName.Maui/DependencyInjection.cs | 35 ++++++++++ src/AppName.Maui/MainPage.xaml.cs | 5 +- src/AppName.Maui/MauiProgram.cs | 8 +-- .../src/features/bonds/api/BondDataSource.ts | 13 ++++ .../bonds/api/HybridBridgeSource.test.ts | 42 +++++++++++ .../features/bonds/api/HybridBridgeSource.ts | 14 ++++ .../bonds/api/createBondDataSource.ts | 16 +++++ .../features/bonds/api/mapValuations.test.ts | 70 +++++++++++++++++++ .../src/features/bonds/api/mapValuations.ts | 17 +++++ src/web/src/features/bonds/index.ts | 3 + src/web/src/features/bonds/types.ts | 29 ++++++++ .../Bonds/GetValuationsUseCaseTests.cs | 15 ++++ .../Gateways/MarketDataProviderTests.cs | 14 ++++ 20 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 src/AppName.Maui/Bridge/AppBridge.cs create mode 100644 src/AppName.Maui/Bridge/BondsBridge.cs create mode 100644 src/AppName.Maui/Bridge/IWebBridge.cs create mode 100644 src/AppName.Maui/DependencyInjection.cs create mode 100644 src/web/src/features/bonds/api/BondDataSource.ts create mode 100644 src/web/src/features/bonds/api/HybridBridgeSource.test.ts create mode 100644 src/web/src/features/bonds/api/HybridBridgeSource.ts create mode 100644 src/web/src/features/bonds/api/createBondDataSource.ts create mode 100644 src/web/src/features/bonds/api/mapValuations.test.ts create mode 100644 src/web/src/features/bonds/api/mapValuations.ts create mode 100644 src/web/src/features/bonds/index.ts create mode 100644 src/web/src/features/bonds/types.ts diff --git a/Directory.Packages.props b/Directory.Packages.props index f637ca4..b2dadb8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs index 025b9c7..1286630 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs @@ -33,7 +33,9 @@ public async Task> ExecuteAsync(Cancellati catch (Exception ex) when (ex is not OperationCanceledException) { // Freshness is best-effort; a provider outage must serve stale cache, - // not fail the caller. Snapshots below are whatever we already have. + // not fail the caller. + await refreshStateRepo.SetAsync( + new BondValuationRefreshState(BondValuationRefreshState.SingletonId, state.LastAsOf, today), ct); } } diff --git a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs index f1116be..94fcebd 100644 --- a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs +++ b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs @@ -93,7 +93,7 @@ public async Task> GetAllIssuanceTermsAsync(Cance var bonds = new List(); - foreach (var r in records) + foreach (var r in records.DistinctBy(r => r.BondCode)) { var conversionPrice = ParseDecimal(r.ConversionPriceAtIssuance); diff --git a/src/AppName.Maui/AppName.Maui.csproj b/src/AppName.Maui/AppName.Maui.csproj index be9bc18..c97ebff 100644 --- a/src/AppName.Maui/AppName.Maui.csproj +++ b/src/AppName.Maui/AppName.Maui.csproj @@ -68,6 +68,7 @@ + diff --git a/src/AppName.Maui/Bridge/AppBridge.cs b/src/AppName.Maui/Bridge/AppBridge.cs new file mode 100644 index 0000000..0a27a5d --- /dev/null +++ b/src/AppName.Maui/Bridge/AppBridge.cs @@ -0,0 +1,17 @@ +using AppName.Application.Dtos; + +namespace AppName.Maui.Bridge; + +/// +/// The single object set as the HybridWebView invoke target. JavaScript resolves +/// calls by bare method name against this instance, so every JS-callable method +/// is surfaced here and delegated to the owning feature bridge. Method names must +/// stay globally unique across features. +/// +public sealed class AppBridge(BondsBridge bonds) +{ + /// + /// Returns today's conversion valuation for every cached convertible bond. + /// + public Task> GetValuations() => bonds.GetValuations(); +} diff --git a/src/AppName.Maui/Bridge/BondsBridge.cs b/src/AppName.Maui/Bridge/BondsBridge.cs new file mode 100644 index 0000000..6b9637b --- /dev/null +++ b/src/AppName.Maui/Bridge/BondsBridge.cs @@ -0,0 +1,15 @@ +using AppName.Application.Dtos; +using AppName.Application.UseCases.Bonds; + +namespace AppName.Maui.Bridge; + +/// +/// Methods on this object are invoked from JavaScript via HybridWebView. +/// +public sealed class BondsBridge(GetValuationsUseCase getValuations) : IWebBridge +{ + /// + /// Returns today's conversion valuation for every cached convertible bond. + /// + public Task> GetValuations() => getValuations.ExecuteAsync(); +} diff --git a/src/AppName.Maui/Bridge/IWebBridge.cs b/src/AppName.Maui/Bridge/IWebBridge.cs new file mode 100644 index 0000000..1b1d7a4 --- /dev/null +++ b/src/AppName.Maui/Bridge/IWebBridge.cs @@ -0,0 +1,10 @@ +namespace AppName.Maui.Bridge; + +/// +/// Marker for a feature bridge whose public methods are invokable from +/// JavaScript via the MAUI HybridWebView. Implementations are auto-registered +/// in DI and composed onto the single invoke target. +/// +public interface IWebBridge +{ +} diff --git a/src/AppName.Maui/DependencyInjection.cs b/src/AppName.Maui/DependencyInjection.cs new file mode 100644 index 0000000..3e2c503 --- /dev/null +++ b/src/AppName.Maui/DependencyInjection.cs @@ -0,0 +1,35 @@ +using AppName.Application; +using AppName.Infrastructure; +using AppName.Maui.Bridge; +using Microsoft.Extensions.DependencyInjection; + +namespace AppName.Maui; + +public static class DependencyInjection +{ + /// + /// Registers the presentation (MAUI/UI) layer: every + /// feature bridge (by assembly scan), the aggregate + /// invoke target, and the pages. + /// + public static IServiceCollection AddPresentation(this IServiceCollection services) + { + services.Scan(s => s.FromAssemblyOf() + .AddClasses(c => c.AssignableTo()) + .AsSelf().WithTransientLifetime()); + + services.AddTransient(); + services.AddTransient(); + return services; + } + + /// + /// Composition root: wires every layer (application, infrastructure, + /// presentation) so the host only needs a single registration call. + /// + public static IServiceCollection AddAppServices(this IServiceCollection services, string dbPath) + => services + .AddApplication() + .AddInfrastructure(dbPath) + .AddPresentation(); +} diff --git a/src/AppName.Maui/MainPage.xaml.cs b/src/AppName.Maui/MainPage.xaml.cs index c109100..f9b49e8 100644 --- a/src/AppName.Maui/MainPage.xaml.cs +++ b/src/AppName.Maui/MainPage.xaml.cs @@ -1,9 +1,12 @@ +using AppName.Maui.Bridge; + namespace AppName.Maui; public partial class MainPage : ContentPage { - public MainPage() + public MainPage(AppBridge appBridge) { InitializeComponent(); + HybridView.SetInvokeJavaScriptTarget(appBridge); } } diff --git a/src/AppName.Maui/MauiProgram.cs b/src/AppName.Maui/MauiProgram.cs index ce771e2..a1bb7d6 100644 --- a/src/AppName.Maui/MauiProgram.cs +++ b/src/AppName.Maui/MauiProgram.cs @@ -1,5 +1,3 @@ -using AppName.Application; -using AppName.Infrastructure; using AppName.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -22,11 +20,7 @@ public static MauiApp CreateMauiApp() var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db"); - builder.Services.AddApplication(); - - builder.Services.AddInfrastructure(dbPath); - - builder.Services.AddTransient(); + builder.Services.AddAppServices(dbPath); #if DEBUG builder.Logging.AddDebug(); diff --git a/src/web/src/features/bonds/api/BondDataSource.ts b/src/web/src/features/bonds/api/BondDataSource.ts new file mode 100644 index 0000000..9f617a5 --- /dev/null +++ b/src/web/src/features/bonds/api/BondDataSource.ts @@ -0,0 +1,13 @@ +import type { ConversionValuation } from "../types"; + +/** + * Transport-agnostic access to convertible-bond valuations. Implemented over the + * MAUI HybridWebView bridge now, and (later) over an HTTP API — callers depend on + * this interface, never on a concrete transport. + */ +export interface BondDataSource { + /** + * Returns today's conversion valuation for every cached convertible bond. + */ + getValuations(): Promise; +} diff --git a/src/web/src/features/bonds/api/HybridBridgeSource.test.ts b/src/web/src/features/bonds/api/HybridBridgeSource.test.ts new file mode 100644 index 0000000..9b2a4e1 --- /dev/null +++ b/src/web/src/features/bonds/api/HybridBridgeSource.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { HybridBridgeSource } from "./HybridBridgeSource"; + +declare global { + // eslint-disable-next-line no-var + var HybridWebView: { InvokeDotNet: (name: string, args?: unknown[]) => Promise }; +} + +describe("HybridBridgeSource", () => { + beforeEach(() => { + globalThis.HybridWebView = { InvokeDotNet: vi.fn() }; + }); + + it("invokes GetValuations and maps the result to the domain type", async () => { + (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue([ + { + symbol: "11011", + conversionShares: 2000, + conversionValue: 120000, + stockPrice: 60, + asOf: "2026-07-02", + bondPrice: null, + isInTheMoney: null, + }, + ]); + + const result = await new HybridBridgeSource().getValuations(); + + expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("GetValuations"); + expect(result).toEqual([ + { + symbol: "11011", + conversionShares: 2000, + conversionValue: 120000, + stockPrice: 60, + asOf: "2026-07-02", + bondPrice: null, + isInTheMoney: null, + }, + ]); + }); +}); diff --git a/src/web/src/features/bonds/api/HybridBridgeSource.ts b/src/web/src/features/bonds/api/HybridBridgeSource.ts new file mode 100644 index 0000000..916eefe --- /dev/null +++ b/src/web/src/features/bonds/api/HybridBridgeSource.ts @@ -0,0 +1,14 @@ +import { invoke } from "../../../bridge/client"; +import type { BondDataSource } from "./BondDataSource"; +import { mapValuations } from "./mapValuations"; +import type { ConversionValuation, ConversionValuationWire } from "../types"; + +/** + * {@link BondDataSource} implemented over the MAUI HybridWebView bridge. + */ +export class HybridBridgeSource implements BondDataSource { + async getValuations(): Promise { + const wire = await invoke("GetValuations"); + return mapValuations(wire); + } +} diff --git a/src/web/src/features/bonds/api/createBondDataSource.ts b/src/web/src/features/bonds/api/createBondDataSource.ts new file mode 100644 index 0000000..ad5d61e --- /dev/null +++ b/src/web/src/features/bonds/api/createBondDataSource.ts @@ -0,0 +1,16 @@ +import type { BondDataSource } from "./BondDataSource"; +import { HybridBridgeSource } from "./HybridBridgeSource"; + +/** + * Selects the live transport. Uses the HybridWebView bridge when running inside + * the MAUI host; the HTTP API source is not built yet. + */ +export function createBondDataSource(): BondDataSource { + const hasBridge = typeof (globalThis as { HybridWebView?: unknown }).HybridWebView !== "undefined"; + + if (hasBridge) { + return new HybridBridgeSource(); + } + + throw new Error("HTTP bond data source is not implemented yet."); +} diff --git a/src/web/src/features/bonds/api/mapValuations.test.ts b/src/web/src/features/bonds/api/mapValuations.test.ts new file mode 100644 index 0000000..26fdc03 --- /dev/null +++ b/src/web/src/features/bonds/api/mapValuations.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { mapValuations } from "./mapValuations"; +import type { ConversionValuationWire } from "../types"; + +describe("mapValuations", () => { + it("maps a fully-populated wire record to numbers", () => { + const wire: ConversionValuationWire[] = [ + { + symbol: "11011", + conversionShares: 2000, + conversionValue: 120000, + stockPrice: 60, + asOf: "2026-07-02", + bondPrice: 100000, + isInTheMoney: true, + }, + ]; + + const [v] = mapValuations(wire); + + expect(v).toEqual({ + symbol: "11011", + conversionShares: 2000, + conversionValue: 120000, + stockPrice: 60, + asOf: "2026-07-02", + bondPrice: 100000, + isInTheMoney: true, + }); + }); + + it("coerces string-encoded numbers", () => { + const wire: ConversionValuationWire[] = [ + { + symbol: "11011", + conversionShares: "2000", + conversionValue: "120000", + stockPrice: "60", + asOf: "2026-07-02", + bondPrice: "100000", + isInTheMoney: false, + }, + ]; + + const [v] = mapValuations(wire); + + expect(v.conversionValue).toBe(120000); + expect(v.bondPrice).toBe(100000); + expect(typeof v.conversionValue).toBe("number"); + }); + + it("preserves null bondPrice and isInTheMoney without coercing to a number or false", () => { + const wire: ConversionValuationWire[] = [ + { + symbol: "11011", + conversionShares: 2000, + conversionValue: 120000, + stockPrice: 60, + asOf: "2026-07-02", + bondPrice: null, + isInTheMoney: null, + }, + ]; + + const [v] = mapValuations(wire); + + expect(v.bondPrice).toBeNull(); + expect(v.isInTheMoney).toBeNull(); + }); +}); diff --git a/src/web/src/features/bonds/api/mapValuations.ts b/src/web/src/features/bonds/api/mapValuations.ts new file mode 100644 index 0000000..17a8a6b --- /dev/null +++ b/src/web/src/features/bonds/api/mapValuations.ts @@ -0,0 +1,17 @@ +import type { ConversionValuation, ConversionValuationWire } from "../types"; + +/** + * Maps raw bridge records to the domain type: coerces numeric fields to numbers + * and preserves null `bondPrice` / `isInTheMoney`. + */ +export function mapValuations(wire: ConversionValuationWire[]): ConversionValuation[] { + return wire.map((w) => ({ + symbol: w.symbol, + conversionShares: Number(w.conversionShares), + conversionValue: Number(w.conversionValue), + stockPrice: Number(w.stockPrice), + asOf: w.asOf, + bondPrice: w.bondPrice === null ? null : Number(w.bondPrice), + isInTheMoney: w.isInTheMoney, + })); +} diff --git a/src/web/src/features/bonds/index.ts b/src/web/src/features/bonds/index.ts new file mode 100644 index 0000000..d685a4f --- /dev/null +++ b/src/web/src/features/bonds/index.ts @@ -0,0 +1,3 @@ +export { createBondDataSource } from "./api/createBondDataSource"; +export type { BondDataSource } from "./api/BondDataSource"; +export type { ConversionValuation } from "./types"; diff --git a/src/web/src/features/bonds/types.ts b/src/web/src/features/bonds/types.ts new file mode 100644 index 0000000..2e0655b --- /dev/null +++ b/src/web/src/features/bonds/types.ts @@ -0,0 +1,29 @@ +/** + * A convertible bond's conversion valuation, as consumed by the UI. + * `bondPrice` / `isInTheMoney` are null when no bond quote was available — + * render "—", never a false "out-of-the-money". + */ +export interface ConversionValuation { + symbol: string; + conversionShares: number; + conversionValue: number; // NT$ + stockPrice: number; + asOf: string; // ISO date, "YYYY-MM-DD" + bondPrice: number | null; + isInTheMoney: boolean | null; +} + +/** + * Raw record shape as it arrives over the HybridWebView bridge (camelCased C# + * DTO). Numeric fields are typed loosely to guard against string-encoded + * decimals from any transport. + */ +export interface ConversionValuationWire { + symbol: string; + conversionShares: number | string; + conversionValue: number | string; + stockPrice: number | string; + asOf: string; + bondPrice: number | string | null; + isInTheMoney: boolean | null; +} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs index e3c92a3..c6a9fa7 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs @@ -22,6 +22,8 @@ public Fixture() Snapshots.Setup(s => s.GetAllAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); Refresh.Setup(r => r.ExecuteAsync(It.IsAny())).Returns(Task.CompletedTask); + RefreshState.Setup(r => r.SetAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); } public Fixture WithLastAttempt(DateOnly date) @@ -97,4 +99,17 @@ public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() var dto = Assert.Single(dtos); // no throw; stale cache served Assert.Null(dto.BondPrice); } + + [Fact] + public async Task ExecuteAsync_StampsTodayAttempt_WhenRefreshThrows() + { + var fx = new Fixture() + .WithLastAttempt(new DateOnly(2026, 7, 1)) + .WithFailingRefresh(); + + await fx.Build().ExecuteAsync(); + + fx.RefreshState.Verify(r => r.SetAsync( + It.Is(s => s.LastAttemptDate == TwToday), It.IsAny()), Times.Once); + } } diff --git a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs index a1d133d..815fc76 100644 --- a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs +++ b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs @@ -213,6 +213,20 @@ public async Task GetAllIssuanceTermsAsync_MapsAllValidRows_AndSkipsUnparseable( Assert.DoesNotContain(bonds, b => b.Symbol == "22222"); } + [Fact] + public async Task GetAllIssuanceTermsAsync_DedupesByBondCode_KeepingFirst() + { + var fx = new Fixture().WithIssuance( + new TpexBondIssuanceRecord { BondCode = "11011", ShortName = "台泥一永", IssuerCode = "1101", ConversionPriceAtIssuance = "36.5000" }, + new TpexBondIssuanceRecord { BondCode = "11011", ShortName = "重複", IssuerCode = "1101", ConversionPriceAtIssuance = "40.0000" }); + + var bonds = await fx.Build().GetAllIssuanceTermsAsync(); + + var only = Assert.Single(bonds); + Assert.Equal("11011", only.Symbol); + Assert.Equal(36.5m, only.ConversionPrice); // first occurrence wins + } + [Fact] public async Task GetStockQuotesAsync_ResolvesAgainstSingleSnapshot_PreferringTwse() { From 43fffcc54bdfff56aeac190e42dd5837ee2e140b Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 13:37:44 -0600 Subject: [PATCH 12/28] feat(api): scaffold AppName.Api project and register central package versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ASP.NET Core Web API project shell (Microsoft.NET.Sdk.Web, net10.0) with project references to Application/Infrastructure/Domain and a minimal placeholder Program.cs. Registers PackageVersion entries in Directory.Packages.props for Npgsql.EntityFrameworkCore.PostgreSQL (9.0.0, matching pinned EF Core 9.0.0), Microsoft.AspNetCore.Authentication.JwtBearer (10.0.0), Microsoft.AspNetCore.OpenApi (10.0.0), and Scalar.AspNetCore (2.16.11). No endpoints or Program composition yet — that lands in a later task. --- AppName.sln | 15 +++++++++++++++ Directory.Packages.props | 4 ++++ src/AppName.Api/AppName.Api.csproj | 19 +++++++++++++++++++ src/AppName.Api/Program.cs | 4 ++++ 4 files changed, 42 insertions(+) create mode 100644 src/AppName.Api/AppName.Api.csproj create mode 100644 src/AppName.Api/Program.cs diff --git a/AppName.sln b/AppName.sln index ce64bfc..80d634b 100644 --- a/AppName.sln +++ b/AppName.sln @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppName.Infrastructure.Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppName.Maui", "src\AppName.Maui\AppName.Maui.csproj", "{89E211BB-FCAF-4A7D-8252-314AE63C4930}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppName.Api", "src\AppName.Api\AppName.Api.csproj", "{A1F39E8D-3C9A-49A7-916A-A1F537AA8861}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +117,18 @@ Global {89E211BB-FCAF-4A7D-8252-314AE63C4930}.Release|x64.Build.0 = Release|Any CPU {89E211BB-FCAF-4A7D-8252-314AE63C4930}.Release|x86.ActiveCfg = Release|Any CPU {89E211BB-FCAF-4A7D-8252-314AE63C4930}.Release|x86.Build.0 = Release|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Debug|x64.Build.0 = Debug|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Debug|x86.Build.0 = Debug|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Release|Any CPU.Build.0 = Release|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Release|x64.ActiveCfg = Release|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Release|x64.Build.0 = Release|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Release|x86.ActiveCfg = Release|Any CPU + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -127,5 +141,6 @@ Global {B2CEEB9C-E4D3-4578-ABF3-261A249E0611} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {0F95507E-D2E4-48DD-AB43-70A0E26195E9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {89E211BB-FCAF-4A7D-8252-314AE63C4930} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A1F39E8D-3C9A-49A7-916A-A1F537AA8861} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/Directory.Packages.props b/Directory.Packages.props index b2dadb8..d27c156 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,8 @@ + + @@ -11,6 +13,8 @@ + + diff --git a/src/AppName.Api/AppName.Api.csproj b/src/AppName.Api/AppName.Api.csproj new file mode 100644 index 0000000..7dd6b67 --- /dev/null +++ b/src/AppName.Api/AppName.Api.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + + + + + + + + + + + + + + + diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs new file mode 100644 index 0000000..206acbb --- /dev/null +++ b/src/AppName.Api/Program.cs @@ -0,0 +1,4 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +// Placeholder entry point only — real composition/endpoints land in a later task (Task 3). +app.Run(); From 4376a3d97076362581283f69be407b028689f3e9 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 13:42:16 -0600 Subject: [PATCH 13/28] refactor(infra): switch persistence provider from SQLite to PostgreSQL (Npgsql) --- src/AppName.Infrastructure/AppName.Infrastructure.csproj | 2 +- src/AppName.Infrastructure/DependencyInjection.cs | 4 ++-- src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/AppName.Infrastructure/AppName.Infrastructure.csproj b/src/AppName.Infrastructure/AppName.Infrastructure.csproj index 3010d20..195815b 100644 --- a/src/AppName.Infrastructure/AppName.Infrastructure.csproj +++ b/src/AppName.Infrastructure/AppName.Infrastructure.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index 0241709..7f52eef 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -11,9 +11,9 @@ namespace AppName.Infrastructure; public static class DependencyInjection { - public static IServiceCollection AddInfrastructure(this IServiceCollection services, string dbPath) + public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString) { - services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); + services.AddDbContextFactory(options => options.UseNpgsql(connectionString)); services.AddOptions(); services.AddOptions(); diff --git a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs index 88e4b31..2fef065 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs @@ -13,9 +13,9 @@ public sealed class AppDbContextFactory : IDesignTimeDbContextFactory() - .UseSqlite("Data Source=app.db") + .UseNpgsql("Host=localhost;Port=5432;Database=appname;Username=postgres;Password=postgres") .Options; return new AppDbContext(options); From 13c6a038763d8d7cf67a837d879109fc356a9b03 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 13:48:17 -0600 Subject: [PATCH 14/28] feat(api): add valuation/refresh endpoints, JWT auth stub, CORS, OpenAPI Replace the Task 1 placeholder Program.cs with real composition (AddApplication/AddInfrastructure), OpenAPI + Scalar UI, a JWT bearer auth stub with a local dev token-minting endpoint, a named CORS policy for the React dev origin, and the four bond/valuation data endpoints, all gated behind RequireAuthorization(). --- src/AppName.Api/Endpoints/AuthEndpoints.cs | 66 ++++++++++++++ src/AppName.Api/Endpoints/BondEndpoints.cs | 50 +++++++++++ src/AppName.Api/Program.cs | 91 +++++++++++++++++++- src/AppName.Api/appsettings.Development.json | 21 +++++ src/AppName.Api/appsettings.json | 20 +++++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/AppName.Api/Endpoints/AuthEndpoints.cs create mode 100644 src/AppName.Api/Endpoints/BondEndpoints.cs create mode 100644 src/AppName.Api/appsettings.Development.json create mode 100644 src/AppName.Api/appsettings.json diff --git a/src/AppName.Api/Endpoints/AuthEndpoints.cs b/src/AppName.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..8cdfc78 --- /dev/null +++ b/src/AppName.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,66 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text.Json.Serialization; +using Microsoft.IdentityModel.Tokens; + +namespace AppName.Api.Endpoints; + +/// +/// Local development authentication endpoints. +/// +public static class AuthEndpoints +{ + /// + /// Maps the dev token-minting endpoint under the given route group. + /// + public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) + { + // DEV STUB ONLY: mints a JWT for a hardcoded placeholder user so the React + // and MAUI clients can obtain a bearer token while developing locally. + // This must be replaced by a real identity provider (e.g. OIDC/Entra ID) + // before any non-local deployment — there is no credential check here. + group.MapPost("/auth/token", (IConfiguration config) => + { + var jwtSection = config.GetSection("Jwt"); + var issuer = jwtSection["Issuer"] ?? throw new InvalidOperationException("Jwt:Issuer is not configured."); + var audience = jwtSection["Audience"] ?? throw new InvalidOperationException("Jwt:Audience is not configured."); + var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); + + var signingKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(key)); + var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); + + var expires = DateTime.UtcNow.AddHours(1); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, "dev-user"), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: expires, + signingCredentials: credentials); + + var accessToken = new JwtSecurityTokenHandler().WriteToken(token); + + return Results.Ok(new TokenResponse(accessToken, (int)TimeSpan.FromHours(1).TotalSeconds)); + }) + .AllowAnonymous() + .WithName("MintDevToken"); + + return group; + } + + /// + /// Response body for the dev token endpoint (OAuth2-style snake_case field + /// names, matching what most bearer-token clients expect). + /// + /// The minted bearer token. + /// Token lifetime in seconds. + private sealed record TokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); +} diff --git a/src/AppName.Api/Endpoints/BondEndpoints.cs b/src/AppName.Api/Endpoints/BondEndpoints.cs new file mode 100644 index 0000000..5b0c35c --- /dev/null +++ b/src/AppName.Api/Endpoints/BondEndpoints.cs @@ -0,0 +1,50 @@ +using AppName.Application.UseCases.Bonds; + +namespace AppName.Api.Endpoints; + +/// +/// Convertible-bond valuation and refresh endpoints. +/// +public static class BondEndpoints +{ + /// + /// Maps the valuation/refresh endpoints under the given route group. All + /// endpoints here require an authenticated caller. + /// + public static RouteGroupBuilder MapBondEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/valuations", async (GetValuationsUseCase useCase, CancellationToken ct) => + { + var valuations = await useCase.ExecuteAsync(ct); + return Results.Ok(valuations); + }) + .RequireAuthorization() + .WithName("GetValuations"); + + group.MapGet("/valuations/{symbol}", async (string symbol, GetConversionValuationUseCase useCase, CancellationToken ct) => + { + var valuation = await useCase.ExecuteAsync(symbol, ct); + return valuation is null ? Results.NotFound() : Results.Ok(valuation); + }) + .RequireAuthorization() + .WithName("GetValuationBySymbol"); + + group.MapPost("/valuations/refresh", async (IRefreshAllBondsUseCase useCase, CancellationToken ct) => + { + await useCase.ExecuteAsync(ct); + return Results.Accepted(); + }) + .RequireAuthorization() + .WithName("RefreshAllValuations"); + + group.MapPost("/bonds/{symbol}/refresh", async (string symbol, RefreshBondDataUseCase useCase, CancellationToken ct) => + { + await useCase.ExecuteAsync(symbol, ct); + return Results.Accepted(); + }) + .RequireAuthorization() + .WithName("RefreshBondData"); + + return group; + } +} diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index 206acbb..e9b70ab 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -1,4 +1,93 @@ +using System.Text; +using AppName.Api.Endpoints; +using AppName.Application; +using AppName.Infrastructure; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Scalar.AspNetCore; + var builder = WebApplication.CreateBuilder(args); + +// --- Composition ----------------------------------------------------------- + +var connectionString = builder.Configuration.GetConnectionString("AppDb"); + +if (string.IsNullOrWhiteSpace(connectionString)) +{ + throw new InvalidOperationException( + "Connection string 'ConnectionStrings:AppDb' is not configured. " + + "Set it in appsettings.{Environment}.json, user-secrets, or the " + + "ConnectionStrings__AppDb environment variable."); +} + +builder.Services.AddApplication().AddInfrastructure(connectionString); + +// --- OpenAPI ----------------------------------------------------------- + +builder.Services.AddOpenApi(); + +// --- Auth (JWT bearer, dev stub) ---------------------------------------- + +var jwtSection = builder.Configuration.GetSection("Jwt"); +var jwtIssuer = jwtSection["Issuer"] ?? throw new InvalidOperationException("Jwt:Issuer is not configured."); +var jwtAudience = jwtSection["Audience"] ?? throw new InvalidOperationException("Jwt:Audience is not configured."); +var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtIssuer, + ValidateAudience = true, + ValidAudience = jwtAudience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + +builder.Services.AddAuthorization(); + +// --- CORS ---------------------------------------------------------------- + +const string CorsPolicyName = "ClientOrigins"; +var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get() ?? []; + +builder.Services.AddCors(options => +{ + options.AddPolicy(CorsPolicyName, policy => + policy.WithOrigins(corsOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials()); +}); + var app = builder.Build(); -// Placeholder entry point only — real composition/endpoints land in a later task (Task 3). + +// --- Migrations -------------------------------------------------------- +// Startup migration (db.Database.Migrate()) is intentionally deferred to a +// later task — this scaffold does not apply EF Core migrations on boot. +// When that lands, it belongs here, before the app starts serving requests. + +// --- Middleware pipeline --------------------------------------------------- + +app.MapOpenApi(); + +if (app.Environment.IsDevelopment()) +{ + app.MapScalarApiReference(); +} + +app.UseCors(CorsPolicyName); +app.UseAuthentication(); +app.UseAuthorization(); + +var api = app.MapGroup("/api"); +api.MapAuthEndpoints(); +api.MapBondEndpoints(); + app.Run(); diff --git a/src/AppName.Api/appsettings.Development.json b/src/AppName.Api/appsettings.Development.json new file mode 100644 index 0000000..00ed607 --- /dev/null +++ b/src/AppName.Api/appsettings.Development.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "// NOTE": "Local dev Postgres only. Never put real/production credentials here — those come from user-secrets or environment variables (e.g. ConnectionStrings__AppDb).", + "AppDb": "Host=localhost;Port=5432;Database=appname_dev;Username=appname_dev;Password=appname_dev" + }, + "Jwt": { + "// NOTE": "Dev-only signing key for the local /api/auth/token stub. Not a production secret — production values must come from user-secrets/env and a real IdP must replace this endpoint before deployment.", + "Issuer": "appname-api-dev", + "Audience": "appname-clients-dev", + "Key": "dev-only-signing-key-not-for-production-use-32chars-min" + }, + "Cors": { + "Origins": [ "http://localhost:5173" ] + } +} diff --git a/src/AppName.Api/appsettings.json b/src/AppName.Api/appsettings.json new file mode 100644 index 0000000..f60c120 --- /dev/null +++ b/src/AppName.Api/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "AppDb": "" + }, + "Jwt": { + "Issuer": "", + "Audience": "", + "Key": "" + }, + "Cors": { + "Origins": [] + } +} From c4b7d7be15bad60c5ef7c6ddbf787841ed8199eb Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 13:59:00 -0600 Subject: [PATCH 15/28] refactor(maui): make MAUI a DB-free API client (drop Infrastructure, add BondsApiClient) Removes the AppName.Infrastructure project reference and the startup db.Database.Migrate() call so the MAUI head no longer touches a database. Adds IBondsApiClient/BondsApiClient (typed HttpClient wrapping GET /api/valuations) plus a minimal DevAuthTokenProvider + BearerTokenHandler that mint and attach a bearer token from the API's local-dev auth stub. BondsBridge now delegates to IBondsApiClient instead of GetValuationsUseCase; AppBridge and the HybridWebView JS contract are unchanged. --- src/AppName.Maui/AppName.Maui.csproj | 6 +- src/AppName.Maui/Bridge/BondsBridge.cs | 6 +- src/AppName.Maui/DependencyInjection.cs | 59 +++++++++++-- src/AppName.Maui/MauiProgram.cs | 16 +--- .../Services/BearerTokenHandler.cs | 22 +++++ src/AppName.Maui/Services/BondsApiClient.cs | 16 ++++ .../Services/DevAuthTokenProvider.cs | 83 +++++++++++++++++++ .../Services/IAuthTokenProvider.cs | 13 +++ src/AppName.Maui/Services/IBondsApiClient.cs | 17 ++++ 9 files changed, 213 insertions(+), 25 deletions(-) create mode 100644 src/AppName.Maui/Services/BearerTokenHandler.cs create mode 100644 src/AppName.Maui/Services/BondsApiClient.cs create mode 100644 src/AppName.Maui/Services/DevAuthTokenProvider.cs create mode 100644 src/AppName.Maui/Services/IAuthTokenProvider.cs create mode 100644 src/AppName.Maui/Services/IBondsApiClient.cs diff --git a/src/AppName.Maui/AppName.Maui.csproj b/src/AppName.Maui/AppName.Maui.csproj index c97ebff..b631f82 100644 --- a/src/AppName.Maui/AppName.Maui.csproj +++ b/src/AppName.Maui/AppName.Maui.csproj @@ -68,12 +68,16 @@ + + - diff --git a/src/AppName.Maui/Bridge/BondsBridge.cs b/src/AppName.Maui/Bridge/BondsBridge.cs index 6b9637b..f5aec05 100644 --- a/src/AppName.Maui/Bridge/BondsBridge.cs +++ b/src/AppName.Maui/Bridge/BondsBridge.cs @@ -1,15 +1,15 @@ using AppName.Application.Dtos; -using AppName.Application.UseCases.Bonds; +using AppName.Maui.Services; namespace AppName.Maui.Bridge; /// /// Methods on this object are invoked from JavaScript via HybridWebView. /// -public sealed class BondsBridge(GetValuationsUseCase getValuations) : IWebBridge +public sealed class BondsBridge(IBondsApiClient bondsApiClient) : IWebBridge { /// /// Returns today's conversion valuation for every cached convertible bond. /// - public Task> GetValuations() => getValuations.ExecuteAsync(); + public Task> GetValuations() => bondsApiClient.GetValuationsAsync(); } diff --git a/src/AppName.Maui/DependencyInjection.cs b/src/AppName.Maui/DependencyInjection.cs index 3e2c503..bab403c 100644 --- a/src/AppName.Maui/DependencyInjection.cs +++ b/src/AppName.Maui/DependencyInjection.cs @@ -1,12 +1,32 @@ -using AppName.Application; -using AppName.Infrastructure; using AppName.Maui.Bridge; +using AppName.Maui.Services; using Microsoft.Extensions.DependencyInjection; namespace AppName.Maui; public static class DependencyInjection { + /// + /// Base address of AppName.Api, the server the MAUI head talks to for + /// every piece of data. Local-dev default only - not real per-platform + /// configuration. + /// + /// + /// The Android emulator runs in its own virtual network and cannot reach + /// the host machine via localhost (that resolves to the emulator + /// itself); it must use the emulator's special host-loopback alias + /// 10.0.2.2 instead. iOS simulators, Windows, and MacCatalyst can + /// all reach the host via localhost directly. This should be + /// replaced by proper per-environment configuration before this ships + /// beyond a developer's own machine. + /// + private static readonly string ApiBaseUrl = +#if ANDROID + "http://10.0.2.2:5000/api/"; +#else + "http://localhost:5000/api/"; +#endif + /// /// Registers the presentation (MAUI/UI) layer: every /// feature bridge (by assembly scan), the aggregate @@ -24,12 +44,37 @@ public static IServiceCollection AddPresentation(this IServiceCollection service } /// - /// Composition root: wires every layer (application, infrastructure, - /// presentation) so the host only needs a single registration call. + /// Registers the HTTP client(s) used to reach AppName.Api: an + /// unauthenticated named client for the local-dev token stub, the + /// bearer-token delegating handler that uses it, and the typed + /// pipeline that attaches it. + /// + public static IServiceCollection AddApiClient(this IServiceCollection services) + { + // Unauthenticated - used only to fetch the dev bearer token itself, + // so it must not go through BearerTokenHandler (that would recurse). + services.AddHttpClient(DevAuthTokenProvider.AuthClientName, client => + client.BaseAddress = new Uri(ApiBaseUrl)); + + services.AddSingleton(); + services.AddTransient(); + + services.AddHttpClient(client => + client.BaseAddress = new Uri(ApiBaseUrl)) + .AddHttpMessageHandler(); + + return services; + } + + /// + /// Composition root: wires everything the MAUI head needs - the API + /// client pipeline and the presentation layer - so the host only needs a + /// single registration call. MAUI is a thin HTTP client over + /// AppName.Api: it does not reference AppName.Infrastructure, run + /// use-cases, or touch a database directly. /// - public static IServiceCollection AddAppServices(this IServiceCollection services, string dbPath) + public static IServiceCollection AddAppServices(this IServiceCollection services) => services - .AddApplication() - .AddInfrastructure(dbPath) + .AddApiClient() .AddPresentation(); } diff --git a/src/AppName.Maui/MauiProgram.cs b/src/AppName.Maui/MauiProgram.cs index a1bb7d6..71c9431 100644 --- a/src/AppName.Maui/MauiProgram.cs +++ b/src/AppName.Maui/MauiProgram.cs @@ -1,7 +1,4 @@ -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace AppName.Maui; @@ -18,21 +15,12 @@ public static MauiApp CreateMauiApp() fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); }); - var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db"); - - builder.Services.AddAppServices(dbPath); + builder.Services.AddAppServices(); #if DEBUG builder.Logging.AddDebug(); #endif - var app = builder.Build(); - - using (var db = app.Services.GetRequiredService>().CreateDbContext()) - { - db.Database.Migrate(); - } - - return app; + return builder.Build(); } } diff --git a/src/AppName.Maui/Services/BearerTokenHandler.cs b/src/AppName.Maui/Services/BearerTokenHandler.cs new file mode 100644 index 0000000..951f2af --- /dev/null +++ b/src/AppName.Maui/Services/BearerTokenHandler.cs @@ -0,0 +1,22 @@ +using System.Net.Http.Headers; + +namespace AppName.Maui.Services; + +/// +/// Attaches a bearer token obtained from to +/// every outgoing request on the pipeline it is added to. +/// +public sealed class BearerTokenHandler(IAuthTokenProvider tokenProvider) : DelegatingHandler +{ + /// + /// Fetches (or reuses the cached) bearer token and sets it on the + /// request's Authorization header before continuing the pipeline. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var token = await tokenProvider.GetTokenAsync(ct); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + return await base.SendAsync(request, ct); + } +} diff --git a/src/AppName.Maui/Services/BondsApiClient.cs b/src/AppName.Maui/Services/BondsApiClient.cs new file mode 100644 index 0000000..ce8d1d4 --- /dev/null +++ b/src/AppName.Maui/Services/BondsApiClient.cs @@ -0,0 +1,16 @@ +using System.Net.Http.Json; +using AppName.Application.Dtos; + +namespace AppName.Maui.Services; + +/// +public sealed class BondsApiClient(HttpClient httpClient) : IBondsApiClient +{ + /// + public async Task> GetValuationsAsync(CancellationToken ct = default) + { + var valuations = await httpClient.GetFromJsonAsync>("valuations", ct); + + return valuations ?? []; + } +} diff --git a/src/AppName.Maui/Services/DevAuthTokenProvider.cs b/src/AppName.Maui/Services/DevAuthTokenProvider.cs new file mode 100644 index 0000000..ca07637 --- /dev/null +++ b/src/AppName.Maui/Services/DevAuthTokenProvider.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace AppName.Maui.Services; + +/// +/// +/// LOCAL-DEV STUB ONLY. Calls the anonymous POST /api/auth/token +/// endpoint on AppName.Api, which mints a JWT for a hardcoded placeholder +/// user with no credential check whatsoever. The token is cached in memory +/// for the lifetime of the app and refreshed shortly before it expires. This +/// must be replaced by a real sign-in flow (e.g. OIDC) before the app talks +/// to anything other than a local dev API. +/// +public sealed class DevAuthTokenProvider(IHttpClientFactory httpClientFactory) : IAuthTokenProvider +{ + /// + /// Name of the named used to reach the dev + /// token endpoint. Deliberately separate from the bond-valuation client + /// pipeline so the bearer-token handler is never attached to it (which + /// would otherwise recurse back into this provider). + /// + internal const string AuthClientName = "AppName.Maui.AuthClient"; + + private readonly SemaphoreSlim _gate = new(1, 1); + private string? _cachedToken; + private DateTimeOffset _expiresAt = DateTimeOffset.MinValue; + + /// + public async Task GetTokenAsync(CancellationToken ct = default) + { + if (TryGetCachedToken(out var cached)) + { + return cached; + } + + await _gate.WaitAsync(ct); + try + { + if (TryGetCachedToken(out cached)) + { + return cached; + } + + var client = httpClientFactory.CreateClient(AuthClientName); + using var response = await client.PostAsync("auth/token", content: null, ct); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: ct) + ?? throw new InvalidOperationException("Dev auth token endpoint returned no content."); + + _cachedToken = payload.AccessToken; + // 1-minute safety margin so a token never expires mid-request. + _expiresAt = DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn) - TimeSpan.FromMinutes(1); + + return _cachedToken; + } + finally + { + _gate.Release(); + } + } + + private bool TryGetCachedToken(out string token) + { + if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt) + { + token = _cachedToken; + return true; + } + + token = string.Empty; + return false; + } + + /// + /// Response body of the dev token endpoint (OAuth2-style snake_case + /// field names). + /// + private sealed record TokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); +} diff --git a/src/AppName.Maui/Services/IAuthTokenProvider.cs b/src/AppName.Maui/Services/IAuthTokenProvider.cs new file mode 100644 index 0000000..106dc09 --- /dev/null +++ b/src/AppName.Maui/Services/IAuthTokenProvider.cs @@ -0,0 +1,13 @@ +namespace AppName.Maui.Services; + +/// +/// Supplies a bearer token for authenticated calls to AppName.Api. +/// +public interface IAuthTokenProvider +{ + /// + /// Returns a valid bearer token, minting and caching a new one if none + /// is cached or the cached token has expired. + /// + Task GetTokenAsync(CancellationToken ct = default); +} diff --git a/src/AppName.Maui/Services/IBondsApiClient.cs b/src/AppName.Maui/Services/IBondsApiClient.cs new file mode 100644 index 0000000..2570291 --- /dev/null +++ b/src/AppName.Maui/Services/IBondsApiClient.cs @@ -0,0 +1,17 @@ +using AppName.Application.Dtos; + +namespace AppName.Maui.Services; + +/// +/// HTTP access to the bond-valuation endpoints exposed by AppName.Api. This +/// is the MAUI head's only source of bond data - the app no longer reads a +/// local database directly. +/// +public interface IBondsApiClient +{ + /// + /// Fetches today's conversion valuation for every cached convertible + /// bond from GET /api/valuations. + /// + Task> GetValuationsAsync(CancellationToken ct = default); +} From f272df2bcfb8619ad7f100c4bef4285de5d58407 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 14:04:41 -0600 Subject: [PATCH 16/28] test(infra): restore SQLite provider reference for tests after Npgsql switch AppName.Infrastructure switched to Npgsql (Task 2), which removed the transitive EF Sqlite provider the test project relied on. Add it back directly to AppName.Infrastructure.Tests so the in-memory SQLite fixtures keep working, and point DependencyInjectionTests at a dummy Postgres connection string since it now only resolves services. --- .../AppName.Infrastructure.Tests.csproj | 1 + .../AppName.Infrastructure.Tests/DependencyInjectionTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj b/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj index 3f77c63..b1bae34 100644 --- a/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj +++ b/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs index 614d928..c5d5fc1 100644 --- a/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs +++ b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs @@ -9,7 +9,7 @@ public class DependencyInjectionTests public void AddInfrastructure_Resolves_MarketDataProvider() { var services = new ServiceCollection(); - services.AddInfrastructure(":memory:"); + services.AddInfrastructure("Host=localhost;Database=test"); using var sp = services.BuildServiceProvider(); Assert.NotNull(sp.GetService()); } @@ -18,7 +18,7 @@ public void AddInfrastructure_Resolves_MarketDataProvider() public void AddInfrastructure_Resolves_BondRepository() { var services = new ServiceCollection(); - services.AddInfrastructure(":memory:"); + services.AddInfrastructure("Host=localhost;Database=test"); using var sp = services.BuildServiceProvider(); Assert.NotNull(sp.GetService()); } From 45ff10385f7f67bd17256a2356461548ee628cef Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 14:12:15 -0600 Subject: [PATCH 17/28] fix(api): reject empty (not just null) JWT config values at startup --- src/AppName.Api/Endpoints/AuthEndpoints.cs | 21 ++++++++++++++++++--- src/AppName.Api/Program.cs | 21 ++++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/AppName.Api/Endpoints/AuthEndpoints.cs b/src/AppName.Api/Endpoints/AuthEndpoints.cs index 8cdfc78..c58f48b 100644 --- a/src/AppName.Api/Endpoints/AuthEndpoints.cs +++ b/src/AppName.Api/Endpoints/AuthEndpoints.cs @@ -22,9 +22,24 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) group.MapPost("/auth/token", (IConfiguration config) => { var jwtSection = config.GetSection("Jwt"); - var issuer = jwtSection["Issuer"] ?? throw new InvalidOperationException("Jwt:Issuer is not configured."); - var audience = jwtSection["Audience"] ?? throw new InvalidOperationException("Jwt:Audience is not configured."); - var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); + var issuer = jwtSection["Issuer"]; + var audience = jwtSection["Audience"]; + var key = jwtSection["Key"]; + + if (string.IsNullOrWhiteSpace(issuer)) + { + throw new InvalidOperationException("Jwt:Issuer is not configured."); + } + + if (string.IsNullOrWhiteSpace(audience)) + { + throw new InvalidOperationException("Jwt:Audience is not configured."); + } + + if (string.IsNullOrWhiteSpace(key)) + { + throw new InvalidOperationException("Jwt:Key is not configured."); + } var signingKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(key)); var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index e9b70ab..4b1336e 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -29,9 +29,24 @@ // --- Auth (JWT bearer, dev stub) ---------------------------------------- var jwtSection = builder.Configuration.GetSection("Jwt"); -var jwtIssuer = jwtSection["Issuer"] ?? throw new InvalidOperationException("Jwt:Issuer is not configured."); -var jwtAudience = jwtSection["Audience"] ?? throw new InvalidOperationException("Jwt:Audience is not configured."); -var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); +var jwtIssuer = jwtSection["Issuer"]; +var jwtAudience = jwtSection["Audience"]; +var jwtKey = jwtSection["Key"]; + +if (string.IsNullOrWhiteSpace(jwtIssuer)) +{ + throw new InvalidOperationException("Jwt:Issuer is not configured."); +} + +if (string.IsNullOrWhiteSpace(jwtAudience)) +{ + throw new InvalidOperationException("Jwt:Audience is not configured."); +} + +if (string.IsNullOrWhiteSpace(jwtKey)) +{ + throw new InvalidOperationException("Jwt:Key is not configured."); +} builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) From 8a638639c49d5b0f40582fcb6db486c671e48bb3 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 14:55:14 -0600 Subject: [PATCH 18/28] feat(infra): add user secrets and JSON configuration support for AppDbContextFactory --- Directory.Packages.props | 2 ++ .../AppName.Infrastructure.csproj | 3 +++ .../Persistence/AppDbContextFactory.cs | 25 ++++++++++++++++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d27c156..77dbb1d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,8 @@ + + diff --git a/src/AppName.Infrastructure/AppName.Infrastructure.csproj b/src/AppName.Infrastructure/AppName.Infrastructure.csproj index 195815b..bb8c33b 100644 --- a/src/AppName.Infrastructure/AppName.Infrastructure.csproj +++ b/src/AppName.Infrastructure/AppName.Infrastructure.csproj @@ -2,6 +2,7 @@ net10.0 + 50d23b30-9e8f-47a4-9e3a-2228420a7fcb @@ -12,6 +13,8 @@ + + diff --git a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs index 2fef065..7d66cce 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; namespace AppName.Infrastructure.Persistence; @@ -9,13 +10,31 @@ namespace AppName.Infrastructure.Persistence; /// public sealed class AppDbContextFactory : IDesignTimeDbContextFactory { + /// + /// Name of the connection string read from configuration (appsettings.json or user secrets). + /// + private const string ConnectionStringName = "AppDb"; + /// public AppDbContext CreateDbContext(string[] args) { - // Design-time only (used by `dotnet ef`); the running app uses AddInfrastructure's - // app-supplied connectionString instead of this placeholder. + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddUserSecrets(optional: true) + .Build(); + + var connectionString = configuration.GetConnectionString(ConnectionStringName); + + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException( + $"The connection string '{ConnectionStringName}' is missing or empty. " + + $"Set it with: dotnet user-secrets set \"ConnectionStrings:{ConnectionStringName}\" \"\""); + } + var options = new DbContextOptionsBuilder() - .UseNpgsql("Host=localhost;Port=5432;Database=appname;Username=postgres;Password=postgres") + .UseNpgsql(connectionString) .Options; return new AppDbContext(options); From 9f39dea6862dfb9a32cd8fff40d14b954595671c Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 15:00:29 -0600 Subject: [PATCH 19/28] feat(infra): add YugabyteDB CA certificate handling and connection string normalization --- src/AppName.Api/AppName.Api.csproj | 6 ++ src/AppName.Api/certs/yugabyte-ca.crt | 74 +++++++++++++++++++ .../AppName.Infrastructure.csproj | 6 ++ .../DependencyInjection.cs | 2 + .../Persistence/AppDbContextFactory.cs | 2 + .../Persistence/NpgsqlConnectionString.cs | 33 +++++++++ .../certs/yugabyte-ca.crt | 74 +++++++++++++++++++ 7 files changed, 197 insertions(+) create mode 100644 src/AppName.Api/certs/yugabyte-ca.crt create mode 100644 src/AppName.Infrastructure/Persistence/NpgsqlConnectionString.cs create mode 100644 src/AppName.Infrastructure/certs/yugabyte-ca.crt diff --git a/src/AppName.Api/AppName.Api.csproj b/src/AppName.Api/AppName.Api.csproj index 7dd6b67..c9817a8 100644 --- a/src/AppName.Api/AppName.Api.csproj +++ b/src/AppName.Api/AppName.Api.csproj @@ -16,4 +16,10 @@ + + + + + diff --git a/src/AppName.Api/certs/yugabyte-ca.crt b/src/AppName.Api/certs/yugabyte-ca.crt new file mode 100644 index 0000000..a8af5fc --- /dev/null +++ b/src/AppName.Api/certs/yugabyte-ca.crt @@ -0,0 +1,74 @@ +-----BEGIN CERTIFICATE----- +MIIGxDCCBKygAwIBAgITF8MQH/VpOvqeGxybP3vBoEPj0TANBgkqhkiG9w0BAQsF +ADCBhDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRIwEAYDVQQHEwlTdW5ueXZh +bGUxFTATBgNVBAoTDFl1Z2FieXRlIEluYzEXMBUGA1UECxMOWXVnYWJ5dGUgQ2xv +dWQxJDAiBgNVBAMTG1l1Z2FieXRlIENsb3VkIFJvb3QgQ0EgcHJvZDAeFw0yNTA5 +MTgxNjIzMjBaFw0zMDA5MTcxNjIzMTlaMIGWMQswCQYDVQQGEwJVUzELMAkGA1UE +CBMCQ0ExEjAQBgNVBAcTCVN1bm55dmFsZTEXMBUGA1UEChMOWXVnYWJ5dGVEQiBJ +bmMxIDAeBgNVBAsTF1l1Z2FieXRlREIgQWVvbiBNYW5hZ2VkMSswKQYDVQQDEyJZ +dWdhYnl0ZSBDbG91ZCBTdWJvcmRpbmF0ZSBDQSBwcm9kMIIBojANBgkqhkiG9w0B +AQEFAAOCAY8AMIIBigKCAYEAl1CO4UpzZYYDqVuzLUyhNEah0a0VMNzgYQkCxXGN +QOqA0sp60bfJiAaUe1fO1A/VYSur3kpuxWk3/CxBrR6RTsZKpFy1XgeK6McecWUV +ACOtINMwrmHYg0hb032bPSvwDweTddkG9wzuD4Md9+5FK6ebjDhq/kPwSQHhG1lS +qygAcwRJgHcb3Ad6ZrtG5hYNG2Ikt86ipIV/6zgwHYoaDjI4u5Nl5Gqblkea2yn6 +M+Z0AM0u+k6FsMkqu9IMXYC466ZTNXHB4pMLVgRkiUvLnUnA/nROGTHmi1ivLGDO +TvKPlEkVvbdZYbutG9isGUg61Fhqb1hRISPdC1uxD4RBoooIBHoOP27gWcdVa+qZ +Q/N6oYy6Yw3R58d0JJrjE5BwppAK9q4EMlRIj9JEbQIyBhtbkqPn+PwwvUHRFavY +s3BKmx2BSuYWaDCSmbDSOLxuf2bzaHIwrhlBgDhWwtVl4F5W1tzDV7FPl19vkl77 +UPQo87d3C55rofMJ9BRsdqHbAgMBAAGjggGZMIIBlTAOBgNVHQ8BAf8EBAMCAQYw +HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFLnilBT2qOKcpt+IED4inSpiiLCnMB8GA1UdIwQYMBaAFGCPfEqV +jRfctFj3jSsv+QkB/hI7MIGNBggrBgEFBQcBAQSBgDB+MHwGCCsGAQUFBzAChnBo +dHRwOi8vcHJpdmF0ZWNhLWNvbnRlbnQtNjExZTIyZjYtMDAwMC0yMGM5LWE2NTAt +ZDRmNTQ3ZjdlZTFjLnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20vYWExNDA4ODNkNGE3 +YTJjMDBhMDkvY2EuY3J0MIGCBgNVHR8EezB5MHegdaBzhnFodHRwOi8vcHJpdmF0 +ZWNhLWNvbnRlbnQtNjExZTIyZjYtMDAwMC0yMGM5LWE2NTAtZDRmNTQ3ZjdlZTFj +LnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20vYWExNDA4ODNkNGE3YTJjMDBhMDkvY3Js +LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAt9HGIX1Btl0Fb70GpySsxv94632Kvs6G +1zTL21lpAnLmngezaGOuOzR/swwCIfHj3rf+wFKY6HQ371pLgEUvfe3Ve5aVKIs/ +krvGzUIJybaMIZ2rtO88C+X+a+LInRJLJMhJ366X+jTNURLV2S2twCQYDnodyYWA +6z+Fq5fPs+O5khj6qCork6tyEgV9F1bLJNRatNRIYBOhif6GeR3bFOBoSb++d1KB +OrqB5fMIXnqcwV7Zjz8goaWQgeWf1VTyAeavUfKLmZl/SzmKfOGUeux5ezZk77KA +beXE/wMCCARVeV4LPf4nOIcJIciF94A2kZuFpyJmM0VVKbcGuSQe58HxVS3iwmN6 +GHGegOqDy8yWknbrjADDyYzJvhUJciKN/Nu0XarXvCSR84BnNfKLB0zBNIUAlAUq +iT3NnyYZYlhD/lHZOtFRphyOQXU6VVkRrRjXer7LF9b7BWYaly1f3FzQf9gwoPyO +sprdOhIsaGIbAzx/hoOgUg28HNyfwih6pI80vGqRg2Zgri8Qb8cOSXVkb6Y0Voic +Y9KaDmilwQAALxGW/H/ScXNExKvWy5o7+H4v3Id/Dr026n1wPE3qOlERoaFmBj0U ++E5UevW1SOwyBjS3XR29cOz89FDIq418DGVpSex2q26ALmNX9E/A0XCPP2bk5lFX +hDzC5OY3o1A= +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIF+jCCA+KgAwIBAgITQSClZYc7h4sJ9bg7LGKSKpUKFTANBgkqhkiG9w0BAQsF +ADCBhDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRIwEAYDVQQHEwlTdW5ueXZh +bGUxFTATBgNVBAoTDFl1Z2FieXRlIEluYzEXMBUGA1UECxMOWXVnYWJ5dGUgQ2xv +dWQxJDAiBgNVBAMTG1l1Z2FieXRlIENsb3VkIFJvb3QgQ0EgcHJvZDAeFw0yMTA4 +MjAwOTI4MTNaFw0zMTA4MTgwOTI4MTJaMIGEMQswCQYDVQQGEwJVUzELMAkGA1UE +CBMCQ0ExEjAQBgNVBAcTCVN1bm55dmFsZTEVMBMGA1UEChMMWXVnYWJ5dGUgSW5j +MRcwFQYDVQQLEw5ZdWdhYnl0ZSBDbG91ZDEkMCIGA1UEAxMbWXVnYWJ5dGUgQ2xv +dWQgUm9vdCBDQSBwcm9kMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +0tTsrSyBfa09rA8ylcZRtxeMLzI3vE3++W9DLV5FK7knsrg45epjcf8zGRLlcKkN +00qaPpTMCwmHvJlyfGhxqrZhKBCtGosRyOvHkLtOhwkW8fHzrx2sm3UTjQpdjv/F +aQxj54YToyMUw66fdMl5PvA+tUbYwZHZEVM9NKtzGE4/j9bZUIQpj+bbJ/el8zY+ +WsquZrZ1aA75tC4FzhRYMsEkrRH0iF+T6S3g4VAsn3qRfV+t/aswAAle6gPe+aP3 +py5znRnJ5a0kunKEgpL7YJJ5AiqpVjyXNlL3LCvHvB5Lo4AHhVkfYafB8rs/301Q +Frdn4OeZdELv0kI7Ch1nI2/qIakEdodrOT2bTB3E1BMtSfN/z0wGC+sH1Fj3gtQ2 +2Ez/AINeDSqJ0tagSU4XMzrLRXy92ToR5trzwy7sEISzxS5BcSuy55lQBhv1vztW +qaC2mfbYrvuVEBb9skF+YDSC+aM/QI5iVGO0m91e1b+okOnZeo7M1YEc5RnjrGOW +a1Q3L6+O/+le/7D8x5cEBBLdwf/DqbFmrIXsSaWMOt+MAopzBPcdyF0NEg//fA6Y +W9pVn8kqWTo1pzY2CIViRIyIFx74D1/fEXLZvjzgckRbxbayNlL/+DHtHkPThbuX +i7BaY3P1mivtgOC0BoZObiVIdX91AB7h4+WjHFf8NGUCAwEAAaNjMGEwDgYDVR0P +AQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFGCPfEqVjRfctFj3 +jSsv+QkB/hI7MB8GA1UdIwQYMBaAFGCPfEqVjRfctFj3jSsv+QkB/hI7MA0GCSqG +SIb3DQEBCwUAA4ICAQBn4vQjhhMYEUx+wz9ammb88NTbQvtx3KWgxzhPyR/ekj5X +bW1SxCnQwOTGqbk9rTRdTc5JB0WH4AqD5wijM+qtuYDwUUwkWBGn9XLjy9WN/PCz +X4ePteWvtE06o70EosAG8I7UM7MN1qnZdWoB+qfP9sxx3vyfWGHvHwMFRaq2ea1C +otN5fryj3X/Y3oyIMC0oeSAqcYX97zz9dToNl9Ue8nUDiUo4CHED15VM5RLyx/dO ++ujQ+4OiNQT5mxn8zlM1bOyj+t5mB3E1IGdNtaTcpWulrO4VR/0qrDRHeU26iurM +9GFYl19Z0afo3bYiyNiLV7omNmEcARTAXTPLTI06veZjIafVJwZZTwIoJb9wV7rv +D4cHS9IdEkn5PomMk5X96AOZKWnfvxPsORqgunG9o+azFSgOrLc5MI7OwjGO9M4J +jx6IbjAj2tCrnaE1XPsWR3B3nL6aFtfIYtLMu4vb6HXQ8aYSbocyBO788o7g4vBm +4yNfo1BHB1UCV7UFS5N+MnrUIJITHmJuSkwfFGUxAiNqR5lt0lOW9mFN6FTWH/wT +uHYiQRE3S9hMRqpUOUMmWRRpqHTxYl/FUrqOR8k4g1mN34ZqOpJZOwNnVMOsa+fh +o1DZJoSu3+Cu5ZEv1xOCWs0bOoinIt45bqT0jrEXQDDhwGWR3gC64VMqffla1g== +-----END CERTIFICATE----- diff --git a/src/AppName.Infrastructure/AppName.Infrastructure.csproj b/src/AppName.Infrastructure/AppName.Infrastructure.csproj index bb8c33b..0b27360 100644 --- a/src/AppName.Infrastructure/AppName.Infrastructure.csproj +++ b/src/AppName.Infrastructure/AppName.Infrastructure.csproj @@ -20,4 +20,10 @@ + + + + + diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index 7f52eef..ee5e452 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -13,6 +13,8 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString) { + connectionString = NpgsqlConnectionString.ResolveRootCertificate(connectionString); + services.AddDbContextFactory(options => options.UseNpgsql(connectionString)); services.AddOptions(); diff --git a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs index 7d66cce..d7bc4a0 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs @@ -33,6 +33,8 @@ public AppDbContext CreateDbContext(string[] args) $"Set it with: dotnet user-secrets set \"ConnectionStrings:{ConnectionStringName}\" \"\""); } + connectionString = NpgsqlConnectionString.ResolveRootCertificate(connectionString); + var options = new DbContextOptionsBuilder() .UseNpgsql(connectionString) .Options; diff --git a/src/AppName.Infrastructure/Persistence/NpgsqlConnectionString.cs b/src/AppName.Infrastructure/Persistence/NpgsqlConnectionString.cs new file mode 100644 index 0000000..646067e --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/NpgsqlConnectionString.cs @@ -0,0 +1,33 @@ +using Npgsql; + +namespace AppName.Infrastructure.Persistence; + +/// +/// Helpers for normalizing a Npgsql/PostgreSQL connection string before it is handed to EF Core. +/// +public static class NpgsqlConnectionString +{ + /// + /// Resolves a relative Root Certificate path in the supplied connection string into an + /// absolute path under the application's base directory, so a cert bundled with the deployment + /// (for example a YugabyteDB CA cert copied to the build output) is found regardless of the + /// process's current working directory. Absolute paths and empty values are left unchanged. + /// + /// + /// The connection string to normalize. + /// + /// + /// The connection string with an absolute Root Certificate path when one was specified. + /// + public static string ResolveRootCertificate(string connectionString) + { + var builder = new NpgsqlConnectionStringBuilder(connectionString); + + if (!string.IsNullOrEmpty(builder.RootCertificate) && !Path.IsPathRooted(builder.RootCertificate)) + { + builder.RootCertificate = Path.Combine(AppContext.BaseDirectory, builder.RootCertificate); + } + + return builder.ConnectionString; + } +} diff --git a/src/AppName.Infrastructure/certs/yugabyte-ca.crt b/src/AppName.Infrastructure/certs/yugabyte-ca.crt new file mode 100644 index 0000000..a8af5fc --- /dev/null +++ b/src/AppName.Infrastructure/certs/yugabyte-ca.crt @@ -0,0 +1,74 @@ +-----BEGIN CERTIFICATE----- +MIIGxDCCBKygAwIBAgITF8MQH/VpOvqeGxybP3vBoEPj0TANBgkqhkiG9w0BAQsF +ADCBhDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRIwEAYDVQQHEwlTdW5ueXZh +bGUxFTATBgNVBAoTDFl1Z2FieXRlIEluYzEXMBUGA1UECxMOWXVnYWJ5dGUgQ2xv +dWQxJDAiBgNVBAMTG1l1Z2FieXRlIENsb3VkIFJvb3QgQ0EgcHJvZDAeFw0yNTA5 +MTgxNjIzMjBaFw0zMDA5MTcxNjIzMTlaMIGWMQswCQYDVQQGEwJVUzELMAkGA1UE +CBMCQ0ExEjAQBgNVBAcTCVN1bm55dmFsZTEXMBUGA1UEChMOWXVnYWJ5dGVEQiBJ +bmMxIDAeBgNVBAsTF1l1Z2FieXRlREIgQWVvbiBNYW5hZ2VkMSswKQYDVQQDEyJZ +dWdhYnl0ZSBDbG91ZCBTdWJvcmRpbmF0ZSBDQSBwcm9kMIIBojANBgkqhkiG9w0B +AQEFAAOCAY8AMIIBigKCAYEAl1CO4UpzZYYDqVuzLUyhNEah0a0VMNzgYQkCxXGN +QOqA0sp60bfJiAaUe1fO1A/VYSur3kpuxWk3/CxBrR6RTsZKpFy1XgeK6McecWUV +ACOtINMwrmHYg0hb032bPSvwDweTddkG9wzuD4Md9+5FK6ebjDhq/kPwSQHhG1lS +qygAcwRJgHcb3Ad6ZrtG5hYNG2Ikt86ipIV/6zgwHYoaDjI4u5Nl5Gqblkea2yn6 +M+Z0AM0u+k6FsMkqu9IMXYC466ZTNXHB4pMLVgRkiUvLnUnA/nROGTHmi1ivLGDO +TvKPlEkVvbdZYbutG9isGUg61Fhqb1hRISPdC1uxD4RBoooIBHoOP27gWcdVa+qZ +Q/N6oYy6Yw3R58d0JJrjE5BwppAK9q4EMlRIj9JEbQIyBhtbkqPn+PwwvUHRFavY +s3BKmx2BSuYWaDCSmbDSOLxuf2bzaHIwrhlBgDhWwtVl4F5W1tzDV7FPl19vkl77 +UPQo87d3C55rofMJ9BRsdqHbAgMBAAGjggGZMIIBlTAOBgNVHQ8BAf8EBAMCAQYw +HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFLnilBT2qOKcpt+IED4inSpiiLCnMB8GA1UdIwQYMBaAFGCPfEqV +jRfctFj3jSsv+QkB/hI7MIGNBggrBgEFBQcBAQSBgDB+MHwGCCsGAQUFBzAChnBo +dHRwOi8vcHJpdmF0ZWNhLWNvbnRlbnQtNjExZTIyZjYtMDAwMC0yMGM5LWE2NTAt +ZDRmNTQ3ZjdlZTFjLnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20vYWExNDA4ODNkNGE3 +YTJjMDBhMDkvY2EuY3J0MIGCBgNVHR8EezB5MHegdaBzhnFodHRwOi8vcHJpdmF0 +ZWNhLWNvbnRlbnQtNjExZTIyZjYtMDAwMC0yMGM5LWE2NTAtZDRmNTQ3ZjdlZTFj +LnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20vYWExNDA4ODNkNGE3YTJjMDBhMDkvY3Js +LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAt9HGIX1Btl0Fb70GpySsxv94632Kvs6G +1zTL21lpAnLmngezaGOuOzR/swwCIfHj3rf+wFKY6HQ371pLgEUvfe3Ve5aVKIs/ +krvGzUIJybaMIZ2rtO88C+X+a+LInRJLJMhJ366X+jTNURLV2S2twCQYDnodyYWA +6z+Fq5fPs+O5khj6qCork6tyEgV9F1bLJNRatNRIYBOhif6GeR3bFOBoSb++d1KB +OrqB5fMIXnqcwV7Zjz8goaWQgeWf1VTyAeavUfKLmZl/SzmKfOGUeux5ezZk77KA +beXE/wMCCARVeV4LPf4nOIcJIciF94A2kZuFpyJmM0VVKbcGuSQe58HxVS3iwmN6 +GHGegOqDy8yWknbrjADDyYzJvhUJciKN/Nu0XarXvCSR84BnNfKLB0zBNIUAlAUq +iT3NnyYZYlhD/lHZOtFRphyOQXU6VVkRrRjXer7LF9b7BWYaly1f3FzQf9gwoPyO +sprdOhIsaGIbAzx/hoOgUg28HNyfwih6pI80vGqRg2Zgri8Qb8cOSXVkb6Y0Voic +Y9KaDmilwQAALxGW/H/ScXNExKvWy5o7+H4v3Id/Dr026n1wPE3qOlERoaFmBj0U ++E5UevW1SOwyBjS3XR29cOz89FDIq418DGVpSex2q26ALmNX9E/A0XCPP2bk5lFX +hDzC5OY3o1A= +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIF+jCCA+KgAwIBAgITQSClZYc7h4sJ9bg7LGKSKpUKFTANBgkqhkiG9w0BAQsF +ADCBhDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRIwEAYDVQQHEwlTdW5ueXZh +bGUxFTATBgNVBAoTDFl1Z2FieXRlIEluYzEXMBUGA1UECxMOWXVnYWJ5dGUgQ2xv +dWQxJDAiBgNVBAMTG1l1Z2FieXRlIENsb3VkIFJvb3QgQ0EgcHJvZDAeFw0yMTA4 +MjAwOTI4MTNaFw0zMTA4MTgwOTI4MTJaMIGEMQswCQYDVQQGEwJVUzELMAkGA1UE +CBMCQ0ExEjAQBgNVBAcTCVN1bm55dmFsZTEVMBMGA1UEChMMWXVnYWJ5dGUgSW5j +MRcwFQYDVQQLEw5ZdWdhYnl0ZSBDbG91ZDEkMCIGA1UEAxMbWXVnYWJ5dGUgQ2xv +dWQgUm9vdCBDQSBwcm9kMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +0tTsrSyBfa09rA8ylcZRtxeMLzI3vE3++W9DLV5FK7knsrg45epjcf8zGRLlcKkN +00qaPpTMCwmHvJlyfGhxqrZhKBCtGosRyOvHkLtOhwkW8fHzrx2sm3UTjQpdjv/F +aQxj54YToyMUw66fdMl5PvA+tUbYwZHZEVM9NKtzGE4/j9bZUIQpj+bbJ/el8zY+ +WsquZrZ1aA75tC4FzhRYMsEkrRH0iF+T6S3g4VAsn3qRfV+t/aswAAle6gPe+aP3 +py5znRnJ5a0kunKEgpL7YJJ5AiqpVjyXNlL3LCvHvB5Lo4AHhVkfYafB8rs/301Q +Frdn4OeZdELv0kI7Ch1nI2/qIakEdodrOT2bTB3E1BMtSfN/z0wGC+sH1Fj3gtQ2 +2Ez/AINeDSqJ0tagSU4XMzrLRXy92ToR5trzwy7sEISzxS5BcSuy55lQBhv1vztW +qaC2mfbYrvuVEBb9skF+YDSC+aM/QI5iVGO0m91e1b+okOnZeo7M1YEc5RnjrGOW +a1Q3L6+O/+le/7D8x5cEBBLdwf/DqbFmrIXsSaWMOt+MAopzBPcdyF0NEg//fA6Y +W9pVn8kqWTo1pzY2CIViRIyIFx74D1/fEXLZvjzgckRbxbayNlL/+DHtHkPThbuX +i7BaY3P1mivtgOC0BoZObiVIdX91AB7h4+WjHFf8NGUCAwEAAaNjMGEwDgYDVR0P +AQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFGCPfEqVjRfctFj3 +jSsv+QkB/hI7MB8GA1UdIwQYMBaAFGCPfEqVjRfctFj3jSsv+QkB/hI7MA0GCSqG +SIb3DQEBCwUAA4ICAQBn4vQjhhMYEUx+wz9ammb88NTbQvtx3KWgxzhPyR/ekj5X +bW1SxCnQwOTGqbk9rTRdTc5JB0WH4AqD5wijM+qtuYDwUUwkWBGn9XLjy9WN/PCz +X4ePteWvtE06o70EosAG8I7UM7MN1qnZdWoB+qfP9sxx3vyfWGHvHwMFRaq2ea1C +otN5fryj3X/Y3oyIMC0oeSAqcYX97zz9dToNl9Ue8nUDiUo4CHED15VM5RLyx/dO ++ujQ+4OiNQT5mxn8zlM1bOyj+t5mB3E1IGdNtaTcpWulrO4VR/0qrDRHeU26iurM +9GFYl19Z0afo3bYiyNiLV7omNmEcARTAXTPLTI06veZjIafVJwZZTwIoJb9wV7rv +D4cHS9IdEkn5PomMk5X96AOZKWnfvxPsORqgunG9o+azFSgOrLc5MI7OwjGO9M4J +jx6IbjAj2tCrnaE1XPsWR3B3nL6aFtfIYtLMu4vb6HXQ8aYSbocyBO788o7g4vBm +4yNfo1BHB1UCV7UFS5N+MnrUIJITHmJuSkwfFGUxAiNqR5lt0lOW9mFN6FTWH/wT +uHYiQRE3S9hMRqpUOUMmWRRpqHTxYl/FUrqOR8k4g1mN34ZqOpJZOwNnVMOsa+fh +o1DZJoSu3+Cu5ZEv1xOCWs0bOoinIt45bqT0jrEXQDDhwGWR3gC64VMqffla1g== +-----END CERTIFICATE----- From 67cc16c72fa102243d5a893062f9460803a6279a Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 15:23:51 -0600 Subject: [PATCH 20/28] feat(infra): refactor infrastructure setup to use IConfiguration for connection strings and add centralized secret key constants --- src/AppName.Api/Program.cs | 12 +---- src/AppName.Api/SecretKeysConstants.cs | 49 +++++++++++++++++++ .../DependencyInjection.cs | 16 +++++- .../Persistence/AppDbContextFactory.cs | 11 ++--- .../SecretKeysConstants.cs | 21 ++++++++ .../DependencyInjectionTests.cs | 21 +++++++- 6 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 src/AppName.Api/SecretKeysConstants.cs create mode 100644 src/AppName.Infrastructure/SecretKeysConstants.cs diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index 4b1336e..9664d3f 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -10,17 +10,7 @@ // --- Composition ----------------------------------------------------------- -var connectionString = builder.Configuration.GetConnectionString("AppDb"); - -if (string.IsNullOrWhiteSpace(connectionString)) -{ - throw new InvalidOperationException( - "Connection string 'ConnectionStrings:AppDb' is not configured. " + - "Set it in appsettings.{Environment}.json, user-secrets, or the " + - "ConnectionStrings__AppDb environment variable."); -} - -builder.Services.AddApplication().AddInfrastructure(connectionString); +builder.Services.AddApplication().AddInfrastructure(builder.Configuration); // --- OpenAPI ----------------------------------------------------------- diff --git a/src/AppName.Api/SecretKeysConstants.cs b/src/AppName.Api/SecretKeysConstants.cs new file mode 100644 index 0000000..4604f3a --- /dev/null +++ b/src/AppName.Api/SecretKeysConstants.cs @@ -0,0 +1,49 @@ +namespace AppName.Api; + +/// +/// Centralized configuration/secret key names used by the API host. +/// +public static class SecretKeysConstants +{ + /// + /// JWT bearer configuration keys (section Jwt). + /// + public static class Jwt + { + /// + /// Configuration section holding the JWT settings. + /// + public const string Section = "Jwt"; + + /// + /// Token issuer key (Jwt:Issuer). + /// + public const string Issuer = "Issuer"; + + /// + /// Token audience key (Jwt:Audience). + /// + public const string Audience = "Audience"; + + /// + /// Signing key (Jwt:Key). + /// + public const string Key = "Key"; + } + + /// + /// CORS configuration keys and the client-origins policy name. + /// + public static class Cors + { + /// + /// Configuration key for the allowed client origins (Cors:Origins). + /// + public const string OriginsSection = "Cors:Origins"; + + /// + /// Name of the CORS policy applied to client origins. + /// + public const string PolicyName = "ClientOrigins"; + } +} diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index ee5e452..d279f2d 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -5,17 +5,29 @@ using AppName.Infrastructure.Gateways; using AppName.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace AppName.Infrastructure; public static class DependencyInjection { - public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString) + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { + var connectionString = configuration.GetConnectionString(SecretKeysConstants.ConnectionStrings.AppDb); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + $"Connection string 'ConnectionStrings:{SecretKeysConstants.ConnectionStrings.AppDb}' is not configured. " + + "Set it in appsettings.{Environment}.json, user-secrets, or the " + + $"ConnectionStrings__{SecretKeysConstants.ConnectionStrings.AppDb} environment variable."); + } + connectionString = NpgsqlConnectionString.ResolveRootCertificate(connectionString); - services.AddDbContextFactory(options => options.UseNpgsql(connectionString)); + services.AddDbContextFactory(options => + options.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure())); services.AddOptions(); services.AddOptions(); diff --git a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs index d7bc4a0..f382768 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs @@ -10,11 +10,6 @@ namespace AppName.Infrastructure.Persistence; ///
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory { - /// - /// Name of the connection string read from configuration (appsettings.json or user secrets). - /// - private const string ConnectionStringName = "AppDb"; - /// public AppDbContext CreateDbContext(string[] args) { @@ -24,13 +19,13 @@ public AppDbContext CreateDbContext(string[] args) .AddUserSecrets(optional: true) .Build(); - var connectionString = configuration.GetConnectionString(ConnectionStringName); + var connectionString = configuration.GetConnectionString(SecretKeysConstants.ConnectionStrings.AppDb); if (string.IsNullOrEmpty(connectionString)) { throw new InvalidOperationException( - $"The connection string '{ConnectionStringName}' is missing or empty. " + - $"Set it with: dotnet user-secrets set \"ConnectionStrings:{ConnectionStringName}\" \"\""); + $"The connection string '{SecretKeysConstants.ConnectionStrings.AppDb}' is missing or empty. " + + $"Set it with: dotnet user-secrets set \"ConnectionStrings:{SecretKeysConstants.ConnectionStrings.AppDb}\" \"\""); } connectionString = NpgsqlConnectionString.ResolveRootCertificate(connectionString); diff --git a/src/AppName.Infrastructure/SecretKeysConstants.cs b/src/AppName.Infrastructure/SecretKeysConstants.cs new file mode 100644 index 0000000..6ed897d --- /dev/null +++ b/src/AppName.Infrastructure/SecretKeysConstants.cs @@ -0,0 +1,21 @@ +namespace AppName.Infrastructure; + +/// +/// Centralized configuration/secret key names used to read values from +/// IConfiguration (appsettings, user-secrets, environment variables). +/// +public static class SecretKeysConstants +{ + /// + /// Named connection strings read from the ConnectionStrings section. + /// + public static class ConnectionStrings + { + /// + /// Application database connection string + /// (ConnectionStrings:AppDb, or the + /// ConnectionStrings__AppDb environment variable). + /// + public const string AppDb = "AppDb"; + } +} diff --git a/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs index c5d5fc1..81cea07 100644 --- a/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs +++ b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs @@ -1,15 +1,24 @@ using AppName.Domain.Abstractions; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace AppName.Infrastructure.Tests; public class DependencyInjectionTests { + private static IConfiguration Configuration(string connectionString) => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"ConnectionStrings:{SecretKeysConstants.ConnectionStrings.AppDb}"] = connectionString, + }) + .Build(); + [Fact] public void AddInfrastructure_Resolves_MarketDataProvider() { var services = new ServiceCollection(); - services.AddInfrastructure("Host=localhost;Database=test"); + services.AddInfrastructure(Configuration("Host=localhost;Database=test")); using var sp = services.BuildServiceProvider(); Assert.NotNull(sp.GetService()); } @@ -18,8 +27,16 @@ public void AddInfrastructure_Resolves_MarketDataProvider() public void AddInfrastructure_Resolves_BondRepository() { var services = new ServiceCollection(); - services.AddInfrastructure("Host=localhost;Database=test"); + services.AddInfrastructure(Configuration("Host=localhost;Database=test")); using var sp = services.BuildServiceProvider(); Assert.NotNull(sp.GetService()); } + + [Fact] + public void AddInfrastructure_Throws_WhenConnectionStringMissing() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + Assert.Throws(() => services.AddInfrastructure(configuration)); + } } From a3d45041d91e295542004de66b8aa12ff8ce87d1 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 16:11:51 -0600 Subject: [PATCH 21/28] feat(auth): implement JWT authentication setup and configuration validation --- .../AuthenticationExtensions.cs | 40 +++++++++++++++ src/AppName.Api/Authentication/JwtSettings.cs | 41 ++++++++++++++++ src/AppName.Api/Endpoints/AuthEndpoints.cs | 28 +++-------- src/AppName.Api/Program.cs | 49 +++---------------- 4 files changed, 93 insertions(+), 65 deletions(-) create mode 100644 src/AppName.Api/Authentication/AuthenticationExtensions.cs create mode 100644 src/AppName.Api/Authentication/JwtSettings.cs diff --git a/src/AppName.Api/Authentication/AuthenticationExtensions.cs b/src/AppName.Api/Authentication/AuthenticationExtensions.cs new file mode 100644 index 0000000..8f8b95b --- /dev/null +++ b/src/AppName.Api/Authentication/AuthenticationExtensions.cs @@ -0,0 +1,40 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +namespace AppName.Api.Authentication; + +/// +/// Service-collection extensions for wiring up API authentication. +/// +public static class AuthenticationExtensions +{ + /// + /// Registers JWT bearer authentication using the validated Jwt + /// configuration section. + /// + public static IServiceCollection AddJwtAuthentication( + this IServiceCollection services, IConfiguration configuration) + { + var jwt = JwtSettings.FromConfiguration(configuration); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + + return services; + } +} diff --git a/src/AppName.Api/Authentication/JwtSettings.cs b/src/AppName.Api/Authentication/JwtSettings.cs new file mode 100644 index 0000000..f7a1a4e --- /dev/null +++ b/src/AppName.Api/Authentication/JwtSettings.cs @@ -0,0 +1,41 @@ +namespace AppName.Api.Authentication; + +/// +/// Validated JWT bearer settings read from the Jwt configuration section. +/// +/// +/// Token issuer (Jwt:Issuer). +/// +/// +/// Token audience (Jwt:Audience). +/// +/// +/// Symmetric signing key (Jwt:Key). +/// +public sealed record JwtSettings(string Issuer, string Audience, string Key) +{ + /// + /// Reads the Jwt section and validates that issuer, audience, and key + /// are all present, throwing otherwise. + /// + public static JwtSettings FromConfiguration(IConfiguration configuration) + { + var section = configuration.GetSection(SecretKeysConstants.Jwt.Section); + var issuer = Require(section, SecretKeysConstants.Jwt.Issuer); + var audience = Require(section, SecretKeysConstants.Jwt.Audience); + var key = Require(section, SecretKeysConstants.Jwt.Key); + return new JwtSettings(issuer, audience, key); + } + + private static string Require(IConfigurationSection section, string name) + { + var value = section[name]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"{SecretKeysConstants.Jwt.Section}:{name} is not configured."); + } + + return value; + } +} diff --git a/src/AppName.Api/Endpoints/AuthEndpoints.cs b/src/AppName.Api/Endpoints/AuthEndpoints.cs index c58f48b..d5a5295 100644 --- a/src/AppName.Api/Endpoints/AuthEndpoints.cs +++ b/src/AppName.Api/Endpoints/AuthEndpoints.cs @@ -1,6 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text.Json.Serialization; +using AppName.Api.Authentication; using Microsoft.IdentityModel.Tokens; namespace AppName.Api.Endpoints; @@ -21,27 +22,10 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) // before any non-local deployment — there is no credential check here. group.MapPost("/auth/token", (IConfiguration config) => { - var jwtSection = config.GetSection("Jwt"); - var issuer = jwtSection["Issuer"]; - var audience = jwtSection["Audience"]; - var key = jwtSection["Key"]; + var jwt = JwtSettings.FromConfiguration(config); - if (string.IsNullOrWhiteSpace(issuer)) - { - throw new InvalidOperationException("Jwt:Issuer is not configured."); - } - - if (string.IsNullOrWhiteSpace(audience)) - { - throw new InvalidOperationException("Jwt:Audience is not configured."); - } - - if (string.IsNullOrWhiteSpace(key)) - { - throw new InvalidOperationException("Jwt:Key is not configured."); - } - - var signingKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(key)); + var signingKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(jwt.Key)); + var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); var expires = DateTime.UtcNow.AddHours(1); @@ -53,8 +37,8 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) }; var token = new JwtSecurityToken( - issuer: issuer, - audience: audience, + issuer: jwt.Issuer, + audience: jwt.Audience, claims: claims, expires: expires, signingCredentials: credentials); diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index 9664d3f..2478b7c 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -1,9 +1,8 @@ -using System.Text; +using AppName.Api.Authentication; using AppName.Api.Endpoints; +using SecretKeysConstants = AppName.Api.SecretKeysConstants; using AppName.Application; using AppName.Infrastructure; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -18,53 +17,17 @@ // --- Auth (JWT bearer, dev stub) ---------------------------------------- -var jwtSection = builder.Configuration.GetSection("Jwt"); -var jwtIssuer = jwtSection["Issuer"]; -var jwtAudience = jwtSection["Audience"]; -var jwtKey = jwtSection["Key"]; - -if (string.IsNullOrWhiteSpace(jwtIssuer)) -{ - throw new InvalidOperationException("Jwt:Issuer is not configured."); -} - -if (string.IsNullOrWhiteSpace(jwtAudience)) -{ - throw new InvalidOperationException("Jwt:Audience is not configured."); -} - -if (string.IsNullOrWhiteSpace(jwtKey)) -{ - throw new InvalidOperationException("Jwt:Key is not configured."); -} - -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtIssuer, - ValidateAudience = true, - ValidAudience = jwtAudience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromSeconds(30), - }; - }); +builder.Services.AddJwtAuthentication(builder.Configuration); builder.Services.AddAuthorization(); // --- CORS ---------------------------------------------------------------- -const string CorsPolicyName = "ClientOrigins"; -var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get() ?? []; +var corsOrigins = builder.Configuration.GetSection(SecretKeysConstants.Cors.OriginsSection).Get() ?? []; builder.Services.AddCors(options => { - options.AddPolicy(CorsPolicyName, policy => + options.AddPolicy(SecretKeysConstants.Cors.PolicyName, policy => policy.WithOrigins(corsOrigins) .AllowAnyHeader() .AllowAnyMethod() @@ -87,7 +50,7 @@ app.MapScalarApiReference(); } -app.UseCors(CorsPolicyName); +app.UseCors(SecretKeysConstants.Cors.PolicyName); app.UseAuthentication(); app.UseAuthorization(); From 1aa69246f2c5d424eaf03282349400d0544484c7 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 13 Jul 2026 16:14:20 -0600 Subject: [PATCH 22/28] feat(cors): add CORS policy registration extension method and refactor Program.cs --- src/AppName.Api/Cors/CorsExtensions.cs | 32 ++++++++++++++++++++++++++ src/AppName.Api/Program.cs | 12 ++-------- 2 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 src/AppName.Api/Cors/CorsExtensions.cs diff --git a/src/AppName.Api/Cors/CorsExtensions.cs b/src/AppName.Api/Cors/CorsExtensions.cs new file mode 100644 index 0000000..50d7d81 --- /dev/null +++ b/src/AppName.Api/Cors/CorsExtensions.cs @@ -0,0 +1,32 @@ +using SecretKeysConstants = AppName.Api.SecretKeysConstants; + +namespace AppName.Api.Cors; + +/// +/// Service-collection extensions for wiring up API CORS. +/// +public static class CorsExtensions +{ + /// + /// Registers the API CORS policy, allowing the configured origins from the + /// Cors:Origins configuration section with credentials. + /// + public static IServiceCollection AddApiCors( + this IServiceCollection services, IConfiguration configuration) + { + var origins = configuration + .GetSection(SecretKeysConstants.Cors.OriginsSection) + .Get() ?? []; + + services.AddCors(options => + { + options.AddPolicy(SecretKeysConstants.Cors.PolicyName, policy => + policy.WithOrigins(origins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials()); + }); + + return services; + } +} diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index 2478b7c..32e714c 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -1,4 +1,5 @@ using AppName.Api.Authentication; +using AppName.Api.Cors; using AppName.Api.Endpoints; using SecretKeysConstants = AppName.Api.SecretKeysConstants; using AppName.Application; @@ -23,16 +24,7 @@ // --- CORS ---------------------------------------------------------------- -var corsOrigins = builder.Configuration.GetSection(SecretKeysConstants.Cors.OriginsSection).Get() ?? []; - -builder.Services.AddCors(options => -{ - options.AddPolicy(SecretKeysConstants.Cors.PolicyName, policy => - policy.WithOrigins(corsOrigins) - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials()); -}); +builder.Services.AddApiCors(builder.Configuration); var app = builder.Build(); From 800139a67adcd84390cd84cedfac824e93e0a6c0 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Wed, 15 Jul 2026 11:58:43 -0600 Subject: [PATCH 23/28] feat(api): implement HTTP client configuration and authentication for AppName.Api --- Directory.Packages.props | 1 + src/AppName.Api/appsettings.Development.json | 4 -- src/AppName.Api/appsettings.json | 5 +- src/AppName.Maui/AppName.Maui.csproj | 9 ++++ src/AppName.Maui/Bridge/BondsBridge.cs | 2 +- .../Clients/ApiClientConfiguration.cs | 39 +++++++++++++++ .../Clients/ApiClientHttpPipeline.cs | 21 ++++++++ src/AppName.Maui/Clients/ApiClientOptions.cs | 17 +++++++ .../Clients/Auth/AuthHttpPipeline.cs | 27 ++++++++++ .../Auth}/BearerTokenHandler.cs | 3 +- .../Auth}/DevAuthTokenProvider.cs | 7 ++- .../Auth}/IAuthTokenProvider.cs | 2 +- .../Bonds}/BondsApiClient.cs | 2 +- .../Clients/Bonds/BondsHttpPipeline.cs | 22 ++++++++ .../Bonds}/IBondsApiClient.cs | 2 +- src/AppName.Maui/DependencyInjection.cs | 50 ++++--------------- src/AppName.Maui/MauiProgram.cs | 27 +++++++++- src/AppName.Maui/appsettings.Development.json | 5 ++ src/AppName.Maui/appsettings.json | 5 ++ 19 files changed, 196 insertions(+), 54 deletions(-) create mode 100644 src/AppName.Maui/Clients/ApiClientConfiguration.cs create mode 100644 src/AppName.Maui/Clients/ApiClientHttpPipeline.cs create mode 100644 src/AppName.Maui/Clients/ApiClientOptions.cs create mode 100644 src/AppName.Maui/Clients/Auth/AuthHttpPipeline.cs rename src/AppName.Maui/{Services => Clients/Auth}/BearerTokenHandler.cs (95%) rename src/AppName.Maui/{Services => Clients/Auth}/DevAuthTokenProvider.cs (98%) rename src/AppName.Maui/{Services => Clients/Auth}/IAuthTokenProvider.cs (90%) rename src/AppName.Maui/{Services => Clients/Bonds}/BondsApiClient.cs (92%) create mode 100644 src/AppName.Maui/Clients/Bonds/BondsHttpPipeline.cs rename src/AppName.Maui/{Services => Clients/Bonds}/IBondsApiClient.cs (93%) create mode 100644 src/AppName.Maui/appsettings.Development.json create mode 100644 src/AppName.Maui/appsettings.json diff --git a/Directory.Packages.props b/Directory.Packages.props index 77dbb1d..6349ada 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/src/AppName.Api/appsettings.Development.json b/src/AppName.Api/appsettings.Development.json index 00ed607..6032a4d 100644 --- a/src/AppName.Api/appsettings.Development.json +++ b/src/AppName.Api/appsettings.Development.json @@ -5,10 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "ConnectionStrings": { - "// NOTE": "Local dev Postgres only. Never put real/production credentials here — those come from user-secrets or environment variables (e.g. ConnectionStrings__AppDb).", - "AppDb": "Host=localhost;Port=5432;Database=appname_dev;Username=appname_dev;Password=appname_dev" - }, "Jwt": { "// NOTE": "Dev-only signing key for the local /api/auth/token stub. Not a production secret — production values must come from user-secrets/env and a real IdP must replace this endpoint before deployment.", "Issuer": "appname-api-dev", diff --git a/src/AppName.Api/appsettings.json b/src/AppName.Api/appsettings.json index f60c120..e9403ff 100644 --- a/src/AppName.Api/appsettings.json +++ b/src/AppName.Api/appsettings.json @@ -6,9 +6,6 @@ } }, "AllowedHosts": "*", - "ConnectionStrings": { - "AppDb": "" - }, "Jwt": { "Issuer": "", "Audience": "", @@ -17,4 +14,4 @@ "Cors": { "Origins": [] } -} +} \ No newline at end of file diff --git a/src/AppName.Maui/AppName.Maui.csproj b/src/AppName.Maui/AppName.Maui.csproj index b631f82..4047977 100644 --- a/src/AppName.Maui/AppName.Maui.csproj +++ b/src/AppName.Maui/AppName.Maui.csproj @@ -69,9 +69,18 @@ + + + + + + + +