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..cbbb3ee 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 + - os: windows-latest + tfm: net10.0-windows10.0.19041.0 + - os: macos-26 + tfm: net10.0-ios + - os: macos-26 + tfm: net10.0-maccatalyst + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode 26.6 + if: runner.os == 'macOS' + run: sudo xcode-select -s /Applications/Xcode_26.6.app + + - 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 workloads + run: dotnet workload restore src/AppName.Maui/AppName.Maui.csproj + + - name: Build MAUI head (${{ matrix.tfm }}) + run: dotnet build src/AppName.Maui/AppName.Maui.csproj -f ${{ matrix.tfm }} -c Release diff --git a/.gitignore b/.gitignore index 3064557..55279e0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,10 @@ node_modules/ dist/ src/AppName.Maui/Resources/Raw/web/ +# Docs (not tracked) +/docs/ + # OS .DS_Store + +.playwright-cli 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f6c67d1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# Project conventions + +## C# documentation & display naming + +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. + +### 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..f88d422 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,14 +1,26 @@ - - + + + + + + + + + + + + + + + + + + - - - - 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/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/justfile b/justfile new file mode 100644 index 0000000..ff71a39 --- /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, e.g. `just migrate-add AddSomething` (offline; does not touch the database). +migrate-add name: + dotnet ef migrations add {{name}} \ + --project src/AppName.Infrastructure \ + --startup-project src/AppName.Infrastructure \ + --output-dir Migrations + +# Apply pending migrations to the ConnectionStrings:AppDb database — mind which one you point at. +migrate-update: + dotnet ef database update \ + --project src/AppName.Infrastructure \ + --startup-project src/AppName.Infrastructure + +# List migrations and which are applied (`--no-connect` to skip the database round-trip). +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.Api/AppName.Api.csproj b/src/AppName.Api/AppName.Api.csproj new file mode 100644 index 0000000..70c6d38 --- /dev/null +++ b/src/AppName.Api/AppName.Api.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + + + + + + + + + + + + + + + + + + + + + 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/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/Endpoints/AuthEndpoints.cs b/src/AppName.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..d5a5295 --- /dev/null +++ b/src/AppName.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,65 @@ +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; + +/// +/// 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 jwt = JwtSettings.FromConfiguration(config); + + var signingKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(jwt.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: jwt.Issuer, + audience: jwt.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 new file mode 100644 index 0000000..ad6c84f --- /dev/null +++ b/src/AppName.Api/Program.cs @@ -0,0 +1,46 @@ +using AppName.Api.Authentication; +using AppName.Api.Cors; +using AppName.Api.Endpoints; +using AppName.Application; +using AppName.Infrastructure; +using Scalar.AspNetCore; +using SecretKeysConstants = AppName.Api.SecretKeysConstants; + +var builder = WebApplication.CreateBuilder(args); + +// --- Composition ----------------------------------------------------------- + +builder.Services.AddApplication().AddInfrastructure(builder.Configuration); + +// --- OpenAPI ----------------------------------------------------------- + +builder.Services.AddOpenApi(); + +// --- Auth (JWT bearer, dev stub) ---------------------------------------- + +builder.Services.AddJwtAuthentication(builder.Configuration); + +builder.Services.AddAuthorization(); + +// --- CORS ---------------------------------------------------------------- + +builder.Services.AddApiCors(builder.Configuration); + +var app = builder.Build(); + +app.MapOpenApi(); + +if (app.Environment.IsDevelopment()) +{ + app.MapScalarApiReference(); +} + +app.UseCors(SecretKeysConstants.Cors.PolicyName); +app.UseAuthentication(); +app.UseAuthorization(); + +var api = app.MapGroup("/api"); +api.MapAuthEndpoints(); +api.MapBondEndpoints(); + +app.Run(); 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.Api/appsettings.Development.json b/src/AppName.Api/appsettings.Development.json new file mode 100644 index 0000000..6032a4d --- /dev/null +++ b/src/AppName.Api/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "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..e9403ff --- /dev/null +++ b/src/AppName.Api/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Jwt": { + "Issuer": "", + "Audience": "", + "Key": "" + }, + "Cors": { + "Origins": [] + } +} \ No newline at end of file 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.Application/DependencyInjection.cs b/src/AppName.Application/DependencyInjection.cs index 9e173e3..44931e9 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,11 @@ public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { - services.AddTransient(); - services.AddTransient(); + services.AddSingleton(TimeProvider.System); + 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..db38214 --- /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 populated from the live CB quote when +/// available, and null when no bond quote can be fetched. +/// +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, 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. 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/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/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/GetConversionValuationUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs new file mode 100644 index 0000000..80e75ae --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs @@ -0,0 +1,52 @@ +using AppName.Application.Dtos; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +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 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) + { + 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); + + 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: bondPrice, IsInTheMoney: isInTheMoney); + } +} diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs new file mode 100644 index 0000000..1286630 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs @@ -0,0 +1,48 @@ +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. + await refreshStateRepo.SetAsync( + new BondValuationRefreshState(BondValuationRefreshState.SingletonId, state.LastAsOf, today), ct); + } + } + + 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/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/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/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.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..89bdcb5 --- /dev/null +++ b/src/AppName.Domain/Abstractions/IMarketDataProvider.cs @@ -0,0 +1,45 @@ +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 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); + + /// + /// 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.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/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.Domain/Entities/BondQuote.cs b/src/AppName.Domain/Entities/BondQuote.cs new file mode 100644 index 0000000..85ae198 --- /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. +/// +/// +/// 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/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.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..0b27360 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 @@ -10,7 +11,19 @@ - + + + + + + + + + + + + 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/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 6fb4536..46dc9e9 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -1,16 +1,48 @@ 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; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace AppName.Infrastructure; public static class DependencyInjection { - public static IServiceCollection AddInfrastructure(this IServiceCollection services, string dbPath) + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { - services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); - services.AddScoped(); + 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, npgsql => npgsql.EnableRetryOnFailure()) + .ReplaceService()); + + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddTpexHttpClients(); + services.AddTwseHttpClients(); + services.AddTwseMisHttpClients(); + + services.AddScoped(); + services.AddScoped(); + 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..94fcebd --- /dev/null +++ b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +using AppName.Infrastructure.Clients.Tpex; +using AppName.Infrastructure.Clients.Twse; +using AppName.Infrastructure.Clients.TwseMis; + +namespace AppName.Infrastructure.Gateways; + +/// +public sealed class MarketDataProvider( + ITpexBondIssuanceApiClient issuance, + ITwseStockQuoteApiClient twseStocks, + ITpexStockQuoteApiClient tpexStocks, + ITwseMisBondQuoteApiClient misBonds) : 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 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); + } + + /// + public async Task> GetAllIssuanceTermsAsync(CancellationToken ct = default) + { + var records = await issuance.GetAllAsync(ct); + + var bonds = new List(); + + foreach (var r in records.DistinctBy(r => r.BondCode)) + { + 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; + + 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. + /// + 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/20260715180907_InitialCreate.Designer.cs b/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.Designer.cs new file mode 100644 index 0000000..912adbc --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.Designer.cs @@ -0,0 +1,98 @@ +// +using System; +using AppName.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260715180907_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("LastAsOf") + .HasColumnType("date"); + + b.Property("LastAttemptDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("BondValuationRefreshStates"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("AsOf") + .HasColumnType("date"); + + b.Property("BondPrice") + .HasColumnType("numeric"); + + b.Property("ConversionShares") + .HasColumnType("numeric"); + + b.Property("ConversionValue") + .HasColumnType("numeric"); + + b.Property("IsInTheMoney") + .HasColumnType("boolean"); + + b.Property("StockPrice") + .HasColumnType("numeric"); + + b.HasKey("Symbol"); + + b.ToTable("BondValuationSnapshots"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("ConversionPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParValue") + .HasColumnType("numeric"); + + b.Property("UnderlyingSymbol") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Symbol"); + + b.ToTable("ConvertibleBonds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.cs b/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.cs new file mode 100644 index 0000000..a7cbc2d --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BondValuationRefreshStates", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false), + LastAsOf = table.Column(type: "date", nullable: true), + LastAttemptDate = table.Column(type: "date", 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: "numeric", nullable: false), + ConversionValue = table.Column(type: "numeric", nullable: false), + StockPrice = table.Column(type: "numeric", nullable: false), + AsOf = table.Column(type: "date", nullable: false), + BondPrice = table.Column(type: "numeric", nullable: true), + IsInTheMoney = table.Column(type: "boolean", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BondValuationSnapshots", x => x.Symbol); + }); + + 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: "numeric", nullable: false), + ConversionPrice = table.Column(type: "numeric", 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: "BondValuationRefreshStates"); + + migrationBuilder.DropTable( + name: "BondValuationSnapshots"); + + 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..b37889d --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,95 @@ +// +using System; +using AppName.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#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") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("LastAsOf") + .HasColumnType("date"); + + b.Property("LastAttemptDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("BondValuationRefreshStates"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("AsOf") + .HasColumnType("date"); + + b.Property("BondPrice") + .HasColumnType("numeric"); + + b.Property("ConversionShares") + .HasColumnType("numeric"); + + b.Property("ConversionValue") + .HasColumnType("numeric"); + + b.Property("IsInTheMoney") + .HasColumnType("boolean"); + + b.Property("StockPrice") + .HasColumnType("numeric"); + + b.HasKey("Symbol"); + + b.ToTable("BondValuationSnapshots"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("ConversionPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParValue") + .HasColumnType("numeric"); + + 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..d29ee55 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContext.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContext.cs @@ -3,17 +3,30 @@ 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 ConvertibleBonds => Set(); - public DbSet Users => Set(); + public DbSet BondValuationSnapshots => Set(); + + public DbSet BondValuationRefreshStates => 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(); + + 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/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs new file mode 100644 index 0000000..703fea0 --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; + +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) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddUserSecrets(optional: true) + .Build(); + + var connectionString = configuration.GetConnectionString(SecretKeysConstants.ConnectionStrings.AppDb); + + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException( + $"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); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .ReplaceService() + .Options; + + return new AppDbContext(options); + } +} 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/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/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/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.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/src/AppName.Infrastructure/Persistence/YugabyteHistoryRepository.cs b/src/AppName.Infrastructure/Persistence/YugabyteHistoryRepository.cs new file mode 100644 index 0000000..a619fdb --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/YugabyteHistoryRepository.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Internal; + +namespace AppName.Infrastructure.Persistence; + +// EF1001: NpgsqlHistoryRepository lives in the provider's ...Migrations.Internal namespace and is +// marked as an internal API. Subclassing it is the only supported way to change the migration lock +// (see the class summary), so the warning is suppressed deliberately and this file should be +// re-checked whenever the Npgsql provider is upgraded across a major version. +#pragma warning disable EF1001 + +/// +/// An for YugabyteDB that skips EF Core's migration lock. +/// +/// +/// EF Core 9 added a migration lock, which the Npgsql provider takes out by issuing +/// LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE. YugabyteDB does not implement +/// explicit table locks and rejects every lock mode with 0A000: ... not supported yet, so +/// migrations fail before any SQL is applied. Overriding the lock to a no-op is the workaround +/// recommended by the EF/Npgsql maintainers for PostgreSQL-compatible databases +/// (see https://github.com/dotnet/efcore/issues/33731 and https://github.com/npgsql/npgsql/issues/6025). +/// +/// The trade-off: nothing prevents two migrators from running against the database concurrently. +/// That protection is what the lock provided. YugabyteDB gained table-lock support behind the +/// preview enable_object_locking_for_table_locks gflag (yugabyte-db#5384); once that is +/// enabled on the cluster, this class can be removed in favour of the provider's default. +/// +/// +public sealed class YugabyteHistoryRepository(HistoryRepositoryDependencies dependencies) + : NpgsqlHistoryRepository(dependencies) +{ + /// + public override IMigrationsDatabaseLock AcquireDatabaseLock() + => new NoopMigrationsDatabaseLock(this); + + /// + public override Task AcquireDatabaseLockAsync( + CancellationToken cancellationToken = default) + => Task.FromResult(new NoopMigrationsDatabaseLock(this)); + + /// + /// A migration lock handle that holds no database lock and releases nothing on disposal. + /// + private sealed class NoopMigrationsDatabaseLock(IHistoryRepository historyRepository) : IMigrationsDatabaseLock + { + /// + public IHistoryRepository HistoryRepository => historyRepository; + + /// + public void Dispose() + { + } + + /// + public ValueTask DisposeAsync() => default; + } +} + +#pragma warning restore EF1001 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/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----- diff --git a/src/AppName.Maui/App.xaml.cs b/src/AppName.Maui/App.xaml.cs index a05b95f..522ed44 100644 --- a/src/AppName.Maui/App.xaml.cs +++ b/src/AppName.Maui/App.xaml.cs @@ -1,6 +1,6 @@ namespace AppName.Maui; -public partial class App : Application +public partial class App : Microsoft.Maui.Controls.Application { private readonly MainPage _mainPage; diff --git a/src/AppName.Maui/AppName.Maui.csproj b/src/AppName.Maui/AppName.Maui.csproj index be9bc18..4047977 100644 --- a/src/AppName.Maui/AppName.Maui.csproj +++ b/src/AppName.Maui/AppName.Maui.csproj @@ -68,11 +68,25 @@ + + + + + + + + + + + - 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..409395a --- /dev/null +++ b/src/AppName.Maui/Bridge/BondsBridge.cs @@ -0,0 +1,15 @@ +using AppName.Application.Dtos; +using AppName.Maui.Clients.Bonds; + +namespace AppName.Maui.Bridge; + +/// +/// Methods on this object are invoked from JavaScript via HybridWebView. +/// +public sealed class BondsBridge(IBondsApiClient bondsApiClient) : IWebBridge +{ + /// + /// Returns today's conversion valuation for every cached convertible bond. + /// + public Task> GetValuations() => bondsApiClient.GetValuationsAsync(); +} 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/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/Clients/ApiClientConfiguration.cs b/src/AppName.Maui/Clients/ApiClientConfiguration.cs new file mode 100644 index 0000000..a64c246 --- /dev/null +++ b/src/AppName.Maui/Clients/ApiClientConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace AppName.Maui.Clients; + +/// +/// Shared configuration for every client that talks to +/// AppName.Api. Unlike the upstream-provider clients in AppName.Infrastructure - +/// which each target a different host and so carry their own options - the auth +/// and bonds clients here address the same host, so they share one base address. +/// +internal static class ApiClientConfiguration +{ + /// + /// Applies the configured AppName.Api base address to . + /// + internal static void Configure(IServiceProvider sp, HttpClient client) + { + var o = sp.GetRequiredService>().Value; + + if (string.IsNullOrWhiteSpace(o.BaseUrl)) + { + throw new InvalidOperationException( + $"{ApiClientOptions.SectionName}:{nameof(ApiClientOptions.BaseUrl)} is not configured. " + + "Set it in appsettings.Development.json."); + } + + var baseUrl = o.BaseUrl.EndsWith('/') ? o.BaseUrl : o.BaseUrl + "/"; + +#if ANDROID + // 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. + baseUrl = baseUrl.Replace("localhost", "10.0.2.2"); +#endif + + client.BaseAddress = new Uri(baseUrl); + } +} diff --git a/src/AppName.Maui/Clients/ApiClientHttpPipeline.cs b/src/AppName.Maui/Clients/ApiClientHttpPipeline.cs new file mode 100644 index 0000000..fcd5776 --- /dev/null +++ b/src/AppName.Maui/Clients/ApiClientHttpPipeline.cs @@ -0,0 +1,21 @@ +using AppName.Maui.Clients.Auth; +using AppName.Maui.Clients.Bonds; +using Microsoft.Extensions.DependencyInjection; + +namespace AppName.Maui.Clients; + +/// +/// Registers every HTTP client the MAUI head uses to reach AppName.Api, by +/// delegating to each feature's own pipeline. +/// +public static class ApiClientHttpPipeline +{ + /// + /// Adds the auth and bonds client pipelines. Auth is registered first + /// because the bonds pipeline attaches its . + /// + public static IServiceCollection AddApiHttpClients(this IServiceCollection services) + => services + .AddAuthHttpClient() + .AddBondsHttpClient(); +} diff --git a/src/AppName.Maui/Clients/ApiClientOptions.cs b/src/AppName.Maui/Clients/ApiClientOptions.cs new file mode 100644 index 0000000..27be7c6 --- /dev/null +++ b/src/AppName.Maui/Clients/ApiClientOptions.cs @@ -0,0 +1,17 @@ +namespace AppName.Maui.Clients; + +/// +/// Configuration for the AppName.Api HTTP clients. +/// +public sealed class ApiClientOptions +{ + /// + /// Name of the configuration section this binds to. + /// + public const string SectionName = "Api"; + + /// + /// Base address of AppName.Api (expected to end in "/api/"). + /// + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/src/AppName.Maui/Clients/Auth/AuthHttpPipeline.cs b/src/AppName.Maui/Clients/Auth/AuthHttpPipeline.cs new file mode 100644 index 0000000..5508f18 --- /dev/null +++ b/src/AppName.Maui/Clients/Auth/AuthHttpPipeline.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace AppName.Maui.Clients.Auth; + +/// +/// Registers the AppName.Api auth-token HTTP client and the pieces that use it. +/// +public static class AuthHttpPipeline +{ + /// + /// Adds the unauthenticated dev-token named client, the token provider that + /// calls it, and the other client pipelines + /// attach to authenticate their requests. + /// + public static IServiceCollection AddAuthHttpClient(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, ApiClientConfiguration.Configure); + + services.AddSingleton(); + + services.AddTransient(); + + return services; + } +} diff --git a/src/AppName.Maui/Clients/Auth/BearerTokenHandler.cs b/src/AppName.Maui/Clients/Auth/BearerTokenHandler.cs new file mode 100644 index 0000000..0b017b2 --- /dev/null +++ b/src/AppName.Maui/Clients/Auth/BearerTokenHandler.cs @@ -0,0 +1,23 @@ +using System.Net.Http.Headers; + +namespace AppName.Maui.Clients.Auth; + +/// +/// 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/Clients/Auth/DevAuthTokenProvider.cs b/src/AppName.Maui/Clients/Auth/DevAuthTokenProvider.cs new file mode 100644 index 0000000..1eb651b --- /dev/null +++ b/src/AppName.Maui/Clients/Auth/DevAuthTokenProvider.cs @@ -0,0 +1,88 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace AppName.Maui.Clients.Auth; + +/// +/// +/// 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/Clients/Auth/IAuthTokenProvider.cs b/src/AppName.Maui/Clients/Auth/IAuthTokenProvider.cs new file mode 100644 index 0000000..20a26c4 --- /dev/null +++ b/src/AppName.Maui/Clients/Auth/IAuthTokenProvider.cs @@ -0,0 +1,13 @@ +namespace AppName.Maui.Clients.Auth; + +/// +/// 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/Clients/Bonds/BondsApiClient.cs b/src/AppName.Maui/Clients/Bonds/BondsApiClient.cs new file mode 100644 index 0000000..438e441 --- /dev/null +++ b/src/AppName.Maui/Clients/Bonds/BondsApiClient.cs @@ -0,0 +1,16 @@ +using System.Net.Http.Json; +using AppName.Application.Dtos; + +namespace AppName.Maui.Clients.Bonds; + +/// +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/Clients/Bonds/BondsHttpPipeline.cs b/src/AppName.Maui/Clients/Bonds/BondsHttpPipeline.cs new file mode 100644 index 0000000..8deeddc --- /dev/null +++ b/src/AppName.Maui/Clients/Bonds/BondsHttpPipeline.cs @@ -0,0 +1,22 @@ +using AppName.Maui.Clients.Auth; +using Microsoft.Extensions.DependencyInjection; + +namespace AppName.Maui.Clients.Bonds; + +/// +/// Registers the AppName.Api bond-valuation typed HTTP client. +/// +public static class BondsHttpPipeline +{ + /// + /// Adds the typed client, authenticated with the + /// bearer token supplied by . + /// + public static IServiceCollection AddBondsHttpClient(this IServiceCollection services) + { + services.AddHttpClient(ApiClientConfiguration.Configure) + .AddHttpMessageHandler(); + + return services; + } +} diff --git a/src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs b/src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs new file mode 100644 index 0000000..db4b843 --- /dev/null +++ b/src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs @@ -0,0 +1,17 @@ +using AppName.Application.Dtos; + +namespace AppName.Maui.Clients.Bonds; + +/// +/// 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); +} diff --git a/src/AppName.Maui/DependencyInjection.cs b/src/AppName.Maui/DependencyInjection.cs new file mode 100644 index 0000000..7ccf889 --- /dev/null +++ b/src/AppName.Maui/DependencyInjection.cs @@ -0,0 +1,52 @@ +using AppName.Maui.Bridge; +using AppName.Maui.Clients; +using Microsoft.Extensions.Configuration; +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; + } + + /// + /// Registers everything needed to reach AppName.Api: binds + /// from configuration and adds the client + /// pipelines (see ). + /// + public static IServiceCollection AddApiClient(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ApiClientOptions.SectionName)); + + services.AddApiHttpClients(); + + 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, IConfiguration configuration) + => services + .AddApiClient(configuration) + .AddPresentation(); +} diff --git a/src/AppName.Maui/MainPage.xaml.cs b/src/AppName.Maui/MainPage.xaml.cs index bd38dfc..f9b49e8 100644 --- a/src/AppName.Maui/MainPage.xaml.cs +++ b/src/AppName.Maui/MainPage.xaml.cs @@ -4,9 +4,9 @@ namespace AppName.Maui; public partial class MainPage : ContentPage { - public MainPage(UsersBridge usersBridge) + public MainPage(AppBridge appBridge) { InitializeComponent(); - HybridView.SetInvokeJavaScriptTarget(usersBridge); + HybridView.SetInvokeJavaScriptTarget(appBridge); } } diff --git a/src/AppName.Maui/MauiProgram.cs b/src/AppName.Maui/MauiProgram.cs index a17ce32..908fa4a 100644 --- a/src/AppName.Maui/MauiProgram.cs +++ b/src/AppName.Maui/MauiProgram.cs @@ -1,6 +1,6 @@ -using AppName.Application; -using AppName.Infrastructure; -using Microsoft.Extensions.Logging; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace AppName.Maui; @@ -9,6 +9,7 @@ public static class MauiProgram public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); + builder .UseMauiApp() .ConfigureFonts(fonts => @@ -16,16 +17,35 @@ public static MauiApp CreateMauiApp() fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); }); - var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db"); - builder.Services.AddApplication(); - builder.Services.AddInfrastructure(dbPath); + // MAUI has no filesystem appsettings; the JSON is embedded and read + // as a manifest resource stream. There is no ASPNETCORE_ENVIRONMENT + // either, so #if DEBUG selects the Development layer. + var assembly = Assembly.GetExecutingAssembly(); + + using (var stream = assembly.GetManifestResourceStream("AppName.Maui.appsettings.json")) + { + if (stream is not null) + { + builder.Configuration.AddJsonStream(stream); + } + } + +#if DEBUG + using (var stream = assembly.GetManifestResourceStream("AppName.Maui.appsettings.Development.json")) + { + if (stream is not null) + { + builder.Configuration.AddJsonStream(stream); + } + } +#endif - builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddAppServices(builder.Configuration); #if DEBUG builder.Logging.AddDebug(); #endif + return builder.Build(); } } diff --git a/src/AppName.Maui/appsettings.Development.json b/src/AppName.Maui/appsettings.Development.json new file mode 100644 index 0000000..12f8340 --- /dev/null +++ b/src/AppName.Maui/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "Api": { + "BaseUrl": "http://localhost:5000/api/" + } +} diff --git a/src/AppName.Maui/appsettings.json b/src/AppName.Maui/appsettings.json new file mode 100644 index 0000000..d37e17c --- /dev/null +++ b/src/AppName.Maui/appsettings.json @@ -0,0 +1,5 @@ +{ + "Api": { + "BaseUrl": "" + } +} 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/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/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/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/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..0134f7c --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs @@ -0,0 +1,129 @@ +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; + } + + public Fixture WithBondQuote(BondQuote quote) + { + Provider.Setup(p => p.GetBondQuoteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(quote); + return this; + } + } + + [Fact] + 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))) + .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); // 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); + } + + [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/GetValuationsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs new file mode 100644 index 0000000..c6a9fa7 --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs @@ -0,0 +1,115 @@ +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); + RefreshState.Setup(r => r.SetAsync(It.IsAny(), 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); + } + + [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.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); + } +} 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..b1bae34 100644 --- a/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj +++ b/tests/AppName.Infrastructure.Tests/AppName.Infrastructure.Tests.csproj @@ -7,9 +7,11 @@ + + @@ -19,6 +21,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/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/DependencyInjectionTests.cs b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..81cea07 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs @@ -0,0 +1,42 @@ +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(Configuration("Host=localhost;Database=test")); + using var sp = services.BuildServiceProvider(); + Assert.NotNull(sp.GetService()); + } + + [Fact] + public void AddInfrastructure_Resolves_BondRepository() + { + var services = new ServiceCollection(); + 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)); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs new file mode 100644 index 0000000..815fc76 --- /dev/null +++ b/tests/AppName.Infrastructure.Tests/Gateways/MarketDataProviderTests.cs @@ -0,0 +1,263 @@ +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.Clients.TwseMis; +using AppName.Infrastructure.Clients.TwseMis.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 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) + { + 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 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] + 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_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")); + } + + [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 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() + { + 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 + } +} 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); + } +} 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/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); + } +} 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); - } - } -}