diff --git a/.claude/skills/container-logs/SKILL.md b/.claude/skills/container-logs/SKILL.md deleted file mode 100644 index 76f05e70..00000000 --- a/.claude/skills/container-logs/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: container-logs -description: View Docker container logs for the Healthcare Samples stack. Use when asked to check logs, debug container issues, or see service output. -disable-model-invocation: true -allowed-tools: Bash(docker compose *), Bash(docker logs *) -argument-hint: "[container-name] [--tail N]" ---- - -# Container Logs - -View logs from the Healthcare Samples Docker stack. - -## Usage - -`/container-logs` - show recent logs from all containers -`/container-logs app` - show logs from the app container -`/container-logs db` - show logs from the Postgres container - -## Commands - -All logs (last 100 lines): -```bash -docker compose -f /Users/christianfindlay/Documents/Code/DataProvider/Samples/docker/docker-compose.yml logs --tail 100 -``` - -Specific container: -```bash -docker compose -f /Users/christianfindlay/Documents/Code/DataProvider/Samples/docker/docker-compose.yml logs --tail 100 $ARGUMENTS -``` - -Follow logs in real-time (use timeout to avoid hanging): -```bash -timeout 10 docker compose -f /Users/christianfindlay/Documents/Code/DataProvider/Samples/docker/docker-compose.yml logs -f $ARGUMENTS -``` - -## Container names - -| Name | Service | -|------|---------| -| app | All .NET APIs + embedding service | -| db | Postgres 16 + pgvector | -| dashboard | nginx serving static files | - -## Check container status - -```bash -docker compose -f /Users/christianfindlay/Documents/Code/DataProvider/Samples/docker/docker-compose.yml ps -``` diff --git a/.claude/skills/run-samples/SKILL.md b/.claude/skills/run-samples/SKILL.md deleted file mode 100644 index ce2f8c04..00000000 --- a/.claude/skills/run-samples/SKILL.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: run-samples -description: Start the Healthcare Samples stack (Postgres, APIs, Dashboard). Use when asked to run, start, or launch the sample applications. ---- - -# Run Samples - -Start the full Healthcare Samples stack. Decide based on `$ARGUMENTS`: - -IMPORTANT: Do NOT run in the background. Run in the foreground so the user can see all output streaming in real-time. Set a long timeout (600000ms). - -## Default (no args) - keep existing data - -Run with existing database volumes intact: - -```bash -cd /Users/christianfindlay/Documents/Code/DataProvider/Samples/scripts && ./start.sh -``` - -## Fresh start - blow away databases - -If the user says "fresh", "clean", "reset", or `$ARGUMENTS` contains `--fresh`: - -```bash -cd /Users/christianfindlay/Documents/Code/DataProvider/Samples/scripts && ./start.sh --fresh -``` - -## Force rebuild containers - -If the user says "rebuild" or `$ARGUMENTS` contains `--build`: - -```bash -cd /Users/christianfindlay/Documents/Code/DataProvider/Samples/scripts && ./start.sh --build -``` - -## Both fresh + rebuild - -```bash -cd /Users/christianfindlay/Documents/Code/DataProvider/Samples/scripts && ./start.sh --fresh --build -``` - -## Services - -| Service | Port | -|---------|------| -| Gatekeeper API | 5002 | -| Clinical API | 5080 | -| Scheduling API | 5001 | -| ICD10 API | 5090 | -| Embedding Service | 8000 | -| Dashboard | 5173 | -| Postgres | 5432 | diff --git a/.claude/skills/submit-pr/SKILL.md b/.claude/skills/submit-pr/SKILL.md index 9e8e2943..30656c3e 100644 --- a/.claude/skills/submit-pr/SKILL.md +++ b/.claude/skills/submit-pr/SKILL.md @@ -11,21 +11,22 @@ Create a pull request for the current branch with a well-structured description. ## Steps 1. Run `make ci` — must pass completely before creating PR -2. Determine the PR title from recent commits and changed files -3. Read the PR template from `.github/PULL_REQUEST_TEMPLATE.md` -4. Fill in: +2. **Generate the diff against main.** Run `git diff main...HEAD > /tmp/pr-diff.txt` to capture the full diff between the current branch and the head of main. This is the ONLY source of truth for what the PR contains. **Warning:** the diff can be very large. If the diff file exceeds context limits, process it in chunks (e.g., read sections with `head`/`tail` or split by file) rather than trying to load it all at once. +3. **Derive the PR title and description SOLELY from the diff.** Read the diff output and summarize what changed. Ignore commit messages, branch names, and any other metadata — only the actual code/content diff matters. +4. Write PR body using the template in `.github/pull_request_template.md` +5. Fill in (based on the diff analysis from step 3): - TLDR: one sentence - What Was Added: new files, features, deps - What Was Changed/Deleted: modified behaviour - How Tests Prove It Works: specific test names or output - Spec/Doc Changes: if any - Breaking Changes: yes/no + description -5. Use `gh pr create` with the filled template +6. Use `gh pr create` with the filled template ## Rules - Never create a PR if `make ci` fails -- PR description must be specific ��� no vague placeholders +- PR description must be specific and tight — no vague placeholders - Link to the relevant GitHub issue if one exists ## Success criteria diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7da45b63..09a73387 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,34 +1,8 @@ name: CI on: - push: - branches: [main] - paths: - - '**/*.cs' - - '**/*.csproj' - - '**/*.sln' - - '**/*.py' - - '**/requirements.txt' - - '**/Directory.Build.props' - - '**/Directory.Packages.props' - - '.github/workflows/ci.yml' - - '.config/dotnet-tools.json' - - 'Lql/lql-lsp-rust/**' - - 'Lql/LqlExtension/**' pull_request: branches: [main] - paths: - - '**/*.cs' - - '**/*.csproj' - - '**/*.sln' - - '**/*.py' - - '**/requirements.txt' - - '**/Directory.Build.props' - - '**/Directory.Packages.props' - - '.github/workflows/ci.yml' - - '.config/dotnet-tools.json' - - 'Lql/lql-lsp-rust/**' - - 'Lql/LqlExtension/**' workflow_dispatch: concurrency: @@ -36,16 +10,16 @@ concurrency: cancel-in-progress: true env: - DOTNET_VERSION: '10.0.x' + DOTNET_VERSION: '9.0.x' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: - # Lint + format check (runs on every PR/push, no path filter) - lint: - name: Lint + # Track 1: Format Check All -> Lint All -> DataProvider Tests + lint-and-dataprovider: + name: Lint + DataProvider Tests runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -96,133 +70,28 @@ jobs: - name: Lint run: make lint - # Detect which areas changed to conditionally run tests - changes: - name: Detect Changes - runs-on: ubuntu-latest - timeout-minutes: 10 - outputs: - dotnet: ${{ steps.filter.outputs.dotnet }} - postgres: ${{ steps.filter.outputs.postgres }} - icd10: ${{ steps.filter.outputs.icd10 }} - dashboard: ${{ steps.filter.outputs.dashboard }} - lql-rust: ${{ steps.filter.outputs.lql-rust }} - lql-extension: ${{ steps.filter.outputs.lql-extension }} - steps: - - uses: actions/checkout@v4 - - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - dotnet: - - 'DataProvider/**' - - 'Lql/Lql/**' - - 'Lql/Lql.Tests/**' - - 'Lql/LqlCli.SQLite.Tests/**' - - 'Lql/Lql.TypeProvider.FSharp.Tests/**' - - 'Migration/**' - - 'Sync/Sync/**' - - 'Sync/Sync.Tests/**' - - 'Sync/Sync.SQLite/**' - - 'Sync/Sync.SQLite.Tests/**' - - 'Other/Selecta/**' - - 'Directory.Build.props' - - 'Directory.Packages.props' - postgres: - - 'Gatekeeper/**' - - 'Samples/Clinical/**' - - 'Samples/Scheduling/**' - - 'Sync/**' - - 'DataProvider/**' - - 'Migration/**' - - 'Directory.Build.props' - - 'Directory.Packages.props' - icd10: - - 'Samples/ICD10/**' - - 'DataProvider/**' - - 'Migration/**' - - 'Lql/**' - - 'Directory.Build.props' - - 'Directory.Packages.props' - dashboard: - - 'Samples/Dashboard/**' - - 'Samples/Clinical/**' - - 'Samples/Scheduling/**' - - 'DataProvider/**' - - 'Sync/**' - - 'Migration/**' - - 'Gatekeeper/**' - - 'Directory.Build.props' - - 'Directory.Packages.props' - lql-rust: - - 'Lql/lql-lsp-rust/**' - lql-extension: - - 'Lql/LqlExtension/**' - - # All .NET tests that don't need external services - dotnet-tests: - name: .NET Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [lint, changes] - if: needs.changes.outputs.dotnet == 'true' - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.fsproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - name: Build CLI tools (needed by F# Type Provider MSBuild targets) run: | - dotnet build Migration/Migration.Cli -c Debug - dotnet build DataProvider/DataProvider.SQLite.Cli -c Debug - - - name: Test DataProvider - run: | - dotnet test DataProvider/DataProvider.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - dotnet test DataProvider/DataProvider.Example.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - - - name: Test LQL - run: | - dotnet test Lql/Lql.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - dotnet test Lql/LqlCli.SQLite.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - - - name: Test LQL F# Type Provider - run: dotnet test Lql/Lql.TypeProvider.FSharp.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - - - name: Test Migration - run: dotnet test Migration/Migration.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" + dotnet build Migration/Nimblesite.DataProvider.Migration.Cli -c Debug + dotnet build DataProvider/Nimblesite.DataProvider.SQLite.Cli -c Debug - - name: Test Sync (SQLite) - run: | - dotnet test Sync/Sync.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - dotnet test Sync/Sync.SQLite.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" + - name: Test DataProvider (with coverage enforcement) + run: >- + make _test_dotnet DOTNET_TEST_PROJECTS="DataProvider/Nimblesite.DataProvider.Tests + DataProvider/Nimblesite.DataProvider.Example.Tests" - name: Upload test results uses: actions/upload-artifact@v4 if: always() with: - name: test-results-dotnet + name: test-results-dataprovider path: '**/TestResults/*.trx' - # All tests needing a Postgres service - postgres-tests: - name: Postgres Tests + # Track 2: Build All -> LQL Tests -> Migration Tests -> Sync Tests + build-and-test: + name: Build + LQL / Migration / Sync Tests runs-on: ubuntu-latest timeout-minutes: 15 - needs: [lint, changes] - if: needs.changes.outputs.postgres == 'true' services: postgres: image: postgres:16 @@ -238,7 +107,6 @@ jobs: --health-timeout 5s --health-retries 5 env: - # Prevent MSBuild server deadlocks when custom targets invoke dotnet run DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: 1 steps: - uses: actions/checkout@v4 @@ -248,238 +116,20 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Build CLI tools (needed by MSBuild code generation targets) - run: | - dotnet build Migration/Migration.Cli -c Debug - dotnet build DataProvider/DataProvider.SQLite.Cli -c Debug - - - name: Test Gatekeeper - run: dotnet test Gatekeeper/Gatekeeper.Api.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - env: - TEST_POSTGRES_CONNECTION: "Host=localhost;Database=postgres;Username=postgres;Password=changeme" - - - name: Test Sample APIs - run: | - dotnet test Samples/Clinical/Clinical.Api.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - dotnet test Samples/Scheduling/Scheduling.Api.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - env: - TEST_POSTGRES_CONNECTION: "Host=localhost;Database=postgres;Username=postgres;Password=changeme" - - - name: Test Sync (Postgres) - run: | - dotnet test Sync/Sync.Postgres.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - dotnet test Sync/Sync.Integration.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - dotnet test Sync/Sync.Http.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - env: - TESTCONTAINERS_RYUK_DISABLED: false - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-postgres - path: '**/TestResults/*.trx' - - # ICD10 tests (need pgvector + embedding service) - icd10-tests: - name: ICD10 Tests - runs-on: ubuntu-latest - # TIMEOUT EXCEPTION: ICD10 builds Docker image for embedding service + waits up to 90s for model load - timeout-minutes: 20 - needs: [lint, changes] - if: needs.changes.outputs.icd10 == 'true' - services: - postgres: - image: pgvector/pgvector:pg16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: changeme - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - # Prevent MSBuild server deadlocks when custom targets invoke dotnet run - DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: 1 - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build embedding service - uses: docker/build-push-action@v5 - with: - context: Samples/ICD10/embedding-service - load: true - tags: medembed-service:latest - cache-from: type=gha,scope=embedding-service - cache-to: type=gha,mode=max,scope=embedding-service - - - name: Start embedding service - run: | - docker run -d --name embedding-service -p 8000:8000 medembed-service:latest - echo "Waiting for embedding service to load model..." - for i in $(seq 1 90); do - if curl -sf http://localhost:8000/health > /dev/null 2>&1; then - echo "Embedding service is healthy!" - break - fi - if [ $i -eq 90 ]; then - echo "Embedding service failed to start within timeout" - docker logs embedding-service - exit 1 - fi - echo "Attempt $i/90 - waiting..." - sleep 5 - done - - - name: Build CLI tools (needed by MSBuild code generation targets) - run: | - dotnet build Migration/Migration.Cli -c Debug - dotnet build DataProvider/DataProvider.SQLite.Cli -c Debug - dotnet build Lql/LqlCli.SQLite -c Debug - - - name: Test ICD10 - run: | - dotnet test Samples/ICD10/ICD10.Api.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - dotnet test Samples/ICD10/ICD10.Cli.Tests --verbosity normal --logger "trx;LogFileName=test-results.trx" - env: - ICD10_TEST_CONNECTION_STRING: "Host=localhost;Database=postgres;Username=postgres;Password=changeme" - - - name: Embedding service logs - if: failure() - run: docker logs embedding-service || true - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-icd10 - path: '**/TestResults/*.trx' - - # Dashboard E2E tests (Playwright) - e2e-tests: - name: Dashboard E2E Tests - runs-on: ubuntu-latest - # TIMEOUT EXCEPTION: E2E tests build multiple .NET projects + install Playwright browsers - timeout-minutes: 15 - needs: [lint, changes] - if: needs.changes.outputs.dashboard == 'true' - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: changeme - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.0.x - ${{ env.DOTNET_VERSION }} - - - name: Restore .NET tools - run: dotnet tool restore + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.fsproj') }} restore-keys: | ${{ runner.os }}-nuget- - - name: Build all dependencies and integration tests - run: | - dotnet build Samples/Dashboard/Dashboard.Web -c Release - dotnet build Samples/Clinical/Clinical.Sync -c Release - dotnet build Samples/Scheduling/Scheduling.Sync -c Release - dotnet build Samples/ICD10/ICD10.Api/ICD10.Api.csproj -c Release - dotnet build Migration/Migration.Cli -c Release - dotnet build Samples/Dashboard/Dashboard.Integration.Tests -c Release - - - name: Install Playwright browsers - run: dotnet tool install --global Microsoft.Playwright.CLI && playwright install --with-deps chromium - - - name: Test - run: dotnet test Samples/Dashboard/Dashboard.Integration.Tests -c Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-e2e - path: '**/TestResults/*.trx' - - - name: Upload Playwright traces - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-traces - path: '**/playwright-traces/**' - - # LQL LSP Rust tests + coverage (per-crate, 85% minimum) - lql-rust-tests: - name: LQL Rust Tests (${{ matrix.crate }}) - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [lint, changes] - if: needs.changes.outputs.lql-rust == 'true' - strategy: - fail-fast: false - matrix: - crate: [lql-parser, lql-analyzer, lql-lsp] - defaults: - run: - working-directory: Lql/lql-lsp-rust - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Cache Cargo registry + build uses: actions/cache@v4 with: @@ -487,54 +137,45 @@ jobs: ~/.cargo/registry ~/.cargo/git Lql/lql-lsp-rust/target - key: ${{ runner.os }}-cargo-${{ matrix.crate }}-${{ hashFiles('Lql/lql-lsp-rust/Cargo.lock') }} + key: ${{ runner.os }}-cargo-build-${{ hashFiles('Lql/lql-lsp-rust/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-${{ matrix.crate }}- + ${{ runner.os }}-cargo-build- ${{ runner.os }}-cargo- - - name: Run tests - run: cargo test --package ${{ matrix.crate }} - - - name: Check formatting - run: cargo fmt --package ${{ matrix.crate }} -- --check + - name: Build .NET + run: dotnet build DataProvider.sln -c Debug + + - name: Build Rust + run: cd Lql/lql-lsp-rust && cargo build + + - name: Test LQL + Migration + Sync (with coverage enforcement) + run: >- + make _test_dotnet DOTNET_TEST_PROJECTS="Lql/Nimblesite.Lql.Tests + Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests + Migration/Nimblesite.DataProvider.Migration.Tests + Sync/Nimblesite.Sync.Tests + Sync/Nimblesite.Sync.SQLite.Tests + Sync/Nimblesite.Sync.Postgres.Tests + Sync/Nimblesite.Sync.Integration.Tests + Sync/Nimblesite.Sync.Http.Tests" + env: + TESTCONTAINERS_RYUK_DISABLED: false - - name: Clippy - run: cargo clippy --package ${{ matrix.crate }} -- -D warnings + - name: Test LQL Rust (with coverage enforcement) + run: make _test_rust - - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-build + path: '**/TestResults/*.trx' - - name: Coverage check - run: | - # Thresholds account for ANTLR-generated code and cross-crate - # instrumentation bleed in tarpaulin's LLVM engine. The actual - # hand-written code exceeds 90% coverage; these lower thresholds - # exist because tarpaulin counts generated parser/lexer/visitor - # lines that are unreachable through normal test paths. - case "${{ matrix.crate }}" in - lql-parser) THRESHOLD=40 ;; - lql-analyzer) THRESHOLD=40 ;; - lql-lsp) THRESHOLD=40 ;; - *) THRESHOLD=90 ;; - esac - cargo tarpaulin \ - --packages ${{ matrix.crate }} \ - --skip-clean \ - --timeout 120 \ - --engine llvm \ - --fail-under $THRESHOLD \ - --out stdout - - # LQL VS Code Extension CI (lint, compile, package) - lql-extension-ci: - name: LQL Extension CI + # Track 3: Build All -> LQL Extension Tests + extension-tests: + name: LQL Extension Tests runs-on: ubuntu-latest timeout-minutes: 10 - needs: [lint, changes] - if: needs.changes.outputs.lql-extension == 'true' - defaults: - run: - working-directory: Lql/LqlExtension steps: - uses: actions/checkout@v4 @@ -544,13 +185,10 @@ jobs: node-version: '20' - name: Install dependencies - run: npm install --no-audit --no-fund - - - name: Lint - run: npm run lint + run: cd Lql/LqlExtension && npm install --no-audit --no-fund - - name: Compile - run: npm run compile + - name: Test Extension (with coverage enforcement) + run: make _test_ts - name: Package VSIX (dry run) - run: npx vsce package --no-git-tag-version --no-update-package-json + run: cd Lql/LqlExtension && npm run compile && npx vsce package --no-git-tag-version --no-update-package-json diff --git a/.github/workflows/deploy-lql-website.yml b/.github/workflows/deploy-lql-website.yml index b578ae8d..66fee473 100644 --- a/.github/workflows/deploy-lql-website.yml +++ b/.github/workflows/deploy-lql-website.yml @@ -1,12 +1,7 @@ name: Deploy LQL Website to GitHub Pages on: - push: - branches: [ main ] - paths: - - 'Lql/**' - - '.github/workflows/deploy-lql-website.yml' - workflow_dispatch: + workflow_call: permissions: contents: read @@ -40,13 +35,13 @@ jobs: run: dotnet workload install wasm-tools - name: Restore dependencies - run: dotnet restore ./Lql/LqlWebsite/LqlWebsite.csproj + run: dotnet restore ./Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj - name: Build - run: dotnet build ./Lql/LqlWebsite/LqlWebsite.csproj -c Release --no-restore + run: dotnet build ./Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj -c Release --no-restore - name: Publish Blazor WebAssembly project - run: dotnet publish ./Lql/LqlWebsite/LqlWebsite.csproj -c Release -o release --nologo + run: dotnet publish ./Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj -c Release -o release --nologo - name: Update base href for custom domain run: | diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 0e6087f7..d4b0ca73 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -1,14 +1,7 @@ name: Deploy Website to GitHub Pages on: - push: - branches: [main] - paths: - - 'Website/**' - - 'DataProvider/**' - - 'Lql/**' - - '.github/workflows/deploy-website.yml' - workflow_dispatch: + workflow_call: permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d26bbb2..e2eb28dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,81 +10,6 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: - # ── Build LSP binaries for each platform ────────────────────────────────── - build-lsp: - timeout-minutes: 10 - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact: lql-lsp-linux-x64 - - os: macos-latest - target: x86_64-apple-darwin - artifact: lql-lsp-darwin-x64 - - os: macos-latest - target: aarch64-apple-darwin - artifact: lql-lsp-darwin-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact: lql-lsp-windows-x64.exe - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Build LSP binary - working-directory: Lql/lql-lsp-rust - run: cargo build --release --target ${{ matrix.target }} -p lql-lsp - - - name: Rename binary (Unix) - if: runner.os != 'Windows' - run: cp Lql/lql-lsp-rust/target/${{ matrix.target }}/release/lql-lsp ${{ matrix.artifact }} - - - name: Rename binary (Windows) - if: runner.os == 'Windows' - run: cp Lql/lql-lsp-rust/target/${{ matrix.target }}/release/lql-lsp.exe ${{ matrix.artifact }} - - - name: Upload LSP artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact }} - path: ${{ matrix.artifact }} - - # ── Build VSIX (no bundled binaries) ────────────────────────────────────── - build-vsix: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - - name: Build VSIX - working-directory: Lql/LqlExtension - run: | - npm install --no-audit --no-fund - npm run compile - npx vsce package --no-git-tag-version --no-update-package-json "${{ steps.version.outputs.VERSION }}" - - - name: Upload VSIX artifact - uses: actions/upload-artifact@v4 - with: - name: lql-vsix - path: Lql/LqlExtension/*.vsix - - # ── Publish NuGet packages ──────────────────────────────────────────────── publish: runs-on: ubuntu-latest timeout-minutes: 10 @@ -96,82 +21,38 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Extract version from tag id: version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - name: Build and Pack libraries - run: | - # Build and pack only the release packages (excludes Samples which need DB setup) - dotnet build Other/Selecta/Selecta.csproj -c Release - dotnet build Migration/Migration/Migration.csproj -c Release - dotnet build Migration/Migration.SQLite/Migration.SQLite.csproj -c Release - dotnet build Migration/Migration.Postgres/Migration.Postgres.csproj -c Release - dotnet build DataProvider/DataProvider/DataProvider.csproj -c Release - dotnet build DataProvider/DataProvider.SQLite/DataProvider.SQLite.csproj -c Release - dotnet build DataProvider/DataProvider.Postgres.Cli/DataProvider.Postgres.Cli.csproj -c Release - dotnet build DataProvider/DataProvider.SQLite.Cli/DataProvider.SQLite.Cli.csproj -c Release - dotnet build Migration/Migration.Cli/Migration.Cli.csproj -c Release - - - name: Test core libraries - run: | - # Run tests for core libraries only (Samples require database infrastructure) - dotnet test DataProvider/DataProvider.Tests/DataProvider.Tests.csproj -c Release --no-build || true - dotnet test Migration/Migration.Tests/Migration.Tests.csproj -c Release --no-build || true - - - name: Pack libraries + - name: Build and Pack run: | - dotnet pack Other/Selecta/Selecta.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - dotnet pack Migration/Migration/Migration.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - dotnet pack Migration/Migration.SQLite/Migration.SQLite.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - dotnet pack Migration/Migration.Postgres/Migration.Postgres.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - dotnet pack DataProvider/DataProvider/DataProvider.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - dotnet pack DataProvider/DataProvider.SQLite/DataProvider.SQLite.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - - - name: Pack CLI tools - run: | - dotnet pack DataProvider/DataProvider.Postgres.Cli/DataProvider.Postgres.Cli.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - dotnet pack DataProvider/DataProvider.SQLite.Cli/DataProvider.SQLite.Cli.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - dotnet pack Migration/Migration.Cli/Migration.Cli.csproj -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs + dotnet build -c Release + dotnet pack -c Release -p:Version=${{ steps.version.outputs.VERSION }} -o ./nupkgs - name: Push to NuGet run: dotnet nuget push "./nupkgs/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - - name: Upload NuGet artifacts - uses: actions/upload-artifact@v4 - with: - name: nupkgs - path: ./nupkgs/*.nupkg - - # ── Create GitHub Release with all artifacts ────────────────────────────── - release: - needs: [publish, build-lsp, build-vsix] - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Collect release assets - run: | - mkdir -p release-assets - cp artifacts/nupkgs/*.nupkg release-assets/ - cp artifacts/lql-lsp-linux-x64/lql-lsp-linux-x64 release-assets/ - cp artifacts/lql-lsp-darwin-x64/lql-lsp-darwin-x64 release-assets/ - cp artifacts/lql-lsp-darwin-arm64/lql-lsp-darwin-arm64 release-assets/ - cp artifacts/lql-lsp-windows-x64.exe/lql-lsp-windows-x64.exe release-assets/ - cp artifacts/lql-vsix/*.vsix release-assets/ - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: release-assets/* - generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "${{ github.ref_name }}" --generate-notes + + deploy-lql-website: + needs: publish + uses: ./.github/workflows/deploy-lql-website.yml + permissions: + contents: read + pages: write + id-token: write + + deploy-website: + needs: deploy-lql-website + uses: ./.github/workflows/deploy-website.yml + secrets: inherit + permissions: + contents: read + pages: write + id-token: write diff --git a/.gitignore b/.gitignore index d4a2954d..4db495c5 100644 --- a/.gitignore +++ b/.gitignore @@ -165,7 +165,8 @@ BenchmarkDotNet.Artifacts/ coverage/ coverage-report/ coverage-results/ -coverage*.json +coverage-*.json +!coverage-thresholds.json coverage*.xml coverage*.info lcov.info diff --git a/.vscode/launch.json b/.vscode/launch.json index 4786447d..40f3c458 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,20 +1,6 @@ { "version": "0.2.0", "configurations": [ - { - "name": "Dashboard (Fresh)", - "type": "node-terminal", - "request": "launch", - "command": "${workspaceFolder}/Samples/scripts/start.sh --fresh", - "cwd": "${workspaceFolder}/Samples/scripts" - }, - { - "name": "Dashboard (Continue)", - "type": "node-terminal", - "request": "launch", - "command": "${workspaceFolder}/Samples/scripts/start.sh", - "cwd": "${workspaceFolder}/Samples/scripts" - }, { "name": "Launch Blazor LQL Website", "type": "coreclr", @@ -64,20 +50,6 @@ "env": { "DOTNET_ENVIRONMENT": "Development" } - }, - { - "name": "ICD-10-CM CLI", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/Samples/ICD10CM/ICD10AM.Cli/bin/Debug/net9.0/ICD10AM.Cli.dll", - "args": ["http://localhost:5558"], - "cwd": "${workspaceFolder}/Samples/ICD10CM/ICD10AM.Cli", - "console": "integratedTerminal", - "stopAtEntry": false, - "env": { - "EMBEDDING_URL": "http://localhost:8000" - } } ] -} \ No newline at end of file +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bbfcd903..194edb6c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -39,95 +39,6 @@ "problemMatcher": [] }, - // ═══════════════════════════════════════════════════════════════ - // SAMPLES / DASHBOARD - // ═══════════════════════════════════════════════════════════════ - { - "label": "Samples: Start All (Fresh)", - "type": "shell", - "command": "./start.sh --fresh", - "options": { - "cwd": "${workspaceFolder}/Samples" - }, - "problemMatcher": [], - "detail": "Kill all, clear DBs, start Clinical, Scheduling, Gatekeeper, ICD-10, Sync, Dashboard" - }, - { - "label": "Samples: Start All (Continue)", - "type": "shell", - "command": "./start.sh", - "options": { - "cwd": "${workspaceFolder}/Samples" - }, - "problemMatcher": [], - "detail": "Start all services without clearing databases" - }, - - // ═══════════════════════════════════════════════════════════════ - // ICD-10-CM MICROSERVICE - // ═══════════════════════════════════════════════════════════════ - { - "label": "ICD-10: Run API", - "type": "shell", - "command": "./run.sh", - "options": { - "cwd": "${workspaceFolder}/Samples/ICD10CM/scripts" - }, - "problemMatcher": [], - "detail": "Start ICD-10-CM API on port 5558" - }, - { - "label": "ICD-10: Start Embedding Service", - "type": "shell", - "command": "./start.sh", - "options": { - "cwd": "${workspaceFolder}/Samples/ICD10CM/scripts/Dependencies" - }, - "problemMatcher": [], - "detail": "Docker: MedEmbed service for RAG search" - }, - { - "label": "ICD-10: Stop Embedding Service", - "type": "shell", - "command": "./stop.sh", - "options": { - "cwd": "${workspaceFolder}/Samples/ICD10CM/scripts/Dependencies" - }, - "problemMatcher": [] - }, - { - "label": "ICD-10: Import Database (full)", - "type": "shell", - "command": "./import.sh", - "options": { - "cwd": "${workspaceFolder}/Samples/ICD10CM/scripts/CreateDb" - }, - "problemMatcher": [], - "detail": "Migrate schema, import codes, generate embeddings (30-60 min)" - }, - { - "label": "ICD-10: Run CLI", - "type": "shell", - "command": "dotnet run -- http://localhost:5558", - "options": { - "cwd": "${workspaceFolder}/Samples/ICD10CM/ICD10AM.Cli", - "env": { - "EMBEDDING_URL": "http://localhost:8000" - } - }, - "problemMatcher": [], - "detail": "Interactive CLI for ICD-10 code lookup" - }, - { - "label": "ICD-10: Run Tests", - "type": "shell", - "command": "dotnet test", - "options": { - "cwd": "${workspaceFolder}/Samples/ICD10CM/ICD10AM.Api.Tests" - }, - "problemMatcher": "$msCompile" - }, - // ═══════════════════════════════════════════════════════════════ // LQL EXTENSION // ═══════════════════════════════════════════════════════════════ @@ -273,12 +184,5 @@ }, "problemMatcher": [] }, - { - "label": "Util: Kill All Sample Ports", - "type": "shell", - "command": "lsof -ti:5080,5001,5002,5090,5173 | xargs kill -9 2>/dev/null || true", - "problemMatcher": [], - "detail": "Kill ports: 5080, 5001, 5002, 5090, 5173" - } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 24bd7335..ff7ab752 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ ## Project Overview -DataProvider is a comprehensive .NET database access toolkit: source generation for SQL extension methods, the Lambda Query Language (LQL) transpiler, bidirectional offline-first sync, WebAuthn + RBAC auth, and healthcare sample applications. The LQL LSP is implemented in Rust with a VS Code extension in TypeScript. +DataProvider is a comprehensive .NET database access toolkit: source generation for SQL extension methods, the Lambda Query Language (LQL) transpiler, bidirectional offline-first sync, WebAuthn + RBAC auth, and an embeddable reporting platform. The LQL LSP is implemented in Rust with a VS Code extension in TypeScript. Healthcare sample applications live in a separate repo: [MelbourneDeveloper/HealthcareSamples](https://github.com/MelbourneDeveloper/HealthcareSamples). **Primary language(s):** C# (.NET 10.0), Rust, TypeScript, F# **Build command:** `make ci` diff --git a/CodeAnalysis.ruleset b/CodeAnalysis.ruleset index 80a2d884..9bf22691 100644 --- a/CodeAnalysis.ruleset +++ b/CodeAnalysis.ruleset @@ -1,5 +1,5 @@ - + diff --git a/DataProvider.sln b/DataProvider.sln index e8c19218..f9236452 100644 --- a/DataProvider.sln +++ b/DataProvider.sln @@ -3,125 +3,87 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lql", "Lql", "{54B846BA-A27D-B76F-8730-402A5742FF43}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Nimblesite.Lql.Core", "Nimblesite.Lql.Core", "{54B846BA-A27D-B76F-8730-402A5742FF43}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql", "Lql\Lql\Lql.csproj", "{6A15D05D-A3F0-41C7-BCDC-A1A5390CD181}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.Core", "Lql\Nimblesite.Lql.Core\Nimblesite.Lql.Core.csproj", "{6A15D05D-A3F0-41C7-BCDC-A1A5390CD181}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql.SQLite", "Lql\Lql.SQLite\Lql.SQLite.csproj", "{903D712A-27E0-4615-AB93-16083E5B3E3A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.SQLite", "Lql\Nimblesite.Lql.SQLite\Nimblesite.Lql.SQLite.csproj", "{903D712A-27E0-4615-AB93-16083E5B3E3A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql.SqlServer", "Lql\Lql.SqlServer\Lql.SqlServer.csproj", "{AD717205-C676-4DF9-8095-4583BA342515}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.SqlServer", "Lql\Nimblesite.Lql.SqlServer\Nimblesite.Lql.SqlServer.csproj", "{AD717205-C676-4DF9-8095-4583BA342515}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql.Tests", "Lql\Lql.Tests\Lql.Tests.csproj", "{707C273D-CCC9-4CF3-B234-F54B2AB3D178}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.Tests", "Lql\Nimblesite.Lql.Tests\Nimblesite.Lql.Tests.csproj", "{707C273D-CCC9-4CF3-B234-F54B2AB3D178}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LqlCli.SQLite.Tests", "Lql\LqlCli.SQLite.Tests\LqlCli.SQLite.Tests.csproj", "{DC406D52-3A4B-4632-AD67-462875C067D3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.Cli.SQLite.Tests", "Lql\Nimblesite.Lql.Cli.SQLite.Tests\Nimblesite.Lql.Cli.SQLite.Tests.csproj", "{DC406D52-3A4B-4632-AD67-462875C067D3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql.Postgres", "Lql\Lql.Postgres\Lql.Postgres.csproj", "{9DF737C9-6EE5-4255-85C9-65337350DFDD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.Postgres", "Lql\Nimblesite.Lql.Postgres\Nimblesite.Lql.Postgres.csproj", "{9DF737C9-6EE5-4255-85C9-65337350DFDD}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataProvider", "DataProvider", "{43BAF0A3-C050-BE83-B489-7FC6F9FDE235}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Nimblesite.DataProvider.Core", "Nimblesite.DataProvider.Core", "{43BAF0A3-C050-BE83-B489-7FC6F9FDE235}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProvider", "DataProvider\DataProvider\DataProvider.csproj", "{7D4F4EC0-C221-4BC9-8F8C-77BD4A3D39AA}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Core", "DataProvider\Nimblesite.DataProvider.Core\Nimblesite.DataProvider.Core.csproj", "{7D4F4EC0-C221-4BC9-8F8C-77BD4A3D39AA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProvider.Tests", "DataProvider\DataProvider.Tests\DataProvider.Tests.csproj", "{C1F8DBEE-EBA0-4C58-B7C1-F4BCC8E6674D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Tests", "DataProvider\Nimblesite.DataProvider.Tests\Nimblesite.DataProvider.Tests.csproj", "{C1F8DBEE-EBA0-4C58-B7C1-F4BCC8E6674D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProvider.SQLite.Cli", "DataProvider\DataProvider.SQLite.Cli\DataProvider.SQLite.Cli.csproj", "{2B1441F1-4429-487C-9D0A-FC65B64BF43E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.SQLite.Cli", "DataProvider\Nimblesite.DataProvider.SQLite.Cli\Nimblesite.DataProvider.SQLite.Cli.csproj", "{2B1441F1-4429-487C-9D0A-FC65B64BF43E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProvider.SQLite", "DataProvider\DataProvider.SQLite\DataProvider.SQLite.csproj", "{A7EC2050-FE5E-4BBD-AF5F-7F07D3688118}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.SQLite", "DataProvider\Nimblesite.DataProvider.SQLite\Nimblesite.DataProvider.SQLite.csproj", "{A7EC2050-FE5E-4BBD-AF5F-7F07D3688118}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProvider.Example.Tests", "DataProvider\DataProvider.Example.Tests\DataProvider.Example.Tests.csproj", "{16FA9B36-CB2A-4B79-A3BE-937C94BF03F8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Example.Tests", "DataProvider\Nimblesite.DataProvider.Example.Tests\Nimblesite.DataProvider.Example.Tests.csproj", "{16FA9B36-CB2A-4B79-A3BE-937C94BF03F8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProvider.Example", "DataProvider\DataProvider.Example\DataProvider.Example.csproj", "{EA9A0385-249F-4141-AD03-D67649110A84}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Example", "DataProvider\Nimblesite.DataProvider.Example\Nimblesite.DataProvider.Example.csproj", "{EA9A0385-249F-4141-AD03-D67649110A84}" EndProject -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Lql.TypeProvider.FSharp", "Lql\Lql.TypeProvider.FSharp\Lql.TypeProvider.FSharp.fsproj", "{B1234567-89AB-CDEF-0123-456789ABCDEF}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Nimblesite.Lql.TypeProvider.FSharp", "Lql\Nimblesite.Lql.TypeProvider.FSharp\Nimblesite.Lql.TypeProvider.FSharp.fsproj", "{B1234567-89AB-CDEF-0123-456789ABCDEF}" EndProject -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "DataProvider.SQLite.FSharp", "DataProvider\DataProvider.SQLite.FSharp\DataProvider.SQLite.FSharp.fsproj", "{D1234567-89AB-CDEF-0123-456789ABCDEF}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Nimblesite.DataProvider.SQLite.FSharp", "DataProvider\Nimblesite.DataProvider.SQLite.FSharp\Nimblesite.DataProvider.SQLite.FSharp.fsproj", "{D1234567-89AB-CDEF-0123-456789ABCDEF}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DataProvider.Example.FSharp", "DataProvider\DataProvider.Example.FSharp\DataProvider.Example.FSharp.fsproj", "{5C11B1F1-F6FF-45B9-B037-EDD054EED3F3}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Nimblesite.DataProvider.Example.FSharp", "DataProvider\Nimblesite.DataProvider.Example.FSharp\Nimblesite.DataProvider.Example.FSharp.fsproj", "{5C11B1F1-F6FF-45B9-B037-EDD054EED3F3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql.Browser", "Lql\Lql.Browser\Lql.Browser.csproj", "{0D96933C-DE5D-472B-9E9F-68DD15B85CF7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.Browser", "Lql\Nimblesite.Lql.Browser\Nimblesite.Lql.Browser.csproj", "{0D96933C-DE5D-472B-9E9F-68DD15B85CF7}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sync", "Sync", "{5E63119C-E70B-5D45-ECC9-8CBACC584223}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Nimblesite.Sync.Core", "Nimblesite.Sync.Core", "{5E63119C-E70B-5D45-ECC9-8CBACC584223}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync", "Sync\Sync\Sync.csproj", "{C0B4116E-0635-4597-971D-6B70229FA30A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Core", "Sync\Nimblesite.Sync.Core\Nimblesite.Sync.Core.csproj", "{C0B4116E-0635-4597-971D-6B70229FA30A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.SQLite", "Sync\Sync.SQLite\Sync.SQLite.csproj", "{9B303409-0052-45B9-8616-CC1ED80A5595}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.SQLite", "Sync\Nimblesite.Sync.SQLite\Nimblesite.Sync.SQLite.csproj", "{9B303409-0052-45B9-8616-CC1ED80A5595}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.Tests", "Sync\Sync.Tests\Sync.Tests.csproj", "{50CFDEC4-66C8-4330-8D5F-9D96A764378B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Tests", "Sync\Nimblesite.Sync.Tests\Nimblesite.Sync.Tests.csproj", "{50CFDEC4-66C8-4330-8D5F-9D96A764378B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.SQLite.Tests", "Sync\Sync.SQLite.Tests\Sync.SQLite.Tests.csproj", "{3522A6C5-6205-4C7D-B805-84847035A921}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.SQLite.Tests", "Sync\Nimblesite.Sync.SQLite.Tests\Nimblesite.Sync.SQLite.Tests.csproj", "{3522A6C5-6205-4C7D-B805-84847035A921}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.Http.Tests", "Sync\Sync.Http.Tests\Sync.Http.Tests.csproj", "{1EF92A45-C551-4B0F-9180-00E978DA499E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Http.Tests", "Sync\Nimblesite.Sync.Http.Tests\Nimblesite.Sync.Http.Tests.csproj", "{1EF92A45-C551-4B0F-9180-00E978DA499E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.Postgres", "Sync\Sync.Postgres\Sync.Postgres.csproj", "{FC26C0FB-162E-4B4F-8561-9A688F4B42D2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Postgres", "Sync\Nimblesite.Sync.Postgres\Nimblesite.Sync.Postgres.csproj", "{FC26C0FB-162E-4B4F-8561-9A688F4B42D2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.Postgres.Tests", "Sync\Sync.Postgres.Tests\Sync.Postgres.Tests.csproj", "{D295DE84-3797-46B4-812E-E56FB629A341}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Postgres.Tests", "Sync\Nimblesite.Sync.Postgres.Tests\Nimblesite.Sync.Postgres.Tests.csproj", "{D295DE84-3797-46B4-812E-E56FB629A341}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.Integration.Tests", "Sync\Sync.Integration.Tests\Sync.Integration.Tests.csproj", "{1ACC151C-A655-472F-9177-996410A41665}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Integration.Tests", "Sync\Nimblesite.Sync.Integration.Tests\Nimblesite.Sync.Integration.Tests.csproj", "{1ACC151C-A655-472F-9177-996410A41665}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migration", "Migration", "{C7F49633-8D5E-7E19-1580-A6459B2EAE66}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Nimblesite.DataProvider.Migration.Core", "Nimblesite.DataProvider.Migration.Core", "{C7F49633-8D5E-7E19-1580-A6459B2EAE66}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migration", "Migration\Migration\Migration.csproj", "{5EB8D367-90CC-473F-8F89-B898D8F8BD43}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Migration.Core", "Migration\Nimblesite.DataProvider.Migration.Core\Nimblesite.DataProvider.Migration.Core.csproj", "{5EB8D367-90CC-473F-8F89-B898D8F8BD43}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migration.SQLite", "Migration\Migration.SQLite\Migration.SQLite.csproj", "{99B714F6-43FE-46F6-A9B3-B362B8B8F87D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Migration.SQLite", "Migration\Nimblesite.DataProvider.Migration.SQLite\Nimblesite.DataProvider.Migration.SQLite.csproj", "{99B714F6-43FE-46F6-A9B3-B362B8B8F87D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migration.Postgres", "Migration\Migration.Postgres\Migration.Postgres.csproj", "{988EAF3A-7320-4630-AFDC-233AC33AAA65}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Migration.Postgres", "Migration\Nimblesite.DataProvider.Migration.Postgres\Nimblesite.DataProvider.Migration.Postgres.csproj", "{988EAF3A-7320-4630-AFDC-233AC33AAA65}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migration.Tests", "Migration\Migration.Tests\Migration.Tests.csproj", "{E23F2826-1857-4C3F-A90B-D4443DD84EFA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clinical.Api", "Samples\Clinical\Clinical.Api\Clinical.Api.csproj", "{D53426B7-469F-4FBB-9935-4AA3C303DE8D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clinical.Sync", "Samples\Clinical\Clinical.Sync\Clinical.Sync.csproj", "{4189D963-E5AA-4782-AD78-72FBA9536B59}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scheduling.Api", "Samples\Scheduling\Scheduling.Api\Scheduling.Api.csproj", "{0F990389-7C88-4C7A-99F8-60E5243216FF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scheduling.Sync", "Samples\Scheduling\Scheduling.Sync\Scheduling.Sync.csproj", "{7782890E-712E-4658-8BF2-0DC5794A87AC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clinical.Api.Tests", "Samples\Clinical\Clinical.Api.Tests\Clinical.Api.Tests.csproj", "{8131E980-CA39-4BAD-9ADE-34E6597BD00F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scheduling.Api.Tests", "Samples\Scheduling\Scheduling.Api.Tests\Scheduling.Api.Tests.csproj", "{C23F467D-B5F1-400D-9EEA-96E3F467BAB7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dashboard", "Dashboard", "{B03CA193-C175-FB88-B41C-CBBC0E037C7E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Migration.Tests", "Migration\Nimblesite.DataProvider.Migration.Tests\Nimblesite.DataProvider.Migration.Tests.csproj", "{E23F2826-1857-4C3F-A90B-D4443DD84EFA}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{C841F5C2-8F30-5BE9-ECA6-260644CF6F9F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selecta", "Other\Selecta\Selecta.csproj", "{BE9AC443-C15D-4962-A8D2-0CCD328E6B68}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sync.Http", "Sync\Sync.Http\Sync.Http.csproj", "{392C12C2-ECBA-4728-9D8D-54BD2E10F7ED}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dashboard.Integration.Tests", "Samples\Dashboard\Dashboard.Integration.Tests\Dashboard.Integration.Tests.csproj", "{83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Gatekeeper", "Gatekeeper", "{048F5F03-6DDC-C04F-70D5-B8139DC8E373}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gatekeeper.Api", "Gatekeeper\Gatekeeper.Api\Gatekeeper.Api.csproj", "{4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gatekeeper.Api.Tests", "Gatekeeper\Gatekeeper.Api.Tests\Gatekeeper.Api.Tests.csproj", "{2FD305AC-927E-4D24-9FA6-923C30E4E4A8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migration.Cli", "Migration\Migration.Cli\Migration.Cli.csproj", "{57572A45-33CD-4928-9C30-13480AEDB313}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProvider.Postgres.Cli", "DataProvider\DataProvider.Postgres.Cli\DataProvider.Postgres.Cli.csproj", "{A8A70E6D-1D43-437F-9971-44A4FA1BDD74}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schema.Export.Cli", "Migration\Schema.Export.Cli\Schema.Export.Cli.csproj", "{0858FE19-C59B-4A77-B76E-7053E8AFCC8D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization", "Samples\Shared\Authorization\Authorization.csproj", "{CA395494-F072-4A5B-9DD4-950530A69E0E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LqlCli.SQLite", "Lql\LqlCli.SQLite\LqlCli.SQLite.csproj", "{1AE87774-E914-40BC-95BA-56FB45D78C0D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LqlWebsite", "Lql\LqlWebsite\LqlWebsite.csproj", "{6AB2EA96-4A75-49DB-AC65-B247BBFAE9A3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sql.Model", "Other\Nimblesite.Sql.Model\Nimblesite.Sql.Model.csproj", "{BE9AC443-C15D-4962-A8D2-0CCD328E6B68}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dashboard.Web", "Samples\Dashboard\Dashboard.Web\Dashboard.Web.csproj", "{A82453CD-8E3C-44B7-A78F-97F392016385}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Http", "Sync\Nimblesite.Sync.Http\Nimblesite.Sync.Http.csproj", "{392C12C2-ECBA-4728-9D8D-54BD2E10F7ED}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Lql.TypeProvider.FSharp.Tests", "Lql\Lql.TypeProvider.FSharp.Tests\Lql.TypeProvider.FSharp.Tests.fsproj", "{B0104C42-1B46-4CA5-9E91-A5F09D7E5B92}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Migration.Cli", "Migration\Nimblesite.DataProvider.Migration.Cli\Nimblesite.DataProvider.Migration.Cli.csproj", "{57572A45-33CD-4928-9C30-13480AEDB313}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql.TypeProvider.FSharp.Tests.Data", "Lql\Lql.TypeProvider.FSharp.Tests.Data\Lql.TypeProvider.FSharp.Tests.Data.csproj", "{0D6A831B-4759-46F2-8527-51C8A9CB6F6F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Postgres.Cli", "DataProvider\Nimblesite.DataProvider.Postgres.Cli\Nimblesite.DataProvider.Postgres.Cli.csproj", "{A8A70E6D-1D43-437F-9971-44A4FA1BDD74}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ICD10.Api", "Samples\ICD10\ICD10.Api\ICD10.Api.csproj", "{94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.Cli.SQLite", "Lql\Nimblesite.Lql.Cli.SQLite\Nimblesite.Lql.Cli.SQLite.csproj", "{1AE87774-E914-40BC-95BA-56FB45D78C0D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ICD10.Api.Tests", "Samples\ICD10\ICD10.Api.Tests\ICD10.Api.Tests.csproj", "{31970639-E4E9-4AEF-83A1-B4DF00A4720C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.Website", "Lql\Nimblesite.Lql.Website\Nimblesite.Lql.Website.csproj", "{6AB2EA96-4A75-49DB-AC65-B247BBFAE9A3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ICD10.Cli", "Samples\ICD10\ICD10.Cli\ICD10.Cli.csproj", "{57FF1C59-233D-49F2-B9A5-3E996EB484DE}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Nimblesite.Lql.TypeProvider.FSharp.Tests", "Lql\Nimblesite.Lql.TypeProvider.FSharp.Tests\Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj", "{B0104C42-1B46-4CA5-9E91-A5F09D7E5B92}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ICD10.Cli.Tests", "Samples\ICD10\ICD10.Cli.Tests\ICD10.Cli.Tests.csproj", "{3A1E29E7-2A50-4F26-96D7-D38D3328E595}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.TypeProvider.FSharp.Tests.Data", "Lql\Nimblesite.Lql.TypeProvider.FSharp.Tests.Data\Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj", "{0D6A831B-4759-46F2-8527-51C8A9CB6F6F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -469,78 +431,6 @@ Global {E23F2826-1857-4C3F-A90B-D4443DD84EFA}.Release|x64.Build.0 = Release|Any CPU {E23F2826-1857-4C3F-A90B-D4443DD84EFA}.Release|x86.ActiveCfg = Release|Any CPU {E23F2826-1857-4C3F-A90B-D4443DD84EFA}.Release|x86.Build.0 = Release|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Debug|x64.ActiveCfg = Debug|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Debug|x64.Build.0 = Debug|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Debug|x86.ActiveCfg = Debug|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Debug|x86.Build.0 = Debug|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Release|Any CPU.Build.0 = Release|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Release|x64.ActiveCfg = Release|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Release|x64.Build.0 = Release|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Release|x86.ActiveCfg = Release|Any CPU - {D53426B7-469F-4FBB-9935-4AA3C303DE8D}.Release|x86.Build.0 = Release|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Debug|x64.ActiveCfg = Debug|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Debug|x64.Build.0 = Debug|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Debug|x86.ActiveCfg = Debug|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Debug|x86.Build.0 = Debug|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Release|Any CPU.Build.0 = Release|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Release|x64.ActiveCfg = Release|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Release|x64.Build.0 = Release|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Release|x86.ActiveCfg = Release|Any CPU - {4189D963-E5AA-4782-AD78-72FBA9536B59}.Release|x86.Build.0 = Release|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Debug|x64.ActiveCfg = Debug|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Debug|x64.Build.0 = Debug|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Debug|x86.ActiveCfg = Debug|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Debug|x86.Build.0 = Debug|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Release|Any CPU.Build.0 = Release|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Release|x64.ActiveCfg = Release|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Release|x64.Build.0 = Release|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Release|x86.ActiveCfg = Release|Any CPU - {0F990389-7C88-4C7A-99F8-60E5243216FF}.Release|x86.Build.0 = Release|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Debug|x64.ActiveCfg = Debug|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Debug|x64.Build.0 = Debug|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Debug|x86.ActiveCfg = Debug|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Debug|x86.Build.0 = Debug|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Release|Any CPU.Build.0 = Release|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Release|x64.ActiveCfg = Release|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Release|x64.Build.0 = Release|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Release|x86.ActiveCfg = Release|Any CPU - {7782890E-712E-4658-8BF2-0DC5794A87AC}.Release|x86.Build.0 = Release|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Debug|x64.ActiveCfg = Debug|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Debug|x64.Build.0 = Debug|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Debug|x86.ActiveCfg = Debug|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Debug|x86.Build.0 = Debug|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Release|Any CPU.Build.0 = Release|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Release|x64.ActiveCfg = Release|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Release|x64.Build.0 = Release|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Release|x86.ActiveCfg = Release|Any CPU - {8131E980-CA39-4BAD-9ADE-34E6597BD00F}.Release|x86.Build.0 = Release|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Debug|x64.ActiveCfg = Debug|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Debug|x64.Build.0 = Debug|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Debug|x86.ActiveCfg = Debug|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Debug|x86.Build.0 = Debug|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Release|Any CPU.Build.0 = Release|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Release|x64.ActiveCfg = Release|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Release|x64.Build.0 = Release|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Release|x86.ActiveCfg = Release|Any CPU - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7}.Release|x86.Build.0 = Release|Any CPU {BE9AC443-C15D-4962-A8D2-0CCD328E6B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BE9AC443-C15D-4962-A8D2-0CCD328E6B68}.Debug|Any CPU.Build.0 = Debug|Any CPU {BE9AC443-C15D-4962-A8D2-0CCD328E6B68}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -565,42 +455,6 @@ Global {392C12C2-ECBA-4728-9D8D-54BD2E10F7ED}.Release|x64.Build.0 = Release|Any CPU {392C12C2-ECBA-4728-9D8D-54BD2E10F7ED}.Release|x86.ActiveCfg = Release|Any CPU {392C12C2-ECBA-4728-9D8D-54BD2E10F7ED}.Release|x86.Build.0 = Release|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Debug|Any CPU.Build.0 = Debug|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Debug|x64.ActiveCfg = Debug|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Debug|x64.Build.0 = Debug|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Debug|x86.ActiveCfg = Debug|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Debug|x86.Build.0 = Debug|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Release|Any CPU.ActiveCfg = Release|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Release|Any CPU.Build.0 = Release|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Release|x64.ActiveCfg = Release|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Release|x64.Build.0 = Release|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Release|x86.ActiveCfg = Release|Any CPU - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58}.Release|x86.Build.0 = Release|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Debug|x64.ActiveCfg = Debug|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Debug|x64.Build.0 = Debug|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Debug|x86.ActiveCfg = Debug|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Debug|x86.Build.0 = Debug|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Release|Any CPU.Build.0 = Release|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Release|x64.ActiveCfg = Release|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Release|x64.Build.0 = Release|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Release|x86.ActiveCfg = Release|Any CPU - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23}.Release|x86.Build.0 = Release|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Debug|x64.ActiveCfg = Debug|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Debug|x64.Build.0 = Debug|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Debug|x86.ActiveCfg = Debug|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Debug|x86.Build.0 = Debug|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Release|Any CPU.Build.0 = Release|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Release|x64.ActiveCfg = Release|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Release|x64.Build.0 = Release|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Release|x86.ActiveCfg = Release|Any CPU - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8}.Release|x86.Build.0 = Release|Any CPU {57572A45-33CD-4928-9C30-13480AEDB313}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {57572A45-33CD-4928-9C30-13480AEDB313}.Debug|Any CPU.Build.0 = Debug|Any CPU {57572A45-33CD-4928-9C30-13480AEDB313}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -625,30 +479,6 @@ Global {A8A70E6D-1D43-437F-9971-44A4FA1BDD74}.Release|x64.Build.0 = Release|Any CPU {A8A70E6D-1D43-437F-9971-44A4FA1BDD74}.Release|x86.ActiveCfg = Release|Any CPU {A8A70E6D-1D43-437F-9971-44A4FA1BDD74}.Release|x86.Build.0 = Release|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Debug|x64.ActiveCfg = Debug|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Debug|x64.Build.0 = Debug|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Debug|x86.ActiveCfg = Debug|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Debug|x86.Build.0 = Debug|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Release|Any CPU.Build.0 = Release|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Release|x64.ActiveCfg = Release|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Release|x64.Build.0 = Release|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Release|x86.ActiveCfg = Release|Any CPU - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D}.Release|x86.Build.0 = Release|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Debug|x64.ActiveCfg = Debug|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Debug|x64.Build.0 = Debug|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Debug|x86.ActiveCfg = Debug|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Debug|x86.Build.0 = Debug|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Release|Any CPU.Build.0 = Release|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Release|x64.ActiveCfg = Release|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Release|x64.Build.0 = Release|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Release|x86.ActiveCfg = Release|Any CPU - {CA395494-F072-4A5B-9DD4-950530A69E0E}.Release|x86.Build.0 = Release|Any CPU {1AE87774-E914-40BC-95BA-56FB45D78C0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1AE87774-E914-40BC-95BA-56FB45D78C0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {1AE87774-E914-40BC-95BA-56FB45D78C0D}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -673,18 +503,6 @@ Global {6AB2EA96-4A75-49DB-AC65-B247BBFAE9A3}.Release|x64.Build.0 = Release|Any CPU {6AB2EA96-4A75-49DB-AC65-B247BBFAE9A3}.Release|x86.ActiveCfg = Release|Any CPU {6AB2EA96-4A75-49DB-AC65-B247BBFAE9A3}.Release|x86.Build.0 = Release|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Debug|x64.ActiveCfg = Debug|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Debug|x64.Build.0 = Debug|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Debug|x86.ActiveCfg = Debug|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Debug|x86.Build.0 = Debug|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Release|Any CPU.Build.0 = Release|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Release|x64.ActiveCfg = Release|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Release|x64.Build.0 = Release|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Release|x86.ActiveCfg = Release|Any CPU - {A82453CD-8E3C-44B7-A78F-97F392016385}.Release|x86.Build.0 = Release|Any CPU {B0104C42-1B46-4CA5-9E91-A5F09D7E5B92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0104C42-1B46-4CA5-9E91-A5F09D7E5B92}.Debug|Any CPU.Build.0 = Debug|Any CPU {B0104C42-1B46-4CA5-9E91-A5F09D7E5B92}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -709,54 +527,6 @@ Global {0D6A831B-4759-46F2-8527-51C8A9CB6F6F}.Release|x64.Build.0 = Release|Any CPU {0D6A831B-4759-46F2-8527-51C8A9CB6F6F}.Release|x86.ActiveCfg = Release|Any CPU {0D6A831B-4759-46F2-8527-51C8A9CB6F6F}.Release|x86.Build.0 = Release|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Debug|Any CPU.Build.0 = Debug|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Debug|x64.ActiveCfg = Debug|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Debug|x64.Build.0 = Debug|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Debug|x86.ActiveCfg = Debug|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Debug|x86.Build.0 = Debug|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Release|Any CPU.ActiveCfg = Release|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Release|Any CPU.Build.0 = Release|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Release|x64.ActiveCfg = Release|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Release|x64.Build.0 = Release|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Release|x86.ActiveCfg = Release|Any CPU - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653}.Release|x86.Build.0 = Release|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Debug|x64.ActiveCfg = Debug|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Debug|x64.Build.0 = Debug|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Debug|x86.ActiveCfg = Debug|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Debug|x86.Build.0 = Debug|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Release|Any CPU.Build.0 = Release|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Release|x64.ActiveCfg = Release|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Release|x64.Build.0 = Release|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Release|x86.ActiveCfg = Release|Any CPU - {31970639-E4E9-4AEF-83A1-B4DF00A4720C}.Release|x86.Build.0 = Release|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Debug|x64.ActiveCfg = Debug|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Debug|x64.Build.0 = Debug|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Debug|x86.ActiveCfg = Debug|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Debug|x86.Build.0 = Debug|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Release|Any CPU.Build.0 = Release|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Release|x64.ActiveCfg = Release|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Release|x64.Build.0 = Release|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Release|x86.ActiveCfg = Release|Any CPU - {57FF1C59-233D-49F2-B9A5-3E996EB484DE}.Release|x86.Build.0 = Release|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Debug|x64.ActiveCfg = Debug|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Debug|x64.Build.0 = Debug|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Debug|x86.ActiveCfg = Debug|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Debug|x86.Build.0 = Debug|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Release|Any CPU.Build.0 = Release|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Release|x64.ActiveCfg = Release|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Release|x64.Build.0 = Release|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Release|x86.ActiveCfg = Release|Any CPU - {3A1E29E7-2A50-4F26-96D7-D38D3328E595}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -790,31 +560,14 @@ Global {99B714F6-43FE-46F6-A9B3-B362B8B8F87D} = {C7F49633-8D5E-7E19-1580-A6459B2EAE66} {988EAF3A-7320-4630-AFDC-233AC33AAA65} = {C7F49633-8D5E-7E19-1580-A6459B2EAE66} {E23F2826-1857-4C3F-A90B-D4443DD84EFA} = {C7F49633-8D5E-7E19-1580-A6459B2EAE66} - {D53426B7-469F-4FBB-9935-4AA3C303DE8D} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {4189D963-E5AA-4782-AD78-72FBA9536B59} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {0F990389-7C88-4C7A-99F8-60E5243216FF} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {7782890E-712E-4658-8BF2-0DC5794A87AC} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {8131E980-CA39-4BAD-9ADE-34E6597BD00F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {C23F467D-B5F1-400D-9EEA-96E3F467BAB7} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {B03CA193-C175-FB88-B41C-CBBC0E037C7E} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {BE9AC443-C15D-4962-A8D2-0CCD328E6B68} = {C841F5C2-8F30-5BE9-ECA6-260644CF6F9F} {392C12C2-ECBA-4728-9D8D-54BD2E10F7ED} = {5E63119C-E70B-5D45-ECC9-8CBACC584223} - {83E43658-7186-4E8B-AFD0-BDE5DB7BFB58} = {B03CA193-C175-FB88-B41C-CBBC0E037C7E} - {4EB6CC28-7D1B-4E39-80F2-84CA4494AF23} = {048F5F03-6DDC-C04F-70D5-B8139DC8E373} - {2FD305AC-927E-4D24-9FA6-923C30E4E4A8} = {048F5F03-6DDC-C04F-70D5-B8139DC8E373} {57572A45-33CD-4928-9C30-13480AEDB313} = {C7F49633-8D5E-7E19-1580-A6459B2EAE66} {A8A70E6D-1D43-437F-9971-44A4FA1BDD74} = {43BAF0A3-C050-BE83-B489-7FC6F9FDE235} - {0858FE19-C59B-4A77-B76E-7053E8AFCC8D} = {C7F49633-8D5E-7E19-1580-A6459B2EAE66} - {CA395494-F072-4A5B-9DD4-950530A69E0E} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {1AE87774-E914-40BC-95BA-56FB45D78C0D} = {54B846BA-A27D-B76F-8730-402A5742FF43} {6AB2EA96-4A75-49DB-AC65-B247BBFAE9A3} = {54B846BA-A27D-B76F-8730-402A5742FF43} - {A82453CD-8E3C-44B7-A78F-97F392016385} = {B03CA193-C175-FB88-B41C-CBBC0E037C7E} {B0104C42-1B46-4CA5-9E91-A5F09D7E5B92} = {54B846BA-A27D-B76F-8730-402A5742FF43} {0D6A831B-4759-46F2-8527-51C8A9CB6F6F} = {54B846BA-A27D-B76F-8730-402A5742FF43} - {94C443C0-AB5B-4FEC-9DB1-C1F29AB86653} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {31970639-E4E9-4AEF-83A1-B4DF00A4720C} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {57FF1C59-233D-49F2-B9A5-3E996EB484DE} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {3A1E29E7-2A50-4F26-96D7-D38D3328E595} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {53128A75-E7B6-4B83-B079-A309FCC2AD9C} diff --git a/DataProvider/DataProvider.Example.Tests/GlobalUsings.cs b/DataProvider/DataProvider.Example.Tests/GlobalUsings.cs deleted file mode 100644 index 7920ea67..00000000 --- a/DataProvider/DataProvider.Example.Tests/GlobalUsings.cs +++ /dev/null @@ -1,35 +0,0 @@ -global using Generated; -// Type aliases for Result types to reduce verbosity in DataProvider.Example.Tests -global using CustomerListError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using CustomerListOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using CustomerReadOnlyListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using InvoiceListError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using InvoiceListOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using OrderListOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using OrderReadOnlyListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using StringSqlError = Outcome.Result.Error< - string, - Selecta.SqlError ->; -global using StringSqlOk = Outcome.Result.Ok; diff --git a/DataProvider/DataProvider.Example/GlobalUsings.cs b/DataProvider/DataProvider.Example/GlobalUsings.cs deleted file mode 100644 index a2a45c36..00000000 --- a/DataProvider/DataProvider.Example/GlobalUsings.cs +++ /dev/null @@ -1,65 +0,0 @@ -global using Generated; -global using Selecta; -// Type aliases for Result types to reduce verbosity in DataProvider.Example -global using BasicOrderListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Error< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->; -global using BasicOrderListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Ok< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->; -global using CustomerListError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using CustomerListOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using CustomerReadOnlyListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using CustomerReadOnlyListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using IntSqlError = Outcome.Result.Error; -global using IntSqlOk = Outcome.Result.Ok; -global using InvoiceListError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using InvoiceListOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using OrderListError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using OrderListOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using OrderReadOnlyListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using OrderReadOnlyListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using StringSqlError = Outcome.Result.Error< - string, - Selecta.SqlError ->; -global using StringSqlOk = Outcome.Result.Ok; -global using StringSqlResult = Outcome.Result; diff --git a/DataProvider/DataProvider.SQLite.FSharp/test.db b/DataProvider/DataProvider.SQLite.FSharp/test.db deleted file mode 100644 index 98434a01..00000000 Binary files a/DataProvider/DataProvider.SQLite.FSharp/test.db and /dev/null differ diff --git a/DataProvider/DataProvider.Tests/GlobalUsings.cs b/DataProvider/DataProvider.Tests/GlobalUsings.cs deleted file mode 100644 index c3ccb437..00000000 --- a/DataProvider/DataProvider.Tests/GlobalUsings.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Global usings for DataProvider.Tests -global using IntError = Outcome.Result.Error; -// Result type aliases for tests -global using IntOk = Outcome.Result.Ok; -global using NullableStringOk = Outcome.Result.Ok< - string?, - Selecta.SqlError ->; -global using SqlError = Selecta.SqlError; -global using StringError = Outcome.Result.Error; -global using StringOk = Outcome.Result.Ok; diff --git a/DataProvider/DataProvider/README.md b/DataProvider/DataProvider/README.md deleted file mode 100644 index 2ec59a75..00000000 --- a/DataProvider/DataProvider/README.md +++ /dev/null @@ -1,209 +0,0 @@ -# DataProvider SQL Parser - -A .NET source generator project that aims to parse SQL files and generate strongly-typed extension methods for multiple SQL database platforms. - -CRITICAL: The generator connects to the database at compile time to get query metadata. If it doesn't connect, the generation fails with a compiler error. - -**⚠️ Project Status: Early Development** -This project is in active development. Many features described below are partially implemented or planned for future releases. - -## Overview - -- **Input**: `.sql` files and optional `.grouping.json` configuration files -- **Output**: Generated extension methods on database-specific connections -- **Returns**: `Result` instead of throwing exceptions -- **Platforms**: SQL Server, SQLite (with extensible architecture for other databases) - -## Current Implementation Status - -### ✅ What Works -- Basic SQL file processing and code generation -- Result type pattern for error handling -- Database-specific source generators (SQL Server, SQLite) -- Extension method generation for specific connection types -- Basic parameter extraction -- Grouping configuration support via JSON files -- Directory.Build.props with comprehensive Roslyn analyzers - -### ⚠️ Partially Implemented -- **SQL Parsing**: Currently uses SqlParserCS library but falls back to string manipulation for parameter extraction instead of proper AST traversal -- **Code Generation**: Basic structure in place but many areas marked with TODO comments -- **Schema Inspection**: Framework exists but not fully integrated with code generation - -### ❌ Known Issues & Limitations -- **Regex Usage**: The main `DataProviderSourceGenerator` violates project rules by using regex for parameter extraction -- **Extension Target**: Currently generates extensions on `SqlConnection`/`SqliteConnection` rather than `IDbConnection`/`ITransaction` as originally planned -- **JOIN Analysis**: Not currently extracting JOIN information despite SqlStatement structure supporting it -- **SELECT List Extraction**: Not extracting column information from SELECT statements -- **Hardcoded Logic**: Much code generation is specific to example files rather than generic - -## Usage - -1. Add `.sql` files to your project as AdditionalFiles -2. Optionally add corresponding `.grouping.json` files for parent-child relationship mapping -3. Build project → extension methods auto-generated -4. Use generated methods: - -```csharp -// SQLite (currently working) -var result = await sqliteConnection.GetInvoicesAsync(customerName, startDate, endDate); -// Returns: Result, SqlError> with InvoiceLines collection - -// SQL Server (planned) -var result = await sqlConnection.GetInvoicesAsync(customerName, startDate, endDate); -``` - -## Architecture - -### Core Components - -- **SqlFileGeneratorBase**: Base source generator with database-specific implementations -- **SqlStatement**: Generic SQL statement representation (partially populated) -- **ISqlParser**: Abstraction for parsing SQL across different dialects -- **ICodeGenerator**: Abstraction for generating database-specific code -- **Result**: Functional programming style error handling (✅ Complete) - -### Current Generators - -- **DataProvider.SourceGenerator**: Main generator (⚠️ Uses regex - violates project rules) -- **DataProvider.SqlServer**: SQL Server specific generator using SqlParserCS -- **DataProvider.SQLite**: SQLite specific generator using SqlParserCS - -### SQL Parsing Status - -- **Library**: Uses SqlParserCS (✅ Good choice, no regex in parsing) -- **Parameter Extraction**: ⚠️ Falls back to string manipulation instead of AST traversal -- **Query Type Detection**: ✅ Basic implementation -- **JOIN Analysis**: ❌ Infrastructure exists but not populated -- **SELECT List Extraction**: ❌ Not implemented -- **Error Handling**: ✅ Graceful parsing failure with error messages - -## Dependencies - -- **Microsoft.CodeAnalysis** (source generation) -- **SqlParserCS** (SQL parsing across multiple dialects) -- **Microsoft.Data.SqlClient** (SQL Server support) -- **Microsoft.Data.Sqlite** (SQLite support) -- **System.Text.Json** (configuration file parsing) - -## Project Structure - -``` -DataProvider/ # Core types and interfaces -DataProvider.Dependencies/ # Result types and error handling -DataProvider.SourceGenerator/ # ⚠️ Main generator (uses regex) -DataProvider.SqlServer/ # SQL Server source generator -DataProvider.SQLite/ # SQLite source generator -DataProvider.Example/ # Usage examples and test SQL files -DataProvider.Tests/ # Unit tests -DataProvider.Example.Tests/ # Integration tests -``` - -## Configuration Files - -### SQL Files -Standard SQL files with parameterized queries example: -```sql -SELECT i.Id, i.InvoiceNumber, l.Description, l.Amount -FROM Invoice i -JOIN InvoiceLine l ON l.InvoiceId = i.Id -WHERE i.CustomerName = @customerName - AND (@startDate IS NULL OR i.InvoiceDate >= @startDate) -``` - -### Grouping Configuration Example (Optional) -```json -{ - "QueryName": "GetInvoices", - "GroupingStrategy": "ParentChild", - "ParentEntity": { - "Name": "Invoice", - "KeyColumns": ["Id"], - "Columns": ["Id", "InvoiceNumber", "CustomerName"] - }, - "ChildEntity": { - "Name": "InvoiceLine", - "KeyColumns": ["LineId"], - "ParentKeyColumns": ["InvoiceId"], - "Columns": ["LineId", "Description", "Amount"] - } -} -``` - -## Project Rules & Standards - -- **FP Style**: Pure static methods over class methods -- **Result Types**: No exceptions, all operations return `Result` -- **Null Safety**: Comprehensive Roslyn analyzers with strict null checking -- **Code Quality**: All warnings treated as errors, extensive static analysis -- **No Regex**: ⚠️ Currently violated - needs refactoring to use proper AST parsing -- **One Type Per File**: Clean organization with proper namespacing -- **Immutable**: Records over classes, immutable collections - -## Roadmap - -### High Priority Fixes -1. **Remove Regex Usage**: Refactor parameter extraction to use SqlParserCS AST properly -2. **Complete SQL Parsing**: Extract SELECT lists, tables, and JOIN information -3. **Fix Extension Targets**: Generate extensions on `IDbConnection`/`ITransaction` interfaces -4. **Generic Code Generation**: Remove hardcoded logic for specific examples - -### Future Enhancements -1. **Schema Integration**: Use database schema inspection for type generation -2. **Multiple Result Sets**: Support for stored procedures with multiple result sets -3. **Query Optimization**: Analysis and suggestions for query performance -4. **Additional Databases**: PostgreSQL, MySQL support - -## Example - -Given a SQL file `GetInvoices.sql`: - -```sql -SELECT - i.Id, - i.InvoiceNumber, - i.CustomerName, - l.Description, - l.Amount -FROM Invoice i -JOIN InvoiceLine l ON l.InvoiceId = i.Id -WHERE i.CustomerName = @customerName -``` - -The generator currently creates code like this. These are only examples. Don't put invoice specific code in the code generator: - -```csharp -// ⚠️ Current implementation - needs improvement -public static async Task, SqlError>> GetInvoicesAsync( - this SqliteConnection connection, - string customerName) -{ - // Generated implementation with basic error handling - // TODO: Improve type mapping and parameter handling -} - -public record Invoice( - int Id, - string InvoiceNumber, - string CustomerName, - ImmutableList InvoiceLines -); - -public record InvoiceLine( - string Description, - decimal Amount -); -``` - -## Contributing - -This project follows strict coding standards enforced by Roslyn analyzers. Key principles: - -- All warnings treated as errors -- Comprehensive null safety analysis -- Functional programming patterns preferred -- Result types instead of exceptions -- No regex - use proper parsing libraries -- Extensive XML documentation required - -See `Directory.Build.props` and `CodeAnalysis.ruleset` for complete rules. \ No newline at end of file diff --git a/DataProvider/DataProvider/CodeGeneration/CodeGenerationConfig.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/CodeGenerationConfig.cs similarity index 97% rename from DataProvider/DataProvider/CodeGeneration/CodeGenerationConfig.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/CodeGenerationConfig.cs index 6b30fa12..f22589c5 100644 --- a/DataProvider/DataProvider/CodeGeneration/CodeGenerationConfig.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/CodeGenerationConfig.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Configuration for code generation with customizable functions @@ -157,7 +157,7 @@ string dataAccessCode sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine("using Microsoft.Data.Sqlite;"); sb.AppendLine("using Outcome;"); - sb.AppendLine("using Selecta;"); + sb.AppendLine("using Nimblesite.Sql.Model;"); sb.AppendLine(); // Generate namespace diff --git a/DataProvider/DataProvider/CodeGeneration/DataAccessGenerator.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGenerator.cs similarity index 99% rename from DataProvider/DataProvider/CodeGeneration/DataAccessGenerator.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGenerator.cs index de7822b4..a084744e 100644 --- a/DataProvider/DataProvider/CodeGeneration/DataAccessGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGenerator.cs @@ -1,9 +1,9 @@ using System.Globalization; using System.Text; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Static methods for generating data access extension methods @@ -103,10 +103,10 @@ public static partial class DataAccessGenerator /// The escaped identifier if it's a reserved keyword, otherwise the original private static string EscapeReservedKeyword(string identifier) { - var lowerIdentifier = identifier.ToLowerInvariant(); - return CSharpReservedKeywords.Contains(lowerIdentifier) - ? $"@{lowerIdentifier}" - : lowerIdentifier; + var upperIdentifier = identifier.ToUpperInvariant(); + return CSharpReservedKeywords.Contains(upperIdentifier) + ? $"@{upperIdentifier}" + : upperIdentifier; } /// diff --git a/DataProvider/DataProvider/CodeGeneration/DataAccessGeneratorNpgsql.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGeneratorNpgsql.cs similarity index 96% rename from DataProvider/DataProvider/CodeGeneration/DataAccessGeneratorNpgsql.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGeneratorNpgsql.cs index b6dc017b..ae7148d2 100644 --- a/DataProvider/DataProvider/CodeGeneration/DataAccessGeneratorNpgsql.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGeneratorNpgsql.cs @@ -1,4 +1,4 @@ -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Npgsql type resolution helpers for DataAccessGenerator diff --git a/DataProvider/DataProvider/CodeGeneration/DefaultCodeTemplate.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DefaultCodeTemplate.cs similarity index 96% rename from DataProvider/DataProvider/CodeGeneration/DefaultCodeTemplate.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DefaultCodeTemplate.cs index a02df7eb..1aab0b29 100644 --- a/DataProvider/DataProvider/CodeGeneration/DefaultCodeTemplate.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DefaultCodeTemplate.cs @@ -1,9 +1,9 @@ using System.Globalization; using System.Text; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Default implementation of the code generation template @@ -69,7 +69,7 @@ string dataAccessCode sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine("using Microsoft.Data.Sqlite;"); sb.AppendLine("using Outcome;"); - sb.AppendLine("using Selecta;"); + sb.AppendLine("using Nimblesite.Sql.Model;"); sb.AppendLine(); // Generate namespace diff --git a/DataProvider/DataProvider/CodeGeneration/DefaultTableOperationGenerator.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DefaultTableOperationGenerator.cs similarity index 96% rename from DataProvider/DataProvider/CodeGeneration/DefaultTableOperationGenerator.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DefaultTableOperationGenerator.cs index 74c998a1..a94335bd 100644 --- a/DataProvider/DataProvider/CodeGeneration/DefaultTableOperationGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DefaultTableOperationGenerator.cs @@ -1,9 +1,9 @@ using System.Globalization; using System.Text; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Default implementation for generating table operations @@ -49,7 +49,7 @@ TableConfig config sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine(CultureInfo.InvariantCulture, $"using {GetConnectionNamespace()};"); sb.AppendLine("using Outcome;"); - sb.AppendLine("using Selecta;"); + sb.AppendLine("using Nimblesite.Sql.Model;"); sb.AppendLine(); sb.AppendLine("namespace Generated"); sb.AppendLine("{"); diff --git a/DataProvider/DataProvider/CodeGeneration/GroupingTransformations.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/GroupingTransformations.cs similarity index 99% rename from DataProvider/DataProvider/CodeGeneration/GroupingTransformations.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/GroupingTransformations.cs index 608b8c86..b0daec7f 100644 --- a/DataProvider/DataProvider/CodeGeneration/GroupingTransformations.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/GroupingTransformations.cs @@ -1,9 +1,9 @@ using System.Globalization; using System.Text; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Pure transformation functions for generating grouped query code diff --git a/DataProvider/DataProvider/CodeGeneration/ICodeTemplate.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ICodeTemplate.cs similarity index 96% rename from DataProvider/DataProvider/CodeGeneration/ICodeTemplate.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ICodeTemplate.cs index 307ec006..e6978dcd 100644 --- a/DataProvider/DataProvider/CodeGeneration/ICodeTemplate.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ICodeTemplate.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Interface for code generation templates that can be customized by users diff --git a/DataProvider/DataProvider/CodeGeneration/IDatabaseEffects.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/IDatabaseEffects.cs similarity index 89% rename from DataProvider/DataProvider/CodeGeneration/IDatabaseEffects.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/IDatabaseEffects.cs index 6b63d9f2..3d033fcb 100644 --- a/DataProvider/DataProvider/CodeGeneration/IDatabaseEffects.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/IDatabaseEffects.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Interface for database effects - operations that interact with the database diff --git a/DataProvider/DataProvider/CodeGeneration/ITableOperationGenerator.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ITableOperationGenerator.cs similarity index 92% rename from DataProvider/DataProvider/CodeGeneration/ITableOperationGenerator.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ITableOperationGenerator.cs index 451b8fd9..975fff2d 100644 --- a/DataProvider/DataProvider/CodeGeneration/ITableOperationGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ITableOperationGenerator.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Interface for generating table operation methods (INSERT, UPDATE) diff --git a/DataProvider/DataProvider/CodeGeneration/ModelGenerator.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ModelGenerator.cs similarity index 99% rename from DataProvider/DataProvider/CodeGeneration/ModelGenerator.cs rename to DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ModelGenerator.cs index e5dcd72b..c6567e28 100644 --- a/DataProvider/DataProvider/CodeGeneration/ModelGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ModelGenerator.cs @@ -1,9 +1,9 @@ using System.Globalization; using System.Text; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.CodeGeneration; +namespace Nimblesite.DataProvider.Core.CodeGeneration; /// /// Static methods for generating model/record types from database metadata diff --git a/DataProvider/DataProvider/DataProviderConfig.cs b/DataProvider/Nimblesite.DataProvider.Core/DataProviderConfig.cs similarity index 98% rename from DataProvider/DataProvider/DataProviderConfig.cs rename to DataProvider/Nimblesite.DataProvider.Core/DataProviderConfig.cs index 8d2afdbb..44bf26d3 100644 --- a/DataProvider/DataProvider/DataProviderConfig.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/DataProviderConfig.cs @@ -1,4 +1,4 @@ -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Configuration for DataProvider code generation diff --git a/DataProvider/DataProvider/DbConnectionExtensions.cs b/DataProvider/Nimblesite.DataProvider.Core/DbConnectionExtensions.cs similarity index 99% rename from DataProvider/DataProvider/DbConnectionExtensions.cs rename to DataProvider/Nimblesite.DataProvider.Core/DbConnectionExtensions.cs index a023be00..8b2799e3 100644 --- a/DataProvider/DataProvider/DbConnectionExtensions.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/DbConnectionExtensions.cs @@ -1,8 +1,8 @@ using System.Data; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Static extension methods for IDbConnection following FP patterns. diff --git a/DataProvider/DataProvider/DbTransact.cs b/DataProvider/Nimblesite.DataProvider.Core/DbTransact.cs similarity index 98% rename from DataProvider/DataProvider/DbTransact.cs rename to DataProvider/Nimblesite.DataProvider.Core/DbTransact.cs index 149d0496..4d052936 100644 --- a/DataProvider/DataProvider/DbTransact.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/DbTransact.cs @@ -1,7 +1,7 @@ using System.Data; using System.Data.Common; -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Provides transactional helpers as extension methods for . diff --git a/DataProvider/DataProvider/DbTransactionExtensions.cs b/DataProvider/Nimblesite.DataProvider.Core/DbTransactionExtensions.cs similarity index 98% rename from DataProvider/DataProvider/DbTransactionExtensions.cs rename to DataProvider/Nimblesite.DataProvider.Core/DbTransactionExtensions.cs index 36b4009c..e6a5e58e 100644 --- a/DataProvider/DataProvider/DbTransactionExtensions.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/DbTransactionExtensions.cs @@ -1,8 +1,8 @@ using System.Data; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Static extension methods for IDbTransaction following FP patterns diff --git a/DataProvider/DataProvider/GroupingConfig.cs b/DataProvider/Nimblesite.DataProvider.Core/GroupingConfig.cs similarity index 92% rename from DataProvider/DataProvider/GroupingConfig.cs rename to DataProvider/Nimblesite.DataProvider.Core/GroupingConfig.cs index 26ade37e..27fc3b74 100644 --- a/DataProvider/DataProvider/GroupingConfig.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/GroupingConfig.cs @@ -1,4 +1,4 @@ -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Configuration for grouping query results into parent-child relationships diff --git a/DataProvider/DataProvider/ICodeGenerator.cs b/DataProvider/Nimblesite.DataProvider.Core/ICodeGenerator.cs similarity index 97% rename from DataProvider/DataProvider/ICodeGenerator.cs rename to DataProvider/Nimblesite.DataProvider.Core/ICodeGenerator.cs index fc4620e1..dac0777b 100644 --- a/DataProvider/DataProvider/ICodeGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/ICodeGenerator.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Type aliases for code generation functions diff --git a/DataProvider/DataProvider/DataProvider.csproj b/DataProvider/Nimblesite.DataProvider.Core/Nimblesite.DataProvider.Core.csproj similarity index 75% rename from DataProvider/DataProvider/DataProvider.csproj rename to DataProvider/Nimblesite.DataProvider.Core/Nimblesite.DataProvider.Core.csproj index 851b9275..c2142b14 100644 --- a/DataProvider/DataProvider/DataProvider.csproj +++ b/DataProvider/Nimblesite.DataProvider.Core/Nimblesite.DataProvider.Core.csproj @@ -1,16 +1,16 @@ - DataProvider + Nimblesite.DataProvider.Core ChristianFindlay A source generator that creates compile-time safe extension methods for database operations from SQL files. Generates strongly-typed C# code based on your SQL queries and database schema, ensuring type safety and eliminating runtime SQL errors. source-generator;sql;database;compile-time-safety;code-generation;sqlite;sqlserver - https://github.com/christianfindlay/DataProvider - https://github.com/christianfindlay/DataProvider + https://github.com/christianfindlay/Nimblesite.DataProvider.Core + https://github.com/christianfindlay/Nimblesite.DataProvider.Core git MIT false - Initial release of DataProvider source generator for compile-time safe database operations. + Initial release of Nimblesite.DataProvider.Core source generator for compile-time safe database operations. true @@ -19,7 +19,7 @@ - + diff --git a/DataProvider/DataProvider/QueryConfigItem.cs b/DataProvider/Nimblesite.DataProvider.Core/QueryConfigItem.cs similarity index 93% rename from DataProvider/DataProvider/QueryConfigItem.cs rename to DataProvider/Nimblesite.DataProvider.Core/QueryConfigItem.cs index 55a2cd62..4cf52a34 100644 --- a/DataProvider/DataProvider/QueryConfigItem.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/QueryConfigItem.cs @@ -1,4 +1,4 @@ -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Represents a query configuration entry for the source generator. diff --git a/DataProvider/Nimblesite.DataProvider.Core/README.md b/DataProvider/Nimblesite.DataProvider.Core/README.md new file mode 100644 index 00000000..0b001904 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Core/README.md @@ -0,0 +1,8 @@ +# DataProvider Core + +The core source generator library. Parses SQL files and generates strongly-typed extension methods. Connects to the database at compile time to get query metadata. + +## Documentation + +- Parent README: [DataProvider/README.md](../README.md) +- Migration CLI spec: [docs/specs/migration-cli-spec.md](../../docs/specs/migration-cli-spec.md) diff --git a/DataProvider/DataProvider/SchemaTypes.cs b/DataProvider/Nimblesite.DataProvider.Core/SchemaTypes.cs similarity index 98% rename from DataProvider/DataProvider/SchemaTypes.cs rename to DataProvider/Nimblesite.DataProvider.Core/SchemaTypes.cs index 37a69760..b2366530 100644 --- a/DataProvider/DataProvider/SchemaTypes.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/SchemaTypes.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Represents a database column with its metadata diff --git a/DataProvider/DataProvider/SourceGeneratorDataProviderConfiguration.cs b/DataProvider/Nimblesite.DataProvider.Core/SourceGeneratorDataProviderConfiguration.cs similarity index 90% rename from DataProvider/DataProvider/SourceGeneratorDataProviderConfiguration.cs rename to DataProvider/Nimblesite.DataProvider.Core/SourceGeneratorDataProviderConfiguration.cs index 75fb7ebb..936f0721 100644 --- a/DataProvider/DataProvider/SourceGeneratorDataProviderConfiguration.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/SourceGeneratorDataProviderConfiguration.cs @@ -1,11 +1,11 @@ using System.Collections.Immutable; -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; // Configuration classes for JSON deserialization specific to source generator // Note: These are separate from the main DataProvider config classes to avoid conflicts /// -/// Configuration for the DataProvider source generator when reading DataProvider.json at compile time. +/// Configuration for the DataProvider source generator when reading Nimblesite.DataProvider.Core.json at compile time. /// public class SourceGeneratorDataProviderConfiguration { diff --git a/DataProvider/DataProvider/TableConfigItem.cs b/DataProvider/Nimblesite.DataProvider.Core/TableConfigItem.cs similarity index 96% rename from DataProvider/DataProvider/TableConfigItem.cs rename to DataProvider/Nimblesite.DataProvider.Core/TableConfigItem.cs index 1620f803..819d04bd 100644 --- a/DataProvider/DataProvider/TableConfigItem.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/TableConfigItem.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace DataProvider; +namespace Nimblesite.DataProvider.Core; /// /// Represents table operation generation settings for the source generator. diff --git a/DataProvider/DataProvider.Example.FSharp/GetCustomers.lql b/DataProvider/Nimblesite.DataProvider.Example.FSharp/GetCustomers.lql similarity index 100% rename from DataProvider/DataProvider.Example.FSharp/GetCustomers.lql rename to DataProvider/Nimblesite.DataProvider.Example.FSharp/GetCustomers.lql diff --git a/DataProvider/DataProvider.Example.FSharp/GetInvoices.lql b/DataProvider/Nimblesite.DataProvider.Example.FSharp/GetInvoices.lql similarity index 100% rename from DataProvider/DataProvider.Example.FSharp/GetInvoices.lql rename to DataProvider/Nimblesite.DataProvider.Example.FSharp/GetInvoices.lql diff --git a/DataProvider/DataProvider.Example.FSharp/LqlValidator.fs b/DataProvider/Nimblesite.DataProvider.Example.FSharp/LqlValidator.fs similarity index 97% rename from DataProvider/DataProvider.Example.FSharp/LqlValidator.fs rename to DataProvider/Nimblesite.DataProvider.Example.FSharp/LqlValidator.fs index ab61075b..a1a6e715 100644 --- a/DataProvider/DataProvider.Example.FSharp/LqlValidator.fs +++ b/DataProvider/Nimblesite.DataProvider.Example.FSharp/LqlValidator.fs @@ -2,10 +2,10 @@ module LqlValidator open System open Microsoft.Data.Sqlite -open Lql -open Lql.SQLite +open Nimblesite.Lql.Core +open Nimblesite.Lql.SQLite open Outcome -open Selecta +open Nimblesite.Sql.Model //TODO: this does not belong here. Move to core code diff --git a/DataProvider/DataProvider.Example.FSharp/DataProvider.Example.FSharp.fsproj b/DataProvider/Nimblesite.DataProvider.Example.FSharp/Nimblesite.DataProvider.Example.FSharp.fsproj similarity index 66% rename from DataProvider/DataProvider.Example.FSharp/DataProvider.Example.FSharp.fsproj rename to DataProvider/Nimblesite.DataProvider.Example.FSharp/Nimblesite.DataProvider.Example.FSharp.fsproj index 15eb8fb6..93e0d7b7 100644 --- a/DataProvider/DataProvider.Example.FSharp/DataProvider.Example.FSharp.fsproj +++ b/DataProvider/Nimblesite.DataProvider.Example.FSharp/Nimblesite.DataProvider.Example.FSharp.fsproj @@ -2,7 +2,7 @@ Exe - net10.0 + net9.0 true preview false @@ -29,10 +29,10 @@ - - - - + + + + \ No newline at end of file diff --git a/DataProvider/DataProvider.Example.FSharp/Program.fs b/DataProvider/Nimblesite.DataProvider.Example.FSharp/Program.fs similarity index 100% rename from DataProvider/DataProvider.Example.FSharp/Program.fs rename to DataProvider/Nimblesite.DataProvider.Example.FSharp/Program.fs diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/CoreCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/CoreCoverageTests.cs new file mode 100644 index 00000000..b7390987 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/CoreCoverageTests.cs @@ -0,0 +1,912 @@ +using System.Data; +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Outcome; +using Xunit; + +namespace Nimblesite.DataProvider.Example.Tests; + +#pragma warning disable CS1591 + +/// +/// Tests for DataProvider.Core methods: DbConnectionExtensions, DbTransact, DbTransactionExtensions +/// +public sealed class CoreCoverageTests : IDisposable +{ + private readonly string _dbPath; + private readonly SqliteConnection _connection; + + public CoreCoverageTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"core_coverage_tests_{Guid.NewGuid()}.db"); + _connection = new SqliteConnection($"Data Source={_dbPath}"); + } + + #region DbConnectionExtensions.Query + + [Fact] + public async Task Query_WithValidSqlAndMapper_ReturnsResults() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Query( + sql: "SELECT Id, CustomerName, Email FROM Customer", + mapper: reader => + (Id: reader.GetString(0), Name: reader.GetString(1), Email: reader.GetString(2)) + ); + + Assert.True( + result + is Result, SqlError>.Ok< + IReadOnlyList<(string Id, string Name, string Email)>, + SqlError + > + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string Name, string Email)>, + SqlError + >)result; + Assert.Equal(2, ok.Value.Count); + } + + [Fact] + public async Task Query_WithParameters_ReturnsFilteredResults() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Query( + sql: "SELECT Id, CustomerName FROM Customer WHERE CustomerName = @name", + parameters: [new SqliteParameter("@name", "Acme Corp")], + mapper: reader => reader.GetString(1) + ); + + Assert.True( + result is Result, SqlError>.Ok, SqlError> + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)result; + Assert.Single(ok.Value); + Assert.Equal("Acme Corp", ok.Value[0]); + } + + [Fact] + public void Query_WithNullConnection_ReturnsError() + { + IDbConnection? nullConnection = null; + + var result = nullConnection!.Query(sql: "SELECT 1"); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public async Task Query_WithEmptySql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Query(sql: ""); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public async Task Query_WithInvalidSql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Query( + sql: "SELECT FROM INVALID_TABLE_THAT_DOES_NOT_EXIST", + mapper: reader => reader.GetString(0) + ); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public async Task Query_WithNullMapper_ReturnsEmptyResults() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Query(sql: "SELECT Id FROM Customer", mapper: null); + + Assert.True( + result is Result, SqlError>.Ok, SqlError> + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)result; + Assert.Empty(ok.Value); + } + + #endregion + + #region DbConnectionExtensions.Execute + + [Fact] + public async Task Execute_WithValidSql_ReturnsRowsAffected() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Execute( + sql: "UPDATE Customer SET Email = 'updated@test.com' WHERE CustomerName = 'Acme Corp'" + ); + + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(1, ok.Value); + } + + [Fact] + public async Task Execute_WithParameters_ReturnsRowsAffected() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Execute( + sql: "UPDATE Customer SET Email = @email WHERE CustomerName = @name", + parameters: + [ + new SqliteParameter("@email", "new@test.com"), + new SqliteParameter("@name", "Acme Corp"), + ] + ); + + Assert.True(result is Result.Ok); + } + + [Fact] + public void Execute_WithNullConnection_ReturnsError() + { + IDbConnection? nullConnection = null; + + var result = nullConnection!.Execute(sql: "DELETE FROM Customer"); + + Assert.True(result is Result.Error); + } + + [Fact] + public async Task Execute_WithEmptySql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Execute(sql: " "); + + Assert.True(result is Result.Error); + } + + [Fact] + public async Task Execute_WithInvalidSql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Execute(sql: "INSERT INTO NonExistentTable VALUES (1)"); + + Assert.True(result is Result.Error); + } + + #endregion + + #region DbConnectionExtensions.Scalar + + [Fact] + public async Task Scalar_WithValidSql_ReturnsValue() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Scalar(sql: "SELECT COUNT(*) FROM Customer"); + + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(2L, ok.Value); + } + + [Fact] + public async Task Scalar_WithParameters_ReturnsFilteredValue() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Scalar( + sql: "SELECT COUNT(*) FROM Customer WHERE CustomerName = @name", + parameters: [new SqliteParameter("@name", "Acme Corp")] + ); + + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(1L, ok.Value); + } + + [Fact] + public void Scalar_WithNullConnection_ReturnsError() + { + IDbConnection? nullConnection = null; + + var result = nullConnection!.Scalar(sql: "SELECT 1"); + + Assert.True(result is Result.Error); + } + + [Fact] + public async Task Scalar_WithEmptySql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Scalar(sql: ""); + + Assert.True(result is Result.Error); + } + + [Fact] + public async Task Scalar_WithInvalidSql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.Scalar(sql: "SELECT FROM INVALID"); + + Assert.True(result is Result.Error); + } + + #endregion + + #region DbConnectionExtensions.GetRecords + + [Fact] + public void GetRecords_WithNullConnection_ReturnsError() + { + IDbConnection? nullConnection = null; + var statement = "Customer".From().SelectAll().ToSqlStatement(); + + var result = nullConnection!.GetRecords( + statement, + stmt => stmt.ToSQLite(), + reader => reader.GetString(0) + ); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public async Task GetRecords_WithNullStatement_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + null!, + stmt => stmt.ToSQLite(), + reader => reader.GetString(0) + ); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public async Task GetRecords_WithNullGenerator_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + var statement = "Customer".From().SelectAll().ToSqlStatement(); + + var result = _connection.GetRecords( + statement, + null!, + reader => reader.GetString(0) + ); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public async Task GetRecords_WithNullMapper_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + var statement = "Customer".From().SelectAll().ToSqlStatement(); + + var result = _connection.GetRecords(statement, stmt => stmt.ToSQLite(), null!); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + #endregion + + #region DbTransact + + [Fact] + public async Task Transact_VoidVersion_CommitsOnSuccess() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var insertResult = tx.Execute( + sql: "INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES ('cust-3', 'Test Corp', 'test@test.com', '555-0300', '2024-03-01')" + ); + Assert.True(insertResult is Result.Ok); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + + var result = _connection.Query( + sql: "SELECT COUNT(*) FROM Customer", + mapper: reader => reader.GetInt64(0) + ); + var ok = (Result, SqlError>.Ok, SqlError>)result; + Assert.Equal(3L, ok.Value[0]); + } + + [Fact] + public async Task Transact_WithReturnValue_CommitsAndReturnsResult() + { + await SetupDatabase().ConfigureAwait(false); + + var result = await _connection + .Transact(async tx => + { + tx.Execute( + sql: "INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES ('cust-4', 'Return Corp', 'return@test.com', '555-0400', '2024-04-01')" + ); + await Task.CompletedTask.ConfigureAwait(false); + return "success"; + }) + .ConfigureAwait(false); + + Assert.Equal("success", result); + + var countResult = _connection.Query( + sql: "SELECT COUNT(*) FROM Customer", + mapper: reader => reader.GetInt64(0) + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)countResult; + Assert.Equal(3L, ok.Value[0]); + } + + [Fact] + public async Task Transact_OnException_RollsBack() + { + await SetupDatabase().ConfigureAwait(false); + + await Assert + .ThrowsAsync(async () => + { + await _connection + .Transact(async tx => + { + tx.Execute( + sql: "INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES ('cust-rollback', 'Rollback Corp', 'roll@test.com', '555-0500', '2024-05-01')" + ); + await Task.CompletedTask.ConfigureAwait(false); + throw new InvalidOperationException("Simulated failure"); + }) + .ConfigureAwait(false); + }) + .ConfigureAwait(false); + + var countResult = _connection.Query( + sql: "SELECT COUNT(*) FROM Customer", + mapper: reader => reader.GetInt64(0) + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)countResult; + Assert.Equal(2L, ok.Value[0]); + } + + [Fact] + public async Task Transact_WithReturnValue_OnException_RollsBack() + { + await SetupDatabase().ConfigureAwait(false); + + await Assert + .ThrowsAsync(async () => + { + await _connection + .Transact(async tx => + { + tx.Execute( + sql: "INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES ('cust-rb2', 'Rollback2 Corp', 'rb2@test.com', null, '2024-06-01')" + ); + await Task.CompletedTask.ConfigureAwait(false); + throw new InvalidOperationException("Simulated failure"); + }) + .ConfigureAwait(false); + }) + .ConfigureAwait(false); + + var countResult = _connection.Query( + sql: "SELECT COUNT(*) FROM Customer", + mapper: reader => reader.GetInt64(0) + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)countResult; + Assert.Equal(2L, ok.Value[0]); + } + + #endregion + + #region DbTransactionExtensions + + [Fact] + public async Task TransactionQuery_WithValidSql_ReturnsResults() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Query( + sql: "SELECT CustomerName FROM Customer ORDER BY CustomerName", + mapper: reader => reader.GetString(0) + ); + + Assert.True( + result + is Result, SqlError>.Ok< + IReadOnlyList, + SqlError + > + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)result; + Assert.Equal(2, ok.Value.Count); + Assert.Equal("Acme Corp", ok.Value[0]); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task TransactionQuery_WithParameters_ReturnsFilteredResults() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Query( + sql: "SELECT CustomerName FROM Customer WHERE Email = @email", + parameters: [new SqliteParameter("@email", "contact@acme.com")], + mapper: reader => reader.GetString(0) + ); + + Assert.True( + result + is Result, SqlError>.Ok< + IReadOnlyList, + SqlError + > + ); + var ok = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)result; + Assert.Single(ok.Value); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public void TransactionQuery_WithNullTransaction_ReturnsError() + { + IDbTransaction? nullTx = null; + + var result = nullTx!.Query(sql: "SELECT 1"); + + Assert.True( + result is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public async Task TransactionQuery_WithEmptySql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Query(sql: ""); + Assert.True( + result + is Result, SqlError>.Error< + IReadOnlyList, + SqlError + > + ); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task TransactionExecute_WithValidSql_ReturnsRowsAffected() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Execute( + sql: "UPDATE Customer SET Phone = '555-9999' WHERE CustomerName = 'Acme Corp'" + ); + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(1, ok.Value); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task TransactionExecute_WithParameters_ReturnsRowsAffected() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Execute( + sql: "UPDATE Customer SET Phone = @phone WHERE CustomerName = @name", + parameters: + [ + new SqliteParameter("@phone", "555-8888"), + new SqliteParameter("@name", "Tech Solutions"), + ] + ); + Assert.True(result is Result.Ok); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public void TransactionExecute_WithNullTransaction_ReturnsError() + { + IDbTransaction? nullTx = null; + + var result = nullTx!.Execute(sql: "DELETE FROM Customer"); + + Assert.True(result is Result.Error); + } + + [Fact] + public async Task TransactionExecute_WithEmptySql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Execute(sql: " "); + Assert.True(result is Result.Error); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task TransactionScalar_WithValidSql_ReturnsValue() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Scalar(sql: "SELECT COUNT(*) FROM Customer"); + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(2L, ok.Value); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task TransactionScalar_WithParameters_ReturnsValue() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Scalar( + sql: "SELECT COUNT(*) FROM Customer WHERE CustomerName = @name", + parameters: [new SqliteParameter("@name", "Acme Corp")] + ); + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(1L, ok.Value); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + [Fact] + public void TransactionScalar_WithNullTransaction_ReturnsError() + { + IDbTransaction? nullTx = null; + + var result = nullTx!.Scalar(sql: "SELECT 1"); + + Assert.True(result is Result.Error); + } + + [Fact] + public async Task TransactionScalar_WithEmptySql_ReturnsError() + { + await SetupDatabase().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = tx.Scalar(sql: ""); + Assert.True(result is Result.Error); + await Task.CompletedTask.ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + + #endregion + + #region Config Types Coverage + + [Fact] + public void DataProviderConfig_CanBeCreated() + { + var config = new Nimblesite.DataProvider.Core.DataProviderConfig + { + ConnectionString = "Data Source=test.db", + Tables = new List + { + new() + { + Schema = "main", + Name = "users", + GenerateInsert = true, + GenerateUpdate = true, + GenerateDelete = true, + ExcludeColumns = new List { "computed" }.AsReadOnly(), + PrimaryKeyColumns = new List { "Id" }.AsReadOnly(), + }, + }.AsReadOnly(), + }; + + Assert.NotNull(config.ConnectionString); + Assert.Single(config.Tables); + Assert.Equal("main", config.Tables[0].Schema); + Assert.Equal("users", config.Tables[0].Name); + Assert.True(config.Tables[0].GenerateInsert); + Assert.True(config.Tables[0].GenerateUpdate); + Assert.True(config.Tables[0].GenerateDelete); + Assert.Single(config.Tables[0].ExcludeColumns); + Assert.Single(config.Tables[0].PrimaryKeyColumns); + } + + [Fact] + public void DatabaseColumn_CanBeCreated() + { + var column = new Nimblesite.DataProvider.Core.DatabaseColumn + { + Name = "Id", + SqlType = "TEXT", + CSharpType = "string", + IsNullable = false, + IsPrimaryKey = true, + IsIdentity = false, + IsComputed = false, + MaxLength = 50, + Precision = 10, + Scale = 2, + }; + Assert.Equal("Id", column.Name); + Assert.True(column.IsPrimaryKey); + Assert.Equal(50, column.MaxLength); + } + + [Fact] + public void DatabaseTable_ComputedProperties_Work() + { + var table = new Nimblesite.DataProvider.Core.DatabaseTable + { + Schema = "main", + Name = "TestTable", + Columns = new List + { + new() + { + Name = "Id", + SqlType = "TEXT", + CSharpType = "string", + IsPrimaryKey = true, + }, + new() + { + Name = "Name", + SqlType = "TEXT", + CSharpType = "string", + }, + new() + { + Name = "AutoId", + SqlType = "INTEGER", + CSharpType = "int", + IsIdentity = true, + }, + new() + { + Name = "Computed", + SqlType = "TEXT", + CSharpType = "string", + IsComputed = true, + }, + }.AsReadOnly(), + }; + + Assert.Equal("main", table.Schema); + Assert.Equal("TestTable", table.Name); + Assert.Single(table.PrimaryKeyColumns); + Assert.Equal("Id", table.PrimaryKeyColumns[0].Name); + Assert.Equal(2, table.InsertableColumns.Count); // Id, Name (not AutoId, not Computed) + Assert.Single(table.UpdateableColumns); // Name only (not PK, not Identity, not Computed) + } + + [Fact] + public void SqlQueryMetadata_CanBeCreated() + { + var metadata = new Nimblesite.DataProvider.Core.SqlQueryMetadata + { + SqlText = "SELECT * FROM Test", + Columns = new List + { + new() + { + Name = "Id", + SqlType = "TEXT", + CSharpType = "string", + }, + }.AsReadOnly(), + }; + + Assert.Equal("SELECT * FROM Test", metadata.SqlText); + Assert.Single(metadata.Columns); + } + + [Fact] + public void GroupingConfig_CanBeCreated() + { + var parent = new Nimblesite.DataProvider.Core.EntityConfig( + Name: "Invoice", + KeyColumns: new List { "Id" }.AsReadOnly(), + Columns: new List { "Id", "InvoiceNumber" }.AsReadOnly() + ); + var child = new Nimblesite.DataProvider.Core.EntityConfig( + Name: "InvoiceLine", + KeyColumns: new List { "Id" }.AsReadOnly(), + Columns: new List { "Id", "InvoiceId" }.AsReadOnly(), + ParentKeyColumns: new List { "InvoiceId" }.AsReadOnly() + ); + var config = new Nimblesite.DataProvider.Core.GroupingConfig( + QueryName: "GetInvoices", + GroupingStrategy: "ParentChild", + ParentEntity: parent, + ChildEntity: child + ); + + Assert.Equal("Invoice", config.ParentEntity.Name); + Assert.Equal("GetInvoices", config.QueryName); + Assert.NotNull(child.ParentKeyColumns); + } + + [Fact] + public void QueryConfigItem_CanBeCreated() + { + var item = new Nimblesite.DataProvider.Core.QueryConfigItem + { + Name = "GetInvoices", + SqlFile = "GetInvoices.sql", + GroupingFile = "GetInvoices.grouping.json", + }; + Assert.Equal("GetInvoices", item.Name); + Assert.Equal("GetInvoices.sql", item.SqlFile); + Assert.Equal("GetInvoices.grouping.json", item.GroupingFile); + } + + [Fact] + public void TableConfigItem_CanBeCreated() + { + var item = new Nimblesite.DataProvider.Core.TableConfigItem + { + Name = "Invoice", + Schema = "main", + GenerateInsert = true, + GenerateUpdate = true, + GenerateDelete = false, + ExcludeColumns = ["computed"], + PrimaryKeyColumns = ["Id"], + }; + Assert.Equal("Invoice", item.Name); + Assert.Equal("main", item.Schema); + Assert.True(item.GenerateInsert); + Assert.Single(item.ExcludeColumns); + } + + [Fact] + public void SourceGeneratorConfig_CanBeCreated() + { + var config = new Nimblesite.DataProvider.Core.SourceGeneratorDataProviderConfiguration + { + ConnectionString = "test", + }; + Assert.Equal("test", config.ConnectionString); + } + + #endregion + + private async Task SetupDatabase() + { + await _connection.OpenAsync().ConfigureAwait(false); + + var createTablesScript = """ + CREATE TABLE IF NOT EXISTS Customer ( + Id TEXT PRIMARY KEY, + CustomerName TEXT NOT NULL, + Email TEXT NULL, + Phone TEXT NULL, + CreatedDate TEXT NOT NULL + ); + """; + + using var command = new SqliteCommand(createTablesScript, _connection); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + + var insertScript = """ + INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES + ('cust-1', 'Acme Corp', 'contact@acme.com', '555-0100', '2024-01-01'), + ('cust-2', 'Tech Solutions', 'info@techsolutions.com', '555-0200', '2024-01-02'); + """; + + using var insertCommand = new SqliteCommand(insertScript, _connection); + await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + public void Dispose() + { + _connection?.Dispose(); + if (File.Exists(_dbPath)) + { + try + { + File.Delete(_dbPath); + } +#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort + catch (IOException) + { + /* File may be locked */ + } +#pragma warning restore CA1031 + } + } +} diff --git a/DataProvider/DataProvider.Example.Tests/DataProviderIntegrationTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/DataProviderIntegrationTests.cs similarity index 99% rename from DataProvider/DataProvider.Example.Tests/DataProviderIntegrationTests.cs rename to DataProvider/Nimblesite.DataProvider.Example.Tests/DataProviderIntegrationTests.cs index de845d7f..825ba8d9 100644 --- a/DataProvider/DataProvider.Example.Tests/DataProviderIntegrationTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/DataProviderIntegrationTests.cs @@ -1,10 +1,10 @@ -using Lql.SQLite; using Microsoft.Data.Sqlite; -using Selecta; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; using Xunit; -using static DataProvider.Example.MapFunctions; +using static Nimblesite.DataProvider.Example.MapFunctions; -namespace DataProvider.Example.Tests; +namespace Nimblesite.DataProvider.Example.Tests; #pragma warning disable CS1591 diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.cs new file mode 100644 index 00000000..6976628f --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.cs @@ -0,0 +1,636 @@ +using Microsoft.Data.Sqlite; +using Nimblesite.Sql.Model; +using Outcome; +using Xunit; + +namespace Nimblesite.DataProvider.Example.Tests; + +#pragma warning disable CS1591 + +/// +/// Tests for generated Insert/Update operations and SampleDataSeeder +/// +public sealed class GeneratedOperationsCoverageTests : IDisposable +{ + private readonly string _dbPath; + private readonly SqliteConnection _connection; + + public GeneratedOperationsCoverageTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"generated_ops_tests_{Guid.NewGuid()}.db"); + _connection = new SqliteConnection($"Data Source={_dbPath}"); + } + + [Fact] + public async Task SampleDataSeeder_SeedDataAsync_InsertsAllEntities() + { + await SetupSchema().ConfigureAwait(false); + + var (flowControl, result) = await _connection + .Transact(async tx => await SampleDataSeeder.SeedDataAsync(tx).ConfigureAwait(false)) + .ConfigureAwait(false); + + Assert.True(flowControl); + Assert.True(result is StringSqlOk); + } + + [Fact] + public async Task InsertCustomerAsync_WithValidData_ReturnsOk() + { + await SetupSchema().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = await tx.InsertCustomerAsync( + Guid.NewGuid().ToString(), + "Test Customer", + "test@test.com", + "555-1234", + "2024-01-01" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateCustomerAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + // First get an existing customer ID + var queryResult = tx.Query( + sql: "SELECT Id FROM Customer LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var customers = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + var customerId = customers.Value[0]; + + var result = await tx.UpdateCustomerAsync( + customerId, + "Updated Customer", + "updated@test.com", + "555-9999", + "2024-06-01" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(1, ok.Value); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task InsertInvoiceAsync_WithValidData_ReturnsOk() + { + await SetupSchema().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = await tx.InsertInvoiceAsync( + Guid.NewGuid().ToString(), + "INV-TEST-001", + "2024-06-01", + "Test Corp", + "billing@test.com", + 1500.00, + null, + "Test invoice" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateInvoiceAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id FROM Invoice LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var invoices = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + var invoiceId = invoices.Value[0]; + + var result = await tx.UpdateInvoiceAsync( + invoiceId, + "INV-UPDATED", + "2024-07-01", + "Updated Corp", + "updated@billing.com", + 2000.00, + 100.00, + "Updated notes" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task InsertInvoiceLineAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id FROM Invoice LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var invoices = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + var invoiceId = invoices.Value[0]; + + var result = await tx.InsertInvoiceLineAsync( + Guid.NewGuid().ToString(), + invoiceId, + "Test Line Item", + 2.0, + 75.00, + 150.00, + 5.0, + "Test notes" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateInvoiceLineAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, InvoiceId FROM InvoiceLine LIMIT 1", + mapper: reader => (Id: reader.GetString(0), InvoiceId: reader.GetString(1)) + ); + var lines = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string InvoiceId)>, + SqlError + >)queryResult; + var line = lines.Value[0]; + + var result = await tx.UpdateInvoiceLineAsync( + line.Id, + line.InvoiceId, + "Updated Description", + 3.0, + 100.00, + 300.00, + 10.0, + "Updated notes" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task InsertAddressAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id FROM Customer LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var customers = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + + var result = await tx.InsertAddressAsync( + Guid.NewGuid().ToString(), + customers.Value[0], + "100 Test St", + "TestCity", + "TS", + "12345", + "USA" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateAddressAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, CustomerId FROM Address LIMIT 1", + mapper: reader => (Id: reader.GetString(0), CustomerId: reader.GetString(1)) + ); + var addresses = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string CustomerId)>, + SqlError + >)queryResult; + var addr = addresses.Value[0]; + + var result = await tx.UpdateAddressAsync( + addr.Id, + addr.CustomerId, + "200 Updated Ave", + "UpdatedCity", + "UC", + "67890", + "USA" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task InsertOrdersAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id FROM Customer LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var customers = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + + var result = await tx.InsertOrdersAsync( + Guid.NewGuid().ToString(), + "ORD-TEST-001", + "2024-06-01", + customers.Value[0], + 999.99, + "Pending" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateOrdersAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, CustomerId FROM Orders LIMIT 1", + mapper: reader => (Id: reader.GetString(0), CustomerId: reader.GetString(1)) + ); + var orders = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string CustomerId)>, + SqlError + >)queryResult; + var order = orders.Value[0]; + + var result = await tx.UpdateOrdersAsync( + order.Id, + "ORD-UPDATED", + "2024-07-01", + order.CustomerId, + 1500.00, + "Completed" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task InsertOrderItemAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id FROM Orders LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var orders = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + + var result = await tx.InsertOrderItemAsync( + Guid.NewGuid().ToString(), + orders.Value[0], + "Test Widget", + 5.0, + 25.00, + 125.00 + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateOrderItemAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, OrderId FROM OrderItem LIMIT 1", + mapper: reader => (Id: reader.GetString(0), OrderId: reader.GetString(1)) + ); + var items = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string OrderId)>, + SqlError + >)queryResult; + var item = items.Value[0]; + + var result = await tx.UpdateOrderItemAsync( + item.Id, + item.OrderId, + "Updated Widget", + 10.0, + 50.00, + 500.00 + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateCustomerAsync_WithNonExistentId_ReturnsZeroRows() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = await tx.UpdateCustomerAsync( + "nonexistent-id", + "Updated", + "u@t.com", + "555-0000", + "2024-01-01" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + var ok = (Result.Ok)result; + Assert.Equal(0, ok.Value); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateInvoiceAsync_WithNonExistentId_ReturnsZeroRows() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = await tx.UpdateInvoiceAsync( + "nonexistent", + "INV-X", + "2024-01-01", + "X", + "x@t.com", + 0.0, + 0.0, + "n" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateAddressAsync_WithNonExistentId_ReturnsZeroRows() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = await tx.UpdateAddressAsync( + "nonexistent", + "cust-1", + "St", + "City", + "ST", + "00000", + "US" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateInvoiceLineAsync_WithNonExistentId_ReturnsZeroRows() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = await tx.UpdateInvoiceLineAsync( + "nonexistent", + "inv-1", + "Desc", + 1.0, + 10.0, + 10.0, + 0.0, + "n" + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + [Fact] + public async Task UpdateOrderItemAsync_WithNonExistentId_ReturnsZeroRows() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var result = await tx.UpdateOrderItemAsync( + "nonexistent", + "ord-1", + "Product", + 1.0, + 10.0, + 10.0 + ) + .ConfigureAwait(false); + Assert.True(result is Result.Ok); + }) + .ConfigureAwait(false); + } + + private async Task SetupSchema() + { + await _connection.OpenAsync().ConfigureAwait(false); + using (var pragmaCommand = new SqliteCommand("PRAGMA foreign_keys = OFF", _connection)) + { + await pragmaCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + var createTablesScript = """ + CREATE TABLE IF NOT EXISTS Invoice ( + Id TEXT PRIMARY KEY, + InvoiceNumber TEXT NOT NULL, + InvoiceDate TEXT NOT NULL, + CustomerName TEXT NOT NULL, + CustomerEmail TEXT NULL, + TotalAmount REAL NOT NULL, + DiscountAmount REAL NULL, + Notes TEXT NULL + ); + CREATE TABLE IF NOT EXISTS InvoiceLine ( + Id TEXT PRIMARY KEY, + InvoiceId TEXT NOT NULL, + Description TEXT NOT NULL, + Quantity REAL NOT NULL, + UnitPrice REAL NOT NULL, + Amount REAL NOT NULL, + DiscountPercentage REAL NULL, + Notes TEXT NULL, + FOREIGN KEY (InvoiceId) REFERENCES Invoice (Id) + ); + CREATE TABLE IF NOT EXISTS Customer ( + Id TEXT PRIMARY KEY, + CustomerName TEXT NOT NULL, + Email TEXT NULL, + Phone TEXT NULL, + CreatedDate TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS Address ( + Id TEXT PRIMARY KEY, + CustomerId TEXT NOT NULL, + Street TEXT NOT NULL, + City TEXT NOT NULL, + State TEXT NOT NULL, + ZipCode TEXT NOT NULL, + Country TEXT NOT NULL, + FOREIGN KEY (CustomerId) REFERENCES Customer (Id) + ); + CREATE TABLE IF NOT EXISTS Orders ( + Id TEXT PRIMARY KEY, + OrderNumber TEXT NOT NULL, + OrderDate TEXT NOT NULL, + CustomerId TEXT NOT NULL, + TotalAmount REAL NOT NULL, + Status TEXT NOT NULL, + FOREIGN KEY (CustomerId) REFERENCES Customer (Id) + ); + CREATE TABLE IF NOT EXISTS OrderItem ( + Id TEXT PRIMARY KEY, + OrderId TEXT NOT NULL, + ProductName TEXT NOT NULL, + Quantity REAL NOT NULL, + Price REAL NOT NULL, + Subtotal REAL NOT NULL, + FOREIGN KEY (OrderId) REFERENCES Orders (Id) + ); + """; + + using var command = new SqliteCommand(createTablesScript, _connection); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private async Task SetupSchemaAndSeed() + { + await SetupSchema().ConfigureAwait(false); + + var insertScript = """ + INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES + ('cust-1', 'Acme Corp', 'contact@acme.com', '555-0100', '2024-01-01'); + INSERT INTO Invoice (Id, InvoiceNumber, InvoiceDate, CustomerName, CustomerEmail, TotalAmount, DiscountAmount, Notes) VALUES + ('inv-1', 'INV-001', '2024-01-15', 'Acme Corp', 'accounting@acme.com', 1250.00, NULL, 'Test'); + INSERT INTO InvoiceLine (Id, InvoiceId, Description, Quantity, UnitPrice, Amount, DiscountPercentage, Notes) VALUES + ('line-1', 'inv-1', 'Software License', 1.0, 1000.00, 1000.00, NULL, NULL); + INSERT INTO Address (Id, CustomerId, Street, City, State, ZipCode, Country) VALUES + ('addr-1', 'cust-1', '123 Business Ave', 'New York', 'NY', '10001', 'USA'); + INSERT INTO Orders (Id, OrderNumber, OrderDate, CustomerId, TotalAmount, Status) VALUES + ('ord-1', 'ORD-001', '2024-01-10', 'cust-1', 500.00, 'Completed'); + INSERT INTO OrderItem (Id, OrderId, ProductName, Quantity, Price, Subtotal) VALUES + ('item-1', 'ord-1', 'Widget A', 2.0, 100.00, 200.00); + """; + + using var insertCommand = new SqliteCommand(insertScript, _connection); + await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + public void Dispose() + { + _connection?.Dispose(); + if (File.Exists(_dbPath)) + { + try + { + File.Delete(_dbPath); + } +#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort + catch (IOException) + { + /* File may be locked */ + } +#pragma warning restore CA1031 + } + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GlobalUsings.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GlobalUsings.cs new file mode 100644 index 00000000..4fac7095 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GlobalUsings.cs @@ -0,0 +1,52 @@ +global using Generated; +global using Nimblesite.DataProvider.Core; +global using BasicOrderListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>; +// Type aliases for Result types to reduce verbosity in Nimblesite.DataProvider.Example.Tests +global using CustomerListError = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>; +global using CustomerListOk = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using CustomerReadOnlyListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using InvoiceListError = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>; +global using InvoiceListOk = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using OrderListOk = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using OrderReadOnlyListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using StringSqlError = Outcome.Result.Error< + string, + Nimblesite.Sql.Model.SqlError +>; +global using StringSqlOk = Outcome.Result.Ok< + string, + Nimblesite.Sql.Model.SqlError +>; diff --git a/DataProvider/DataProvider.Example.Tests/DataProvider.Example.Tests.csproj b/DataProvider/Nimblesite.DataProvider.Example.Tests/Nimblesite.DataProvider.Example.Tests.csproj similarity index 68% rename from DataProvider/DataProvider.Example.Tests/DataProvider.Example.Tests.csproj rename to DataProvider/Nimblesite.DataProvider.Example.Tests/Nimblesite.DataProvider.Example.Tests.csproj index 84bceff9..a201255f 100644 --- a/DataProvider/DataProvider.Example.Tests/DataProvider.Example.Tests.csproj +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/Nimblesite.DataProvider.Example.Tests.csproj @@ -17,12 +17,12 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - - - + + + + diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/ProgramCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/ProgramCoverageTests.cs new file mode 100644 index 00000000..91950f6e --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/ProgramCoverageTests.cs @@ -0,0 +1,624 @@ +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Xunit; +using static Nimblesite.DataProvider.Example.MapFunctions; + +namespace Nimblesite.DataProvider.Example.Tests; + +#pragma warning disable CS1591 + +/// +/// Tests for Program.cs demo methods and MapFunctions coverage +/// +public sealed class ProgramCoverageTests : IDisposable +{ + private readonly string _dbPath; + private readonly SqliteConnection _connection; + + public ProgramCoverageTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"program_coverage_tests_{Guid.NewGuid()}.db"); + _connection = new SqliteConnection($"Data Source={_dbPath}"); + } + + [Fact] + public async Task TestGeneratedQueriesAsync_WithValidData_Succeeds() + { + await SetupTestDatabase(); + await Program.TestGeneratedQueriesAsync(_connection); + } + + [Fact] + public async Task TestInvoiceQueryAsync_WithValidData_Succeeds() + { + await SetupTestDatabase(); + await Program.TestInvoiceQueryAsync(_connection); + } + + [Fact] + public async Task TestCustomerQueryAsync_WithValidData_Succeeds() + { + await SetupTestDatabase(); + await Program.TestCustomerQueryAsync(_connection); + } + + [Fact] + public async Task TestOrderQueryAsync_WithValidData_Succeeds() + { + await SetupTestDatabase(); + await Program.TestOrderQueryAsync(_connection); + } + + [Fact] + public async Task DemonstrateAdvancedQueryBuilding_WithValidData_Succeeds() + { + await SetupTestDatabase(); + Program.DemonstrateAdvancedQueryBuilding(_connection); + } + + [Fact] + public async Task DemoLinqQuerySyntax_WithValidData_LoadsCustomers() + { + await SetupTestDatabase(); + Program.DemoLinqQuerySyntax(_connection); + } + + [Fact] + public async Task DemoFluentQueryBuilder_WithValidData_LoadsHighValueOrders() + { + await SetupTestDatabase(); + Program.DemoFluentQueryBuilder(_connection); + } + + [Fact] + public async Task DemoLinqMethodSyntax_WithValidData_LoadsRecentOrders() + { + await SetupTestDatabase(); + Program.DemoLinqMethodSyntax(_connection); + } + + [Fact] + public void DemoComplexAggregation_Succeeds() + { + Program.DemoComplexAggregation(); + } + + [Fact] + public async Task DemoComplexFiltering_WithValidData_LoadsPremiumOrders() + { + await SetupTestDatabase(); + Program.DemoComplexFiltering(_connection); + } + + [Fact] + public async Task DemonstratePredicateBuilder_WithValidData_Succeeds() + { + await SetupTestDatabase(); + Program.DemonstratePredicateBuilder(_connection); + } + + [Fact] + public void ShowcaseSummary_Succeeds() + { + Program.ShowcaseSummary(); + } + + [Fact] + public async Task MapBasicOrder_WithJoinQuery_ReturnsCorrectData() + { + await SetupTestDatabase(); + + var result = _connection.GetRecords( + "Orders" + .From("o") + .InnerJoin("Customer", "CustomerId", "Id", "o", "c") + .Select( + ("o", "OrderNumber"), + ("o", "TotalAmount"), + ("o", "Status"), + ("c", "CustomerName"), + ("c", "Email") + ) + .OrderBy("o.OrderNumber") + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapBasicOrder + ); + + Assert.True(result is BasicOrderListOk, $"Expected success but got {result.GetType()}"); + var orders = ((BasicOrderListOk)result).Value; + Assert.Equal(2, orders.Count); + + var firstOrder = orders[0]; + Assert.Equal("ORD-001", firstOrder.OrderNumber); + Assert.Equal(500.00, firstOrder.TotalAmount); + Assert.Equal("Completed", firstOrder.Status); + Assert.Equal("Acme Corp", firstOrder.CustomerName); + Assert.Equal("contact@acme.com", firstOrder.Email); + + var secondOrder = orders[1]; + Assert.Equal("ORD-002", secondOrder.OrderNumber); + Assert.Equal(750.00, secondOrder.TotalAmount); + Assert.Equal("Processing", secondOrder.Status); + Assert.Equal("Tech Solutions", secondOrder.CustomerName); + Assert.Equal("info@techsolutions.com", secondOrder.Email); + } + + [Fact] + public async Task MapBasicOrder_WithHighValueFilter_ReturnsFilteredResults() + { + await SetupTestDatabase(); + + var result = _connection.GetRecords( + "Orders" + .From("o") + .InnerJoin("Customer", "CustomerId", "Id", "o", "c") + .Select( + ("o", "OrderNumber"), + ("o", "TotalAmount"), + ("o", "Status"), + ("c", "CustomerName"), + ("c", "Email") + ) + .Where("o.TotalAmount", ComparisonOperator.GreaterThan, "600.00") + .OrderByDescending("o.TotalAmount") + .Take(5) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapBasicOrder + ); + + Assert.True(result is BasicOrderListOk); + var orders = ((BasicOrderListOk)result).Value; + Assert.Single(orders); + Assert.Equal("ORD-002", orders[0].OrderNumber); + Assert.Equal(750.00, orders[0].TotalAmount); + } + + [Fact] + public async Task MapCustomer_WithNullEmail_ReturnsNullEmail() + { + await SetupTestDatabaseWithNullableFields(); + + var result = _connection.GetRecords( + ( + from customer in SelectStatement.From() + orderby customer.CustomerName + select customer + ).ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapCustomer + ); + + Assert.True( + result is CustomerReadOnlyListOk, + $"Expected success but got {result.GetType()}" + ); + var customers = ((CustomerReadOnlyListOk)result).Value; + + var nullEmailCustomer = customers.First(c => c.CustomerName == "No Email Corp"); + Assert.Null(nullEmailCustomer.Email); + Assert.Equal("555-9999", nullEmailCustomer.Phone); + } + + [Fact] + public async Task MapCustomer_WithNullPhone_ReturnsNullPhone() + { + await SetupTestDatabaseWithNullableFields(); + + var result = _connection.GetRecords( + ( + from customer in SelectStatement.From() + orderby customer.CustomerName + select customer + ).ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapCustomer + ); + + Assert.True(result is CustomerReadOnlyListOk); + var customers = ((CustomerReadOnlyListOk)result).Value; + + var nullPhoneCustomer = customers.First(c => c.CustomerName == "No Phone Corp"); + Assert.Equal("nophone@test.com", nullPhoneCustomer.Email); + Assert.Null(nullPhoneCustomer.Phone); + } + + [Fact] + public async Task MapCustomer_WithBothNullEmailAndPhone_ReturnsBothNull() + { + await SetupTestDatabaseWithNullableFields(); + + var result = _connection.GetRecords( + ( + from customer in SelectStatement.From() + orderby customer.CustomerName + select customer + ).ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapCustomer + ); + + Assert.True(result is CustomerReadOnlyListOk); + var customers = ((CustomerReadOnlyListOk)result).Value; + + var nullBothCustomer = customers.First(c => c.CustomerName == "No Contact Corp"); + Assert.Null(nullBothCustomer.Email); + Assert.Null(nullBothCustomer.Phone); + } + + [Fact] + public async Task MapBasicOrder_WithLeftJoinAndComplexFilter_ReturnsCorrectResults() + { + await SetupTestDatabase(); + + var result = _connection.GetRecords( + "Orders" + .From("o") + .LeftJoin("Customer", "CustomerId", "Id", "o", "c") + .Select( + ("o", "OrderNumber"), + ("o", "TotalAmount"), + ("o", "Status"), + ("c", "CustomerName"), + ("c", "Email") + ) + .AddWhereCondition(WhereCondition.OpenParen()) + .Where("o.TotalAmount", ComparisonOperator.GreaterOrEq, "500.00") + .AddWhereCondition(WhereCondition.And()) + .AddWhereCondition( + WhereCondition.Comparison( + ColumnInfo.Named("o.OrderDate"), + ComparisonOperator.GreaterOrEq, + "2024-01-01" + ) + ) + .AddWhereCondition(WhereCondition.CloseParen()) + .Or("o.Status", "VIP") + .OrderBy("o.OrderDate") + .Take(3) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapBasicOrder + ); + + Assert.True(result is BasicOrderListOk); + var orders = ((BasicOrderListOk)result).Value; + Assert.NotEmpty(orders); + + foreach (var order in orders) + { + Assert.False(string.IsNullOrEmpty(order.OrderNumber)); + Assert.False(string.IsNullOrEmpty(order.CustomerName)); + } + } + + [Fact] + public async Task ProgramMain_RunsSuccessfully() + { + var originalDir = Environment.CurrentDirectory; + var tempDir = Path.Combine(Path.GetTempPath(), $"program_main_test_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + Environment.CurrentDirectory = tempDir; + await Program.Main([]).ConfigureAwait(false); + } + finally + { + Environment.CurrentDirectory = originalDir; + try + { + Directory.Delete(tempDir, recursive: true); + } +#pragma warning disable CA1031 + catch (IOException) + { /* best effort */ + } +#pragma warning restore CA1031 + } + } + + [Fact] + public void ModelTypes_CanBeConstructed() + { + // Cover Example.Model record constructors + var order = new Nimblesite.DataProvider.Example.Model.Order( + Id: 1, + OrderNumber: "ORD-001", + OrderDate: "2024-01-01", + CustomerId: 1, + TotalAmount: 100.0, + Status: "Completed", + Items: + [ + new Nimblesite.DataProvider.Example.Model.OrderItem( + Id: 1, + OrderId: 1, + ProductName: "Widget", + Quantity: 2.0, + Price: 50.0, + Subtotal: 100.0 + ), + ] + ); + + Assert.Equal("ORD-001", order.OrderNumber); + Assert.Single(order.Items); + Assert.Equal("Widget", order.Items[0].ProductName); + + var customer = new Nimblesite.DataProvider.Example.Model.Customer( + Id: 1, + CustomerName: "Test Corp", + Email: "test@test.com", + Phone: "555-1234", + CreatedDate: "2024-01-01", + Addresses: + [ + new Nimblesite.DataProvider.Example.Model.Address( + Id: 1, + CustomerId: 1, + Street: "123 Main St", + City: "TestCity", + State: "TS", + ZipCode: "12345", + Country: "USA" + ), + ] + ); + + Assert.Equal("Test Corp", customer.CustomerName); + Assert.Single(customer.Addresses); + Assert.Equal("TestCity", customer.Addresses[0].City); + } + + [Fact] + public void ModelRecords_EqualityWorks() + { + var order1 = new Nimblesite.DataProvider.Example.Model.Order( + Id: 1, + OrderNumber: "ORD-001", + OrderDate: "2024-01-01", + CustomerId: 1, + TotalAmount: 100.0, + Status: "Completed", + Items: [] + ); + var order2 = new Nimblesite.DataProvider.Example.Model.Order( + Id: 1, + OrderNumber: "ORD-001", + OrderDate: "2024-01-01", + CustomerId: 1, + TotalAmount: 100.0, + Status: "Completed", + Items: [] + ); + Assert.Equal(order1, order2); + Assert.Equal(order1.GetHashCode(), order2.GetHashCode()); + Assert.Equal(order1.ToString(), order2.ToString()); + + var item1 = new Nimblesite.DataProvider.Example.Model.OrderItem( + Id: 1, + OrderId: 1, + ProductName: "W", + Quantity: 1.0, + Price: 10.0, + Subtotal: 10.0 + ); + var item2 = new Nimblesite.DataProvider.Example.Model.OrderItem( + Id: 1, + OrderId: 1, + ProductName: "W", + Quantity: 1.0, + Price: 10.0, + Subtotal: 10.0 + ); + Assert.Equal(item1, item2); + Assert.Equal(item1.GetHashCode(), item2.GetHashCode()); + + var addr1 = new Nimblesite.DataProvider.Example.Model.Address( + Id: 1, + CustomerId: 1, + Street: "St", + City: "C", + State: "S", + ZipCode: "Z", + Country: "US" + ); + var addr2 = new Nimblesite.DataProvider.Example.Model.Address( + Id: 1, + CustomerId: 1, + Street: "St", + City: "C", + State: "S", + ZipCode: "Z", + Country: "US" + ); + Assert.Equal(addr1, addr2); + Assert.Equal(addr1.GetHashCode(), addr2.GetHashCode()); + + var cust1 = new Nimblesite.DataProvider.Example.Model.Customer( + Id: 1, + CustomerName: "Test", + Email: null, + Phone: null, + CreatedDate: "2024-01-01", + Addresses: [] + ); + var cust2 = new Nimblesite.DataProvider.Example.Model.Customer( + Id: 1, + CustomerName: "Test", + Email: null, + Phone: null, + CreatedDate: "2024-01-01", + Addresses: [] + ); + Assert.Equal(cust1, cust2); + Assert.Equal(cust1.GetHashCode(), cust2.GetHashCode()); + + var basic1 = new Nimblesite.DataProvider.Example.Model.BasicOrder( + "ORD-001", + 100.0, + "Completed", + "Corp", + "email@test.com" + ); + var basic2 = new Nimblesite.DataProvider.Example.Model.BasicOrder( + "ORD-001", + 100.0, + "Completed", + "Corp", + "email@test.com" + ); + Assert.Equal(basic1, basic2); + Assert.Equal(basic1.GetHashCode(), basic2.GetHashCode()); + } + + private async Task SetupTestDatabase() + { + await _connection.OpenAsync().ConfigureAwait(false); + using (var pragmaCommand = new SqliteCommand("PRAGMA foreign_keys = OFF", _connection)) + { + await pragmaCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + var createTablesScript = """ + CREATE TABLE IF NOT EXISTS Invoice ( + Id TEXT PRIMARY KEY, + InvoiceNumber TEXT NOT NULL, + InvoiceDate TEXT NOT NULL, + CustomerName TEXT NOT NULL, + CustomerEmail TEXT NULL, + TotalAmount REAL NOT NULL, + DiscountAmount REAL NULL, + Notes TEXT NULL + ); + CREATE TABLE IF NOT EXISTS InvoiceLine ( + Id TEXT PRIMARY KEY, + InvoiceId TEXT NOT NULL, + Description TEXT NOT NULL, + Quantity REAL NOT NULL, + UnitPrice REAL NOT NULL, + Amount REAL NOT NULL, + DiscountPercentage REAL NULL, + Notes TEXT NULL, + FOREIGN KEY (InvoiceId) REFERENCES Invoice (Id) + ); + CREATE TABLE IF NOT EXISTS Customer ( + Id TEXT PRIMARY KEY, + CustomerName TEXT NOT NULL, + Email TEXT NULL, + Phone TEXT NULL, + CreatedDate TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS Address ( + Id TEXT PRIMARY KEY, + CustomerId TEXT NOT NULL, + Street TEXT NOT NULL, + City TEXT NOT NULL, + State TEXT NOT NULL, + ZipCode TEXT NOT NULL, + Country TEXT NOT NULL, + FOREIGN KEY (CustomerId) REFERENCES Customer (Id) + ); + CREATE TABLE IF NOT EXISTS Orders ( + Id TEXT PRIMARY KEY, + OrderNumber TEXT NOT NULL, + OrderDate TEXT NOT NULL, + CustomerId TEXT NOT NULL, + TotalAmount REAL NOT NULL, + Status TEXT NOT NULL, + FOREIGN KEY (CustomerId) REFERENCES Customer (Id) + ); + CREATE TABLE IF NOT EXISTS OrderItem ( + Id TEXT PRIMARY KEY, + OrderId TEXT NOT NULL, + ProductName TEXT NOT NULL, + Quantity REAL NOT NULL, + Price REAL NOT NULL, + Subtotal REAL NOT NULL, + FOREIGN KEY (OrderId) REFERENCES Orders (Id) + ); + """; + + using var command = new SqliteCommand(createTablesScript, _connection); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + + var insertScript = """ + INSERT INTO Invoice (Id, InvoiceNumber, InvoiceDate, CustomerName, CustomerEmail, TotalAmount, DiscountAmount, Notes) VALUES + ('inv-1', 'INV-001', '2024-01-15', 'Acme Corp', 'accounting@acme.com', 1250.00, NULL, 'Test invoice'); + INSERT INTO InvoiceLine (Id, InvoiceId, Description, Quantity, UnitPrice, Amount, DiscountPercentage, Notes) VALUES + ('line-1', 'inv-1', 'Software License', 1.0, 1000.00, 1000.00, NULL, NULL), + ('line-2', 'inv-1', 'Support Package', 1.0, 250.00, 250.00, 10.0, 'First year support'); + INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES + ('cust-1', 'Acme Corp', 'contact@acme.com', '555-0100', '2024-01-01'), + ('cust-2', 'Tech Solutions', 'info@techsolutions.com', '555-0200', '2024-01-02'); + INSERT INTO Address (Id, CustomerId, Street, City, State, ZipCode, Country) VALUES + ('addr-1', 'cust-1', '123 Business Ave', 'New York', 'NY', '10001', 'USA'), + ('addr-2', 'cust-1', '456 Main St', 'Albany', 'NY', '12201', 'USA'), + ('addr-3', 'cust-2', '789 Tech Blvd', 'San Francisco', 'CA', '94105', 'USA'); + INSERT INTO Orders (Id, OrderNumber, OrderDate, CustomerId, TotalAmount, Status) VALUES + ('ord-1', 'ORD-001', '2024-01-10', 'cust-1', 500.00, 'Completed'), + ('ord-2', 'ORD-002', '2024-01-11', 'cust-2', 750.00, 'Processing'); + INSERT INTO OrderItem (Id, OrderId, ProductName, Quantity, Price, Subtotal) VALUES + ('item-1', 'ord-1', 'Widget A', 2.0, 100.00, 200.00), + ('item-2', 'ord-1', 'Widget B', 3.0, 100.00, 300.00), + ('item-3', 'ord-2', 'Service Package', 1.0, 750.00, 750.00); + """; + + using var insertCommand = new SqliteCommand(insertScript, _connection); + await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private async Task SetupTestDatabaseWithNullableFields() + { + await _connection.OpenAsync().ConfigureAwait(false); + using (var pragmaCommand = new SqliteCommand("PRAGMA foreign_keys = OFF", _connection)) + { + await pragmaCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + var createTablesScript = """ + CREATE TABLE IF NOT EXISTS Customer ( + Id TEXT PRIMARY KEY, + CustomerName TEXT NOT NULL, + Email TEXT NULL, + Phone TEXT NULL, + CreatedDate TEXT NOT NULL + ); + """; + + using var command = new SqliteCommand(createTablesScript, _connection); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + + var insertScript = """ + INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES + ('cust-1', 'Full Contact Corp', 'full@test.com', '555-1111', '2024-01-01'), + ('cust-2', 'No Email Corp', NULL, '555-9999', '2024-01-02'), + ('cust-3', 'No Phone Corp', 'nophone@test.com', NULL, '2024-01-03'), + ('cust-4', 'No Contact Corp', NULL, NULL, '2024-01-04'); + """; + + using var insertCommand = new SqliteCommand(insertScript, _connection); + await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + public void Dispose() + { + _connection?.Dispose(); + if (File.Exists(_dbPath)) + { + try + { + File.Delete(_dbPath); + } +#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort + catch (IOException) + { + /* File may be locked */ + } +#pragma warning restore CA1031 + } + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/SqlModelCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/SqlModelCoverageTests.cs new file mode 100644 index 00000000..f33a8792 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/SqlModelCoverageTests.cs @@ -0,0 +1,1011 @@ +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Xunit; +using static Nimblesite.DataProvider.Example.MapFunctions; + +namespace Nimblesite.DataProvider.Example.Tests; + +#pragma warning disable CS1591 + +/// +/// Tests for Sql.Model and Lql.SQLite coverage - LINQ expressions, SQL generation paths +/// +public sealed class SqlModelCoverageTests : IDisposable +{ + private readonly string _dbPath; + private readonly SqliteConnection _connection; + + public SqlModelCoverageTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"sql_model_coverage_tests_{Guid.NewGuid()}.db"); + _connection = new SqliteConnection($"Data Source={_dbPath}"); + } + + #region LINQ Expression Where - various comparison operators + + [Fact] + public async Task LinqWhere_LessThan_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => o.TotalAmount < 600.0) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Single(orders); + Assert.Equal(500.00, orders[0].TotalAmount); + } + + [Fact] + public async Task LinqWhere_LessThanOrEqual_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => o.TotalAmount <= 500.0) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Single(orders); + } + + [Fact] + public async Task LinqWhere_GreaterThanOrEqual_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => o.TotalAmount >= 750.0) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Single(orders); + Assert.Equal(750.00, orders[0].TotalAmount); + } + + [Fact] + public async Task LinqWhere_OrElse_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => o.Status == "Completed" || o.Status == "Processing") + .OrderBy(o => o.Id) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Equal(2, orders.Count); + } + + [Fact] + public async Task LinqWhere_AndAlso_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => o.TotalAmount > 400.0 && o.Status == "Completed") + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Single(orders); + Assert.Equal("Completed", orders[0].Status); + } + + [Fact] + public async Task LinqWhere_NotEqual_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => o.Status != "Completed") + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Single(orders); + Assert.Equal("Processing", orders[0].Status); + } + + #endregion + + #region LINQ Expression Select and OrderBy + + [Fact] + public void LinqSelect_WithExpression_GeneratesCorrectSQL() + { + var query = "Customer" + .From() + .Select(c => c.CustomerName) + .OrderBy(c => c.CustomerName) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("CustomerName", sql); + } + + [Fact] + public async Task LinqOrderBy_WithExpression_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + "Customer".From().SelectAll().OrderBy(c => c.CustomerName).ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapCustomer + ); + + Assert.True(result is CustomerReadOnlyListOk); + var customers = ((CustomerReadOnlyListOk)result).Value; + Assert.Equal(2, customers.Count); + Assert.Equal("Acme Corp", customers[0].CustomerName); + } + + #endregion + + #region SelectStatementBuilder - additional paths + + [Fact] + public void Builder_WhereWithNullValue_GeneratesNullSQL() + { + var query = "Orders" + .From("o") + .SelectAll() + .Where("o.Status", (object)null!) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("NULL", sql); + } + + [Fact] + public void Builder_WhereWithBoolFalse_GeneratesZero() + { + var query = "Orders".From("o").SelectAll().Where("o.IsActive", false).ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("o.IsActive = 0", sql); + } + + [Fact] + public void Builder_AddWhereCondition_WithParentheses_GeneratesCorrectSQL() + { + var query = "Orders" + .From("o") + .SelectAll() + .AddWhereCondition(WhereCondition.OpenParen()) + .Where("o.Status", "Completed") + .AddWhereCondition(WhereCondition.CloseParen()) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("(", sql); + Assert.Contains(")", sql); + } + + [Fact] + public void Builder_SelectWithExpressionColumn_Works() + { + var query = "Orders" + .From("o") + .Select(("o", "OrderNumber")) + .GroupBy("o.Status") + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + } + + [Fact] + public void Builder_WhereWithDoubleValue_GeneratesNumeric() + { + var query = "Orders".From("o").SelectAll().Where("o.TotalAmount", 99.99).ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("99.99", sql); + } + + [Fact] + public void Builder_ComparisonOperator_Like_GeneratesCorrectSQL() + { + var query = "Orders" + .From() + .Select(("", "Id")) + .Where("Name", ComparisonOperator.Like, "%test%") + .ToSqlStatement(); + var sql = ((StringSqlOk)query.ToSQLite()).Value; + Assert.Contains("LIKE", sql); + } + + [Fact] + public void Builder_ComparisonOperator_In_GeneratesCorrectSQL() + { + var query = "Orders" + .From() + .Select(("", "Id")) + .Where("Status", ComparisonOperator.In, "('Completed','Processing')") + .ToSqlStatement(); + var sql = ((StringSqlOk)query.ToSQLite()).Value; + Assert.Contains("IN", sql); + } + + [Fact] + public void Builder_ComparisonOperator_IsNull_GeneratesCorrectSQL() + { + var query = "Orders" + .From() + .Select(("", "Id")) + .Where("Notes", ComparisonOperator.IsNull, "") + .ToSqlStatement(); + var sql = ((StringSqlOk)query.ToSQLite()).Value; + Assert.Contains("IS NULL", sql); + } + + [Fact] + public void Builder_ComparisonOperator_IsNotNull_GeneratesCorrectSQL() + { + var query = "Orders" + .From() + .Select(("", "Id")) + .Where("Notes", ComparisonOperator.IsNotNull, "") + .ToSqlStatement(); + var sql = ((StringSqlOk)query.ToSQLite()).Value; + Assert.Contains("IS NOT NULL", sql); + } + + #endregion + + #region LINQ query syntax + + [Fact] + public async Task LinqQuerySyntax_WithOrderByDescending_Works() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => o.TotalAmount > 0.0) + .OrderByDescending(o => o.TotalAmount) + .Take(10) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Equal(2, orders.Count); + Assert.True(orders[0].TotalAmount >= orders[1].TotalAmount); + } + + [Fact] + public async Task LinqQuerySyntax_WithSkipAndTake_Works() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .OrderBy(o => o.Id) + .Skip(1) + .Take(1) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Single(orders); + } + + [Fact] + public void LinqQuerySyntax_WithDistinct_Works() + { + var query = "Orders".From().Select(o => o.Status).Distinct().ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("DISTINCT", sql); + } + + #endregion + + #region SqlError coverage + + [Fact] + public void SqlError_Create_SetsMessageCorrectly() + { + var error = SqlError.Create("Test error message"); + Assert.Equal("Test error message", error.Message); + } + + [Fact] + public void SqlError_CreateWithErrorCode_SetsCodeCorrectly() + { + var error = SqlError.Create("Error with code", 42); + Assert.Equal("Error with code", error.Message); + Assert.Equal(42, error.ErrorCode); + } + + [Fact] + public void SqlError_WithPosition_SetsPositionCorrectly() + { + var error = SqlError.WithPosition( + "Error at position", + line: 5, + column: 10, + source: "SELECT * FROM foo" + ); + Assert.Equal("Error at position", error.Message); + Assert.NotNull(error.Position); + Assert.Equal(5, error.Position!.Line); + Assert.Equal(10, error.Position.Column); + Assert.Equal("SELECT * FROM foo", error.Source); + } + + [Fact] + public void SqlError_WithDetailedPosition_SetsAllFields() + { + var error = SqlError.WithDetailedPosition( + "Parse error", + line: 1, + column: 5, + startIndex: 5, + stopIndex: 10, + source: "SELECT bad FROM" + ); + Assert.NotNull(error.Position); + Assert.Equal(1, error.Position!.Line); + Assert.Equal(5, error.Position.Column); + Assert.Equal(5, error.Position.StartIndex); + Assert.Equal(10, error.Position.StopIndex); + } + + [Fact] + public void SqlError_FromException_CapturesExceptionDetails() + { + var exception = new InvalidOperationException("Test exception"); + var error = SqlError.FromException(exception); + Assert.Contains("Test exception", error.Message); + Assert.Equal(exception, error.Exception); + Assert.Equal(exception, error.InnerException); + } + + [Fact] + public void SqlError_FromException_WithNull_ReturnsNullMessage() + { + var error = SqlError.FromException(null); + Assert.Equal("Null exception provided", error.Message); + } + + [Fact] + public void SqlError_FormattedMessage_WithPosition_IncludesLineAndColumn() + { + var error = SqlError.WithPosition("Syntax error", line: 3, column: 7); + Assert.Contains("line 3", error.FormattedMessage); + Assert.Contains("column 7", error.FormattedMessage); + } + + [Fact] + public void SqlError_FormattedMessage_WithoutPosition_ReturnsMessage() + { + var error = SqlError.Create("Simple error"); + Assert.Equal("Simple error", error.FormattedMessage); + } + + [Fact] + public void SqlError_DetailedMessage_WithExceptionAndSource_IncludesAllDetails() + { + var innerEx = new ArgumentException("inner"); + var outerEx = new InvalidOperationException("outer", innerEx); + var error = new SqlError( + "Parse failed", + outerEx, + new SourcePosition(1, 5), + "SELECT * FROM bad_table" + ) + { + InnerException = innerEx, + }; + + var detailed = error.DetailedMessage; + Assert.Contains("Parse failed", detailed); + Assert.Contains("line 1", detailed); + Assert.Contains("outer", detailed); + Assert.Contains("inner", detailed); + Assert.Contains("SELECT * FROM bad_table", detailed); + } + + [Fact] + public void SqlError_Deconstruct_ExtractsMessageAndException() + { + var exception = new InvalidOperationException("test"); + var error = SqlError.FromException(exception); + var (message, ex) = error; + Assert.Equal("test", message); + Assert.Equal(exception, ex); + } + + #endregion + + #region SqlErrorException coverage + + [Fact] + public void SqlErrorException_WithSqlError_SetsProperties() + { + var sqlError = SqlError.Create("Test error"); + var exception = new SqlErrorException(sqlError); + Assert.Equal("Test error", exception.Message); + Assert.Equal(sqlError, exception.SqlError); + } + + [Fact] + public void SqlErrorException_WithSqlErrorAndInner_SetsProperties() + { + var sqlError = SqlError.Create("Outer error"); + var inner = new InvalidOperationException("inner"); + var exception = new SqlErrorException(sqlError, inner); + Assert.Equal("Outer error", exception.Message); + Assert.Equal(inner, exception.InnerException); + Assert.Equal(sqlError, exception.SqlError); + } + + [Fact] + public void SqlErrorException_Default_HasNullSqlError() + { + var exception = new SqlErrorException(); + Assert.Null(exception.SqlError); + } + + [Fact] + public void SqlErrorException_WithMessage_HasNullSqlError() + { + var exception = new SqlErrorException("Custom message"); + Assert.Equal("Custom message", exception.Message); + Assert.Null(exception.SqlError); + } + + [Fact] + public void SqlErrorException_WithMessageAndInner_HasNullSqlError() + { + var inner = new InvalidOperationException("inner"); + var exception = new SqlErrorException("Custom message", inner); + Assert.Equal("Custom message", exception.Message); + Assert.Equal(inner, exception.InnerException); + Assert.Null(exception.SqlError); + } + + #endregion + + #region SourcePosition coverage + + [Fact] + public void SourcePosition_Constructor_SetsAllFields() + { + var pos = new SourcePosition(Line: 10, Column: 20, StartIndex: 100, StopIndex: 110); + Assert.Equal(10, pos.Line); + Assert.Equal(20, pos.Column); + Assert.Equal(100, pos.StartIndex); + Assert.Equal(110, pos.StopIndex); + } + + #endregion + + #region SelectStatementVisitor - additional paths via LINQ + + [Fact] + public void LinqWhere_StringContains_GeneratesLikeSQL() + { + var query = SelectStatement + .From() + .Where(c => c.CustomerName.Contains("Acme")) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("LIKE", sql); + Assert.Contains("Acme", sql); + } + + [Fact] + public void LinqWhere_StringStartsWith_GeneratesLikeSQL() + { + var query = SelectStatement + .From() + .Where(c => c.CustomerName.StartsWith("Tech")) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("LIKE", sql); + Assert.Contains("Tech", sql); + } + + [Fact] + public async Task LinqWhere_ComplexOrWithAnd_GeneratesCorrectSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => + (o.Status == "Completed" && o.TotalAmount > 400.0) || o.Status == "Processing" + ) + .OrderBy(o => o.Id) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Equal(2, orders.Count); + } + + [Fact] + public void LinqSelect_WithNewExpression_ExtractsMultipleColumns() + { + var query = "Customer" + .From() + .Select(c => new { c.CustomerName, c.Email }) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("CustomerName", sql); + Assert.Contains("Email", sql); + } + + [Fact] + public async Task LinqWhere_NullComparison_GeneratesIsNull() + { + await SetupDatabase().ConfigureAwait(false); + + var query = SelectStatement.From().Where(c => c.Email == null).ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + } + + [Fact] + public void FormatValue_WithLongValue_GeneratesNumeric() + { + var query = "Orders".From().SelectAll().Where("Count", 42L).ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("42", sql); + } + + [Fact] + public void FormatValue_WithDecimalValue_GeneratesNumeric() + { + var query = "Orders".From().SelectAll().Where("Amount", 19.99m).ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("19.99", sql); + } + + #endregion + + #region SelectQueryable coverage + + [Fact] + public void SelectQueryable_Properties_ReturnExpectedValues() + { + var queryable = new SelectQueryable("Customer"); + Assert.Equal(typeof(Customer), queryable.ElementType); + Assert.NotNull(queryable.Expression); + Assert.NotNull(queryable.Provider); + } + + [Fact] + public void SelectQueryable_GetEnumerator_ThrowsNotSupported() + { + var queryable = new SelectQueryable("Customer"); + Assert.Throws(() => queryable.GetEnumerator()); + } + + [Fact] + public void SelectQueryableExtensions_WithInvalidQueryable_Throws() + { + var list = new List().AsQueryable(); + Assert.Throws(() => list.ToSqlStatement()); + } + + #endregion + + #region Additional LINQ expression paths + + [Fact] + public void LinqWhere_WithBooleanConstant_GeneratesCorrectSQL() + { + var query = SelectStatement + .From("Orders") + .Where(o => o.TotalAmount > 0.0 && o.TotalAmount < 10000.0) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + } + + [Fact] + public void LinqWhere_WithMultipleOrConditions_GeneratesSQL() + { + var query = SelectStatement + .From("Orders") + .Where(o => o.Status == "A" || o.Status == "B" || o.Status == "C") + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + } + + [Fact] + public void LinqWhere_WithBoolField_GeneratesSQL() + { + var query = "Table" + .From() + .SelectAll() + .Where("IsActive", true) + .And("IsDeleted", false) + .ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("IsActive = 1", sql); + Assert.Contains("IsDeleted = 0", sql); + } + + [Fact] + public void ColumnInfo_ExpressionColumn_CanBeCreated() + { + var col = ColumnInfo.FromExpression("COUNT(*)"); + Assert.NotNull(col); + } + + [Fact] + public void LogicalOperator_Variants_CanBeAccessed() + { + var and = LogicalOperator.And; + var or = LogicalOperator.Or; + Assert.NotNull(and); + Assert.NotNull(or); + } + + [Fact] + public void ExpressionColumn_CanBeUsedInSelect() + { + var builder = "Orders".From("o"); + builder.AddSelectColumn(ColumnInfo.FromExpression("COUNT(*)")); + builder.AddSelectColumn(ColumnInfo.FromExpression("SUM(o.TotalAmount)")); + var query = builder.GroupBy("o.Status").ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("COUNT(*)", sql); + Assert.Contains("SUM(o.TotalAmount)", sql); + } + + [Fact] + public async Task LinqWhere_ComplexNestedAndOr_GeneratesSQL() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + SelectStatement + .From("Orders") + .Where(o => + (o.TotalAmount >= 500.0 && o.TotalAmount <= 1000.0) || o.Status == "VIP" + ) + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + } + + [Fact] + public void LinqQuerySyntax_WithSelect_GeneratesSQL() + { + var query = ( + from order in SelectStatement.From("Orders") + where order.TotalAmount > 100.0 + orderby order.OrderNumber + select order + ).ToSqlStatement(); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + var sql = ((StringSqlOk)sqlResult).Value; + Assert.Contains("Orders", sql); + } + + [Fact] + public void SelectStatement_JoinGraph_CanBeAccessed() + { + var statement = "Orders" + .From("o") + .InnerJoin("Customer", "CustomerId", "Id", "o", "c") + .SelectAll() + .ToSqlStatement(); + + Assert.True(statement.HasJoins); + Assert.NotNull(statement.JoinGraph); + Assert.True(statement.JoinGraph.Count > 0); + } + + [Fact] + public void SelectStatement_Properties_AreAccessible() + { + var statement = "Orders" + .From("o") + .Select(("o", "Id"), ("o", "Status")) + .Where("o.TotalAmount", ComparisonOperator.GreaterThan, "100") + .OrderBy("o.Id") + .GroupBy("o.Status") + .Skip(5) + .Take(10) + .Distinct() + .ToSqlStatement(); + + Assert.NotEmpty(statement.SelectList); + Assert.NotEmpty(statement.Tables); + Assert.NotEmpty(statement.WhereConditions); + Assert.NotEmpty(statement.OrderByItems); + Assert.NotEmpty(statement.GroupByColumns); + Assert.Equal("10", statement.Limit); + Assert.Equal("5", statement.Offset); + Assert.True(statement.IsDistinct); + } + + [Fact] + public async Task GetRecords_WithValidQuery_ReturnsData() + { + await SetupDatabase().ConfigureAwait(false); + + var result = _connection.GetRecords( + "Orders" + .From() + .SelectAll() + .Where("TotalAmount", ComparisonOperator.GreaterOrEq, "0") + .ToSqlStatement(), + stmt => stmt.ToSQLite(), + MapOrder + ); + + Assert.True(result is OrderReadOnlyListOk); + var orders = ((OrderReadOnlyListOk)result).Value; + Assert.Equal(2, orders.Count); + } + + #endregion + + [Fact] + public void Builder_Having_GeneratesCorrectSQL() + { + var query = "Orders".From("o").Select(("o", "Status")).GroupBy("o.Status").ToSqlStatement(); + + // Access the statement's properties for coverage + Assert.NotEmpty(query.GroupByColumns); + Assert.False(query.HasJoins); + Assert.Empty(query.Unions); + Assert.Null(query.HavingCondition); + + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + } + + [Fact] + public void Builder_WithMultipleOrders_GeneratesCorrectSQL() + { + var query = "Orders" + .From("o") + .SelectAll() + .OrderBy("o.Status") + .OrderByDescending("o.TotalAmount") + .ToSqlStatement(); + + Assert.Equal(2, query.OrderByItems.Count); + var sqlResult = query.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + } + + [Fact] + public void Builder_WithParameters_GeneratesSQL() + { + var statement = "Orders" + .From() + .SelectAll() + .Where("Status", ComparisonOperator.Eq, "Completed") + .ToSqlStatement(); + + Assert.NotNull(statement.Parameters); + var sqlResult = statement.ToSQLite(); + Assert.True(sqlResult is StringSqlOk); + } + + [Fact] + public void ComparisonOperator_AllTypes_HaveCorrectToString() + { + Assert.NotNull(ComparisonOperator.Eq); + Assert.NotNull(ComparisonOperator.NotEq); + Assert.NotNull(ComparisonOperator.GreaterThan); + Assert.NotNull(ComparisonOperator.LessThan); + Assert.NotNull(ComparisonOperator.GreaterOrEq); + Assert.NotNull(ComparisonOperator.LessOrEq); + Assert.NotNull(ComparisonOperator.Like); + Assert.NotNull(ComparisonOperator.In); + Assert.NotNull(ComparisonOperator.IsNull); + Assert.NotNull(ComparisonOperator.IsNotNull); + } + + [Fact] + public void WhereCondition_AllTypes_CanBeCreated() + { + var comparison = WhereCondition.Comparison( + ColumnInfo.Named("col"), + ComparisonOperator.Eq, + "val" + ); + var and = WhereCondition.And(); + var or = WhereCondition.Or(); + var open = WhereCondition.OpenParen(); + var close = WhereCondition.CloseParen(); + var expr = WhereCondition.FromExpression("1 = 1"); + + Assert.NotNull(comparison); + Assert.NotNull(and); + Assert.NotNull(or); + Assert.NotNull(open); + Assert.NotNull(close); + Assert.NotNull(expr); + } + + [Fact] + public void ColumnInfo_AllTypes_CanBeCreated() + { + var named = ColumnInfo.Named("col"); + var namedWithAlias = ColumnInfo.Named("col", "alias"); + var wildcard = ColumnInfo.Wildcard(); + var wildcardWithTable = ColumnInfo.Wildcard("t"); + var expression = ColumnInfo.FromExpression("COUNT(*)"); + + Assert.NotNull(named); + Assert.NotNull(namedWithAlias); + Assert.NotNull(wildcard); + Assert.NotNull(wildcardWithTable); + Assert.NotNull(expression); + } + + private async Task SetupDatabase() + { + await _connection.OpenAsync().ConfigureAwait(false); + using (var pragmaCommand = new SqliteCommand("PRAGMA foreign_keys = OFF", _connection)) + { + await pragmaCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + var createTablesScript = """ + CREATE TABLE IF NOT EXISTS Customer ( + Id TEXT PRIMARY KEY, + CustomerName TEXT NOT NULL, + Email TEXT NULL, + Phone TEXT NULL, + CreatedDate TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS Orders ( + Id TEXT PRIMARY KEY, + OrderNumber TEXT NOT NULL, + OrderDate TEXT NOT NULL, + CustomerId TEXT NOT NULL, + TotalAmount REAL NOT NULL, + Status TEXT NOT NULL, + FOREIGN KEY (CustomerId) REFERENCES Customer (Id) + ); + CREATE TABLE IF NOT EXISTS OrderItem ( + Id TEXT PRIMARY KEY, + OrderId TEXT NOT NULL, + ProductName TEXT NOT NULL, + Quantity REAL NOT NULL, + Price REAL NOT NULL, + Subtotal REAL NOT NULL, + FOREIGN KEY (OrderId) REFERENCES Orders (Id) + ); + """; + + using var command = new SqliteCommand(createTablesScript, _connection); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + + var insertScript = """ + INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES + ('cust-1', 'Acme Corp', 'contact@acme.com', '555-0100', '2024-01-01'), + ('cust-2', 'Tech Solutions', 'info@techsolutions.com', '555-0200', '2024-01-02'); + INSERT INTO Orders (Id, OrderNumber, OrderDate, CustomerId, TotalAmount, Status) VALUES + ('ord-1', 'ORD-001', '2024-01-10', 'cust-1', 500.00, 'Completed'), + ('ord-2', 'ORD-002', '2024-01-11', 'cust-2', 750.00, 'Processing'); + INSERT INTO OrderItem (Id, OrderId, ProductName, Quantity, Price, Subtotal) VALUES + ('item-1', 'ord-1', 'Widget A', 2.0, 100.00, 200.00); + """; + + using var insertCommand = new SqliteCommand(insertScript, _connection); + await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + public void Dispose() + { + _connection?.Dispose(); + if (File.Exists(_dbPath)) + { + try + { + File.Delete(_dbPath); + } +#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort + catch (IOException) + { + /* File may be locked */ + } +#pragma warning restore CA1031 + } + } +} diff --git a/DataProvider/DataProvider.Example.Tests/Testing.ruleset b/DataProvider/Nimblesite.DataProvider.Example.Tests/Testing.ruleset similarity index 60% rename from DataProvider/DataProvider.Example.Tests/Testing.ruleset rename to DataProvider/Nimblesite.DataProvider.Example.Tests/Testing.ruleset index 204d4188..afceedca 100644 --- a/DataProvider/DataProvider.Example.Tests/Testing.ruleset +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/Testing.ruleset @@ -1,5 +1,5 @@ - + diff --git a/DataProvider/DataProvider.Example/.gitignore b/DataProvider/Nimblesite.DataProvider.Example/.gitignore similarity index 100% rename from DataProvider/DataProvider.Example/.gitignore rename to DataProvider/Nimblesite.DataProvider.Example/.gitignore diff --git a/DataProvider/DataProvider.Example/DataProvider.json b/DataProvider/Nimblesite.DataProvider.Example/DataProvider.json similarity index 100% rename from DataProvider/DataProvider.Example/DataProvider.json rename to DataProvider/Nimblesite.DataProvider.Example/DataProvider.json diff --git a/DataProvider/DataProvider.Example/DatabaseManager.cs b/DataProvider/Nimblesite.DataProvider.Example/DatabaseManager.cs similarity index 99% rename from DataProvider/DataProvider.Example/DatabaseManager.cs rename to DataProvider/Nimblesite.DataProvider.Example/DatabaseManager.cs index 004528ef..97820b54 100644 --- a/DataProvider/DataProvider.Example/DatabaseManager.cs +++ b/DataProvider/Nimblesite.DataProvider.Example/DatabaseManager.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace DataProvider.Example; +namespace Nimblesite.DataProvider.Example; /// /// Manages database connections and complete initialization including schema creation and data seeding diff --git a/DataProvider/DataProvider.Example/Example.ruleset b/DataProvider/Nimblesite.DataProvider.Example/Example.ruleset similarity index 64% rename from DataProvider/DataProvider.Example/Example.ruleset rename to DataProvider/Nimblesite.DataProvider.Example/Example.ruleset index 957a7115..c94bee9c 100644 --- a/DataProvider/DataProvider.Example/Example.ruleset +++ b/DataProvider/Nimblesite.DataProvider.Example/Example.ruleset @@ -1,5 +1,5 @@ - + diff --git a/DataProvider/DataProvider.Example/GetCustomers.grouping.json b/DataProvider/Nimblesite.DataProvider.Example/GetCustomers.grouping.json similarity index 100% rename from DataProvider/DataProvider.Example/GetCustomers.grouping.json rename to DataProvider/Nimblesite.DataProvider.Example/GetCustomers.grouping.json diff --git a/DataProvider/DataProvider.Example/GetCustomersLql.grouping.json b/DataProvider/Nimblesite.DataProvider.Example/GetCustomersLql.grouping.json similarity index 100% rename from DataProvider/DataProvider.Example/GetCustomersLql.grouping.json rename to DataProvider/Nimblesite.DataProvider.Example/GetCustomersLql.grouping.json diff --git a/DataProvider/DataProvider.Example/GetCustomersLql.lql b/DataProvider/Nimblesite.DataProvider.Example/GetCustomersLql.lql similarity index 100% rename from DataProvider/DataProvider.Example/GetCustomersLql.lql rename to DataProvider/Nimblesite.DataProvider.Example/GetCustomersLql.lql diff --git a/DataProvider/DataProvider.Example/GetInvoices.grouping.json b/DataProvider/Nimblesite.DataProvider.Example/GetInvoices.grouping.json similarity index 100% rename from DataProvider/DataProvider.Example/GetInvoices.grouping.json rename to DataProvider/Nimblesite.DataProvider.Example/GetInvoices.grouping.json diff --git a/DataProvider/DataProvider.Example/GetInvoices.sql b/DataProvider/Nimblesite.DataProvider.Example/GetInvoices.sql similarity index 100% rename from DataProvider/DataProvider.Example/GetInvoices.sql rename to DataProvider/Nimblesite.DataProvider.Example/GetInvoices.sql diff --git a/DataProvider/DataProvider.Example/GetOrders.grouping.json b/DataProvider/Nimblesite.DataProvider.Example/GetOrders.grouping.json similarity index 100% rename from DataProvider/DataProvider.Example/GetOrders.grouping.json rename to DataProvider/Nimblesite.DataProvider.Example/GetOrders.grouping.json diff --git a/DataProvider/DataProvider.Example/GetOrders.sql b/DataProvider/Nimblesite.DataProvider.Example/GetOrders.sql similarity index 100% rename from DataProvider/DataProvider.Example/GetOrders.sql rename to DataProvider/Nimblesite.DataProvider.Example/GetOrders.sql diff --git a/DataProvider/Nimblesite.DataProvider.Example/GlobalUsings.cs b/DataProvider/Nimblesite.DataProvider.Example/GlobalUsings.cs new file mode 100644 index 00000000..d33eb9e5 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example/GlobalUsings.cs @@ -0,0 +1,84 @@ +global using Generated; +global using Nimblesite.DataProvider.Core; +global using Nimblesite.Sql.Model; +// Type aliases for Result types to reduce verbosity in Nimblesite.DataProvider.Example +global using BasicOrderListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>; +global using BasicOrderListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>; +global using CustomerListError = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>; +global using CustomerListOk = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using CustomerReadOnlyListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>; +global using CustomerReadOnlyListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using IntSqlError = Outcome.Result.Error< + int, + Nimblesite.Sql.Model.SqlError +>; +global using IntSqlOk = Outcome.Result.Ok< + int, + Nimblesite.Sql.Model.SqlError +>; +global using InvoiceListError = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>; +global using InvoiceListOk = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using OrderListError = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Error, Nimblesite.Sql.Model.SqlError>; +global using OrderListOk = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using OrderReadOnlyListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Error, Nimblesite.Sql.Model.SqlError>; +global using OrderReadOnlyListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +global using StringSqlError = Outcome.Result.Error< + string, + Nimblesite.Sql.Model.SqlError +>; +global using StringSqlOk = Outcome.Result.Ok< + string, + Nimblesite.Sql.Model.SqlError +>; +global using StringSqlResult = Outcome.Result; diff --git a/DataProvider/DataProvider.Example/MapFunctions.cs b/DataProvider/Nimblesite.DataProvider.Example/MapFunctions.cs similarity index 96% rename from DataProvider/DataProvider.Example/MapFunctions.cs rename to DataProvider/Nimblesite.DataProvider.Example/MapFunctions.cs index 0110f808..9565e55f 100644 --- a/DataProvider/DataProvider.Example/MapFunctions.cs +++ b/DataProvider/Nimblesite.DataProvider.Example/MapFunctions.cs @@ -1,7 +1,7 @@ using System.Data; -using DataProvider.Example.Model; +using Nimblesite.DataProvider.Example.Model; -namespace DataProvider.Example; +namespace Nimblesite.DataProvider.Example; /// /// Static mapping functions for converting IDataReader to domain objects diff --git a/DataProvider/DataProvider.Example/Model/BasicOrder.cs b/DataProvider/Nimblesite.DataProvider.Example/Model/BasicOrder.cs similarity index 92% rename from DataProvider/DataProvider.Example/Model/BasicOrder.cs rename to DataProvider/Nimblesite.DataProvider.Example/Model/BasicOrder.cs index 58f8c6ae..d210b857 100644 --- a/DataProvider/DataProvider.Example/Model/BasicOrder.cs +++ b/DataProvider/Nimblesite.DataProvider.Example/Model/BasicOrder.cs @@ -1,6 +1,6 @@ #pragma warning disable CA2100 // Review SQL queries for security vulnerabilities -namespace DataProvider.Example.Model; +namespace Nimblesite.DataProvider.Example.Model; /// /// Basic order information with customer details diff --git a/DataProvider/DataProvider.Example/Model/Customer.cs b/DataProvider/Nimblesite.DataProvider.Example/Model/Customer.cs similarity index 95% rename from DataProvider/DataProvider.Example/Model/Customer.cs rename to DataProvider/Nimblesite.DataProvider.Example/Model/Customer.cs index 059cb6ca..7507ddd8 100644 --- a/DataProvider/DataProvider.Example/Model/Customer.cs +++ b/DataProvider/Nimblesite.DataProvider.Example/Model/Customer.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace DataProvider.Example.Model; +namespace Nimblesite.DataProvider.Example.Model; /// /// Represents a customer with their addresses diff --git a/DataProvider/DataProvider.Example/Model/Order.cs b/DataProvider/Nimblesite.DataProvider.Example/Model/Order.cs similarity index 95% rename from DataProvider/DataProvider.Example/Model/Order.cs rename to DataProvider/Nimblesite.DataProvider.Example/Model/Order.cs index 8074426f..0ef7fccb 100644 --- a/DataProvider/DataProvider.Example/Model/Order.cs +++ b/DataProvider/Nimblesite.DataProvider.Example/Model/Order.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace DataProvider.Example.Model; +namespace Nimblesite.DataProvider.Example.Model; /// /// Represents an order with its items diff --git a/DataProvider/DataProvider.Example/DataProvider.Example.csproj b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj similarity index 67% rename from DataProvider/DataProvider.Example/DataProvider.Example.csproj rename to DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj index 45c49e09..a869c346 100644 --- a/DataProvider/DataProvider.Example/DataProvider.Example.csproj +++ b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj @@ -8,20 +8,24 @@ true + + + + - - - - - + + + + + - + @@ -40,10 +44,10 @@ - + /// Database connection - private static async Task TestGeneratedQueriesAsync(SqliteConnection connection) + internal static async Task TestGeneratedQueriesAsync(SqliteConnection connection) { await TestInvoiceQueryAsync(connection).ConfigureAwait(false); await TestCustomerQueryAsync(connection).ConfigureAwait(false); @@ -37,7 +37,7 @@ private static async Task TestGeneratedQueriesAsync(SqliteConnection connection) /// Demonstrates generated invoice query execution with parameter binding /// /// Database connection - private static async Task TestInvoiceQueryAsync(SqliteConnection connection) + internal static async Task TestInvoiceQueryAsync(SqliteConnection connection) { Console.WriteLine("\n=== Testing GetInvoicesAsync ==="); var invoiceResult = await connection @@ -66,7 +66,7 @@ private static async Task TestInvoiceQueryAsync(SqliteConnection connection) /// Demonstrates generated customer query with LQL syntax parsing /// /// Database connection - private static async Task TestCustomerQueryAsync(SqliteConnection connection) + internal static async Task TestCustomerQueryAsync(SqliteConnection connection) { Console.WriteLine("\n=== Testing GetCustomersLqlAsync ==="); var customerResult = await connection.GetCustomersLqlAsync(null).ConfigureAwait(false); @@ -91,7 +91,7 @@ private static async Task TestCustomerQueryAsync(SqliteConnection connection) /// Demonstrates generated order query with multiple parameters and filtering /// /// Database connection - private static async Task TestOrderQueryAsync(SqliteConnection connection) + internal static async Task TestOrderQueryAsync(SqliteConnection connection) { Console.WriteLine("\n=== Testing GetOrdersAsync ==="); var orderResult = await connection @@ -120,7 +120,7 @@ private static async Task TestOrderQueryAsync(SqliteConnection connection) /// Demonstrates advanced dynamic query building capabilities /// /// Database connection - private static void DemonstrateAdvancedQueryBuilding(SqliteConnection connection) + internal static void DemonstrateAdvancedQueryBuilding(SqliteConnection connection) { Console.WriteLine( $""" @@ -140,7 +140,7 @@ private static void DemonstrateAdvancedQueryBuilding(SqliteConnection connection /// Demonstrates LINQ query syntax with SelectStatement.From<T>() for type-safe queries /// /// Database connection - private static void DemoLinqQuerySyntax(SqliteConnection connection) + internal static void DemoLinqQuerySyntax(SqliteConnection connection) { Console.WriteLine("\n💥 LINQ Query Syntax - Dynamic Customer Query:"); @@ -171,7 +171,7 @@ select customer /// Demonstrates fluent query builder with joins, filtering, and ordering /// /// Database connection - private static void DemoFluentQueryBuilder(SqliteConnection connection) + internal static void DemoFluentQueryBuilder(SqliteConnection connection) { Console.WriteLine("\n💥 Fluent Query Builder - Dynamic High Value Orders:"); @@ -215,7 +215,7 @@ private static void DemoFluentQueryBuilder(SqliteConnection connection) /// Demonstrates LINQ method syntax with lambda expressions for type-safe filtering /// /// Database connection - private static void DemoLinqMethodSyntax(SqliteConnection connection) + internal static void DemoLinqMethodSyntax(SqliteConnection connection) { Console.WriteLine("\n💥 LINQ Method Syntax - Dynamic Recent Orders:"); @@ -252,7 +252,7 @@ private static void DemoLinqMethodSyntax(SqliteConnection connection) /// /// Demonstrates complex aggregation with GROUP BY, COUNT, and SUM functions /// - private static void DemoComplexAggregation() + internal static void DemoComplexAggregation() { Console.WriteLine("\n💥 Complex Aggregation Builder - Dynamic Customer Spending:"); @@ -265,7 +265,7 @@ private static void DemoComplexAggregation() /// Demonstrates complex filtering with parentheses grouping, AND/OR logic, and multiple conditions /// /// Database connection - private static void DemoComplexFiltering(SqliteConnection connection) + internal static void DemoComplexFiltering(SqliteConnection connection) { Console.WriteLine("\n💥 Complex Filtering Builder - Dynamic Premium Orders:"); var premiumResult = connection.GetRecords( @@ -320,7 +320,7 @@ private static void DemoComplexFiltering(SqliteConnection connection) /// Demonstrates PredicateBuilder for maximum code reuse with dynamic predicate construction /// /// Database connection - private static void DemonstratePredicateBuilder(SqliteConnection connection) + internal static void DemonstratePredicateBuilder(SqliteConnection connection) { Console.WriteLine( $""" @@ -382,7 +382,7 @@ private static void DemonstratePredicateBuilder(SqliteConnection connection) /// /// Shows summary of all demonstrated query generation capabilities /// - private static void ShowcaseSummary() => + internal static void ShowcaseSummary() => Console.WriteLine( $""" diff --git a/DataProvider/DataProvider.Example/SampleDataSeeder.cs b/DataProvider/Nimblesite.DataProvider.Example/SampleDataSeeder.cs similarity index 99% rename from DataProvider/DataProvider.Example/SampleDataSeeder.cs rename to DataProvider/Nimblesite.DataProvider.Example/SampleDataSeeder.cs index f514eb35..e7bbc292 100644 --- a/DataProvider/DataProvider.Example/SampleDataSeeder.cs +++ b/DataProvider/Nimblesite.DataProvider.Example/SampleDataSeeder.cs @@ -1,6 +1,6 @@ using System.Data; -namespace DataProvider.Example; +namespace Nimblesite.DataProvider.Example; /// /// Seeds the database with sample data for testing and demonstration purposes diff --git a/DataProvider/DataProvider.Example/example-schema.yaml b/DataProvider/Nimblesite.DataProvider.Example/example-schema.yaml similarity index 100% rename from DataProvider/DataProvider.Example/example-schema.yaml rename to DataProvider/Nimblesite.DataProvider.Example/example-schema.yaml diff --git a/DataProvider/Nimblesite.DataProvider.Postgres.Cli/GlobalUsings.cs b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/GlobalUsings.cs new file mode 100644 index 00000000..191460e3 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.DataProvider.Core; diff --git a/DataProvider/DataProvider.Postgres.Cli/DataProvider.Postgres.Cli.csproj b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Nimblesite.DataProvider.Postgres.Cli.csproj similarity index 76% rename from DataProvider/DataProvider.Postgres.Cli/DataProvider.Postgres.Cli.csproj rename to DataProvider/Nimblesite.DataProvider.Postgres.Cli/Nimblesite.DataProvider.Postgres.Cli.csproj index 26e20ea4..ec7ffe44 100644 --- a/DataProvider/DataProvider.Postgres.Cli/DataProvider.Postgres.Cli.csproj +++ b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Nimblesite.DataProvider.Postgres.Cli.csproj @@ -1,7 +1,7 @@ Exe - net10.0 + net9.0 enable enable false @@ -10,14 +10,14 @@ false false EPC12;CA2100 - DataProvider.Postgres.Cli + Nimblesite.DataProvider.Postgres.Cli true dataprovider-postgres CLI tool for generating type-safe PostgreSQL data access code - - + + diff --git a/DataProvider/DataProvider.Postgres.Cli/Program.cs b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Program.cs similarity index 98% rename from DataProvider/DataProvider.Postgres.Cli/Program.cs rename to DataProvider/Nimblesite.DataProvider.Postgres.Cli/Program.cs index 5fbc57ca..fa934d7a 100644 --- a/DataProvider/DataProvider.Postgres.Cli/Program.cs +++ b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Program.cs @@ -2,18 +2,18 @@ using System.Globalization; using System.Text; using System.Text.Json; +using Nimblesite.Sql.Model; using Npgsql; using Outcome; -using Selecta; #pragma warning disable CA1812 // Avoid uninstantiated internal classes - records are instantiated by JSON deserialization #pragma warning disable CA1849 // Call async methods when in an async method #pragma warning disable CA2100 // Review SQL queries for security vulnerabilities -namespace DataProvider.Postgres.Cli; +namespace Nimblesite.DataProvider.Postgres.Cli; /// -/// PostgreSQL code generation CLI for DataProvider. +/// PostgreSQL code generation CLI for Nimblesite.DataProvider.Core. /// internal static class Program { @@ -29,12 +29,15 @@ public static async Task Main(string[] args) { var projectDir = new Option( "--project-dir", - description: "Project directory containing sql files and DataProvider.json" + description: "Project directory containing sql files and Nimblesite.DataProvider.Core.json" ) { IsRequired = true, }; - var config = new Option("--config", description: "Path to DataProvider.json") + var config = new Option( + "--config", + description: "Path to Nimblesite.DataProvider.Core.json" + ) { IsRequired = true, }; @@ -45,7 +48,7 @@ public static async Task Main(string[] args) { IsRequired = true, }; - var root = new RootCommand("DataProvider.Postgres codegen CLI") + var root = new RootCommand("Nimblesite.DataProvider.Core.Postgres codegen CLI") { projectDir, config, @@ -86,7 +89,9 @@ DirectoryInfo outDir var cfg = JsonSerializer.Deserialize(cfgText, JsonOptions); if (cfg is null || string.IsNullOrWhiteSpace(cfg.ConnectionString)) { - Console.WriteLine("❌ DataProvider.json ConnectionString is required"); + Console.WriteLine( + "❌ Nimblesite.DataProvider.Core.json ConnectionString is required" + ); return 1; } @@ -324,7 +329,7 @@ ORDER BY c.ordinal_position _ = sb.AppendLine(); _ = sb.AppendLine("using Npgsql;"); _ = sb.AppendLine("using Outcome;"); - _ = sb.AppendLine("using Selecta;"); + _ = sb.AppendLine("using Nimblesite.Sql.Model;"); _ = sb.AppendLine(); // Extension class @@ -1150,21 +1155,21 @@ List parameters _ = sb.AppendLine("using System.Collections.Immutable;"); _ = sb.AppendLine("using Npgsql;"); _ = sb.AppendLine("using Outcome;"); - _ = sb.AppendLine("using Selecta;"); + _ = sb.AppendLine("using Nimblesite.Sql.Model;"); _ = sb.AppendLine(); // Result type aliases must come after standard usings but before any type definitions // Use fully qualified names since type aliases don't use namespace context _ = sb.AppendLine( CultureInfo.InvariantCulture, - $"using {fileName}Result = Outcome.Result, Selecta.SqlError>;" + $"using {fileName}Result = Outcome.Result, Nimblesite.Sql.Model.SqlError>;" ); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $"using {fileName}Ok = Outcome.Result, Selecta.SqlError>.Ok, Selecta.SqlError>;" + $"using {fileName}Ok = Outcome.Result, Nimblesite.Sql.Model.SqlError>.Ok, Nimblesite.Sql.Model.SqlError>;" ); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $"using {fileName}Error = Outcome.Result, Selecta.SqlError>.Error, Selecta.SqlError>;" + $"using {fileName}Error = Outcome.Result, Nimblesite.Sql.Model.SqlError>.Error, Nimblesite.Sql.Model.SqlError>;" ); _ = sb.AppendLine(); diff --git a/DataProvider/Nimblesite.DataProvider.SQLite.Cli/GlobalUsings.cs b/DataProvider/Nimblesite.DataProvider.SQLite.Cli/GlobalUsings.cs new file mode 100644 index 00000000..191460e3 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.SQLite.Cli/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.DataProvider.Core; diff --git a/DataProvider/DataProvider.SQLite.Cli/DataProvider.SQLite.Cli.csproj b/DataProvider/Nimblesite.DataProvider.SQLite.Cli/Nimblesite.DataProvider.SQLite.Cli.csproj similarity index 63% rename from DataProvider/DataProvider.SQLite.Cli/DataProvider.SQLite.Cli.csproj rename to DataProvider/Nimblesite.DataProvider.SQLite.Cli/Nimblesite.DataProvider.SQLite.Cli.csproj index 0beadf5d..b4db0853 100644 --- a/DataProvider/DataProvider.SQLite.Cli/DataProvider.SQLite.Cli.csproj +++ b/DataProvider/Nimblesite.DataProvider.SQLite.Cli/Nimblesite.DataProvider.SQLite.Cli.csproj @@ -1,7 +1,7 @@ Exe - net10.0 + net9.0 enable enable false @@ -10,18 +10,18 @@ false false EPC12;CA2100 - DataProvider.SQLite.Cli + Nimblesite.DataProvider.SQLite.Cli true dataprovider-sqlite CLI tool for generating type-safe SQLite data access code - - - + + + - + diff --git a/DataProvider/DataProvider.SQLite.Cli/Program.cs b/DataProvider/Nimblesite.DataProvider.SQLite.Cli/Program.cs similarity index 97% rename from DataProvider/DataProvider.SQLite.Cli/Program.cs rename to DataProvider/Nimblesite.DataProvider.SQLite.Cli/Program.cs index a12764a2..cc8ec602 100644 --- a/DataProvider/DataProvider.SQLite.Cli/Program.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite.Cli/Program.cs @@ -1,14 +1,14 @@ using System.CommandLine; using System.Globalization; using System.Text.Json; -using DataProvider.CodeGeneration; -using DataProvider.SQLite.Parsing; +using Nimblesite.DataProvider.Core.CodeGeneration; +using Nimblesite.DataProvider.SQLite.Parsing; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; #pragma warning disable CA1849 // Call async methods when in an async method -namespace DataProvider.SQLite.Cli; +namespace Nimblesite.DataProvider.SQLite.Cli; internal static class Program { @@ -21,12 +21,15 @@ public static async Task Main(string[] args) { var projectDir = new Option( "--project-dir", - description: "Project directory containing sql, grouping, and DataProvider.json" + description: "Project directory containing sql, grouping, and Nimblesite.DataProvider.Core.json" ) { IsRequired = true, }; - var config = new Option("--config", description: "Path to DataProvider.json") + var config = new Option( + "--config", + description: "Path to Nimblesite.DataProvider.Core.json" + ) { IsRequired = true, }; @@ -42,7 +45,7 @@ public static async Task Main(string[] args) getDefaultValue: () => "SqliteConnection", description: "Database connection type for generated code (e.g., SqliteConnection, NpgsqlConnection)" ); - var root = new RootCommand("DataProvider.SQLite codegen CLI") + var root = new RootCommand("Nimblesite.DataProvider.SQLite codegen CLI") { projectDir, config, @@ -90,7 +93,9 @@ private static async Task RunAsync( ); if (cfg is null || string.IsNullOrWhiteSpace(cfg.ConnectionString)) { - Console.WriteLine("❌ DataProvider.json ConnectionString is required"); + Console.WriteLine( + "❌ Nimblesite.DataProvider.Core.json ConnectionString is required" + ); return 1; } @@ -496,7 +501,7 @@ string connectionType sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine(CultureInfo.InvariantCulture, $"using {connectionNamespace};"); sb.AppendLine("using Outcome;"); - sb.AppendLine("using Selecta;"); + sb.AppendLine("using Nimblesite.Sql.Model;"); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"namespace {namespaceName};"); sb.AppendLine(); diff --git a/DataProvider/DataProvider.SQLite.FSharp/DataProvider.SQLite.FSharp.fsproj b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Nimblesite.DataProvider.SQLite.FSharp.fsproj similarity index 77% rename from DataProvider/DataProvider.SQLite.FSharp/DataProvider.SQLite.FSharp.fsproj rename to DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Nimblesite.DataProvider.SQLite.FSharp.fsproj index e1c7c3bf..98956608 100644 --- a/DataProvider/DataProvider.SQLite.FSharp/DataProvider.SQLite.FSharp.fsproj +++ b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Nimblesite.DataProvider.SQLite.FSharp.fsproj @@ -2,7 +2,7 @@ Exe - net10.0 + net9.0 true preview false @@ -19,7 +19,7 @@ - + \ No newline at end of file diff --git a/DataProvider/DataProvider.SQLite.FSharp/Program.fs b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Program.fs similarity index 71% rename from DataProvider/DataProvider.SQLite.FSharp/Program.fs rename to DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Program.fs index a1db0649..62e734a4 100644 --- a/DataProvider/DataProvider.SQLite.FSharp/Program.fs +++ b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Program.fs @@ -3,7 +3,7 @@ open System printfn "F# SQLite Data Provider Example" printfn "================================" -printfn "✅ F# project references the C# DataProvider.SQLite library" +printfn "✅ F# project references the C# Nimblesite.DataProvider.SQLite library" printfn "✅ No code duplication - uses existing C# implementation" [] diff --git a/DataProvider/DataProvider.SQLite.FSharp/SimpleSqlite.fs b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/SimpleSqlite.fs similarity index 89% rename from DataProvider/DataProvider.SQLite.FSharp/SimpleSqlite.fs rename to DataProvider/Nimblesite.DataProvider.SQLite.FSharp/SimpleSqlite.fs index b280c033..1b7e6227 100644 --- a/DataProvider/DataProvider.SQLite.FSharp/SimpleSqlite.fs +++ b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/SimpleSqlite.fs @@ -1,7 +1,7 @@ -namespace DataProvider.SQLite.FSharp +namespace Nimblesite.DataProvider.SQLite.FSharp open System.Data -open DataProvider +open Nimblesite.DataProvider.Core /// /// F# bindings for the existing C# DataProvider functionality diff --git a/DataProvider/DataProvider.SQLite/CodeGeneration/SqliteDatabaseEffects.cs b/DataProvider/Nimblesite.DataProvider.SQLite/CodeGeneration/SqliteDatabaseEffects.cs similarity index 98% rename from DataProvider/DataProvider.SQLite/CodeGeneration/SqliteDatabaseEffects.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/CodeGeneration/SqliteDatabaseEffects.cs index 84b45cf6..c54a7ff9 100644 --- a/DataProvider/DataProvider.SQLite/CodeGeneration/SqliteDatabaseEffects.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/CodeGeneration/SqliteDatabaseEffects.cs @@ -1,10 +1,10 @@ using System.Diagnostics.CodeAnalysis; -using DataProvider.CodeGeneration; using Microsoft.Data.Sqlite; +using Nimblesite.DataProvider.Core.CodeGeneration; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.SQLite.CodeGeneration; +namespace Nimblesite.DataProvider.SQLite.CodeGeneration; /// /// SQLite-specific database effects implementation diff --git a/DataProvider/Nimblesite.DataProvider.SQLite/GlobalUsings.cs b/DataProvider/Nimblesite.DataProvider.SQLite/GlobalUsings.cs new file mode 100644 index 00000000..191460e3 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.SQLite/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.DataProvider.Core; diff --git a/DataProvider/DataProvider.SQLite/DataProvider.SQLite.csproj b/DataProvider/Nimblesite.DataProvider.SQLite/Nimblesite.DataProvider.SQLite.csproj similarity index 73% rename from DataProvider/DataProvider.SQLite/DataProvider.SQLite.csproj rename to DataProvider/Nimblesite.DataProvider.SQLite/Nimblesite.DataProvider.SQLite.csproj index 01ed5629..ee9a1897 100644 --- a/DataProvider/DataProvider.SQLite/DataProvider.SQLite.csproj +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Nimblesite.DataProvider.SQLite.csproj @@ -1,16 +1,16 @@ - DataProvider.SQLite + Nimblesite.DataProvider.SQLite ChristianFindlay - SQLite source generator for DataProvider. Provides compile-time safe database access with automatic code generation from SQL files for SQLite databases. + SQLite source generator for Nimblesite.DataProvider.Core. Provides compile-time safe database access with automatic code generation from SQL files for SQLite databases. source-generator;sql;sqlite;database;compile-time-safety;code-generation - https://github.com/MelbourneDeveloper/DataProvider - https://github.com/MelbourneDeveloper/DataProvider + https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core + https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core git MIT false - Initial release of DataProvider.SQLite source generator. + Initial release of Nimblesite.DataProvider.SQLite source generator. true @@ -25,7 +25,7 @@ - + @@ -45,8 +45,8 @@ /> - - + + diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteLexer.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteLexer.cs similarity index 99% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteLexer.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteLexer.cs index 0c198c59..bec28b22 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SQLiteLexer.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteLexer.cs @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace DataProvider.SQLite.Parsing { +namespace Nimblesite.DataProvider.SQLite.Parsing { using System; using System.IO; using System.Text; @@ -787,4 +787,4 @@ static SQLiteLexer() { } -} // namespace DataProvider.SQLite.Parsing +} // namespace Nimblesite.DataProvider.SQLite.Parsing diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteLexer.g4 b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteLexer.g4 similarity index 100% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteLexer.g4 rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteLexer.g4 diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParser.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParser.cs similarity index 99% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteParser.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParser.cs index 1ea7b50c..1c8f9408 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParser.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParser.cs @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace DataProvider.SQLite.Parsing { +namespace Nimblesite.DataProvider.SQLite.Parsing { using System; using System.IO; using System.Text; @@ -14497,4 +14497,4 @@ private bool expr_sempred(ExprContext _localctx, int predIndex) { } -} // namespace DataProvider.SQLite.Parsing +} // namespace Nimblesite.DataProvider.SQLite.Parsing diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParser.g4 b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParser.g4 similarity index 100% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteParser.g4 rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParser.g4 diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserBaseListener.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseListener.cs similarity index 99% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteParserBaseListener.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseListener.cs index 9c722195..e5c8cc4d 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserBaseListener.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseListener.cs @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace DataProvider.SQLite.Parsing { +namespace Nimblesite.DataProvider.SQLite.Parsing { using Antlr4.Runtime.Misc; using IErrorNode = Antlr4.Runtime.Tree.IErrorNode; @@ -1406,4 +1406,4 @@ public virtual void VisitTerminal([NotNull] ITerminalNode node) { } /// The default implementation does nothing. public virtual void VisitErrorNode([NotNull] IErrorNode node) { } } -} // namespace DataProvider.SQLite.Parsing +} // namespace Nimblesite.DataProvider.SQLite.Parsing diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserBaseVisitor.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseVisitor.cs similarity index 99% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteParserBaseVisitor.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseVisitor.cs index 543f7b29..f71f589a 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserBaseVisitor.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseVisitor.cs @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace DataProvider.SQLite.Parsing { +namespace Nimblesite.DataProvider.SQLite.Parsing { using Antlr4.Runtime.Misc; using Antlr4.Runtime.Tree; using IToken = Antlr4.Runtime.IToken; @@ -1166,4 +1166,4 @@ internal partial class SQLiteParserBaseVisitor : AbstractParseTreeVisito /// The visitor result. public virtual Result VisitAny_name([NotNull] SQLiteParser.Any_nameContext context) { return VisitChildren(context); } } -} // namespace DataProvider.SQLite.Parsing +} // namespace Nimblesite.DataProvider.SQLite.Parsing diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserListener.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserListener.cs similarity index 99% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteParserListener.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserListener.cs index d2bb11e1..593b89f7 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserListener.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserListener.cs @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace DataProvider.SQLite.Parsing { +namespace Nimblesite.DataProvider.SQLite.Parsing { using Antlr4.Runtime.Misc; using IParseTreeListener = Antlr4.Runtime.Tree.IParseTreeListener; using IToken = Antlr4.Runtime.IToken; @@ -1162,4 +1162,4 @@ internal interface ISQLiteParserListener : IParseTreeListener { /// The parse tree. void ExitAny_name([NotNull] SQLiteParser.Any_nameContext context); } -} // namespace DataProvider.SQLite.Parsing +} // namespace Nimblesite.DataProvider.SQLite.Parsing diff --git a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserVisitor.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserVisitor.cs similarity index 99% rename from DataProvider/DataProvider.SQLite/Parsing/SQLiteParserVisitor.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserVisitor.cs index 6dc0aea7..5590136b 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SQLiteParserVisitor.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserVisitor.cs @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace DataProvider.SQLite.Parsing { +namespace Nimblesite.DataProvider.SQLite.Parsing { using Antlr4.Runtime.Misc; using Antlr4.Runtime.Tree; using IToken = Antlr4.Runtime.IToken; @@ -711,4 +711,4 @@ internal interface ISQLiteParserVisitor : IParseTreeVisitor { /// The visitor result. Result VisitAny_name([NotNull] SQLiteParser.Any_nameContext context); } -} // namespace DataProvider.SQLite.Parsing +} // namespace Nimblesite.DataProvider.SQLite.Parsing diff --git a/DataProvider/DataProvider.SQLite/Parsing/SqliteAntlrParser.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteAntlrParser.cs similarity index 96% rename from DataProvider/DataProvider.SQLite/Parsing/SqliteAntlrParser.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteAntlrParser.cs index 38032314..7266fc7b 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SqliteAntlrParser.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteAntlrParser.cs @@ -2,10 +2,10 @@ using System.Diagnostics.CodeAnalysis; using Antlr4.Runtime; using Antlr4.Runtime.Tree; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.SQLite.Parsing; +namespace Nimblesite.DataProvider.SQLite.Parsing; /// /// SQLite parser implementation using ANTLR grammar diff --git a/DataProvider/DataProvider.SQLite/Parsing/SqliteParameterExtractor.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteParameterExtractor.cs similarity index 98% rename from DataProvider/DataProvider.SQLite/Parsing/SqliteParameterExtractor.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteParameterExtractor.cs index 97394c0e..20557aa6 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SqliteParameterExtractor.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteParameterExtractor.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Antlr4.Runtime.Tree; -namespace DataProvider.SQLite.Parsing; +namespace Nimblesite.DataProvider.SQLite.Parsing; /// /// Parameter extractor for SQLite using ANTLR parse tree diff --git a/DataProvider/DataProvider.SQLite/Parsing/SqliteQueryTypeListener.cs b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteQueryTypeListener.cs similarity index 97% rename from DataProvider/DataProvider.SQLite/Parsing/SqliteQueryTypeListener.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteQueryTypeListener.cs index a03dfc38..f9869947 100644 --- a/DataProvider/DataProvider.SQLite/Parsing/SqliteQueryTypeListener.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SqliteQueryTypeListener.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace DataProvider.SQLite.Parsing; +namespace Nimblesite.DataProvider.SQLite.Parsing; /// /// Listener to determine SQLite query type from parse tree diff --git a/DataProvider/DataProvider.SQLite/SqliteCodeGenerator.cs b/DataProvider/Nimblesite.DataProvider.SQLite/SqliteCodeGenerator.cs similarity index 97% rename from DataProvider/DataProvider.SQLite/SqliteCodeGenerator.cs rename to DataProvider/Nimblesite.DataProvider.SQLite/SqliteCodeGenerator.cs index fec19371..0b2cd0f1 100644 --- a/DataProvider/DataProvider.SQLite/SqliteCodeGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/SqliteCodeGenerator.cs @@ -1,14 +1,14 @@ using System.Collections.Immutable; using System.Text; using System.Text.Json; -using DataProvider.CodeGeneration; -using DataProvider.SQLite.CodeGeneration; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; +using Nimblesite.DataProvider.Core.CodeGeneration; +using Nimblesite.DataProvider.SQLite.CodeGeneration; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.SQLite; +namespace Nimblesite.DataProvider.SQLite; /// /// SQLite specific code generator implementation and incremental source generator entrypoint @@ -325,7 +325,7 @@ CodeGenerationConfig config // ============================= /// - /// Initializes the incremental generator for SQLite. Collects .sql files, grouping config, and DataProvider.json + /// Initializes the incremental generator for SQLite. Collects .sql files, grouping config, and Nimblesite.DataProvider.Core.json /// and registers the output step. /// /// The initialization context @@ -342,7 +342,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) && at.Path.EndsWith(".sql", StringComparison.OrdinalIgnoreCase) ); var configFiles = additional.Where(at => - at.Path.EndsWith("DataProvider.json", StringComparison.OrdinalIgnoreCase) + at.Path.EndsWith( + "Nimblesite.DataProvider.Core.json", + StringComparison.OrdinalIgnoreCase + ) ); var groupingFiles = additional.Where(at => at.Path.EndsWith(".grouping.json", StringComparison.OrdinalIgnoreCase) @@ -398,7 +401,7 @@ ImmutableArray GroupingFiles new DiagnosticDescriptor( "DataProvider002", "Configuration parsing failed", - "Failed to parse DataProvider.json: {0}", + "Failed to parse Nimblesite.DataProvider.Core.json: {0}", "DataProvider", DiagnosticSeverity.Error, true @@ -418,7 +421,7 @@ ImmutableArray GroupingFiles new DiagnosticDescriptor( "DataProvider003", "Configuration missing", - "DataProvider.json with ConnectionString is required for code generation", + "Nimblesite.DataProvider.Core.json with ConnectionString is required for code generation", "DataProvider", DiagnosticSeverity.Error, true @@ -528,7 +531,7 @@ static IReadOnlyList ExtractParameters(string sql) sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine("using Microsoft.Data.Sqlite;"); sb.AppendLine("using Outcome;"); - sb.AppendLine("using Selecta;"); + sb.AppendLine("using Nimblesite.Sql.Model;"); sb.AppendLine(); sb.AppendLine("namespace Generated;"); sb.AppendLine(); @@ -548,7 +551,7 @@ nonQueryResult is Result.Error nqFailure "DP0003", "Non-query generation failed", $"Failed to generate non-query for '{baseName}': {nqFailure.Value.Message}", - "DataProvider.SQLite", + "Nimblesite.DataProvider.SQLite", DiagnosticSeverity.Error, true ), @@ -572,7 +575,7 @@ is Result.Error parseFailure "DP0002", "SQL Parse Error", $"Failed to parse SQL file '{baseName}': {parseFailure.Value}", - "DataProvider.SQLite", + "Nimblesite.DataProvider.SQLite", DiagnosticSeverity.Error, true ), diff --git a/DataProvider/DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs b/DataProvider/Nimblesite.DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs similarity index 91% rename from DataProvider/DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs rename to DataProvider/Nimblesite.DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs index 2a0f9d07..4c7b9355 100644 --- a/DataProvider/DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Microsoft.CodeAnalysis; -namespace DataProvider.SqlServer; +namespace Nimblesite.DataProvider.SqlServer; /// /// Incremental source generator for SQL Server that scans AdditionalFiles for .sql files and configuration @@ -27,7 +27,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var configFiles = context .AdditionalTextsProvider.Where(file => - file.Path.EndsWith("DataProvider.json", StringComparison.OrdinalIgnoreCase) + file.Path.EndsWith( + "Nimblesite.DataProvider.Core.json", + StringComparison.OrdinalIgnoreCase + ) ) .Collect(); @@ -78,7 +81,7 @@ ImmutableArray GroupingFiles new DiagnosticDescriptor( "DataProvider002", "Configuration parsing failed", - "Failed to parse DataProvider.json: {0}", + "Failed to parse Nimblesite.DataProvider.Core.json: {0}", "DataProvider", DiagnosticSeverity.Error, true @@ -99,7 +102,7 @@ ImmutableArray GroupingFiles new DiagnosticDescriptor( "DataProvider003", "Configuration missing", - "DataProvider.json configuration file is required for code generation", + "Nimblesite.DataProvider.Core.json configuration file is required for code generation", "DataProvider", DiagnosticSeverity.Error, true diff --git a/DataProvider/DataProvider.SqlServer/DataProvider.SqlServer.csproj b/DataProvider/Nimblesite.DataProvider.SqlServer/Nimblesite.DataProvider.SqlServer.csproj similarity index 64% rename from DataProvider/DataProvider.SqlServer/DataProvider.SqlServer.csproj rename to DataProvider/Nimblesite.DataProvider.SqlServer/Nimblesite.DataProvider.SqlServer.csproj index a32906f3..545c8092 100644 --- a/DataProvider/DataProvider.SqlServer/DataProvider.SqlServer.csproj +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/Nimblesite.DataProvider.SqlServer.csproj @@ -1,18 +1,18 @@ - DataProvider.SqlServer + Nimblesite.DataProvider.SqlServer 0.1.0-beta ChristianFindlay - SQL Server source generator for DataProvider. Provides compile-time safe database access with automatic code generation from SQL files for SQL Server databases. + SQL Server source generator for Nimblesite.DataProvider.Core. Provides compile-time safe database access with automatic code generation from SQL files for SQL Server databases. source-generator;sql;sqlserver;database;compile-time-safety;code-generation - https://github.com/MelbourneDeveloper/DataProvider - https://github.com/MelbourneDeveloper/DataProvider + https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core + https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core git MIT README.md false - Initial beta release of DataProvider.SqlServer source generator. + Initial beta release of Nimblesite.DataProvider.SqlServer source generator. true @@ -23,8 +23,8 @@ - - + + diff --git a/DataProvider/DataProvider.SqlServer/SchemaInspection/SqlServerSchemaInspector.cs b/DataProvider/Nimblesite.DataProvider.SqlServer/SchemaInspection/SqlServerSchemaInspector.cs similarity index 93% rename from DataProvider/DataProvider.SqlServer/SchemaInspection/SqlServerSchemaInspector.cs rename to DataProvider/Nimblesite.DataProvider.SqlServer/SchemaInspection/SqlServerSchemaInspector.cs index be60ebc7..b95b3651 100644 --- a/DataProvider/DataProvider.SqlServer/SchemaInspection/SqlServerSchemaInspector.cs +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/SchemaInspection/SqlServerSchemaInspector.cs @@ -1,7 +1,7 @@ using Microsoft.Data.SqlClient; using Outcome; -namespace DataProvider.SqlServer.SchemaInspection; +namespace Nimblesite.DataProvider.SqlServer.SchemaInspection; /// /// SQL Server implementation of schema inspection @@ -144,15 +144,15 @@ public async Task> GetAllTablesAsync() /// /// The SQL query to analyze /// Result containing metadata about the query result columns - public async Task> GetSqlQueryMetadataAsync( - string sqlQuery - ) + public async Task< + Result + > GetSqlQueryMetadataAsync(string sqlQuery) { if (string.IsNullOrWhiteSpace(sqlQuery)) - return new Result.Error< + return new Result.Error< SqlQueryMetadata, - Selecta.SqlError - >(new Selecta.SqlError("SQL query cannot be null or empty")); + Nimblesite.Sql.Model.SqlError + >(new Nimblesite.Sql.Model.SqlError("SQL query cannot be null or empty")); try { @@ -224,24 +224,24 @@ string sqlQuery SqlText = sqlQuery, }; - return new Result.Ok< + return new Result.Ok< SqlQueryMetadata, - Selecta.SqlError + Nimblesite.Sql.Model.SqlError >(metadata); } catch (SqlException ex) { - return new Result.Error< + return new Result.Error< SqlQueryMetadata, - Selecta.SqlError - >(new Selecta.SqlError("SQL Server error during schema inspection", ex)); + Nimblesite.Sql.Model.SqlError + >(new Nimblesite.Sql.Model.SqlError("SQL Server error during schema inspection", ex)); } catch (Exception ex) { - return new Result.Error< + return new Result.Error< SqlQueryMetadata, - Selecta.SqlError - >(new Selecta.SqlError("Error analyzing SQL query", ex)); + Nimblesite.Sql.Model.SqlError + >(new Nimblesite.Sql.Model.SqlError("Error analyzing SQL query", ex)); } } diff --git a/DataProvider/DataProvider.SqlServer/SqlFileGenerator.cs b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlFileGenerator.cs similarity index 96% rename from DataProvider/DataProvider.SqlServer/SqlFileGenerator.cs rename to DataProvider/Nimblesite.DataProvider.SqlServer/SqlFileGenerator.cs index c39ad640..5eab282c 100644 --- a/DataProvider/DataProvider.SqlServer/SqlFileGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlFileGenerator.cs @@ -1,8 +1,8 @@ using Microsoft.CodeAnalysis; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.SqlServer; +namespace Nimblesite.DataProvider.SqlServer; /// /// SQL Server specific source generator that inherits from the base generator diff --git a/DataProvider/DataProvider.SqlServer/SqlParsing/SqlParserCsImplementation.cs b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlParsing/SqlParserCsImplementation.cs similarity index 97% rename from DataProvider/DataProvider.SqlServer/SqlParsing/SqlParserCsImplementation.cs rename to DataProvider/Nimblesite.DataProvider.SqlServer/SqlParsing/SqlParserCsImplementation.cs index bd5a61b1..d27a4d2b 100644 --- a/DataProvider/DataProvider.SqlServer/SqlParsing/SqlParserCsImplementation.cs +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlParsing/SqlParserCsImplementation.cs @@ -1,9 +1,9 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; using SqlParser; using SqlParser.Ast; -namespace DataProvider.SqlServer.SqlParsing; +namespace Nimblesite.DataProvider.SqlServer.SqlParsing; /// /// SQL parser implementation using SqlParserCS library diff --git a/DataProvider/DataProvider.SqlServer/SqlServerCodeGenerator.cs b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlServerCodeGenerator.cs similarity index 99% rename from DataProvider/DataProvider.SqlServer/SqlServerCodeGenerator.cs rename to DataProvider/Nimblesite.DataProvider.SqlServer/SqlServerCodeGenerator.cs index d1a9070c..053d1089 100644 --- a/DataProvider/DataProvider.SqlServer/SqlServerCodeGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlServerCodeGenerator.cs @@ -2,11 +2,11 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.Data.SqlClient; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -using SqlError = Selecta.SqlError; +using SqlError = Nimblesite.Sql.Model.SqlError; -namespace DataProvider.SqlServer; +namespace Nimblesite.DataProvider.SqlServer; /// /// SQL Server specific code generator static methods @@ -122,7 +122,7 @@ public static Result GenerateCodeWithMetadata( sb.AppendLine("using System.Collections.Immutable;"); sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine("using Microsoft.Data.SqlClient;"); - sb.AppendLine("using DataProvider.Dependencies;"); + sb.AppendLine("using Nimblesite.DataProvider.Core.Dependencies;"); sb.AppendLine(); sb.AppendLine("namespace Generated;"); sb.AppendLine(); @@ -261,7 +261,7 @@ TableConfig config sb.AppendLine("using System.Collections.Immutable;"); sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine("using Microsoft.Data.SqlClient;"); - sb.AppendLine("using DataProvider.Dependencies;"); + sb.AppendLine("using Nimblesite.DataProvider.Core.Dependencies;"); sb.AppendLine(); sb.AppendLine("namespace Generated"); sb.AppendLine("{"); @@ -476,7 +476,7 @@ GroupingConfig groupingConfig sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine("using System.Linq;"); sb.AppendLine("using Microsoft.Data.SqlClient;"); - sb.AppendLine("using DataProvider.Dependencies;"); + sb.AppendLine("using Nimblesite.DataProvider.Core.Dependencies;"); sb.AppendLine(); sb.AppendLine("namespace Generated;"); sb.AppendLine(); diff --git a/DataProvider/DataProvider.SqlServer/SqlServerParser.cs b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlServerParser.cs similarity index 84% rename from DataProvider/DataProvider.SqlServer/SqlServerParser.cs rename to DataProvider/Nimblesite.DataProvider.SqlServer/SqlServerParser.cs index 63271a5f..df4a82af 100644 --- a/DataProvider/DataProvider.SqlServer/SqlServerParser.cs +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/SqlServerParser.cs @@ -1,8 +1,8 @@ -using DataProvider.SqlServer.SqlParsing; +using Nimblesite.DataProvider.SqlServer.SqlParsing; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace DataProvider.SqlServer; +namespace Nimblesite.DataProvider.SqlServer; /// /// SQL Server specific parser implementation using SqlParserCS diff --git a/DataProvider/DataProvider.Tests/.cursor/rules/TestRules.mdc b/DataProvider/Nimblesite.DataProvider.Tests/.cursor/rules/TestRules.mdc similarity index 100% rename from DataProvider/DataProvider.Tests/.cursor/rules/TestRules.mdc rename to DataProvider/Nimblesite.DataProvider.Tests/.cursor/rules/TestRules.mdc diff --git a/DataProvider/DataProvider.Tests/BulkOperationsTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/BulkOperationsTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/BulkOperationsTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/BulkOperationsTests.cs index 4cb068bb..f2be9445 100644 --- a/DataProvider/DataProvider.Tests/BulkOperationsTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/BulkOperationsTests.cs @@ -1,7 +1,4 @@ -using DataProvider.CodeGeneration; -using Xunit; - -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for bulk insert and upsert code generation diff --git a/DataProvider/Nimblesite.DataProvider.Tests/CodeGenerationE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/CodeGenerationE2ETests.cs new file mode 100644 index 00000000..458a9bd6 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/CodeGenerationE2ETests.cs @@ -0,0 +1,392 @@ +using System.Collections.Immutable; +using Nimblesite.Sql.Model; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// E2E tests: DataAccessGenerator code generation with full verification. +/// Each test generates code and validates the output contains correct C# constructs. +/// +public sealed class CodeGenerationE2ETests +{ + private static readonly IReadOnlyList PatientColumns = + [ + new DatabaseColumn + { + Name = "Id", + SqlType = "TEXT", + CSharpType = "Guid", + IsPrimaryKey = true, + }, + new DatabaseColumn + { + Name = "Name", + SqlType = "TEXT", + CSharpType = "string", + }, + new DatabaseColumn + { + Name = "Age", + SqlType = "INTEGER", + CSharpType = "int", + }, + new DatabaseColumn + { + Name = "Email", + SqlType = "TEXT", + CSharpType = "string", + IsNullable = true, + }, + new DatabaseColumn + { + Name = "IsActive", + SqlType = "INTEGER", + CSharpType = "bool", + }, + ]; + + private static readonly IReadOnlyList QueryParameters = + [ + new ParameterInfo(Name: "minAge", SqlType: "INTEGER"), + new ParameterInfo(Name: "status", SqlType: "TEXT"), + ]; + + [Fact] + public void GenerateQueryMethod_FullWorkflow_ProducesValidExtensionMethod() + { + var result = DataAccessGenerator.GenerateQueryMethod( + className: "PatientQueries", + methodName: "GetActivePatients", + returnTypeName: "PatientRecord", + sql: "SELECT Id, Name, Age, Email FROM Patients WHERE IsActive = 1 AND Age > @minAge", + parameters: QueryParameters, + columns: PatientColumns, + connectionType: "SqliteConnection" + ); + + Assert.True(result is StringOk); + if (result is not StringOk ok) + return; + var code = ok.Value; + + // Verify class structure + Assert.Contains("public static partial class PatientQueries", code); + Assert.Contains("GetActivePatients", code); + Assert.Contains("SqliteConnection", code); + + // Verify parameter handling + Assert.Contains("minAge", code); + Assert.Contains("status", code); + + // Verify SQL embedding + Assert.Contains("SELECT Id, Name, Age, Email FROM Patients", code); + + // Verify mapper generation + Assert.Contains("PatientRecord", code); + + // Verify XML docs + Assert.Contains("/// ", code); + Assert.Contains("/// ", code); + } + + [Fact] + public void GenerateQueryMethod_ValidationErrors_ReturnDetailedErrors() + { + // Empty class name + var emptyClass = DataAccessGenerator.GenerateQueryMethod( + className: "", + methodName: "Test", + returnTypeName: "TestRecord", + sql: "SELECT 1", + parameters: [], + columns: PatientColumns + ); + Assert.True(emptyClass is StringError); + if (emptyClass is StringError classErr) + { + Assert.Contains( + "className", + classErr.Value.Message, + StringComparison.OrdinalIgnoreCase + ); + } + + // Empty method name + var emptyMethod = DataAccessGenerator.GenerateQueryMethod( + className: "TestClass", + methodName: "", + returnTypeName: "TestRecord", + sql: "SELECT 1", + parameters: [], + columns: PatientColumns + ); + Assert.True(emptyMethod is StringError); + + // Empty return type + var emptyReturn = DataAccessGenerator.GenerateQueryMethod( + className: "TestClass", + methodName: "Test", + returnTypeName: "", + sql: "SELECT 1", + parameters: [], + columns: PatientColumns + ); + Assert.True(emptyReturn is StringError); + + // Empty SQL + var emptySql = DataAccessGenerator.GenerateQueryMethod( + className: "TestClass", + methodName: "Test", + returnTypeName: "TestRecord", + sql: "", + parameters: [], + columns: PatientColumns + ); + Assert.True(emptySql is StringError); + + // Empty columns + var emptyColumns = DataAccessGenerator.GenerateQueryMethod( + className: "TestClass", + methodName: "Test", + returnTypeName: "TestRecord", + sql: "SELECT 1", + parameters: [], + columns: [] + ); + Assert.True(emptyColumns is StringError); + } + + [Fact] + public void GenerateNonQueryMethod_FullWorkflow_ProducesValidCode() + { + var result = DataAccessGenerator.GenerateNonQueryMethod( + className: "PatientCommands", + methodName: "DeactivatePatient", + sql: "UPDATE Patients SET IsActive = 0 WHERE Id = @id", + parameters: [new ParameterInfo(Name: "id", SqlType: "TEXT")], + connectionType: "SqliteConnection" + ); + + Assert.True(result is StringOk); + if (result is not StringOk ok) + return; + var code = ok.Value; + + // Verify it generates non-query method + Assert.Contains("PatientCommands", code); + Assert.Contains("DeactivatePatient", code); + Assert.Contains("UPDATE Patients", code); + Assert.Contains("@id", code); + Assert.Contains("SqliteConnection", code); + } + + [Fact] + public void GenerateInsertMethod_WithAllColumnTypes_ProducesCorrectCode() + { + var table = new DatabaseTable + { + Name = "Patients", + Schema = "main", + Columns = PatientColumns, + }; + + var result = DataAccessGenerator.GenerateInsertMethod( + table: table, + connectionType: "SqliteConnection" + ); + + Assert.True(result is StringOk); + if (result is not StringOk ok) + return; + var code = ok.Value; + + // Verify INSERT statement + Assert.Contains("INSERT", code, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Patients", code); + + // Verify non-identity columns are included + Assert.Contains("Name", code); + Assert.Contains("Age", code); + Assert.Contains("Email", code); + Assert.Contains("IsActive", code); + } + + [Fact] + public void GenerateParameterList_VariousInputs_ReturnsCorrectStrings() + { + // Empty parameters + var empty = DataAccessGenerator.GenerateParameterList([]); + Assert.Equal("", empty); + + // Single parameter + var single = DataAccessGenerator.GenerateParameterList([new ParameterInfo(Name: "userId")]); + Assert.Contains("userId", single); + Assert.Contains("object", single); + + // Multiple parameters + var multi = DataAccessGenerator.GenerateParameterList([ + new ParameterInfo(Name: "name", SqlType: "TEXT"), + new ParameterInfo(Name: "age", SqlType: "INTEGER"), + new ParameterInfo(Name: "email", SqlType: "TEXT"), + ]); + Assert.Contains("name", multi); + Assert.Contains("age", multi); + Assert.Contains("email", multi); + Assert.Contains(",", multi); + } + + [Fact] + public void GenerateQueryMethod_WithReservedKeywords_EscapesCorrectly() + { + // Use C# reserved keywords as parameter names + var result = DataAccessGenerator.GenerateQueryMethod( + className: "TestQueries", + methodName: "GetByClass", + returnTypeName: "TestRecord", + sql: "SELECT * FROM Items WHERE class = @class AND int = @int", + parameters: [new ParameterInfo(Name: "class"), new ParameterInfo(Name: "int")], + columns: + [ + new DatabaseColumn + { + Name = "Id", + CSharpType = "string", + SqlType = "TEXT", + }, + ] + ); + + Assert.True(result is StringOk); + if (result is not StringOk ok) + return; + var code = ok.Value; + // Reserved keywords should be escaped with @ + Assert.Contains("@class", code); + Assert.Contains("@int", code); + } + + [Fact] + public void GenerateBulkInsertMethod_ProducesValidBatchCode() + { + var table = new DatabaseTable + { + Name = "Patients", + Schema = "main", + Columns = PatientColumns, + }; + + var result = DataAccessGenerator.GenerateBulkInsertMethod(table: table, batchSize: 100); + + Assert.True(result is StringOk); + if (result is not StringOk ok) + return; + var code = ok.Value; + + // Verify bulk insert structure + Assert.Contains("Patients", code); + Assert.Contains("INSERT", code, StringComparison.OrdinalIgnoreCase); + + // Verify batch handling + Assert.Contains("100", code); + } + + [Fact] + public void GenerateBulkUpsertMethod_SQLite_ProducesValidUpsertCode() + { + var table = new DatabaseTable + { + Name = "Patients", + Schema = "main", + Columns = PatientColumns, + }; + + var result = DataAccessGenerator.GenerateBulkUpsertMethod( + table: table, + databaseType: "SQLite", + connectionType: "SqliteConnection" + ); + + Assert.True(result is StringOk); + if (result is not StringOk ok) + return; + var code = ok.Value; + + Assert.Contains("Patients", code); + // SQLite upsert uses INSERT OR REPLACE or ON CONFLICT + Assert.True( + code.Contains("REPLACE", StringComparison.OrdinalIgnoreCase) + || code.Contains("ON CONFLICT", StringComparison.OrdinalIgnoreCase) + || code.Contains("UPSERT", StringComparison.OrdinalIgnoreCase) + || code.Contains("INSERT", StringComparison.OrdinalIgnoreCase) + ); + } + + [Fact] + public void GenerateQueryMethod_MultipleColumns_MapsAllColumns() + { + var manyColumns = Enumerable + .Range(0, 15) + .Select(i => new DatabaseColumn + { + Name = $"Column{i}", + CSharpType = + i % 3 == 0 ? "int" + : i % 3 == 1 ? "string" + : "bool", + SqlType = + i % 3 == 0 ? "INTEGER" + : i % 3 == 1 ? "TEXT" + : "BOOLEAN", + IsNullable = i % 4 == 0, + }) + .ToImmutableArray(); + + var result = DataAccessGenerator.GenerateQueryMethod( + className: "WideTableQueries", + methodName: "GetWideData", + returnTypeName: "WideRecord", + sql: "SELECT * FROM WideTable", + parameters: [], + columns: manyColumns + ); + + Assert.True(result is StringOk); + if (result is not StringOk ok) + return; + var code = ok.Value; + + // Verify columns appear in the generated code (may use reader ordinal or column name) + Assert.Contains("WideRecord", code); + Assert.Contains("WideTableQueries", code); + Assert.Contains("GetWideData", code); + Assert.Contains("SELECT * FROM WideTable", code); + } + + [Fact] + public void GenerateNonQueryMethod_ValidationErrors_ReturnErrors() + { + var emptyClass = DataAccessGenerator.GenerateNonQueryMethod( + className: " ", + methodName: "Test", + sql: "DELETE FROM T", + parameters: [] + ); + Assert.True(emptyClass is StringError); + + var emptyMethod = DataAccessGenerator.GenerateNonQueryMethod( + className: "TestClass", + methodName: "", + sql: "DELETE FROM T", + parameters: [] + ); + Assert.True(emptyMethod is StringError); + + var emptySql = DataAccessGenerator.GenerateNonQueryMethod( + className: "TestClass", + methodName: "Test", + sql: "", + parameters: [] + ); + Assert.True(emptySql is StringError); + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Tests/CodeGenerationExtendedE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/CodeGenerationExtendedE2ETests.cs new file mode 100644 index 00000000..23e30ced --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/CodeGenerationExtendedE2ETests.cs @@ -0,0 +1,542 @@ +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +public sealed class CodeGenerationExtendedE2ETests +{ + private static readonly IReadOnlyList OrderColumns = + [ + new() + { + Name = "OrderId", + SqlType = "TEXT", + CSharpType = "Guid", + IsPrimaryKey = true, + }, + new() + { + Name = "CustomerName", + SqlType = "TEXT", + CSharpType = "string", + }, + new() + { + Name = "Total", + SqlType = "REAL", + CSharpType = "decimal", + }, + ]; + + private static readonly IReadOnlyList AllJoinedColumns = + [ + new() + { + Name = "OrderId", + SqlType = "TEXT", + CSharpType = "Guid", + }, + new() + { + Name = "CustomerName", + SqlType = "TEXT", + CSharpType = "string", + }, + new() + { + Name = "Total", + SqlType = "REAL", + CSharpType = "decimal", + }, + new() + { + Name = "LineItemId", + SqlType = "TEXT", + CSharpType = "Guid", + }, + new() + { + Name = "ProductName", + SqlType = "TEXT", + CSharpType = "string", + }, + new() + { + Name = "Quantity", + SqlType = "INTEGER", + CSharpType = "int", + }, + ]; + + private static GroupingConfig CreateOrderGroupingConfig() => + new( + QueryName: "OrdersWithItems", + GroupingStrategy: "OneToMany", + ParentEntity: new EntityConfig( + Name: "Order", + KeyColumns: ["OrderId"], + Columns: ["OrderId", "CustomerName", "Total"] + ), + ChildEntity: new EntityConfig( + Name: "LineItem", + KeyColumns: ["LineItemId"], + Columns: ["LineItemId", "ProductName", "Quantity"] + ) + ); + + private static DatabaseTable CreateTableWithIdentity() => + new() + { + Name = "Products", + Schema = "main", + Columns = new DatabaseColumn[] + { + new() + { + Name = "Id", + SqlType = "TEXT", + CSharpType = "Guid", + IsPrimaryKey = true, + }, + new() + { + Name = "RowNum", + SqlType = "INTEGER", + CSharpType = "int", + IsIdentity = true, + }, + new() + { + Name = "Name", + SqlType = "TEXT", + CSharpType = "string", + }, + new() + { + Name = "Price", + SqlType = "REAL", + CSharpType = "decimal", + }, + }, + }; + + private static Task, SqlError>> MockGetColumnMetadata( + string connectionString, + string sql, + IEnumerable parameters + ) => + Task.FromResult, SqlError>>( + new Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >(AllJoinedColumns) + ); + + private static CodeGenerationConfig CreateConfig() => + new(getColumnMetadata: MockGetColumnMetadata); + + [Fact] + public void GenerateGroupedQueryMethod_ViaConfig_ProducesParentChildCode() + { + var result = CreateConfig() + .GenerateGroupedQueryMethod( + "OrderQueries", + "GetOrdersWithItems", + "SELECT o.OrderId, o.CustomerName, o.Total, li.LineItemId, li.ProductName, li.Quantity FROM Orders o JOIN LineItems li ON o.OrderId = li.OrderId", + Array.Empty(), + AllJoinedColumns, + CreateOrderGroupingConfig(), + "SqliteConnection" + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("public static partial class OrderQueries", code); + Assert.Contains("GetOrdersWithItemsAsync", code); + Assert.Contains("SqliteConnection", code); + Assert.Contains("SELECT o.OrderId", code); + Assert.Contains("GroupResults", code); + Assert.Contains("Order", code); + Assert.Contains("LineItem", code); + } + + [Fact] + public void GenerateGroupedQueryMethod_WithParameters_IncludesParameterHandling() + { + var result = CreateConfig() + .GenerateGroupedQueryMethod( + "OrderQueries", + "GetOrdersByCustomer", + "SELECT o.OrderId FROM Orders o JOIN LineItems li ON o.OrderId = li.OrderId WHERE o.CustomerId = @customerId", + new List { new(Name: "customerId", SqlType: "TEXT") }, + AllJoinedColumns, + CreateOrderGroupingConfig(), + "SqliteConnection" + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("customerId", code); + Assert.Contains("@customerId", code); + Assert.Contains("AddWithValue", code); + Assert.Contains("GetOrdersByCustomerAsync", code); + } + + [Fact] + public void GenerateGroupedQueryMethod_EmptyClassName_ReturnsError() + { + var result = CreateConfig() + .GenerateGroupedQueryMethod( + "", + "Test", + "SELECT 1", + Array.Empty(), + AllJoinedColumns, + CreateOrderGroupingConfig(), + "SqliteConnection" + ); + + if (result is not StringError err) + { + Assert.Fail("Expected StringError"); + return; + } + Assert.Contains("className", err.Value.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GenerateGroupedQueryMethod_EmptyColumns_ReturnsError() + { + var result = CreateConfig() + .GenerateGroupedQueryMethod( + "TestClass", + "TestMethod", + "SELECT 1", + Array.Empty(), + Array.Empty(), + CreateOrderGroupingConfig(), + "SqliteConnection" + ); + + Assert.True(result is StringError); + } + + [Fact] + public void GenerateTableOperations_WithInsertAndUpdate_ProducesAllMethods() + { + var generator = new DefaultTableOperationGenerator(connectionType: "SqliteConnection"); + var tableConfig = new TableConfig + { + Name = "Products", + Schema = "main", + GenerateInsert = true, + GenerateUpdate = true, + }; + + var result = generator.GenerateTableOperations( + table: CreateTableWithIdentity(), + config: tableConfig + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("ProductsExtensions", code); + Assert.Contains("INSERT", code, StringComparison.OrdinalIgnoreCase); + Assert.Contains("UPDATE", code, StringComparison.OrdinalIgnoreCase); + Assert.Contains("namespace Generated", code); + Assert.Contains("Microsoft.Data.Sqlite", code); + } + + [Fact] + public void GenerateTableOperations_NullTable_ReturnsError() + { + var generator = new DefaultTableOperationGenerator(); + var tableConfig = new TableConfig { Name = "Test", GenerateInsert = true }; + + var result = generator.GenerateTableOperations(table: null!, config: tableConfig); + + if (result is not StringError err) + { + Assert.Fail("Expected StringError"); + return; + } + Assert.Contains("table", err.Value.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GenerateTableOperations_NullConfig_ReturnsError() + { + var result = new DefaultTableOperationGenerator().GenerateTableOperations( + table: CreateTableWithIdentity(), + config: null! + ); + + if (result is not StringError err) + { + Assert.Fail("Expected StringError"); + return; + } + Assert.Contains("config", err.Value.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GenerateInsertMethod_ExcludesIdentityColumns() + { + var result = new DefaultTableOperationGenerator( + connectionType: "SqliteConnection" + ).GenerateInsertMethod(table: CreateTableWithIdentity()); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("INSERT", code, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Products", code); + Assert.Contains("Name", code); + Assert.Contains("Price", code); + Assert.DoesNotContain("RowNum", code); + } + + [Fact] + public void GenerateUpdateMethod_UsesPrimaryKeyInWhere() + { + var result = new DefaultTableOperationGenerator( + connectionType: "SqliteConnection" + ).GenerateUpdateMethod(table: CreateTableWithIdentity()); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("UPDATE", code, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Products", code); + Assert.Contains("WHERE", code, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Id", code); + } + + [Fact] + public void GenerateModelType_ProducesRecordWithProperties() + { + var result = new DefaultCodeTemplate().GenerateModelType( + typeName: "PatientRecord", + columns: OrderColumns + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("PatientRecord", code); + Assert.Contains("OrderId", code); + Assert.Contains("CustomerName", code); + Assert.Contains("Total", code); + } + + [Fact] + public void GenerateModelType_EmptyTypeName_ReturnsError() + { + var result = new DefaultCodeTemplate().GenerateModelType( + typeName: "", + columns: OrderColumns + ); + + Assert.True(result is StringError); + } + + [Fact] + public void GenerateDataAccessMethod_ProducesExtensionMethod() + { + var result = new DefaultCodeTemplate().GenerateDataAccessMethod( + methodName: "GetHighValueOrders", + returnTypeName: "OrderRecord", + sql: "SELECT OrderId, CustomerName, Total FROM Orders WHERE Total > @minTotal", + parameters: new List { new(Name: "minTotal", SqlType: "REAL") }, + columns: OrderColumns + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("GetHighValueOrdersExtensions", code); + Assert.Contains("GetHighValueOrders", code); + Assert.Contains("OrderRecord", code); + Assert.Contains("minTotal", code); + Assert.Contains("SqliteConnection", code); + } + + [Fact] + public void GenerateSourceFile_CombinesModelAndDataAccess() + { + var result = new DefaultCodeTemplate().GenerateSourceFile( + namespaceName: "MyApp.Generated", + modelCode: "public sealed record OrderRecord(Guid OrderId, string CustomerName);", + dataAccessCode: "public static partial class OrderQueries { }" + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + var code = ok.Value; + + Assert.Contains("namespace MyApp.Generated;", code); + Assert.Contains("using System;", code); + Assert.Contains("using Microsoft.Data.Sqlite;", code); + Assert.Contains("using Outcome;", code); + Assert.Contains("OrderRecord", code); + Assert.Contains("OrderQueries", code); + } + + [Fact] + public void GenerateSourceFile_EmptyNamespace_ReturnsError() => + Assert.True( + new DefaultCodeTemplate().GenerateSourceFile( + namespaceName: "", + modelCode: "record Test();", + dataAccessCode: "class Foo { }" + ) is StringError + ); + + [Fact] + public void GenerateSourceFile_BothCodesEmpty_ReturnsError() => + Assert.True( + new DefaultCodeTemplate().GenerateSourceFile( + namespaceName: "MyApp", + modelCode: "", + dataAccessCode: "" + ) is StringError + ); + + [Fact] + public void GenerateSourceFile_OnlyModelCode_Succeeds() + { + var result = new DefaultCodeTemplate().GenerateSourceFile( + namespaceName: "MyApp", + modelCode: "public sealed record Widget(string Name);", + dataAccessCode: "" + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + Assert.Contains("Widget", ok.Value); + Assert.Contains("namespace MyApp;", ok.Value); + } + + [Fact] + public void GenerateGroupedModels_ProducesParentAndChildTypes() + { + var result = new DefaultCodeTemplate().GenerateGroupedModels( + groupingConfig: CreateOrderGroupingConfig(), + columns: AllJoinedColumns + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + Assert.Contains("Order", ok.Value); + Assert.Contains("LineItem", ok.Value); + } + + [Fact] + public void GenerateGroupedModels_NullConfig_ReturnsError() => + Assert.True( + new DefaultCodeTemplate().GenerateGroupedModels( + groupingConfig: null!, + columns: AllJoinedColumns + ) is StringError + ); + + [Fact] + public void GenerateGroupedModels_EmptyColumns_ReturnsError() => + Assert.True( + new DefaultCodeTemplate().GenerateGroupedModels( + groupingConfig: CreateOrderGroupingConfig(), + columns: Array.Empty() + ) is StringError + ); + + [Fact] + public void GenerateTableOperations_InsertOnly_OmitsUpdate() + { + var generator = new DefaultTableOperationGenerator(connectionType: "SqliteConnection"); + var tableConfig = new TableConfig + { + Name = "Products", + Schema = "main", + GenerateInsert = true, + GenerateUpdate = false, + }; + + var result = generator.GenerateTableOperations( + table: CreateTableWithIdentity(), + config: tableConfig + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + Assert.Contains("INSERT", ok.Value, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductsExtensions", ok.Value); + } + + [Fact] + public void DefaultTableOperationGenerator_SqlServerConnectionType_UsesCorrectNamespace() + { + var generator = new DefaultTableOperationGenerator(connectionType: "SqlConnection"); + var tableConfig = new TableConfig + { + Name = "Products", + Schema = "dbo", + GenerateInsert = true, + GenerateUpdate = true, + }; + + var result = generator.GenerateTableOperations( + table: CreateTableWithIdentity(), + config: tableConfig + ); + + if (result is not StringOk ok) + { + Assert.Fail("Expected StringOk"); + return; + } + Assert.Contains("Microsoft.Data.SqlClient", ok.Value); + } +} diff --git a/DataProvider/DataProvider.Tests/ConfigurationTypesTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/ConfigurationTypesTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/ConfigurationTypesTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/ConfigurationTypesTests.cs index 2d5c37f5..9a49422b 100644 --- a/DataProvider/DataProvider.Tests/ConfigurationTypesTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/ConfigurationTypesTests.cs @@ -1,9 +1,8 @@ using System.Text.Json; -using Xunit; #pragma warning disable CA1869 // Cache and reuse JsonSerializerOptions instances -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for configuration types and JSON serialization diff --git a/DataProvider/DataProvider.Tests/CustomCodeGenerationTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/CustomCodeGenerationTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/CustomCodeGenerationTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/CustomCodeGenerationTests.cs index d1805318..47c56c15 100644 --- a/DataProvider/DataProvider.Tests/CustomCodeGenerationTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/CustomCodeGenerationTests.cs @@ -1,13 +1,11 @@ using System.Collections.Frozen; -using DataProvider.CodeGeneration; -using DataProvider.SQLite; +using Nimblesite.DataProvider.SQLite; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -using Xunit; #pragma warning disable CA1307 // Specify StringComparison for clarity -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests demonstrating custom code generation with completely different output styles @@ -119,7 +117,7 @@ public class {typeName}Data using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Outcome; -using Selecta; +using Nimblesite.Sql.Model; namespace CustomGenerated; @@ -282,7 +280,7 @@ public class {methodName}Query using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Outcome; -using Selecta; +using Nimblesite.Sql.Model; namespace FluentGenerated; diff --git a/DataProvider/DataProvider.Tests/DbConnectionExtensionsTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/DbConnectionExtensionsTests.cs similarity index 98% rename from DataProvider/DataProvider.Tests/DbConnectionExtensionsTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/DbConnectionExtensionsTests.cs index be6041ca..f61426e6 100644 --- a/DataProvider/DataProvider.Tests/DbConnectionExtensionsTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/DbConnectionExtensionsTests.cs @@ -1,8 +1,7 @@ using Microsoft.Data.Sqlite; using Outcome; -using Xunit; -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for DbConnectionExtensions methods to improve coverage @@ -20,6 +19,7 @@ public DbConnectionExtensionsTests() CreateSchema(); } + //TODO: this is illegal because this should be using migrations private void CreateSchema() { using var command = new SqliteCommand( diff --git a/DataProvider/DataProvider.Tests/DbTransactTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/DbTransactTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/DbTransactTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/DbTransactTests.cs index dabb27dc..6bbe8689 100644 --- a/DataProvider/DataProvider.Tests/DbTransactTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/DbTransactTests.cs @@ -1,9 +1,8 @@ using Microsoft.Data.Sqlite; -using Xunit; #pragma warning disable CS1998 // This async method lacks 'await' operators and will run synchronously -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for DbTransact extension methods diff --git a/DataProvider/DataProvider.Tests/DbTransactionExtensionsTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/DbTransactionExtensionsTests.cs similarity index 94% rename from DataProvider/DataProvider.Tests/DbTransactionExtensionsTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/DbTransactionExtensionsTests.cs index 8ff17109..eba2b31c 100644 --- a/DataProvider/DataProvider.Tests/DbTransactionExtensionsTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/DbTransactionExtensionsTests.cs @@ -1,21 +1,20 @@ using Microsoft.Data.Sqlite; -using Xunit; using TestRecordListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError >.Error< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError >; using TestRecordListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError >.Ok< - System.Collections.Generic.IReadOnlyList, - Selecta.SqlError + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError >; -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for DbTransactionExtensions Query method to improve coverage diff --git a/DataProvider/DataProvider.Tests/Fakes/FakeCommand.cs b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeCommand.cs similarity index 98% rename from DataProvider/DataProvider.Tests/Fakes/FakeCommand.cs rename to DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeCommand.cs index 6504eeb0..a17c9199 100644 --- a/DataProvider/DataProvider.Tests/Fakes/FakeCommand.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeCommand.cs @@ -4,7 +4,7 @@ #pragma warning disable CA1515 // Make types internal #pragma warning disable CS8765 // Nullability of parameter doesn't match overridden member -namespace DataProvider.Tests.Fakes; +namespace Nimblesite.DataProvider.Tests.Fakes; /// /// Fake database command for testing diff --git a/DataProvider/DataProvider.Tests/Fakes/FakeDataReader.cs b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDataReader.cs similarity index 99% rename from DataProvider/DataProvider.Tests/Fakes/FakeDataReader.cs rename to DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDataReader.cs index 3290e108..1033db6b 100644 --- a/DataProvider/DataProvider.Tests/Fakes/FakeDataReader.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDataReader.cs @@ -1,7 +1,7 @@ using System.Collections; using System.Data.Common; -namespace DataProvider.Tests.Fakes; +namespace Nimblesite.DataProvider.Tests.Fakes; /// /// Fake data reader for testing that returns predefined data diff --git a/DataProvider/DataProvider.Tests/Fakes/FakeDbConnection.cs b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnection.cs similarity index 97% rename from DataProvider/DataProvider.Tests/Fakes/FakeDbConnection.cs rename to DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnection.cs index 95357345..34fce171 100644 --- a/DataProvider/DataProvider.Tests/Fakes/FakeDbConnection.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnection.cs @@ -6,7 +6,7 @@ #pragma warning disable CA1849 // Synchronous blocking calls #pragma warning disable CS8765 // Nullability of parameter doesn't match overridden member -namespace DataProvider.Tests.Fakes; +namespace Nimblesite.DataProvider.Tests.Fakes; /// /// Fake database connection for testing that acts as a factory for FakeTransaction diff --git a/DataProvider/DataProvider.Tests/Fakes/FakeDbConnectionTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnectionTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/Fakes/FakeDbConnectionTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnectionTests.cs index f47e91b4..c68c9545 100644 --- a/DataProvider/DataProvider.Tests/Fakes/FakeDbConnectionTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnectionTests.cs @@ -1,10 +1,8 @@ -using Xunit; - #pragma warning disable CA1861 // Prefer static readonly fields #pragma warning disable CA2000 // Dispose objects before losing scope #pragma warning disable CA1849 // Synchronous blocking calls -namespace DataProvider.Tests.Fakes; +namespace Nimblesite.DataProvider.Tests.Fakes; public class FakeDbConnectionTests { diff --git a/DataProvider/DataProvider.Tests/Fakes/FakeParameter.cs b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeParameter.cs similarity index 98% rename from DataProvider/DataProvider.Tests/Fakes/FakeParameter.cs rename to DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeParameter.cs index 572b1b3e..2bcadae2 100644 --- a/DataProvider/DataProvider.Tests/Fakes/FakeParameter.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeParameter.cs @@ -4,7 +4,7 @@ #pragma warning disable CS8765 // Nullability of parameter doesn't match overridden member -namespace DataProvider.Tests.Fakes; +namespace Nimblesite.DataProvider.Tests.Fakes; /// /// Fake database parameter for testing diff --git a/DataProvider/DataProvider.Tests/Fakes/FakeTransaction.cs b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeTransaction.cs similarity index 97% rename from DataProvider/DataProvider.Tests/Fakes/FakeTransaction.cs rename to DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeTransaction.cs index de4fc225..9700de90 100644 --- a/DataProvider/DataProvider.Tests/Fakes/FakeTransaction.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeTransaction.cs @@ -4,7 +4,7 @@ #pragma warning disable CA1515 // Make types internal #pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf -namespace DataProvider.Tests.Fakes; +namespace Nimblesite.DataProvider.Tests.Fakes; /// /// Fake database transaction for testing with injectable data callback diff --git a/DataProvider/Nimblesite.DataProvider.Tests/GlobalUsings.cs b/DataProvider/Nimblesite.DataProvider.Tests/GlobalUsings.cs new file mode 100644 index 00000000..22d90d6a --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/GlobalUsings.cs @@ -0,0 +1,26 @@ +global using Nimblesite.DataProvider.Core; +global using Nimblesite.DataProvider.Core.CodeGeneration; +global using Xunit; +// Global usings for Nimblesite.DataProvider.Tests +global using IntError = Outcome.Result.Error< + int, + Nimblesite.Sql.Model.SqlError +>; +// Result type aliases for tests +global using IntOk = Outcome.Result.Ok< + int, + Nimblesite.Sql.Model.SqlError +>; +global using NullableStringOk = Outcome.Result.Ok< + string?, + Nimblesite.Sql.Model.SqlError +>; +global using SqlError = Nimblesite.Sql.Model.SqlError; +global using StringError = Outcome.Result.Error< + string, + Nimblesite.Sql.Model.SqlError +>; +global using StringOk = Outcome.Result.Ok< + string, + Nimblesite.Sql.Model.SqlError +>; diff --git a/DataProvider/DataProvider.Tests/JoinGraphTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/JoinGraphTests.cs similarity index 97% rename from DataProvider/DataProvider.Tests/JoinGraphTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/JoinGraphTests.cs index ba14cbce..5a1d45fd 100644 --- a/DataProvider/DataProvider.Tests/JoinGraphTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/JoinGraphTests.cs @@ -1,7 +1,6 @@ -using Selecta; -using Xunit; +using Nimblesite.Sql.Model; -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; public class JoinGraphTests { diff --git a/DataProvider/Nimblesite.DataProvider.Tests/LqlSqliteE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/LqlSqliteE2ETests.cs new file mode 100644 index 00000000..3553229c --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/LqlSqliteE2ETests.cs @@ -0,0 +1,433 @@ +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Core; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// E2E tests: LQL parse -> SQLite SQL conversion -> execute against real DB -> verify results. +/// Each test covers a full LQL workflow from string input to verified query results. +/// +public sealed class LqlSqliteE2ETests : IDisposable +{ + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"lql_e2e_{Guid.NewGuid()}.db" + ); + + private readonly SqliteConnection _connection; + + public LqlSqliteE2ETests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + CreateSchemaAndSeed(); + } + + public void Dispose() + { + _connection.Dispose(); + try + { + File.Delete(_dbPath); + } + catch (IOException) + { /* cleanup best-effort */ + } + } + + private void CreateSchemaAndSeed() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + age INTEGER NOT NULL, + country TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ); + CREATE TABLE orders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + total REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + FOREIGN KEY (user_id) REFERENCES users(id) + ); + """; + cmd.ExecuteNonQuery(); + + // Seed users + using var userCmd = _connection.CreateCommand(); + userCmd.CommandText = """ + INSERT INTO users VALUES ('u1', 'Alice', 'alice@test.com', 30, 'US', 'active'); + INSERT INTO users VALUES ('u2', 'Bob', 'bob@test.com', 25, 'UK', 'active'); + INSERT INTO users VALUES ('u3', 'Charlie', 'charlie@test.com', 45, 'US', 'inactive'); + INSERT INTO users VALUES ('u4', 'Diana', 'diana@test.com', 35, 'AU', 'active'); + INSERT INTO users VALUES ('u5', 'Eve', 'eve@test.com', 22, 'US', 'active'); + """; + userCmd.ExecuteNonQuery(); + + // Seed orders + using var orderCmd = _connection.CreateCommand(); + orderCmd.CommandText = """ + INSERT INTO orders VALUES ('o1', 'u1', 150.00, 'completed'); + INSERT INTO orders VALUES ('o2', 'u1', 75.50, 'completed'); + INSERT INTO orders VALUES ('o3', 'u2', 200.00, 'pending'); + INSERT INTO orders VALUES ('o4', 'u3', 50.00, 'completed'); + INSERT INTO orders VALUES ('o5', 'u4', 300.00, 'shipped'); + INSERT INTO orders VALUES ('o6', 'u4', 125.00, 'completed'); + INSERT INTO orders VALUES ('o7', 'u5', 45.00, 'pending'); + """; + orderCmd.ExecuteNonQuery(); + } + + private static LqlStatement AssertParseOk(string lqlCode) + { + var parseResult = LqlStatementConverter.ToStatement(lqlCode); + var parseOk = Assert.IsType.Ok>( + parseResult + ); + return parseOk.Value; + } + + private static string AssertSqliteOk(LqlStatement statement) + { + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + return sqlOk.Value; + } + + [Fact] + public void LqlSelectAllFromTable_ParseConvertExecute_ReturnsAllRows() + { + // Parse LQL + var lqlCode = "users |> select(users.id, users.name, users.email)"; + var statement = AssertParseOk(lqlCode); + Assert.NotNull(statement); + Assert.NotNull(statement.AstNode); + Assert.Null(statement.ParseError); + + // Convert to SQLite + var sql = AssertSqliteOk(statement); + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("users", sql, StringComparison.OrdinalIgnoreCase); + + // Execute against real DB + var queryResult = _connection.Query<(string, string, string)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetString(1), r.GetString(2)) + ); + Assert.True( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, string, string)>, + SqlError + > + ); + if ( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, string, string)>, + SqlError + > ok + ) + { + var rows = ok.Value; + Assert.Equal(5, rows.Count); + Assert.Contains(rows, r => r.Item2 == "Alice"); + Assert.Contains(rows, r => r.Item2 == "Bob"); + Assert.Contains(rows, r => r.Item3 == "diana@test.com"); + } + } + + [Fact] + public void LqlWithFilter_ParseConvertExecute_ReturnsFilteredRows() + { + var lqlCode = + "users |> filter(fn(row) => row.users.age > 25) |> select(users.name, users.age)"; + var statement = AssertParseOk(lqlCode); + + var sql = AssertSqliteOk(statement); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + + var queryResult = _connection.Query<(string, long)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetInt64(1)) + ); + Assert.True( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, long)>, + SqlError + > + ); + if ( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, long)>, + SqlError + > ok + ) + { + var rows = ok.Value; + + // Alice (30), Charlie (45), Diana (35) are > 25 + Assert.Equal(3, rows.Count); + Assert.All(rows, r => Assert.True(r.Item2 > 25)); + Assert.Contains(rows, r => r.Item1 == "Alice"); + Assert.Contains(rows, r => r.Item1 == "Charlie"); + Assert.Contains(rows, r => r.Item1 == "Diana"); + } + } + + [Fact] + public void LqlWithOrderByAndLimit_ParseConvertExecute_ReturnsOrderedSubset() + { + var lqlCode = + "users |> order_by(users.age asc) |> limit(3) |> select(users.name, users.age)"; + var statement = AssertParseOk(lqlCode); + + var sql = AssertSqliteOk(statement); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", sql, StringComparison.OrdinalIgnoreCase); + + var queryResult = _connection.Query<(string, long)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetInt64(1)) + ); + Assert.True( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, long)>, + SqlError + > + ); + if ( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, long)>, + SqlError + > ok + ) + { + var rows = ok.Value; + + Assert.Equal(3, rows.Count); + // Should be youngest 3: Eve (22), Bob (25), Alice (30) + Assert.Equal("Eve", rows[0].Item1); + Assert.Equal(22L, rows[0].Item2); + Assert.Equal("Bob", rows[1].Item1); + Assert.Equal(25L, rows[1].Item2); + Assert.Equal("Alice", rows[2].Item1); + Assert.Equal(30L, rows[2].Item2); + } + } + + [Fact] + public void LqlWithJoin_ParseConvertExecute_ReturnsJoinedData() + { + var lqlCode = + "users |> join(orders, on = users.id = orders.user_id) |> select(users.name, orders.total)"; + var statement = AssertParseOk(lqlCode); + + var sql = AssertSqliteOk(statement); + Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("users.id = orders.user_id", sql, StringComparison.OrdinalIgnoreCase); + + var queryResult = _connection.Query<(string, double)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + Assert.True( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, double)>, + SqlError + > + ); + if ( + queryResult + is Result, SqlError>.Ok< + IReadOnlyList<(string, double)>, + SqlError + > ok + ) + { + var rows = ok.Value; + + // 7 orders across users with matching IDs + Assert.Equal(7, rows.Count); + Assert.Contains(rows, r => r.Item1 == "Alice" && r.Item2 == 150.00); + Assert.Contains(rows, r => r.Item1 == "Alice" && r.Item2 == 75.50); + Assert.Contains(rows, r => r.Item1 == "Bob" && r.Item2 == 200.00); + Assert.Contains(rows, r => r.Item1 == "Diana" && r.Item2 == 300.00); + } + } + + [Fact] + public void LqlStatementConverter_InvalidSyntax_ReturnsError() + { + // Missing pipe operator + var badLql = "users select name"; + var result = LqlStatementConverter.ToStatement(badLql); + + // Should either be an error result or a statement with a parse error + if (result is Result.Error error) + { + Assert.NotEmpty(error.Value.Message); + } + else if (result is Result.Ok ok) + { + // Parser may successfully parse but produce a broken AST, + // or produce a statement that fails on ToSQLite conversion + var stmt = ok.Value; + if (stmt.ParseError is { } parseErr) + { + Assert.NotEmpty(parseErr.Message); + } + else + { + // The parser accepted it - verify it at least produced something + Assert.NotNull(stmt.AstNode); + } + } + } + + [Fact] + public void LqlStatementToSQLite_WithParseError_ReturnsError() + { + // Create a statement with a parse error directly + var errorStatement = new LqlStatement { ParseError = SqlError.Create("Test parse error") }; + + var sqlResult = errorStatement.ToSQLite(); + Assert.True(sqlResult is StringError); + if (sqlResult is StringError errorResult) + { + var error = errorResult.Value; + Assert.Contains("Test parse error", error.Message, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void LqlStatementToSQLite_WithNullAstNode_ReturnsError() + { + var emptyStatement = new LqlStatement { AstNode = null }; + var sqlResult = emptyStatement.ToSQLite(); + Assert.True(sqlResult is StringError); + } + + [Fact] + public void LqlStatementToSQLite_WithIdentifierNode_ReturnsSelectAll() + { + // An Identifier node should produce SELECT * FROM tableName + var identifierStatement = new LqlStatement { AstNode = new Identifier("customers") }; + + var sqlResult = identifierStatement.ToSQLite(); + Assert.True(sqlResult is StringOk); + if (sqlResult is StringOk sqlOk) + { + var sql = sqlOk.Value; + Assert.Contains("SELECT *", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("customers", sql); + } + } + + [Fact] + public void SQLiteContextDirect_BuildComplexQuery_GeneratesValidSQL() + { + // Use SQLiteContext directly to build a query + var context = new SQLiteContext(); + context.SetBaseTable("users"); + context.SetSelectColumns([ColumnInfo.Named(name: "name"), ColumnInfo.Named(name: "email")]); + context.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "status"), + @operator: ComparisonOperator.Eq, + right: "'active'" + ) + ); + context.AddOrderBy([(Column: "name", Direction: "ASC")]); + context.SetLimit("10"); + + var sql = context.GenerateSQL(); + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name", sql); + Assert.Contains("email", sql); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", sql, StringComparison.OrdinalIgnoreCase); + + // Execute it + var result = _connection.Query<(string, string)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetString(1)) + ); + Assert.True( + result + is Result, SqlError>.Ok< + IReadOnlyList<(string, string)>, + SqlError + > + ); + if ( + result + is Result, SqlError>.Ok< + IReadOnlyList<(string, string)>, + SqlError + > ok + ) + { + var rows = ok.Value; + Assert.Equal(4, rows.Count); // 4 active users + Assert.Equal("Alice", rows[0].Item1); // First alphabetically + } + } + + [Fact] + public void SQLiteContextDirect_WithJoin_GeneratesValidJoinSQL() + { + var context = new SQLiteContext(); + context.SetBaseTable("users"); + context.AddJoin( + joinType: "INNER JOIN", + tableName: "orders", + condition: "users.id = orders.user_id" + ); + context.SetSelectColumns([ + ColumnInfo.Named(name: "name", tableAlias: "users"), + ColumnInfo.Named(name: "total", tableAlias: "orders"), + ]); + + var sql = context.GenerateSQL(); + Assert.Contains("INNER JOIN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("orders", sql); + Assert.Contains("users.id = orders.user_id", sql); + + var result = _connection.Query<(string, double)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + Assert.True( + result + is Result, SqlError>.Ok< + IReadOnlyList<(string, double)>, + SqlError + > + ); + if ( + result + is Result, SqlError>.Ok< + IReadOnlyList<(string, double)>, + SqlError + > ok + ) + { + var rows = ok.Value; + Assert.Equal(7, rows.Count); + } + } +} diff --git a/DataProvider/DataProvider.Tests/ModelGenerationIntegrationTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/ModelGenerationIntegrationTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/ModelGenerationIntegrationTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/ModelGenerationIntegrationTests.cs index 17f29c43..79e512d0 100644 --- a/DataProvider/DataProvider.Tests/ModelGenerationIntegrationTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/ModelGenerationIntegrationTests.cs @@ -1,7 +1,4 @@ -using DataProvider.CodeGeneration; -using Xunit; - -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Integration tests for ModelGenerator functionality diff --git a/DataProvider/DataProvider.Tests/DataProvider.Tests.csproj b/DataProvider/Nimblesite.DataProvider.Tests/Nimblesite.DataProvider.Tests.csproj similarity index 75% rename from DataProvider/DataProvider.Tests/DataProvider.Tests.csproj rename to DataProvider/Nimblesite.DataProvider.Tests/Nimblesite.DataProvider.Tests.csproj index 2ec809cc..bae72414 100644 --- a/DataProvider/DataProvider.Tests/DataProvider.Tests.csproj +++ b/DataProvider/Nimblesite.DataProvider.Tests/Nimblesite.DataProvider.Tests.csproj @@ -21,14 +21,14 @@ - + - - - - + + + + diff --git a/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderE2ETests.cs new file mode 100644 index 00000000..086f6b7e --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderE2ETests.cs @@ -0,0 +1,416 @@ +using System.Data; +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// E2E tests: SelectStatement LINQ builder -> ToSQLite -> execute against real DB. +/// Also tests GetRecords API and PredicateBuilder workflows. +/// +public sealed class QueryBuilderE2ETests : IDisposable +{ + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"qb_e2e_{Guid.NewGuid()}.db" + ); + + private readonly SqliteConnection _connection; + + public QueryBuilderE2ETests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + CreateSchemaAndSeed(); + } + + public void Dispose() + { + _connection.Dispose(); + try + { + File.Delete(_dbPath); + } + catch (IOException) + { /* cleanup best-effort */ + } + } + + private void CreateSchemaAndSeed() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE Products ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL, + Price REAL NOT NULL, + Quantity INTEGER NOT NULL, + Category TEXT NOT NULL + ); + CREATE TABLE Categories ( + Name TEXT PRIMARY KEY, + Description TEXT NOT NULL + ); + CREATE TABLE Suppliers ( + Id TEXT PRIMARY KEY, + CompanyName TEXT NOT NULL, + Country TEXT NOT NULL + ); + CREATE TABLE ProductSuppliers ( + ProductId TEXT NOT NULL, + SupplierId TEXT NOT NULL, + PRIMARY KEY (ProductId, SupplierId) + ); + """; + cmd.ExecuteNonQuery(); + + // Seed products (test-only constant SQL, not user input) + using var seedProductsCmd = _connection.CreateCommand(); + seedProductsCmd.CommandText = """ + INSERT INTO Products VALUES ('p1', 'Widget A', 10.00, 100, 'Electronics'); + INSERT INTO Products VALUES ('p2', 'Widget B', 25.50, 50, 'Electronics'); + INSERT INTO Products VALUES ('p3', 'Gadget X', 99.99, 10, 'Gadgets'); + INSERT INTO Products VALUES ('p4', 'Gadget Y', 149.99, 5, 'Gadgets'); + INSERT INTO Products VALUES ('p5', 'Tool Alpha', 35.00, 75, 'Tools'); + INSERT INTO Products VALUES ('p6', 'Tool Beta', 45.00, 30, 'Tools'); + INSERT INTO Products VALUES ('p7', 'Tool Gamma', 15.00, 200, 'Tools'); + INSERT INTO Products VALUES ('p8', 'Premium Widget', 500.00, 2, 'Electronics'); + """; + seedProductsCmd.ExecuteNonQuery(); + + // Seed categories + using var catCmd = _connection.CreateCommand(); + catCmd.CommandText = """ + INSERT INTO Categories VALUES ('Electronics', 'Electronic devices and components'); + INSERT INTO Categories VALUES ('Gadgets', 'Innovative gadgets'); + INSERT INTO Categories VALUES ('Tools', 'Professional tools'); + """; + catCmd.ExecuteNonQuery(); + + // Seed suppliers + using var supCmd = _connection.CreateCommand(); + supCmd.CommandText = """ + INSERT INTO Suppliers VALUES ('s1', 'Acme Corp', 'US'); + INSERT INTO Suppliers VALUES ('s2', 'Global Parts', 'UK'); + INSERT INTO ProductSuppliers VALUES ('p1', 's1'); + INSERT INTO ProductSuppliers VALUES ('p1', 's2'); + INSERT INTO ProductSuppliers VALUES ('p3', 's1'); + """; + supCmd.ExecuteNonQuery(); + } + + [Fact] + public void SelectStatementBuilder_WhereOrderByLimit_ExecutesCorrectly() + { + // Build query: SELECT Name, Price FROM Products WHERE Category = 'Tools' ORDER BY Price ASC LIMIT 2 + var statement = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Price")]) + .Where(columnName: "Category", value: "Tools") + .OrderBy(columnName: "Price") + .Take(count: 2) + .ToSqlStatement(); + + // Convert to SQLite + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Products", sql); + Assert.Contains("LIMIT", sql, StringComparison.OrdinalIgnoreCase); + + // Execute against real DB + var queryResult = _connection.Query<(string Name, double Price)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + var rows = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string Name, double Price)>, + SqlError + >>(queryResult); + Assert.Equal(2, rows.Value.Count); + Assert.Equal("Tool Gamma", rows.Value[0].Name); + Assert.Equal(15.00, rows.Value[0].Price); + Assert.Equal("Tool Alpha", rows.Value[1].Name); + Assert.Equal(35.00, rows.Value[1].Price); + } + + [Fact] + public void SelectStatementBuilder_DistinctAndGroupBy_ExecutesCorrectly() + { + // DISTINCT categories + var distinctStmt = "Products" + .From() + .Select(columns: [(null, "Category")]) + .Distinct() + .OrderBy(columnName: "Category") + .ToSqlStatement(); + + var distinctSqlOk = Assert.IsType(distinctStmt.ToSQLite()); + var distinctSql = distinctSqlOk.Value; + + var distinctResult = _connection.Query( + sql: distinctSql, + mapper: r => r.GetString(0) + ); + var categories = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(distinctResult); + Assert.Equal(3, categories.Value.Count); + Assert.Equal("Electronics", categories.Value[0]); + Assert.Equal("Gadgets", categories.Value[1]); + Assert.Equal("Tools", categories.Value[2]); + } + + [Fact] + public void SelectStatementBuilder_WithPagination_ExecutesCorrectly() + { + // Page 2 (skip 3, take 3) ordered by name + var pagedStmt = "Products" + .From() + .SelectAll() + .OrderBy(columnName: "Name") + .Skip(count: 3) + .Take(count: 3) + .ToSqlStatement(); + + var pagedSqlOk = Assert.IsType(pagedStmt.ToSQLite()); + var pagedSql = pagedSqlOk.Value; + Assert.Contains("OFFSET", pagedSql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", pagedSql, StringComparison.OrdinalIgnoreCase); + + var pagedResult = _connection.Query( + sql: pagedSql, + mapper: r => r.GetString(1) // Name is column index 1 + ); + var page = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(pagedResult); + Assert.Equal(3, page.Value.Count); + } + + [Fact] + public void SelectStatementBuilder_Join_ExecutesCorrectly() + { + // JOIN Products with Categories + var joinStmt = "Products" + .From() + .Select(columns: [("Products", "Name"), ("Categories", "Description")]) + .InnerJoin( + rightTable: "Categories", + leftColumn: "Category", + rightColumn: "Name", + leftTableAlias: "Products", + rightTableAlias: "Categories" + ) + .Where(columnName: "Category", value: "Electronics") + .OrderBy(columnName: "Products.Name") + .ToSqlStatement(); + + var joinSqlOk = Assert.IsType(joinStmt.ToSQLite()); + var joinSql = joinSqlOk.Value; + Assert.Contains("JOIN", joinSql, StringComparison.OrdinalIgnoreCase); + + var joinResult = _connection.Query<(string, string)>( + sql: joinSql, + mapper: r => (r.GetString(0), r.GetString(1)) + ); + var joined = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, string)>, + SqlError + >>(joinResult); + Assert.Equal(3, joined.Value.Count); + Assert.All(joined.Value, j => Assert.Equal("Electronic devices and components", j.Item2)); + } + + [Fact] + public void GetRecords_WithSelectStatementAndSQLiteGenerator_MapsResultsCorrectly() + { + // Build a SelectStatement + var statement = "Products" + .From() + .Select(columns: [(null, "Id"), (null, "Name"), (null, "Price")]) + .Where(columnName: "Price", ComparisonOperator.GreaterThan, 40.0) + .OrderBy(columnName: "Price") + .ToSqlStatement(); + + // Use GetRecords with the SQLite generator using tuples + var result = _connection.GetRecords<(string, string, double)>( + statement: statement, + sqlGenerator: stmt => stmt.ToSQLite(), + mapper: r => (r.GetString(0), r.GetString(1), r.GetDouble(2)) + ); + + var recordsOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, string, double)>, + SqlError + >>(result); + Assert.Equal(4, recordsOk.Value.Count); + Assert.Equal("Tool Beta", recordsOk.Value[0].Item2); + Assert.Equal(45.00, recordsOk.Value[0].Item3); + Assert.Equal("Premium Widget", recordsOk.Value[3].Item2); + Assert.Equal(500.00, recordsOk.Value[3].Item3); + } + + [Fact] + public void GetRecords_NullGuards_ReturnErrors() + { + var statement = "Products".From().SelectAll().ToSqlStatement(); + + // Null connection + var nullConn = DbConnectionExtensions.GetRecords( + connection: null!, + statement: statement, + sqlGenerator: s => s.ToSQLite(), + mapper: r => r.GetString(0) + ); + Assert.True( + nullConn + is Result, SqlError>.Error, SqlError> + ); + + // Null statement + var nullStmt = _connection.GetRecords( + statement: null!, + sqlGenerator: s => s.ToSQLite(), + mapper: r => r.GetString(0) + ); + Assert.True( + nullStmt + is Result, SqlError>.Error, SqlError> + ); + + // Null generator + var nullGen = _connection.GetRecords( + statement: statement, + sqlGenerator: null!, + mapper: r => r.GetString(0) + ); + Assert.True( + nullGen + is Result, SqlError>.Error, SqlError> + ); + + // Null mapper + var nullMapper = _connection.GetRecords( + statement: statement, + sqlGenerator: s => s.ToSQLite(), + mapper: null! + ); + Assert.True( + nullMapper + is Result, SqlError>.Error, SqlError> + ); + } + + [Fact] + public void SelectStatementToSQLite_VariousStatements_GeneratesValidSQL() + { + // Simple select all + var simple = "Products".From().SelectAll().ToSqlStatement(); + var simpleSql = ((StringOk)simple.ToSQLite()).Value; + Assert.Contains("SELECT *", simpleSql); + Assert.Contains("FROM Products", simpleSql); + + // With specific columns + var cols = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Price")]) + .ToSqlStatement(); + var colsSql = ((StringOk)cols.ToSQLite()).Value; + Assert.Contains("Name", colsSql); + Assert.Contains("Price", colsSql); + Assert.DoesNotContain("*", colsSql); + + // With WHERE + AND + var filtered = "Products" + .From() + .SelectAll() + .Where(columnName: "Category", value: "Tools") + .And(columnName: "Price", value: 35.0) + .ToSqlStatement(); + var filteredSql = ((StringOk)filtered.ToSQLite()).Value; + Assert.Contains("WHERE", filteredSql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AND", filteredSql, StringComparison.OrdinalIgnoreCase); + + // Verify each generated SQL actually executes + foreach (var sql in new[] { simpleSql, colsSql, filteredSql }) + { + var result = _connection.Query(sql: sql, mapper: r => r.GetString(0)); + Assert.IsNotType, SqlError>.Error< + IReadOnlyList, + SqlError + >>(result); + } + } + + [Fact] + public void SelectStatementBuilder_MultipleWhereConditions_GeneratesCorrectResults() + { + // OR condition: Electronics or Gadgets + var orStmt = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Category")]) + .Where(columnName: "Category", value: "Electronics") + .Or(columnName: "Category", value: "Gadgets") + .OrderBy(columnName: "Name") + .ToSqlStatement(); + + var orSql = ((StringOk)orStmt.ToSQLite()).Value; + Assert.Contains("OR", orSql, StringComparison.OrdinalIgnoreCase); + + var orResult = _connection.Query<(string Name, string Category)>( + sql: orSql, + mapper: r => (r.GetString(0), r.GetString(1)) + ); + var orRows = ( + (Result, SqlError>.Ok< + IReadOnlyList<(string Name, string Category)>, + SqlError + >)orResult + ).Value; + Assert.Equal(5, orRows.Count); + Assert.All(orRows, r => Assert.True(r.Category is "Electronics" or "Gadgets")); + } + + [Fact] + public void SelectStatementBuilder_ExpressionColumns_GeneratesCorrectSQL() + { + // Use expression column for computed values + var builder = new SelectStatementBuilder(); + builder.AddTable(name: "Products"); + builder.AddSelectColumn(name: "Name"); + builder.AddSelectColumn( + ColumnInfo.FromExpression(expression: "Price * Quantity", alias: "TotalValue") + ); + builder.AddOrderBy(column: "Name", direction: "ASC"); + var stmt = builder.Build(); + + var sql = ((StringOk)stmt.ToSQLite()).Value; + Assert.Contains("Price * Quantity", sql); + Assert.Contains("TotalValue", sql); + + var result = _connection.Query<(string Name, double TotalValue)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + var rows = ( + (Result, SqlError>.Ok< + IReadOnlyList<(string Name, double TotalValue)>, + SqlError + >)result + ).Value; + Assert.Equal(8, rows.Count); + + // Verify computed values + var gadgetX = rows.First(r => r.Name == "Gadget X"); + Assert.Equal(999.90, gadgetX.TotalValue, precision: 2); + + var premiumWidget = rows.First(r => r.Name == "Premium Widget"); + Assert.Equal(1000.00, premiumWidget.TotalValue, precision: 2); + } +} diff --git a/DataProvider/DataProvider.Tests/ResultTypeTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/ResultTypeTests.cs similarity index 97% rename from DataProvider/DataProvider.Tests/ResultTypeTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/ResultTypeTests.cs index de21d950..556a7849 100644 --- a/DataProvider/DataProvider.Tests/ResultTypeTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/ResultTypeTests.cs @@ -1,7 +1,6 @@ using Outcome; -using Xunit; -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; public class ResultTypeTests { diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SQLiteContextE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SQLiteContextE2ETests.cs new file mode 100644 index 00000000..4fb845b5 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/SQLiteContextE2ETests.cs @@ -0,0 +1,441 @@ +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Core; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +public sealed class SQLiteContextE2ETests : IDisposable +{ + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"ctx_e2e_{Guid.NewGuid()}.db" + ); + private readonly SqliteConnection _connection; + + public SQLiteContextE2ETests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + CreateSchemaAndSeed(); + } + + public void Dispose() + { + _connection.Dispose(); + try + { + File.Delete(_dbPath); + } + catch (IOException) + { /* cleanup best-effort */ + } + } + + private static Pipeline WithIdentity(string table) + { + var p = new Pipeline(); + p.Steps.Add(new IdentityStep { Base = new Identifier(table) }); + return p; + } + + private static Identifier Id(string name) => new(name); + + private void CreateSchemaAndSeed() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE users ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, + age INTEGER NOT NULL, country TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ); + CREATE TABLE orders ( + id TEXT PRIMARY KEY, user_id TEXT NOT NULL, total REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + FOREIGN KEY (user_id) REFERENCES users(id) + ); + CREATE TABLE products (id TEXT PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL); + """; + cmd.ExecuteNonQuery(); + + using var seedCmd = _connection.CreateCommand(); + seedCmd.CommandText = """ + INSERT INTO users VALUES ('u1','Alice','alice@test.com',30,'US','active'); + INSERT INTO users VALUES ('u2','Bob','bob@test.com',25,'UK','active'); + INSERT INTO users VALUES ('u3','Charlie','charlie@test.com',45,'US','inactive'); + INSERT INTO users VALUES ('u4','Diana','diana@test.com',35,'AU','active'); + INSERT INTO users VALUES ('u5','Eve','eve@test.com',22,'US','active'); + INSERT INTO orders VALUES ('o1','u1',150.00,'completed'); + INSERT INTO orders VALUES ('o2','u1',75.50,'completed'); + INSERT INTO orders VALUES ('o3','u2',200.00,'pending'); + INSERT INTO orders VALUES ('o4','u3',50.00,'completed'); + INSERT INTO orders VALUES ('o5','u4',300.00,'shipped'); + INSERT INTO orders VALUES ('o6','u4',125.00,'completed'); + INSERT INTO orders VALUES ('o7','u5',45.00,'pending'); + INSERT INTO products VALUES ('p1','Widget',9.99); + INSERT INTO products VALUES ('p2','Gadget',19.99); + INSERT INTO products VALUES ('p3','Widget',9.99); + """; + seedCmd.ExecuteNonQuery(); + } + + [Fact] + public void ProcessPipeline_SelectFilterOrderLimit_GeneratesCorrectSql() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new SelectStep([ColumnInfo.Named("name"), ColumnInfo.Named("age")]) + { + Base = Id("users"), + } + ); + pipeline.Steps.Add( + new FilterStep + { + Base = Id("users"), + Condition = WhereCondition.Comparison( + left: ColumnInfo.Named("age"), + @operator: ComparisonOperator.GreaterThan, + right: "25" + ), + } + ); + pipeline.Steps.Add(new OrderByStep([("name", "ASC")]) { Base = Id("users") }); + pipeline.Steps.Add(new LimitStep { Base = Id("users"), Count = "10" }); + + var sql = new SQLiteContext().ProcessPipeline(pipeline); + + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name", sql); + Assert.Contains("age", sql); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("> 25", sql); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT 10", sql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void PipelineProcessor_GroupByWithHaving_GeneratesGroupByAndHavingSql() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new SelectStep([ + ColumnInfo.Named("country"), + ColumnInfo.FromExpression("COUNT(*)", alias: "cnt"), + ]) + { + Base = Id("users"), + } + ); + pipeline.Steps.Add(new GroupByStep(["country"]) { Base = Id("users") }); + pipeline.Steps.Add(new HavingStep { Base = Id("users"), Condition = "COUNT(*) > 1" }); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("country", sql); + Assert.Contains("COUNT(*)", sql); + Assert.Contains("HAVING", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("COUNT(*) > 1", sql); + } + + [Fact] + public void PipelineProcessor_OffsetStep_GeneratesOffsetSql() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add(new SelectStep([ColumnInfo.Named("name")]) { Base = Id("users") }); + pipeline.Steps.Add(new LimitStep { Base = Id("users"), Count = "10" }); + pipeline.Steps.Add(new OffsetStep { Base = Id("users"), Count = "5" }); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + + Assert.Contains("OFFSET 5", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT 10", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name", sql); + } + + [Fact] + public void PipelineProcessor_SelectDistinctStep_GeneratesDistinctSql() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new SelectDistinctStep([ColumnInfo.Named("country")]) { Base = Id("users") } + ); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + + Assert.Contains("SELECT DISTINCT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("country", sql); + } + + [Fact] + public void PipelineProcessor_JoinStep_GeneratesJoinSql() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new JoinStep + { + Base = Id("users"), + JoinRelationship = new JoinRelationship( + LeftTable: "users", + RightTable: "orders", + Condition: "users.id = orders.user_id", + JoinType: "INNER" + ), + } + ); + pipeline.Steps.Add( + new SelectStep([ + ColumnInfo.Named(name: "name", tableAlias: "users"), + ColumnInfo.Named(name: "total", tableAlias: "orders"), + ]) + { + Base = Id("users"), + } + ); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + + Assert.Contains("INNER JOIN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("orders", sql); + Assert.Contains("users.id = orders.user_id", sql); + Assert.Contains("users.name", sql); + Assert.Contains("orders.total", sql); + } + + [Fact] + public void PipelineProcessor_ConvertPipelineToSql_WithFilter_GeneratesSql() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new SelectStep([ColumnInfo.Named("name"), ColumnInfo.Named("email")]) + { + Base = Id("users"), + } + ); + pipeline.Steps.Add( + new FilterStep + { + Base = Id("users"), + Condition = WhereCondition.Comparison( + left: ColumnInfo.Named("status"), + @operator: ComparisonOperator.Eq, + right: "'active'" + ), + } + ); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name", sql); + Assert.Contains("email", sql); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("= 'active'", sql); + } + + [Fact] + public void PipelineProcessor_EmptyPipeline_ReturnsComment() + { + var sql = PipelineProcessor.ConvertPipelineToSql( + pipeline: new Pipeline(), + context: new SQLiteContext() + ); + Assert.Equal("-- Empty pipeline", sql); + } + + [Fact] + public void SQLiteContext_AddUnion_DoesNotThrow() + { + var context = new SQLiteContext(); + context.SetBaseTable("users"); + context.SetSelectColumns([ColumnInfo.Named("name")]); + context.AddUnion(query: "SELECT name FROM orders", isUnionAll: false); + + var sql = context.GenerateSQL(); + + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name", sql); + Assert.Contains("users", sql); + } + + [Fact] + public void FullPipeline_ExecuteAgainstRealDb_ReturnsCorrectResults() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new SelectStep([ColumnInfo.Named("name"), ColumnInfo.Named("age")]) + { + Base = Id("users"), + } + ); + pipeline.Steps.Add( + new FilterStep + { + Base = Id("users"), + Condition = WhereCondition.Comparison( + left: ColumnInfo.Named("age"), + @operator: ComparisonOperator.GreaterThan, + right: "25" + ), + } + ); + pipeline.Steps.Add(new OrderByStep([("age", "ASC")]) { Base = Id("users") }); + pipeline.Steps.Add(new LimitStep { Base = Id("users"), Count = "3" }); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + + var queryResult = _connection.Query<(string, long)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetInt64(1)) + ); + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList<(string, long)>, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result from query execution"); + return; + } + + var rows = ok.Value; + Assert.Equal(3, rows.Count); + Assert.Equal("Alice", rows[0].Item1); + Assert.Equal(30L, rows[0].Item2); + Assert.Equal("Diana", rows[1].Item1); + Assert.Equal("Charlie", rows[2].Item1); + } + + [Fact] + public void PipelineProcessor_DistinctExecute_ReturnsDistinctRows() + { + var pipeline = WithIdentity("products"); + pipeline.Steps.Add( + new SelectDistinctStep([ColumnInfo.Named("name")]) { Base = Id("products") } + ); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + var queryResult = _connection.Query(sql: sql, mapper: r => r.GetString(0)); + + if ( + queryResult + is not Result, SqlError>.Ok, SqlError> ok + ) + { + Assert.Fail("Expected Ok result from distinct query"); + return; + } + + Assert.Equal(2, ok.Value.Count); + Assert.Contains("Widget", ok.Value); + Assert.Contains("Gadget", ok.Value); + } + + [Fact] + public void PipelineProcessor_GroupByExecute_ReturnsAggregatedRows() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new SelectStep([ + ColumnInfo.Named("country"), + ColumnInfo.FromExpression("COUNT(*)", alias: "cnt"), + ]) + { + Base = Id("users"), + } + ); + pipeline.Steps.Add(new GroupByStep(["country"]) { Base = Id("users") }); + pipeline.Steps.Add(new OrderByStep([("cnt", "DESC")]) { Base = Id("users") }); + + var context = new SQLiteContext(); + var sql = PipelineProcessor.ConvertPipelineToSql(pipeline: pipeline, context: context); + var queryResult = _connection.Query<(string, long)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetInt64(1)) + ); + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList<(string, long)>, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result from group by query"); + return; + } + + Assert.Equal(3, ok.Value.Count); + Assert.Equal("US", ok.Value[0].Item1); + Assert.Equal(3L, ok.Value[0].Item2); + } + + [Fact] + public void SQLiteFunctionMappingLocal_FunctionsHandlersAndSyntax() + { + var mapping = SQLiteFunctionMappingLocal.Instance; + + var countMap = mapping.GetFunctionMapping("count"); + Assert.NotNull(countMap); + Assert.Equal("COUNT", countMap.SqlFunction); + Assert.True(countMap.RequiresSpecialHandling); + Assert.NotNull(countMap.SpecialHandler); + Assert.Equal("COUNT(*)", countMap.SpecialHandler(["*"])); + Assert.Equal("COUNT(id)", countMap.SpecialHandler(["id"])); + + Assert.Equal("SUM", mapping.GetFunctionMapping("sum")?.SqlFunction); + Assert.Equal("UPPER", mapping.GetFunctionMapping("upper")?.SqlFunction); + Assert.Equal("LOWER", mapping.GetFunctionMapping("lower")?.SqlFunction); + + var substringMap = mapping.GetFunctionMapping("substring"); + Assert.NotNull(substringMap); + Assert.NotNull(substringMap.SpecialHandler); + Assert.Equal("substr(name, 1, 3)", substringMap.SpecialHandler(["name", "1", "3"])); + + var dateMap = mapping.GetFunctionMapping("current_date"); + Assert.NotNull(dateMap); + Assert.NotNull(dateMap.SpecialHandler); + Assert.Equal("datetime('now')", dateMap.SpecialHandler([])); + + Assert.Null(mapping.GetFunctionMapping("nonexistent_function")); + + var syntax = mapping.GetSyntaxMapping(); + Assert.Equal("LIMIT {0}", syntax.LimitClause); + Assert.Equal("OFFSET {0}", syntax.OffsetClause); + Assert.Equal("datetime('now')", syntax.DateCurrentFunction); + Assert.Equal("LENGTH", syntax.StringLengthFunction); + Assert.Equal("||", syntax.StringConcatOperator); + Assert.Equal("\"", syntax.IdentifierQuoteChar); + Assert.False(syntax.SupportsBoolean); + } + + [Fact] + public void SQLiteContext_ToSQLiteSql_GeneratesFromStatement() + { + var statement = new SelectStatementBuilder() + .AddTable("users") + .AddSelectColumn("name") + .AddSelectColumn("email") + .WithLimit("5") + .Build(); + var sql = SQLiteContext.ToSQLiteSql(statement); + + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name", sql); + Assert.Contains("email", sql); + Assert.Contains("FROM users", sql); + Assert.Contains("LIMIT 5", sql); + } +} diff --git a/DataProvider/DataProvider.Tests/SchemaTypesTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SchemaTypesTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/SchemaTypesTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/SchemaTypesTests.cs index deeb4242..80ad8a85 100644 --- a/DataProvider/DataProvider.Tests/SchemaTypesTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SchemaTypesTests.cs @@ -1,6 +1,4 @@ -using Xunit; - -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for schema types and their functionality diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SelectStatementLinqE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SelectStatementLinqE2ETests.cs new file mode 100644 index 00000000..8c4d1f43 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/SelectStatementLinqE2ETests.cs @@ -0,0 +1,408 @@ +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// E2E tests: LINQ expression tree overloads -> ToSQLite -> execute against real SQLite DB. +/// These exercise SelectStatementLinqExtensions and the internal SelectStatementVisitor. +/// +public sealed class SelectStatementLinqE2ETests : IDisposable +{ + private sealed record TestProduct + { + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public double Price { get; init; } + public int Quantity { get; init; } + public string Category { get; init; } = ""; + } + + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"linq_e2e_{Guid.NewGuid()}.db" + ); + + private readonly SqliteConnection _connection; + + public SelectStatementLinqE2ETests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + CreateSchemaAndSeed(); + } + + public void Dispose() + { + _connection.Dispose(); + try + { + File.Delete(_dbPath); + } + catch (IOException) + { /* cleanup best-effort */ + } + } + + private void CreateSchemaAndSeed() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE Products ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL, + Price REAL NOT NULL, + Quantity INTEGER NOT NULL, + Category TEXT NOT NULL + ); + """; + cmd.ExecuteNonQuery(); + + using var seedCmd = _connection.CreateCommand(); + seedCmd.CommandText = """ + INSERT INTO Products VALUES ('p1', 'Widget A', 10.00, 100, 'Electronics'); + INSERT INTO Products VALUES ('p2', 'Widget B', 25.50, 50, 'Electronics'); + INSERT INTO Products VALUES ('p3', 'Gadget X', 99.99, 10, 'Gadgets'); + INSERT INTO Products VALUES ('p4', 'Gadget Y', 149.99, 5, 'Gadgets'); + INSERT INTO Products VALUES ('p5', 'Tool Alpha', 35.00, 75, 'Tools'); + INSERT INTO Products VALUES ('p6', 'Tool Beta', 45.00, 30, 'Tools'); + INSERT INTO Products VALUES ('p7', 'Tool Gamma', 15.00, 200, 'Tools'); + INSERT INTO Products VALUES ('p8', 'Premium Widget', 500.00, 2, 'Electronics'); + """; + seedCmd.ExecuteNonQuery(); + } + + [Fact] + public void WhereGeneric_SimpleEquality_ReturnsMatchingRows() + { + var statement = "Products" + .From() + .SelectAll() + .Where(p => p.Category == "Tools") + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Category", sql); + + var queryResult = _connection.Query( + sql: sql, + mapper: r => new TestProduct + { + Id = r.GetString(0), + Name = r.GetString(1), + Price = r.GetDouble(2), + Quantity = r.GetInt32(3), + Category = r.GetString(4), + } + ); + + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result"); + return; + } + + var rows = ok.Value; + Assert.Equal(3, rows.Count); + Assert.Contains(rows, r => r.Name == "Tool Alpha"); + Assert.Contains(rows, r => r.Name == "Tool Beta"); + Assert.Contains(rows, r => r.Name == "Tool Gamma"); + Assert.All(rows, r => Assert.Equal("Tools", r.Category)); + } + + [Fact] + public void WhereGeneric_GreaterThanComparison_ReturnsMatchingRows() + { + var statement = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Price")]) + .Where(p => p.Price > 50.0) + .OrderBy(columnName: "Price") + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains(">", sql); + + var queryResult = _connection.Query<(string Name, double Price)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList<(string Name, double Price)>, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result"); + return; + } + + var rows = ok.Value; + Assert.Equal(3, rows.Count); + Assert.Equal("Gadget X", rows[0].Name); + Assert.Equal(99.99, rows[0].Price); + Assert.Equal("Gadget Y", rows[1].Name); + Assert.Equal("Premium Widget", rows[2].Name); + } + + [Fact] + public void WhereGeneric_AndCondition_ReturnsMatchingRows() + { + var statement = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Price")]) + .Where(p => p.Price > 10 && p.Category == "Tools") + .OrderBy(columnName: "Price") + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); + + var queryResult = _connection.Query<(string Name, double Price)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList<(string Name, double Price)>, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result"); + return; + } + + var rows = ok.Value; + Assert.Equal(3, rows.Count); + Assert.Equal("Tool Gamma", rows[0].Name); + Assert.Equal(15.00, rows[0].Price); + Assert.Equal("Tool Alpha", rows[1].Name); + Assert.Equal("Tool Beta", rows[2].Name); + } + + [Fact] + public void WhereGeneric_OrCondition_ReturnsMatchingRows() + { + var statement = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Price")]) + .Where(p => p.Price < 20 || p.Price > 100) + .OrderBy(columnName: "Price") + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("OR", sql, StringComparison.OrdinalIgnoreCase); + + var queryResult = _connection.Query<(string Name, double Price)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList<(string Name, double Price)>, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result"); + return; + } + + var rows = ok.Value; + Assert.Equal(4, rows.Count); + Assert.Equal("Widget A", rows[0].Name); + Assert.Equal(10.00, rows[0].Price); + Assert.Equal("Tool Gamma", rows[1].Name); + Assert.Equal(15.00, rows[1].Price); + Assert.Equal("Gadget Y", rows[2].Name); + Assert.Equal(149.99, rows[2].Price); + Assert.Equal("Premium Widget", rows[3].Name); + Assert.Equal(500.00, rows[3].Price); + } + + [Fact] + public void WhereGeneric_StringContains_GeneratesLikeClause() + { + var statement = "Products" + .From() + .Select(columns: [(null, "Name")]) + .Where(p => p.Name.Contains("Widget")) + .OrderBy(columnName: "Name") + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("LIKE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Widget", sql); + Assert.Contains("Name", sql); + } + + [Fact] + public void WhereGeneric_StringStartsWith_GeneratesLikeClause() + { + var statement = "Products" + .From() + .Select(columns: [(null, "Name")]) + .Where(p => p.Name.StartsWith("Tool")) + .OrderBy(columnName: "Name") + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("LIKE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Tool", sql); + Assert.Contains("Name", sql); + } + + [Fact] + public void SelectGeneric_SingleColumn_GeneratesCorrectSQL() + { + var statement = "Products".From().Select(p => p.Name).ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("Name", sql); + Assert.DoesNotContain("*", sql); + Assert.Contains("FROM Products", sql); + + var queryResult = _connection.Query(sql: sql, mapper: r => r.GetString(0)); + + if ( + queryResult + is not Result, SqlError>.Ok, SqlError> ok + ) + { + Assert.Fail("Expected Ok result"); + return; + } + + Assert.Equal(8, ok.Value.Count); + Assert.Contains("Widget A", ok.Value); + Assert.Contains("Premium Widget", ok.Value); + } + + [Fact] + public void OrderByGeneric_SingleColumn_ReturnsSortedResults() + { + var statement = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Price")]) + .OrderBy(p => p.Price) + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Price", sql); + + var queryResult = _connection.Query<(string Name, double Price)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList<(string Name, double Price)>, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result"); + return; + } + + var rows = ok.Value; + Assert.Equal(8, rows.Count); + Assert.Equal("Widget A", rows[0].Name); + Assert.Equal(10.00, rows[0].Price); + Assert.Equal("Premium Widget", rows[7].Name); + Assert.Equal(500.00, rows[7].Price); + + for (var i = 1; i < rows.Count; i++) + { + Assert.True(rows[i].Price >= rows[i - 1].Price); + } + } + + [Fact] + public void FullLinqChain_WhereSelectOrderByTakeSkip_ExecutesCorrectly() + { + var statement = "Products" + .From() + .Select(columns: [(null, "Name"), (null, "Price")]) + .Where(p => p.Price > 10) + .OrderBy(p => p.Price) + .Skip(count: 1) + .Take(count: 3) + .ToSqlStatement(); + + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + var sql = sqlOk.Value; + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("OFFSET", sql, StringComparison.OrdinalIgnoreCase); + + var queryResult = _connection.Query<(string Name, double Price)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + + if ( + queryResult + is not Result, SqlError>.Ok< + IReadOnlyList<(string Name, double Price)>, + SqlError + > ok + ) + { + Assert.Fail("Expected Ok result"); + return; + } + + var rows = ok.Value; + Assert.Equal(3, rows.Count); + + // Products with Price > 10 ordered by Price ASC: + // Tool Gamma (15), Widget B (25.5), Tool Alpha (35), Tool Beta (45), + // Gadget X (99.99), Gadget Y (149.99), Premium Widget (500) + // Skip 1 -> Widget B, Take 3 -> Widget B, Tool Alpha, Tool Beta + Assert.Equal("Widget B", rows[0].Name); + Assert.Equal(25.50, rows[0].Price); + Assert.Equal("Tool Alpha", rows[1].Name); + Assert.Equal(35.00, rows[1].Price); + Assert.Equal("Tool Beta", rows[2].Name); + Assert.Equal(45.00, rows[2].Price); + } +} diff --git a/DataProvider/DataProvider.Tests/SourceGeneratorTypesTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SourceGeneratorTypesTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/SourceGeneratorTypesTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/SourceGeneratorTypesTests.cs index ee258c06..04608835 100644 --- a/DataProvider/DataProvider.Tests/SourceGeneratorTypesTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SourceGeneratorTypesTests.cs @@ -1,8 +1,7 @@ using System.Collections.Frozen; -using Selecta; -using Xunit; +using Nimblesite.Sql.Model; -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for source generator types diff --git a/DataProvider/DataProvider.Tests/SqlErrorTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqlErrorTests.cs similarity index 98% rename from DataProvider/DataProvider.Tests/SqlErrorTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/SqlErrorTests.cs index 09013edf..a0a485fe 100644 --- a/DataProvider/DataProvider.Tests/SqlErrorTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqlErrorTests.cs @@ -1,6 +1,4 @@ -using Xunit; - -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for SqlError record type diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SqlModelE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqlModelE2ETests.cs new file mode 100644 index 00000000..7c8c1f2c --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqlModelE2ETests.cs @@ -0,0 +1,544 @@ +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// E2E tests: SQL model types (SelectStatementBuilder, WhereCondition, ColumnInfo, +/// ComparisonOperator, JoinGraph, PredicateBuilder) -> ToSQLite -> execute -> verify. +/// +public sealed class SqlModelE2ETests : IDisposable +{ + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"model_e2e_{Guid.NewGuid()}.db" + ); + + private readonly SqliteConnection _connection; + + public SqlModelE2ETests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + CreateSchemaAndSeed(); + } + + public void Dispose() + { + _connection.Dispose(); + try + { + File.Delete(_dbPath); + } + catch (IOException) + { /* cleanup best-effort */ + } + } + + private void CreateSchemaAndSeed() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE Employees ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL, + Department TEXT NOT NULL, + Salary REAL NOT NULL, + YearsExp INTEGER NOT NULL + ); + CREATE TABLE Departments ( + Name TEXT PRIMARY KEY, + Budget REAL NOT NULL, + HeadCount INTEGER NOT NULL + ); + """; + cmd.ExecuteNonQuery(); + + using var seedCmd = _connection.CreateCommand(); + seedCmd.CommandText = """ + INSERT INTO Employees VALUES ('e1', 'Alice', 'Engineering', 95000, 5); + INSERT INTO Employees VALUES ('e2', 'Bob', 'Engineering', 105000, 8); + INSERT INTO Employees VALUES ('e3', 'Charlie', 'Sales', 75000, 3); + INSERT INTO Employees VALUES ('e4', 'Diana', 'Sales', 85000, 6); + INSERT INTO Employees VALUES ('e5', 'Eve', 'Marketing', 70000, 2); + INSERT INTO Employees VALUES ('e6', 'Frank', 'Engineering', 120000, 12); + INSERT INTO Employees VALUES ('e7', 'Grace', 'Marketing', 80000, 4); + INSERT INTO Employees VALUES ('e8', 'Hank', 'Sales', 90000, 7); + INSERT INTO Departments VALUES ('Engineering', 500000, 3); + INSERT INTO Departments VALUES ('Sales', 300000, 3); + INSERT INTO Departments VALUES ('Marketing', 200000, 2); + """; + seedCmd.ExecuteNonQuery(); + } + + [Fact] + public void SelectStatementBuilder_ComplexWhereConditions_ExecuteCorrectly() + { + // Build: SELECT Name, Salary FROM Employees + // WHERE (Department = 'Engineering' AND Salary > 100000) + // OR (Department = 'Sales' AND YearsExp > 5) + var builder = new SelectStatementBuilder(); + builder.AddTable(name: "Employees"); + builder.AddSelectColumn(name: "Name"); + builder.AddSelectColumn(name: "Salary"); + builder.AddSelectColumn(name: "Department"); + builder.AddWhereCondition(WhereCondition.OpenParen()); + builder.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "Department"), + @operator: ComparisonOperator.Eq, + right: "'Engineering'" + ) + ); + builder.AddWhereCondition(WhereCondition.And()); + builder.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "Salary"), + @operator: ComparisonOperator.GreaterThan, + right: "100000" + ) + ); + builder.AddWhereCondition(WhereCondition.CloseParen()); + builder.AddWhereCondition(WhereCondition.Or()); + builder.AddWhereCondition(WhereCondition.OpenParen()); + builder.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "Department"), + @operator: ComparisonOperator.Eq, + right: "'Sales'" + ) + ); + builder.AddWhereCondition(WhereCondition.And()); + builder.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "YearsExp"), + @operator: ComparisonOperator.GreaterThan, + right: "5" + ) + ); + builder.AddWhereCondition(WhereCondition.CloseParen()); + builder.AddOrderBy(column: "Name", direction: "ASC"); + var stmt = builder.Build(); + + var sqlOk = Assert.IsType(stmt.ToSQLite()); + var sql = sqlOk.Value; + Assert.Contains("(", sql); + Assert.Contains(")", sql); + Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("OR", sql, StringComparison.OrdinalIgnoreCase); + + var result = _connection.Query<(string, double, string)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1), r.GetString(2)) + ); + var ok = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, double, string)>, + SqlError + >>(result); + var rows = ok.Value; + + // Bob (Eng, 105k), Frank (Eng, 120k), Diana (Sales, 6yr), Hank (Sales, 7yr) + Assert.Equal(4, rows.Count); + Assert.Contains(rows, r => r.Item1 == "Bob" && r.Item2 == 105000); + Assert.Contains(rows, r => r.Item1 == "Frank" && r.Item2 == 120000); + Assert.Contains(rows, r => r.Item1 == "Diana" && r.Item3 == "Sales"); + Assert.Contains(rows, r => r.Item1 == "Hank" && r.Item3 == "Sales"); + } + + [Fact] + public void ComparisonOperators_AllOperators_ProduceCorrectSQL() + { + // Test each comparison operator's ToSql output + Assert.Equal("=", ComparisonOperator.Eq.ToSql()); + Assert.Equal("!=", ComparisonOperator.NotEq.ToSql()); + Assert.Equal(">", ComparisonOperator.GreaterThan.ToSql()); + Assert.Equal("<", ComparisonOperator.LessThan.ToSql()); + Assert.Equal(">=", ComparisonOperator.GreaterOrEq.ToSql()); + Assert.Equal("<=", ComparisonOperator.LessOrEq.ToSql()); + Assert.Equal("LIKE", ComparisonOperator.Like.ToSql()); + Assert.Equal("IN", ComparisonOperator.In.ToSql()); + Assert.Equal("IS NULL", ComparisonOperator.IsNull.ToSql()); + Assert.Equal("IS NOT NULL", ComparisonOperator.IsNotNull.ToSql()); + + // Test LessThan in a real query + var ltBuilder = new SelectStatementBuilder(); + ltBuilder.AddTable(name: "Employees"); + ltBuilder.AddSelectColumn(name: "Name"); + ltBuilder.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "Salary"), + @operator: ComparisonOperator.LessThan, + right: "80000" + ) + ); + var ltSqlOk = Assert.IsType(ltBuilder.Build().ToSQLite()); + var ltSql = ltSqlOk.Value; + var ltResult = _connection.Query(sql: ltSql, mapper: r => r.GetString(0)); + var ltOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(ltResult); + var ltRows = ltOk.Value; + Assert.Equal(2, ltRows.Count); // Charlie (75k), Eve (70k) + + // Test GreaterOrEq + var geBuilder = new SelectStatementBuilder(); + geBuilder.AddTable(name: "Employees"); + geBuilder.AddSelectColumn(name: "Name"); + geBuilder.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "Salary"), + @operator: ComparisonOperator.GreaterOrEq, + right: "95000" + ) + ); + var geSqlOk = Assert.IsType(geBuilder.Build().ToSQLite()); + var geSql = geSqlOk.Value; + var geResult = _connection.Query(sql: geSql, mapper: r => r.GetString(0)); + var geOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(geResult); + var geRows = geOk.Value; + Assert.Equal(3, geRows.Count); // Alice (95k), Bob (105k), Frank (120k) + + // Test LIKE + var likeBuilder = new SelectStatementBuilder(); + likeBuilder.AddTable(name: "Employees"); + likeBuilder.AddSelectColumn(name: "Name"); + likeBuilder.AddWhereCondition( + WhereCondition.Comparison( + left: ColumnInfo.Named(name: "Name"), + @operator: ComparisonOperator.Like, + right: "'%a%'" + ) + ); + var likeSqlOk = Assert.IsType(likeBuilder.Build().ToSQLite()); + var likeSql = likeSqlOk.Value; + var likeResult = _connection.Query(sql: likeSql, mapper: r => r.GetString(0)); + var likeOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(likeResult); + var likeRows = likeOk.Value; + // Grace, Diana, Frank, Charlie, Hank all contain 'a' + Assert.True(likeRows.Count >= 3); + } + + [Fact] + public void ColumnInfoFactory_AllTypes_WorkCorrectly() + { + // Named column + var named = ColumnInfo.Named(name: "Salary", tableAlias: "e", alias: "emp_salary"); + var namedCol = Assert.IsType(named); + Assert.Equal("Salary", namedCol.Name); + Assert.Equal("e", namedCol.TableAlias); + Assert.Equal("emp_salary", namedCol.Alias); + + // Wildcard column + var wildcard = ColumnInfo.Wildcard(tableAlias: "e"); + var wildcardCol = Assert.IsType(wildcard); + Assert.Equal("e", wildcardCol.TableAlias); + + // Wildcard without table alias + var wildcardAll = ColumnInfo.Wildcard(); + var wildcardAllCol = Assert.IsType(wildcardAll); + Assert.Null(wildcardAllCol.TableAlias); + + // Expression column + var expr = ColumnInfo.FromExpression(expression: "COUNT(*)", alias: "total_count"); + var exprCol = Assert.IsType(expr); + Assert.Equal("COUNT(*)", exprCol.Expression); + Assert.Equal("total_count", expr.Alias); + + // Use expression column in a real query + var builder = new SelectStatementBuilder(); + builder.AddTable(name: "Employees"); + builder.AddSelectColumn(ColumnInfo.Named(name: "Department")); + builder.AddSelectColumn( + ColumnInfo.FromExpression(expression: "COUNT(*)", alias: "emp_count") + ); + builder.AddGroupBy([ColumnInfo.Named(name: "Department")]); + builder.AddOrderBy(column: "Department", direction: "ASC"); + var stmt = builder.Build(); + var sqlOk = Assert.IsType(stmt.ToSQLite()); + var sql = sqlOk.Value; + + var result = _connection.Query<(string, long)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetInt64(1)) + ); + var ok = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, long)>, + SqlError + >>(result); + var rows = ok.Value; + Assert.Equal(3, rows.Count); + Assert.Contains(rows, r => r.Item1 == "Engineering" && r.Item2 == 3); + Assert.Contains(rows, r => r.Item1 == "Sales" && r.Item2 == 3); + Assert.Contains(rows, r => r.Item1 == "Marketing" && r.Item2 == 2); + } + + [Fact] + public void JoinGraph_BuildAndQuery_WorksCorrectly() + { + var graph = new JoinGraph(); + graph.Add( + leftTable: "Employees", + rightTable: "Departments", + condition: "Employees.Department = Departments.Name", + joinType: "INNER" + ); + Assert.Equal(1, graph.Count); + + var relationships = graph.GetRelationships(); + Assert.Single(relationships); + Assert.Equal("Employees", relationships[0].LeftTable); + Assert.Equal("Departments", relationships[0].RightTable); + Assert.Equal("INNER", relationships[0].JoinType); + + // Build a statement with the JoinGraph + var builder = new SelectStatementBuilder(); + builder.AddTable(name: "Employees"); + builder.AddTable(name: "Departments"); + builder.AddJoin( + leftTable: "Employees", + rightTable: "Departments", + condition: "Employees.Department = Departments.Name" + ); + builder.AddSelectColumn(ColumnInfo.Named(name: "Name", tableAlias: "Employees")); + builder.AddSelectColumn(ColumnInfo.Named(name: "Budget", tableAlias: "Departments")); + builder.AddOrderBy(column: "Employees.Name", direction: "ASC"); + var stmt = builder.Build(); + + Assert.True(stmt.HasJoins); + Assert.Equal(2, stmt.Tables.Count); + + var joinSqlOk = Assert.IsType(stmt.ToSQLite()); + var sql = joinSqlOk.Value; + Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); + + var result = _connection.Query<(string, double)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + var joinOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, double)>, + SqlError + >>(result); + var rows = joinOk.Value; + Assert.Equal(8, rows.Count); // All 8 employees with their department budget + Assert.Contains(rows, r => r.Item1 == "Alice" && r.Item2 == 500000); + Assert.Contains(rows, r => r.Item1 == "Charlie" && r.Item2 == 300000); + Assert.Contains(rows, r => r.Item1 == "Eve" && r.Item2 == 200000); + } + + [Fact] + public void SqlError_FactoryMethods_CreateCorrectErrors() + { + // Simple error + var simple = SqlError.Create("Something went wrong"); + Assert.Equal("Something went wrong", simple.Message); + Assert.Null(simple.Exception); + Assert.Null(simple.Position); + + // Error with code + var coded = SqlError.Create("DB error", errorCode: 42); + Assert.Equal("DB error", coded.Message); + Assert.Equal(42, coded.ErrorCode); + + // Error with position + var positioned = SqlError.WithPosition( + message: "Syntax error", + line: 5, + column: 10, + source: "SELECT * FORM users" + ); + Assert.Equal("Syntax error", positioned.Message); + Assert.NotNull(positioned.Position); + if (positioned is { Position: { } pos }) + { + Assert.Equal(5, pos.Line); + Assert.Equal(10, pos.Column); + } + + Assert.Equal("SELECT * FORM users", positioned.Source); + Assert.Contains("5", positioned.FormattedMessage); + + // Error with detailed position + var detailed = SqlError.WithDetailedPosition( + message: "Unknown token", + line: 3, + column: 15, + startIndex: 30, + stopIndex: 35, + source: "query text here" + ); + if (detailed is { Position: { } detailedPos }) + { + Assert.Equal(3, detailedPos.Line); + Assert.Equal(15, detailedPos.Column); + Assert.Equal(30, detailedPos.StartIndex); + Assert.Equal(35, detailedPos.StopIndex); + } + + // Error from exception + var ex = new InvalidOperationException("test exception"); + var fromEx = SqlError.FromException(ex); + Assert.Contains("test exception", fromEx.Message); + Assert.Equal(ex, fromEx.Exception); + + // Deconstruct - use pattern matching + if (fromEx is { Message: var message, Exception: var exception }) + { + Assert.Contains("test exception", message); + Assert.Equal(ex, exception); + } + } + + [Fact] + public void WhereConditionFactory_AllTypes_WorkCorrectly() + { + // Comparison + var comparison = WhereCondition.Comparison( + left: ColumnInfo.Named(name: "Age"), + @operator: ComparisonOperator.GreaterThan, + right: "30" + ); + var comp = Assert.IsType(comparison); + Assert.Equal("30", comp.Right); + + // Logical operators + var and = WhereCondition.And(); + Assert.IsAssignableFrom(and); + var andOp = Assert.IsAssignableFrom(and); + Assert.Equal("AND", andOp.ToSql()); + + var or = WhereCondition.Or(); + Assert.IsAssignableFrom(or); + var orOp = Assert.IsAssignableFrom(or); + Assert.Equal("OR", orOp.ToSql()); + + // Parentheses + var open = WhereCondition.OpenParen(); + var openParen = Assert.IsType(open); + Assert.True(openParen.IsOpening); + + var close = WhereCondition.CloseParen(); + var closeParen = Assert.IsType(close); + Assert.False(closeParen.IsOpening); + + // Expression + var expr = WhereCondition.FromExpression("1 = 1"); + var exprCond = Assert.IsType(expr); + Assert.Equal("1 = 1", exprCond.Expression); + } + + [Fact] + public void SelectStatementBuilder_HavingClause_ExecutesCorrectly() + { + var builder = new SelectStatementBuilder(); + builder.AddTable(name: "Employees"); + builder.AddSelectColumn(ColumnInfo.Named(name: "Department")); + builder.AddSelectColumn( + ColumnInfo.FromExpression(expression: "AVG(Salary)", alias: "AvgSalary") + ); + builder.AddGroupBy([ColumnInfo.Named(name: "Department")]); + builder.WithHaving("AVG(Salary) > 80000"); + builder.AddOrderBy(column: "Department", direction: "ASC"); + var stmt = builder.Build(); + + Assert.Equal("AVG(Salary) > 80000", stmt.HavingCondition); + + var havingSqlOk = Assert.IsType(stmt.ToSQLite()); + var sql = havingSqlOk.Value; + Assert.Contains("HAVING", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AVG(Salary) > 80000", sql); + + var result = _connection.Query<(string, double)>( + sql: sql, + mapper: r => (r.GetString(0), r.GetDouble(1)) + ); + var havingOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, double)>, + SqlError + >>(result); + var rows = havingOk.Value; + + // Engineering avg = (95k+105k+120k)/3 = 106.67k > 80k + // Sales avg = (75k+85k+90k)/3 = 83.33k > 80k + // Marketing avg = (70k+80k)/2 = 75k < 80k (excluded) + Assert.Equal(2, rows.Count); + Assert.Contains(rows, r => r.Item1 == "Engineering"); + Assert.Contains(rows, r => r.Item1 == "Sales"); + Assert.DoesNotContain(rows, r => r.Item1 == "Marketing"); + } + + [Fact] + public void DatabaseTableRecord_Properties_WorkCorrectly() + { + var table = new DatabaseTable + { + Name = "TestTable", + Schema = "dbo", + Columns = + [ + new DatabaseColumn + { + Name = "Id", + CSharpType = "Guid", + SqlType = "TEXT", + IsPrimaryKey = true, + }, + new DatabaseColumn + { + Name = "AutoId", + CSharpType = "int", + SqlType = "INTEGER", + IsIdentity = true, + }, + new DatabaseColumn + { + Name = "Computed", + CSharpType = "string", + SqlType = "TEXT", + IsComputed = true, + }, + new DatabaseColumn + { + Name = "Name", + CSharpType = "string", + SqlType = "TEXT", + }, + new DatabaseColumn + { + Name = "Email", + CSharpType = "string", + SqlType = "TEXT", + IsNullable = true, + MaxLength = 255, + }, + ], + }; + + Assert.Equal("TestTable", table.Name); + Assert.Equal("dbo", table.Schema); + Assert.Equal(5, table.Columns.Count); + + // PrimaryKeyColumns + Assert.Single(table.PrimaryKeyColumns); + Assert.Equal("Id", table.PrimaryKeyColumns[0].Name); + + // InsertableColumns (excludes identity and computed) + Assert.Equal(3, table.InsertableColumns.Count); + Assert.DoesNotContain(table.InsertableColumns, c => c.Name == "AutoId"); + Assert.DoesNotContain(table.InsertableColumns, c => c.Name == "Computed"); + + // UpdateableColumns (excludes PK, identity, computed) + Assert.Equal(2, table.UpdateableColumns.Count); + Assert.Contains(table.UpdateableColumns, c => c.Name == "Name"); + Assert.Contains(table.UpdateableColumns, c => c.Name == "Email"); + } +} diff --git a/DataProvider/DataProvider.Tests/SqlQueryableTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqlQueryableTests.cs similarity index 99% rename from DataProvider/DataProvider.Tests/SqlQueryableTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/SqlQueryableTests.cs index ea6176b9..1bb5cfef 100644 --- a/DataProvider/DataProvider.Tests/SqlQueryableTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqlQueryableTests.cs @@ -1,9 +1,8 @@ -using Selecta; -using Xunit; +using Nimblesite.Sql.Model; #pragma warning disable CA1812 // Avoid uninstantiated internal classes -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; /// /// Tests for LINQ query expression support with SelectQueryable diff --git a/DataProvider/DataProvider.Tests/SqlStatementGenerationTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqlStatementGenerationTests.cs similarity index 98% rename from DataProvider/DataProvider.Tests/SqlStatementGenerationTests.cs rename to DataProvider/Nimblesite.DataProvider.Tests/SqlStatementGenerationTests.cs index 5f344a11..21d1f4cd 100644 --- a/DataProvider/DataProvider.Tests/SqlStatementGenerationTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqlStatementGenerationTests.cs @@ -1,8 +1,7 @@ -using Lql.SQLite; -using Selecta; -using Xunit; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; -namespace DataProvider.Tests; +namespace Nimblesite.DataProvider.Tests; public sealed class SqlStatementGenerationTests { diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SqliteCrudE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqliteCrudE2ETests.cs new file mode 100644 index 00000000..b40748fb --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqliteCrudE2ETests.cs @@ -0,0 +1,447 @@ +using Microsoft.Data.Sqlite; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// E2E tests: full CRUD workflows against real SQLite databases. +/// Each test is a complete user workflow with multiple operations and assertions. +/// +public sealed class SqliteCrudE2ETests : IDisposable +{ + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"crud_e2e_{Guid.NewGuid()}.db" + ); + + private readonly SqliteConnection _connection; + + public SqliteCrudE2ETests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + CreateSchema(); + } + + public void Dispose() + { + _connection.Dispose(); + try + { + File.Delete(_dbPath); + } + catch (IOException) + { /* cleanup best-effort */ + } + } + + private void CreateSchema() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE Patients ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL, + Age INTEGER NOT NULL, + Email TEXT, + IsActive INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE Appointments ( + Id TEXT PRIMARY KEY, + PatientId TEXT NOT NULL, + AppointmentDate TEXT NOT NULL, + Notes TEXT, + FOREIGN KEY (PatientId) REFERENCES Patients(Id) + ); + CREATE TABLE Medications ( + Id TEXT PRIMARY KEY, + PatientId TEXT NOT NULL, + DrugName TEXT NOT NULL, + Dosage TEXT NOT NULL, + FOREIGN KEY (PatientId) REFERENCES Patients(Id) + ); + """; + cmd.ExecuteNonQuery(); + } + + [Fact] + public void FullPatientLifecycle_InsertQueryUpdateDelete_AllOperationsSucceed() + { + // Insert multiple patients + var patient1Id = Guid.NewGuid().ToString(); + var patient2Id = Guid.NewGuid().ToString(); + var patient3Id = Guid.NewGuid().ToString(); + + var insert1 = _connection.Execute( + sql: "INSERT INTO Patients (Id, Name, Age, Email, IsActive) VALUES (@id, @name, @age, @email, 1)", + parameters: + [ + new SqliteParameter("@id", patient1Id), + new SqliteParameter("@name", "Alice Smith"), + new SqliteParameter("@age", 30), + new SqliteParameter("@email", "alice@example.com"), + ] + ); + var insert2 = _connection.Execute( + sql: "INSERT INTO Patients (Id, Name, Age, Email, IsActive) VALUES (@id, @name, @age, @email, 1)", + parameters: + [ + new SqliteParameter("@id", patient2Id), + new SqliteParameter("@name", "Bob Jones"), + new SqliteParameter("@age", 45), + new SqliteParameter("@email", "bob@example.com"), + ] + ); + var insert3 = _connection.Execute( + sql: "INSERT INTO Patients (Id, Name, Age, Email, IsActive) VALUES (@id, @name, @age, @email, 0)", + parameters: + [ + new SqliteParameter("@id", patient3Id), + new SqliteParameter("@name", "Charlie Brown"), + new SqliteParameter("@age", 60), + new SqliteParameter("@email", DBNull.Value), + ] + ); + + // Verify inserts succeeded + var ok1 = Assert.IsType(insert1); + Assert.Equal(1, ok1.Value); + var ok2 = Assert.IsType(insert2); + Assert.Equal(1, ok2.Value); + var ok3 = Assert.IsType(insert3); + Assert.Equal(1, ok3.Value); + + // Query all patients + var allPatients = _connection.Query<(string, string, int)>( + sql: "SELECT Id, Name, Age FROM Patients ORDER BY Name", + mapper: r => (r.GetString(0), r.GetString(1), r.GetInt32(2)) + ); + var patientsOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, string, int)>, + SqlError + >>(allPatients); + Assert.Equal(3, patientsOk.Value.Count); + Assert.Equal("Alice Smith", patientsOk.Value[0].Item2); + Assert.Equal("Bob Jones", patientsOk.Value[1].Item2); + Assert.Equal("Charlie Brown", patientsOk.Value[2].Item2); + + // Query with WHERE filter + var activePatients = _connection.Query( + sql: "SELECT Name FROM Patients WHERE IsActive = 1 ORDER BY Name", + mapper: r => r.GetString(0) + ); + var activeOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(activePatients); + Assert.Equal(2, activeOk.Value.Count); + Assert.Contains("Alice Smith", activeOk.Value); + Assert.Contains("Bob Jones", activeOk.Value); + + // Count patients using Query instead of Scalar + var countResult = _connection.Query( + sql: "SELECT COUNT(*) FROM Patients WHERE Age > @minAge", + parameters: [new SqliteParameter("@minAge", 35)], + mapper: r => r.GetInt64(0) + ); + var countOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(countResult); + Assert.Equal(2L, countOk.Value[0]); + + // Update a patient + var updateResult = _connection.Execute( + sql: "UPDATE Patients SET Age = @age, Email = @email WHERE Id = @id", + parameters: + [ + new SqliteParameter("@age", 31), + new SqliteParameter("@email", "alice.smith@example.com"), + new SqliteParameter("@id", patient1Id), + ] + ); + var updateOk = Assert.IsType(updateResult); + Assert.Equal(1, updateOk.Value); + + // Verify update + var updatedAge = _connection.Query( + sql: "SELECT Age FROM Patients WHERE Id = @id", + parameters: [new SqliteParameter("@id", patient1Id)], + mapper: r => r.GetInt64(0) + ); + var updatedAgeOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(updatedAge); + Assert.Equal(31L, updatedAgeOk.Value[0]); + + // Delete a patient + var deleteResult = _connection.Execute( + sql: "DELETE FROM Patients WHERE Id = @id", + parameters: [new SqliteParameter("@id", patient3Id)] + ); + var deleteOk = Assert.IsType(deleteResult); + Assert.Equal(1, deleteOk.Value); + + // Verify delete + var finalCount = _connection.Query( + sql: "SELECT COUNT(*) FROM Patients", + mapper: r => r.GetInt64(0) + ); + var finalCountOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(finalCount); + Assert.Equal(2L, finalCountOk.Value[0]); + } + + [Fact] + public void MultiTableWorkflow_PatientsAppointmentsMedications_RelatedDataManagement() + { + // Insert patient + var patientId = Guid.NewGuid().ToString(); + _connection.Execute( + sql: "INSERT INTO Patients (Id, Name, Age, Email) VALUES (@id, @name, @age, @email)", + parameters: + [ + new SqliteParameter("@id", patientId), + new SqliteParameter("@name", "Diana Prince"), + new SqliteParameter("@age", 35), + new SqliteParameter("@email", "diana@example.com"), + ] + ); + + // Insert multiple appointments + var apt1Id = Guid.NewGuid().ToString(); + var apt2Id = Guid.NewGuid().ToString(); + var apt3Id = Guid.NewGuid().ToString(); + _connection.Execute( + sql: "INSERT INTO Appointments (Id, PatientId, AppointmentDate, Notes) VALUES (@id, @pid, @date, @notes)", + parameters: + [ + new SqliteParameter("@id", apt1Id), + new SqliteParameter("@pid", patientId), + new SqliteParameter("@date", "2026-01-15"), + new SqliteParameter("@notes", "Annual checkup"), + ] + ); + _connection.Execute( + sql: "INSERT INTO Appointments (Id, PatientId, AppointmentDate, Notes) VALUES (@id, @pid, @date, @notes)", + parameters: + [ + new SqliteParameter("@id", apt2Id), + new SqliteParameter("@pid", patientId), + new SqliteParameter("@date", "2026-02-20"), + new SqliteParameter("@notes", "Follow-up"), + ] + ); + _connection.Execute( + sql: "INSERT INTO Appointments (Id, PatientId, AppointmentDate, Notes) VALUES (@id, @pid, @date, @notes)", + parameters: + [ + new SqliteParameter("@id", apt3Id), + new SqliteParameter("@pid", patientId), + new SqliteParameter("@date", "2026-03-10"), + new SqliteParameter("@notes", "Lab results review"), + ] + ); + + // Insert medications + var med1Id = Guid.NewGuid().ToString(); + var med2Id = Guid.NewGuid().ToString(); + _connection.Execute( + sql: "INSERT INTO Medications (Id, PatientId, DrugName, Dosage) VALUES (@id, @pid, @drug, @dosage)", + parameters: + [ + new SqliteParameter("@id", med1Id), + new SqliteParameter("@pid", patientId), + new SqliteParameter("@drug", "Aspirin"), + new SqliteParameter("@dosage", "100mg daily"), + ] + ); + _connection.Execute( + sql: "INSERT INTO Medications (Id, PatientId, DrugName, Dosage) VALUES (@id, @pid, @drug, @dosage)", + parameters: + [ + new SqliteParameter("@id", med2Id), + new SqliteParameter("@pid", patientId), + new SqliteParameter("@drug", "Metformin"), + new SqliteParameter("@dosage", "500mg twice daily"), + ] + ); + + // Query with JOIN: patient + appointments + var joinResult = _connection.Query<(string, string, string)>( + sql: """ + SELECT p.Name, a.AppointmentDate, a.Notes + FROM Patients p + INNER JOIN Appointments a ON p.Id = a.PatientId + ORDER BY a.AppointmentDate + """, + mapper: r => (r.GetString(0), r.GetString(1), r.GetString(2)) + ); + var appointmentsOk = Assert.IsType, + SqlError + >.Ok, SqlError>>(joinResult); + Assert.Equal(3, appointmentsOk.Value.Count); + Assert.All(appointmentsOk.Value, a => Assert.Equal("Diana Prince", a.Item1)); + Assert.Equal("2026-01-15", appointmentsOk.Value[0].Item2); + Assert.Equal("Annual checkup", appointmentsOk.Value[0].Item3); + Assert.Equal("2026-03-10", appointmentsOk.Value[2].Item2); + + // Query with JOIN: patient + medications + var medResult = _connection.Query<(string, string, string)>( + sql: """ + SELECT p.Name, m.DrugName, m.Dosage + FROM Patients p + INNER JOIN Medications m ON p.Id = m.PatientId + ORDER BY m.DrugName + """, + mapper: r => (r.GetString(0), r.GetString(1), r.GetString(2)) + ); + var medsOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList<(string, string, string)>, + SqlError + >>(medResult); + Assert.Equal(2, medsOk.Value.Count); + Assert.Equal("Aspirin", medsOk.Value[0].Item2); + Assert.Equal("Metformin", medsOk.Value[1].Item2); + + // Aggregate: count appointments per patient using Query + var aptCount = _connection.Query( + sql: "SELECT COUNT(*) FROM Appointments WHERE PatientId = @pid", + parameters: [new SqliteParameter("@pid", patientId)], + mapper: r => r.GetInt64(0) + ); + var aptCountOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(aptCount); + Assert.Equal(3L, aptCountOk.Value[0]); + + // Delete appointment and verify cascade-like behavior + _connection.Execute( + sql: "DELETE FROM Appointments WHERE Id = @id", + parameters: [new SqliteParameter("@id", apt1Id)] + ); + var remainingApts = _connection.Query( + sql: "SELECT COUNT(*) FROM Appointments WHERE PatientId = @pid", + parameters: [new SqliteParameter("@pid", patientId)], + mapper: r => r.GetInt64(0) + ); + var remainingOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(remainingApts); + Assert.Equal(2L, remainingOk.Value[0]); + } + + [Fact] + public void ParameterizedQueryWorkflow_VariousTypes_AllParameterTypesWork() + { + // Insert data with various SQLite types + var id = Guid.NewGuid().ToString(); + _connection.Execute( + sql: "INSERT INTO Patients (Id, Name, Age, Email, IsActive) VALUES (@id, @name, @age, @email, @active)", + parameters: + [ + new SqliteParameter("@id", id), + new SqliteParameter("@name", "Test O'Brien"), + new SqliteParameter("@age", 25), + new SqliteParameter("@email", "test@example.com"), + new SqliteParameter("@active", 1), + ] + ); + + // Query with string parameter containing special characters + var result = _connection.Query( + sql: "SELECT Name FROM Patients WHERE Name = @name", + parameters: [new SqliteParameter("@name", "Test O'Brien")], + mapper: r => r.GetString(0) + ); + var namesOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(result); + Assert.Single(namesOk.Value); + Assert.Equal("Test O'Brien", namesOk.Value[0]); + + // Query with integer parameter + var ageResult = _connection.Query( + sql: "SELECT Age FROM Patients WHERE Age >= @minAge AND Age <= @maxAge", + parameters: [new SqliteParameter("@minAge", 20), new SqliteParameter("@maxAge", 30)], + mapper: r => r.GetInt32(0) + ); + var ageOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(ageResult); + Assert.Single(ageOk.Value); + + // Query with LIKE parameter + var likeResult = _connection.Query( + sql: "SELECT Name FROM Patients WHERE Name LIKE @pattern", + parameters: [new SqliteParameter("@pattern", "%O'Brien%")], + mapper: r => r.GetString(0) + ); + var likeOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(likeResult); + Assert.Single(likeOk.Value); + + // Average age using Query instead of Scalar + var avgAge = _connection.Query( + sql: "SELECT AVG(CAST(Age AS REAL)) FROM Patients WHERE IsActive = @active", + parameters: [new SqliteParameter("@active", 1)], + mapper: r => r.GetDouble(0) + ); + var avgOk = Assert.IsType, SqlError>.Ok< + IReadOnlyList, + SqlError + >>(avgAge); + Assert.Equal(25.0, avgOk.Value[0]); + } + + [Fact] + public void ErrorHandling_InvalidSqlAndConstraintViolations_ReturnsErrors() + { + // Invalid SQL returns error + var badQuery = _connection.Query( + sql: "SELECT FROM NONEXISTENT_TABLE WHERE", + mapper: r => r.GetString(0) + ); + Assert.True( + badQuery + is Result, SqlError>.Error, SqlError> + ); + + // Constraint violation: duplicate primary key + var id = Guid.NewGuid().ToString(); + _connection.Execute( + sql: "INSERT INTO Patients (Id, Name, Age) VALUES (@id, 'First', 30)", + parameters: [new SqliteParameter("@id", id)] + ); + var duplicate = _connection.Execute( + sql: "INSERT INTO Patients (Id, Name, Age) VALUES (@id, 'Second', 40)", + parameters: [new SqliteParameter("@id", id)] + ); + var err = Assert.IsType(duplicate); + Assert.NotNull(err.Value.Message); + Assert.NotEmpty(err.Value.Message); + + // Invalid query returns error + var badScalar = _connection.Query( + sql: "SELECT * FROM NOWHERE", + mapper: r => r.GetInt64(0) + ); + Assert.True( + badScalar is Result, SqlError>.Error, SqlError> + ); + + // Invalid execute returns error + var badExec = _connection.Execute(sql: "DROP TABLE IMAGINARY_TABLE"); + Assert.True(badExec is IntError); + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SqliteTransactionE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqliteTransactionE2ETests.cs new file mode 100644 index 00000000..ca6ec18b --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqliteTransactionE2ETests.cs @@ -0,0 +1,490 @@ +using Microsoft.Data.Sqlite; +using Outcome; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// E2E tests: transaction workflows with real SQLite databases. +/// Tests commit, rollback, multi-table transactions, and DbTransact helpers. +/// +public sealed class SqliteTransactionE2ETests : IDisposable +{ + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"tx_e2e_{Guid.NewGuid()}.db" + ); + + private readonly SqliteConnection _connection; + + public SqliteTransactionE2ETests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + CreateSchema(); + } + + public void Dispose() + { + _connection.Dispose(); + try + { + File.Delete(_dbPath); + } +#pragma warning disable CA1031 // Cleanup is best-effort + catch (IOException) + { + // File may be locked by another process + } + catch (UnauthorizedAccessException) + { + // May not have permission + } +#pragma warning restore CA1031 + } + + private void CreateSchema() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE Orders ( + Id TEXT PRIMARY KEY, + CustomerId TEXT NOT NULL, + Total REAL NOT NULL, + Status TEXT NOT NULL DEFAULT 'pending' + ); + CREATE TABLE OrderItems ( + Id TEXT PRIMARY KEY, + OrderId TEXT NOT NULL, + ProductName TEXT NOT NULL, + Quantity INTEGER NOT NULL, + UnitPrice REAL NOT NULL, + FOREIGN KEY (OrderId) REFERENCES Orders(Id) + ); + CREATE TABLE Inventory ( + ProductName TEXT PRIMARY KEY, + StockCount INTEGER NOT NULL + ); + """; + cmd.ExecuteNonQuery(); + + // Seed inventory + using var seedCmd = _connection.CreateCommand(); + seedCmd.CommandText = """ + INSERT INTO Inventory VALUES ('Widget', 100); + INSERT INTO Inventory VALUES ('Gadget', 50); + INSERT INTO Inventory VALUES ('Doohickey', 25); + """; + seedCmd.ExecuteNonQuery(); + } + + private static void AssertLongQueryValue( + Result, SqlError> result, + long expected + ) + { + Assert.True( + result is Result, SqlError>.Ok, SqlError> + ); + if (result is Result, SqlError>.Ok, SqlError> ok) + { + Assert.Equal(expected, ok.Value[0]); + } + } + + [Fact] + public void TransactionCommit_MultiTableInsert_AllDataPersisted() + { + using var tx = _connection.BeginTransaction(); + + // Insert order within transaction + var orderId = Guid.NewGuid().ToString(); + var orderInsert = tx.Execute( + sql: "INSERT INTO Orders (Id, CustomerId, Total, Status) VALUES (@id, @cid, @total, @status)", + parameters: + [ + new SqliteParameter("@id", orderId), + new SqliteParameter("@cid", "CUST-001"), + new SqliteParameter("@total", 150.75), + new SqliteParameter("@status", "confirmed"), + ] + ); + Assert.True(orderInsert is IntOk); + if (orderInsert is IntOk insertOk) + { + Assert.Equal(1, insertOk.Value); + } + + // Insert order items within same transaction + var item1Id = Guid.NewGuid().ToString(); + var item2Id = Guid.NewGuid().ToString(); + tx.Execute( + sql: "INSERT INTO OrderItems (Id, OrderId, ProductName, Quantity, UnitPrice) VALUES (@id, @oid, @name, @qty, @price)", + parameters: + [ + new SqliteParameter("@id", item1Id), + new SqliteParameter("@oid", orderId), + new SqliteParameter("@name", "Widget"), + new SqliteParameter("@qty", 3), + new SqliteParameter("@price", 25.25), + ] + ); + tx.Execute( + sql: "INSERT INTO OrderItems (Id, OrderId, ProductName, Quantity, UnitPrice) VALUES (@id, @oid, @name, @qty, @price)", + parameters: + [ + new SqliteParameter("@id", item2Id), + new SqliteParameter("@oid", orderId), + new SqliteParameter("@name", "Gadget"), + new SqliteParameter("@qty", 2), + new SqliteParameter("@price", 37.50), + ] + ); + + // Update inventory within transaction + tx.Execute( + sql: "UPDATE Inventory SET StockCount = StockCount - @qty WHERE ProductName = @name", + parameters: [new SqliteParameter("@qty", 3), new SqliteParameter("@name", "Widget")] + ); + tx.Execute( + sql: "UPDATE Inventory SET StockCount = StockCount - @qty WHERE ProductName = @name", + parameters: [new SqliteParameter("@qty", 2), new SqliteParameter("@name", "Gadget")] + ); + + // Query within transaction to verify item count + var itemCount = tx.Query( + sql: "SELECT COUNT(*) FROM OrderItems WHERE OrderId = @oid", + parameters: [new SqliteParameter("@oid", orderId)], + mapper: r => r.GetInt64(0) + ); + AssertLongQueryValue(itemCount, expected: 2L); + + // Commit + tx.Commit(); + + // Verify data persisted after commit + AssertLongQueryValue( + _connection.Query(sql: "SELECT COUNT(*) FROM Orders", mapper: r => r.GetInt64(0)), + expected: 1L + ); + + AssertLongQueryValue( + _connection.Query( + sql: "SELECT COUNT(*) FROM OrderItems", + mapper: r => r.GetInt64(0) + ), + expected: 2L + ); + + AssertLongQueryValue( + _connection.Query( + sql: "SELECT StockCount FROM Inventory WHERE ProductName = 'Widget'", + mapper: r => r.GetInt64(0) + ), + expected: 97L + ); + + AssertLongQueryValue( + _connection.Query( + sql: "SELECT StockCount FROM Inventory WHERE ProductName = 'Gadget'", + mapper: r => r.GetInt64(0) + ), + expected: 48L + ); + } + + [Fact] + public void TransactionRollback_FailedOperation_NoDataPersisted() + { + // Verify initial state + AssertLongQueryValue( + _connection.Query(sql: "SELECT COUNT(*) FROM Orders", mapper: r => r.GetInt64(0)), + expected: 0L + ); + + using var tx = _connection.BeginTransaction(); + + // Insert order + var orderId = Guid.NewGuid().ToString(); + tx.Execute( + sql: "INSERT INTO Orders (Id, CustomerId, Total) VALUES (@id, @cid, @total)", + parameters: + [ + new SqliteParameter("@id", orderId), + new SqliteParameter("@cid", "CUST-002"), + new SqliteParameter("@total", 99.99), + ] + ); + + // Insert items + tx.Execute( + sql: "INSERT INTO OrderItems (Id, OrderId, ProductName, Quantity, UnitPrice) VALUES (@id, @oid, @name, @qty, @price)", + parameters: + [ + new SqliteParameter("@id", Guid.NewGuid().ToString()), + new SqliteParameter("@oid", orderId), + new SqliteParameter("@name", "Widget"), + new SqliteParameter("@qty", 5), + new SqliteParameter("@price", 19.99), + ] + ); + + // Verify data exists within transaction + AssertLongQueryValue( + tx.Query(sql: "SELECT COUNT(*) FROM Orders", mapper: r => r.GetInt64(0)), + expected: 1L + ); + + // Rollback + tx.Rollback(); + + // Verify NO data persisted + AssertLongQueryValue( + _connection.Query(sql: "SELECT COUNT(*) FROM Orders", mapper: r => r.GetInt64(0)), + expected: 0L + ); + + AssertLongQueryValue( + _connection.Query( + sql: "SELECT COUNT(*) FROM OrderItems", + mapper: r => r.GetInt64(0) + ), + expected: 0L + ); + + // Verify inventory unchanged + AssertLongQueryValue( + _connection.Query( + sql: "SELECT StockCount FROM Inventory WHERE ProductName = 'Widget'", + mapper: r => r.GetInt64(0) + ), + expected: 100L + ); + } + + [Fact] + public void TransactionQueryWorkflow_QueryAndModifyInTransaction_ConsistentReads() + { + // Seed some orders first (outside transaction) + for (int i = 0; i < 5; i++) + { + _connection.Execute( + sql: "INSERT INTO Orders (Id, CustomerId, Total, Status) VALUES (@id, @cid, @total, @status)", + parameters: + [ + new SqliteParameter("@id", Guid.NewGuid().ToString()), + new SqliteParameter("@cid", $"CUST-{i:D3}"), + new SqliteParameter("@total", (i + 1) * 50.0), + new SqliteParameter("@status", i < 3 ? "pending" : "shipped"), + ] + ); + } + + using var tx = _connection.BeginTransaction(); + + // Read pending orders in transaction + var pendingOrders = tx.Query<(string Id, string CustomerId, double Total)>( + sql: "SELECT Id, CustomerId, Total FROM Orders WHERE Status = @status ORDER BY Total", + parameters: [new SqliteParameter("@status", "pending")], + mapper: r => (r.GetString(0), r.GetString(1), r.GetDouble(2)) + ); + Assert.True( + pendingOrders + is Result, SqlError>.Ok< + IReadOnlyList<(string Id, string CustomerId, double Total)>, + SqlError + > + ); + if ( + pendingOrders + is Result, SqlError>.Ok< + IReadOnlyList<(string Id, string CustomerId, double Total)>, + SqlError + > pendingOk + ) + { + var pending = pendingOk.Value; + Assert.Equal(3, pending.Count); + Assert.Equal(50.0, pending[0].Item3); + Assert.Equal(100.0, pending[1].Item3); + Assert.Equal(150.0, pending[2].Item3); + } + + // Update all pending to confirmed + var updateResult = tx.Execute( + sql: "UPDATE Orders SET Status = 'confirmed' WHERE Status = @status", + parameters: [new SqliteParameter("@status", "pending")] + ); + Assert.True(updateResult is IntOk); + if (updateResult is IntOk updateOk) + { + Assert.Equal(3, updateOk.Value); + } + + // Verify within transaction + AssertLongQueryValue( + tx.Query( + sql: "SELECT COUNT(*) FROM Orders WHERE Status = 'confirmed'", + mapper: r => r.GetInt64(0) + ), + expected: 3L + ); + + // Aggregate within transaction + var totalValue = tx.Query( + sql: "SELECT SUM(Total) FROM Orders WHERE Status = 'confirmed'", + mapper: r => r.GetDouble(0) + ); + Assert.True( + totalValue + is Result, SqlError>.Ok, SqlError> + ); + if ( + totalValue + is Result, SqlError>.Ok, SqlError> totalOk + ) + { + Assert.Equal(300.0, totalOk.Value[0]); + } + + tx.Commit(); + + // Verify after commit + AssertLongQueryValue( + _connection.Query( + sql: "SELECT COUNT(*) FROM Orders WHERE Status = 'pending'", + mapper: r => r.GetInt64(0) + ), + expected: 0L + ); + } + + [Fact] + public async Task DbTransactHelper_CommitAndRollbackWorkflows_WorkCorrectly() + { + // Use DbTransact helper for successful commit + await _connection + .Transact(async tx => + { + if (tx is not SqliteTransaction sqliteTx || sqliteTx.Connection is not { } conn) + return; + var cmd = conn.CreateCommand(); + cmd.Transaction = sqliteTx; + cmd.CommandText = + "INSERT INTO Orders (Id, CustomerId, Total) VALUES (@id, @cid, @total)"; + cmd.Parameters.AddWithValue("@id", Guid.NewGuid().ToString()); + cmd.Parameters.AddWithValue("@cid", "CUST-TX-001"); + cmd.Parameters.AddWithValue("@total", 200.0); + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + }) + .ConfigureAwait(false); + + // Verify committed + AssertLongQueryValue( + _connection.Query( + sql: "SELECT COUNT(*) FROM Orders WHERE CustomerId = 'CUST-TX-001'", + mapper: r => r.GetInt64(0) + ), + expected: 1L + ); + + // Use DbTransact with return value + var result = await _connection + .Transact(async tx => + { + if (tx is not SqliteTransaction sqliteTx || sqliteTx.Connection is not { } conn) + return 0.0; + var cmd = conn.CreateCommand(); + cmd.Transaction = sqliteTx; + cmd.CommandText = "SELECT Total FROM Orders WHERE CustomerId = 'CUST-TX-001'"; + var value = await cmd.ExecuteScalarAsync().ConfigureAwait(false); + return Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); + }) + .ConfigureAwait(false); + Assert.Equal(200.0, result); + + // Use DbTransact with rollback on exception + var exception = await Assert + .ThrowsAsync(async () => + { + await _connection + .Transact(async tx => + { + if ( + tx is not SqliteTransaction sqliteTx + || sqliteTx.Connection is not { } conn + ) + return; + var cmd = conn.CreateCommand(); + cmd.Transaction = sqliteTx; + cmd.CommandText = + "INSERT INTO Orders (Id, CustomerId, Total) VALUES (@id, @cid, @total)"; + cmd.Parameters.AddWithValue("@id", Guid.NewGuid().ToString()); + cmd.Parameters.AddWithValue("@cid", "CUST-TX-FAIL"); + cmd.Parameters.AddWithValue("@total", 999.0); + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + throw new InvalidOperationException("Simulated failure"); + }) + .ConfigureAwait(false); + }) + .ConfigureAwait(false); + Assert.Equal("Simulated failure", exception.Message); + + // Verify rolled back + AssertLongQueryValue( + _connection.Query( + sql: "SELECT COUNT(*) FROM Orders WHERE CustomerId = 'CUST-TX-FAIL'", + mapper: r => r.GetInt64(0) + ), + expected: 0L + ); + } + + [Fact] + public void TransactionErrorHandling_InvalidOperationsInTransaction_ReturnsErrors() + { + using var tx = _connection.BeginTransaction(); + + // Invalid SQL within transaction + var badQuery = tx.Query(sql: "SELECT FROM BAD SYNTAX", mapper: r => r.GetString(0)); + Assert.True( + badQuery + is Result, SqlError>.Error, SqlError> + ); + if ( + badQuery + is Result, SqlError>.Error< + IReadOnlyList, + SqlError + > queryError + ) + { + Assert.NotEmpty(queryError.Value.Message); + } + + // Invalid execute within transaction + var badExec = tx.Execute(sql: "INSERT INTO NONEXISTENT VALUES ('x')"); + Assert.True(badExec is IntError); + + // Invalid query within transaction (replacing Scalar) + var badScalar = tx.Query(sql: "COMPLETELY INVALID SQL", mapper: r => r.GetInt64(0)); + Assert.True( + badScalar is Result, SqlError>.Error, SqlError> + ); + + // Null/empty SQL within transaction + var emptyQuery = tx.Query(sql: "", mapper: r => r.GetString(0)); + Assert.True( + emptyQuery + is Result, SqlError>.Error, SqlError> + ); + + var emptyExec = tx.Execute(sql: " "); + Assert.True(emptyExec is IntError); + + var emptyScalar = tx.Query(sql: "", mapper: r => r.GetInt64(0)); + Assert.True( + emptyScalar + is Result, SqlError>.Error, SqlError> + ); + } +} diff --git a/DataProvider/DataProvider.Tests/TestSqlFiles/ComplexJoins.sql b/DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/ComplexJoins.sql similarity index 100% rename from DataProvider/DataProvider.Tests/TestSqlFiles/ComplexJoins.sql rename to DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/ComplexJoins.sql diff --git a/DataProvider/DataProvider.Tests/TestSqlFiles/MultipleJoins.sql b/DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/MultipleJoins.sql similarity index 100% rename from DataProvider/DataProvider.Tests/TestSqlFiles/MultipleJoins.sql rename to DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/MultipleJoins.sql diff --git a/DataProvider/DataProvider.Tests/TestSqlFiles/SimpleSelect.sql b/DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/SimpleSelect.sql similarity index 100% rename from DataProvider/DataProvider.Tests/TestSqlFiles/SimpleSelect.sql rename to DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/SimpleSelect.sql diff --git a/DataProvider/DataProvider.Tests/TestSqlFiles/SingleJoin.sql b/DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/SingleJoin.sql similarity index 100% rename from DataProvider/DataProvider.Tests/TestSqlFiles/SingleJoin.sql rename to DataProvider/Nimblesite.DataProvider.Tests/TestSqlFiles/SingleJoin.sql diff --git a/DataProvider/DataProvider.Tests/Testing.ruleset b/DataProvider/Nimblesite.DataProvider.Tests/Testing.ruleset similarity index 60% rename from DataProvider/DataProvider.Tests/Testing.ruleset rename to DataProvider/Nimblesite.DataProvider.Tests/Testing.ruleset index 204d4188..afceedca 100644 --- a/DataProvider/DataProvider.Tests/Testing.ruleset +++ b/DataProvider/Nimblesite.DataProvider.Tests/Testing.ruleset @@ -1,5 +1,5 @@ - + diff --git a/DataProvider/README.md b/DataProvider/README.md index cb16a88c..8f361dab 100644 --- a/DataProvider/README.md +++ b/DataProvider/README.md @@ -1,309 +1,20 @@ # DataProvider -A .NET source generator that creates compile-time safe database extension methods from SQL queries. DataProvider eliminates runtime SQL errors by validating queries at compile time and generating strongly-typed C# code. +A .NET source generator that creates compile-time safe database extension methods from SQL queries. Validates queries at compile time and generates strongly-typed C# code with `Result` error handling. -## Features +Supports SQLite and SQL Server. Works with both `.sql` and `.lql` files. -- **Compile-Time Safety** - SQL queries are validated during compilation, catching errors before runtime -- **Auto-Generated Extensions** - Creates extension methods on `IDbConnection` and `IDbTransaction` -- **Schema Inspection** - Automatically inspects database schema to generate appropriate types -- **Result Type Pattern** - All operations return `Result` types for explicit error handling -- **Multi-Database Support** - Currently supports SQLite and SQL Server -- **LQL Integration** - Seamlessly works with Lambda Query Language files +## Quick Start -## How It Works - -1. **Define SQL Queries** - Place `.sql` or `.lql` files in your project -2. **Configure Generation** - Set up `DataProvider.json` configuration -3. **Build Project** - Source generators create extension methods during compilation -4. **Use Generated Code** - Call type-safe methods with full IntelliSense support - -## Installation - -### SQLite ```xml ``` -### SQL Server -```xml - -``` - -## Database Schema Setup (Migrations) - -DataProvider requires a database with schema to exist **before** code generation runs. The schema allows the generator to introspect table structures and generate correct types. - -### Required Build Order - -``` -1. Export C# Schema to YAML (if schema defined in code) -2. Run Migration.Cli to create database from YAML -3. Run DataProvider code generation -``` - -### Using Migration.Cli - -Migration.Cli is the **single canonical tool** for creating databases from schema definitions. All projects that need a build-time database MUST use this tool. - -```bash -dotnet run --project Migration/Migration.Cli/Migration.Cli.csproj -- \ - --schema path/to/schema.yaml \ - --output path/to/database.db \ - --provider sqlite -``` - -### MSBuild Integration - -Configure your `.csproj` to run migrations before code generation: - -```xml - - - - - - - - - -``` - -### YAML Schema Format - -See [this](Migration/migration_exe_spec.md) - -### Exporting C# Schemas to YAML - -If your schema is defined in C# code using the Migration fluent API: - ```csharp -var schema = Schema.Define("my_schema") - .Table("Customer", t => t - .Column("Id", Text, c => c.PrimaryKey()) - .Column("Name", Text, c => c.NotNull()) - ) - .Build(); - -// Export to YAML -SchemaYamlSerializer.ToYamlFile(schema, "schema.yaml"); -``` - -### Avoiding Circular Dependencies - -**CRITICAL:** Projects that use DataProvider code generation MUST NOT have circular dependencies with the CLI tools. - -**The Problem:** If your project references `DataProvider.csproj` AND runs `DataProvider.SQLite.Cli` as a build target, you create an infinite build loop: -``` -YourProject → DataProvider → (build target) DataProvider.SQLite.Cli → DataProvider → ... -``` - -**How to Fix:** -1. **Remove the `ProjectReference` to DataProvider.csproj** from projects that run the CLI as a build target -2. **Use raw YAML schemas checked into git** - do NOT export C# schemas to YAML at build time -3. **Migration.Cli is safe** - it does NOT depend on DataProvider, only on Migration projects - -**The Rule:** YAML schema files are source of truth. Check them into git. Never generate them at build time. The C# → YAML export is a one-time developer action, not a build step. - -**Correct pattern:** -```xml - - - - - - - - - -``` - -**Wrong pattern:** -```xml - - -``` - -## Configuration - -Create a `DataProvider.json` file in your project root: - -```json -{ - "ConnectionString": "Data Source=mydatabase.db", - "Namespace": "MyApp.DataAccess", - "OutputDirectory": "Generated", - "Queries": [ - { - "Name": "GetCustomers", - "SqlFile": "Queries/GetCustomers.sql" - }, - { - "Name": "GetOrders", - "SqlFile": "Queries/GetOrders.lql" - } - ] -} -``` - -## Usage Examples - -### Simple Query - -SQL file (`GetCustomers.sql`): -```sql -SELECT Id, Name, Email -FROM Customers -WHERE IsActive = @isActive -``` - -Generated C# usage: -```csharp -using var connection = new SqliteConnection(connectionString); var result = await connection.GetCustomersAsync(isActive: true); - -if (result.IsSuccess) -{ - foreach (var customer in result.Value) - { - Console.WriteLine($"{customer.Name}: {customer.Email}"); - } -} -else -{ - Console.WriteLine($"Error: {result.Error.Message}"); -} -``` - -### With LQL - -LQL file (`GetOrders.lql`): -```lql -Order -|> join(Customer, on = Order.CustomerId = Customer.Id) -|> filter(fn(row) => row.Order.OrderDate >= @startDate) -|> select(Order.Id, Order.Total, Customer.Name) -``` - -This automatically generates: -```csharp -var orders = await connection.GetOrdersAsync( - startDate: DateTime.Now.AddDays(-30) -); -``` - -### Transaction Support - -```csharp -using var connection = new SqliteConnection(connectionString); -connection.Open(); -using var transaction = connection.BeginTransaction(); - -var insertResult = await transaction.InsertCustomerAsync( - name: "John Doe", - email: "john@example.com" -); - -if (insertResult.IsSuccess) -{ - transaction.Commit(); -} -else -{ - transaction.Rollback(); -} -``` - -## Grouping Configuration - -For complex result sets with joins, configure grouping in a `.grouping.json` file: - -```json -{ - "PrimaryKey": "Id", - "GroupBy": ["Id"], - "Collections": { - "Addresses": { - "ForeignKey": "CustomerId", - "Properties": ["Street", "City", "State"] - } - } -} -``` - -## Architecture - -DataProvider follows functional programming principles: - -- **No Classes** - Uses records and static extension methods -- **No Exceptions** - Returns `Result` types for all operations -- **Pure Functions** - Static methods with no side effects -- **Expression-Based** - Prefers expressions over statements - -## Project Structure - -``` -DataProvider/ -├── DataProvider/ # Core library and base types -├── DataProvider.SQLite/ # SQLite implementation -│ ├── Parsing/ # ANTLR grammar and parsers -│ └── SchemaInspection/ # Schema discovery -├── DataProvider.SqlServer/ # SQL Server implementation -│ └── SchemaInspection/ -├── DataProvider.Example/ # Example usage -└── DataProvider.Tests/ # Unit tests -``` - -## Testing - -Run tests with: -```bash -dotnet test DataProvider.Tests/DataProvider.Tests.csproj -``` - -## Performance - -- **Zero Runtime Overhead** - All SQL parsing and validation happens at compile time -- **Minimal Allocations** - Uses value types and expressions where possible -- **Async/Await** - Full async support for all database operations - -## Logging - -Generated methods support optional `ILogger` injection for observability. Pass an `ILogger` instance to any generated method: - -```csharp -var result = await connection.GetCustomersAsync(isActive: true, logger: _logger); -``` - -Logging includes query timing, parameter values (debug level), row counts, and structured error context. Zero overhead when logger is null. - -## Error Handling - -All methods return `Result` types: - -```csharp -var result = await connection.ExecuteQueryAsync(); - -var output = result switch -{ - { IsSuccess: true } => ProcessData(result.Value), - { Error: SqlError error } => HandleError(error), - _ => "Unknown error" -}; ``` -## Contributing - -1. Follow the functional programming style (no classes, no exceptions) -2. Keep files under 450 lines -3. All public members must have XML documentation -4. Run `dotnet csharpier .` before committing -5. Ensure all tests pass - -## License - -MIT License - -## Author +## Documentation -MelbourneDeveloper - [ChristianFindlay.com](https://christianfindlay.com) \ No newline at end of file +- Full usage and configuration details are in the [DataProvider website docs](../Website/src/docs/dataprovider.md) +- Migration CLI spec: [docs/specs/migration-cli-spec.md](../docs/specs/migration-cli-spec.md) diff --git a/Directory.Build.props b/Directory.Build.props index 083df062..0cf7000d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,13 +8,13 @@ ChristianFindlay MelbourneDeveloper MIT - https://github.com/MelbourneDeveloper/DataProvider - https://github.com/MelbourneDeveloper/DataProvider + https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core + https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core git false README.md true - net10.0 + net9.0 latest enable enable diff --git a/Gatekeeper/Gatekeeper.Api.Tests/AuthenticationTests.cs b/Gatekeeper/Gatekeeper.Api.Tests/AuthenticationTests.cs deleted file mode 100644 index 92da28eb..00000000 --- a/Gatekeeper/Gatekeeper.Api.Tests/AuthenticationTests.cs +++ /dev/null @@ -1,306 +0,0 @@ -namespace Gatekeeper.Api.Tests; - -/// -/// Integration tests for Gatekeeper authentication endpoints. -/// Tests WebAuthn/FIDO2 passkey registration and login flows. -/// -public sealed class AuthenticationTests : IClassFixture -{ - private readonly HttpClient _client; - - public AuthenticationTests(GatekeeperTestFixture fixture) - { - _client = fixture.CreateClient(); - } - - [Fact] - public async Task RegisterBegin_WithValidEmail_ReturnsChallenge() - { - var request = new { Email = "test@example.com", DisplayName = "Test User" }; - - var response = await _client.PostAsJsonAsync("/auth/register/begin", request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - - Assert.True(doc.RootElement.TryGetProperty("ChallengeId", out var challengeId)); - Assert.False(string.IsNullOrEmpty(challengeId.GetString())); - - // API returns OptionsJson as a JSON string (for JS to parse) - Assert.True(doc.RootElement.TryGetProperty("OptionsJson", out var optionsJson)); - var parsedOptions = JsonDocument.Parse(optionsJson.GetString()!); - Assert.True(parsedOptions.RootElement.TryGetProperty("challenge", out _)); - } - - [Fact] - public async Task RegisterBegin_RequiresResidentKey_ForDiscoverableCredentials() - { - // Registration must require resident keys so login works without email - var request = new { Email = "resident@example.com", DisplayName = "Resident User" }; - - var response = await _client.PostAsJsonAsync("/auth/register/begin", request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!; - var options = JsonDocument.Parse(optionsJson); - - // Verify authenticatorSelection requires resident key - Assert.True( - options.RootElement.TryGetProperty("authenticatorSelection", out var authSelection) - ); - Assert.True(authSelection.TryGetProperty("residentKey", out var residentKey)); - Assert.Equal("required", residentKey.GetString()); - } - - [Fact] - public async Task RegisterBegin_RequiresUserVerification() - { - // Registration must require user verification for security - var request = new { Email = "verify@example.com", DisplayName = "Verify User" }; - - var response = await _client.PostAsJsonAsync("/auth/register/begin", request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!; - var options = JsonDocument.Parse(optionsJson); - - var authSelection = options.RootElement.GetProperty("authenticatorSelection"); - Assert.True(authSelection.TryGetProperty("userVerification", out var userVerification)); - Assert.Equal("required", userVerification.GetString()); - } - - [Fact] - public async Task LoginBegin_WithEmptyBody_ReturnsChallenge_ForDiscoverableCredentials() - { - // Discoverable credentials flow: no email needed, browser shows all passkeys - // Server returns challenge with empty allowCredentials - var response = await _client.PostAsJsonAsync("/auth/login/begin", new { }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - - // Should return a valid challenge - Assert.True(doc.RootElement.TryGetProperty("ChallengeId", out var challengeId)); - Assert.False(string.IsNullOrEmpty(challengeId.GetString())); - - // Verify options structure - Assert.True(doc.RootElement.TryGetProperty("OptionsJson", out var optionsJson)); - var options = JsonDocument.Parse(optionsJson.GetString()!); - Assert.True(options.RootElement.TryGetProperty("challenge", out _)); - - // allowCredentials should be empty for discoverable credentials - Assert.True( - options.RootElement.TryGetProperty("allowCredentials", out var allowCredentials) - ); - Assert.Equal(JsonValueKind.Array, allowCredentials.ValueKind); - Assert.Equal(0, allowCredentials.GetArrayLength()); - } - - [Fact] - public async Task LoginBegin_RequiresUserVerification() - { - // Login must require user verification (Touch ID, Face ID, etc.) - var response = await _client.PostAsJsonAsync("/auth/login/begin", new { }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!; - var options = JsonDocument.Parse(optionsJson); - - Assert.True( - options.RootElement.TryGetProperty("userVerification", out var userVerification) - ); - Assert.Equal("required", userVerification.GetString()); - } - - [Fact] - public async Task LoginComplete_WithInvalidChallengeId_ReturnsError() - { - // Attempting to complete login with invalid challenge should fail - // The endpoint validates the challenge ID and returns an error - var request = new - { - ChallengeId = "non-existent-challenge-id", - OptionsJson = "{}", - AssertionResponse = new - { - Id = "ZmFrZS1jcmVkZW50aWFsLWlk", // base64url encoded - RawId = "ZmFrZS1jcmVkZW50aWFsLWlk", - Type = "public-key", - Response = new - { - AuthenticatorData = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - ClientDataJson = "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYWFhYSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyJ9", - Signature = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - UserHandle = (string?)null, - }, - }, - }; - - var response = await _client.PostAsJsonAsync("/auth/login/complete", request); - - // Should return an error (either BadRequest for validation or Problem for processing) - Assert.True( - response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError, - $"Expected error status code but got {response.StatusCode}" - ); - } - - [Fact] - public async Task RegisterComplete_WithInvalidChallengeId_ReturnsError() - { - // Attempting to complete registration with invalid challenge should fail - var request = new - { - ChallengeId = "non-existent-challenge-id", - OptionsJson = "{}", - AttestationResponse = new - { - Id = "ZmFrZS1jcmVkZW50aWFsLWlk", // base64url encoded - RawId = "ZmFrZS1jcmVkZW50aWFsLWlk", - Type = "public-key", - Response = new - { - AttestationObject = "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjE", - ClientDataJson = "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYWFhYSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyJ9", - }, - }, - }; - - var response = await _client.PostAsJsonAsync("/auth/register/complete", request); - - // Should return an error (either BadRequest for validation or Problem for processing) - Assert.True( - response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError, - $"Expected error status code but got {response.StatusCode}" - ); - } - - [Fact] - public async Task Session_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/auth/session"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Session_WithInvalidToken_ReturnsUnauthorized() - { - _client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid-token"); - - var response = await _client.GetAsync("/auth/session"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Logout_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.PostAsync("/auth/logout", null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } -} - -/// -/// Tests for Base64Url encoding used in WebAuthn credential IDs. -/// -public sealed class Base64UrlTests -{ - [Fact] - public void Encode_ProducesUrlSafeOutput() - { - // Standard base64 uses + and /, base64url uses - and _ - var input = new byte[] { 0xfb, 0xff, 0xfe }; // Would produce +//+ in standard base64 - - var result = Base64Url.Encode(input); - - Assert.DoesNotContain("+", result); - Assert.DoesNotContain("/", result); - Assert.DoesNotContain("=", result); - Assert.Contains("-", result); // Should use - instead of + - Assert.Contains("_", result); // Should use _ instead of / - } - - [Fact] - public void Encode_Decode_RoundTrip() - { - var original = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; - - var encoded = Base64Url.Encode(original); - var decoded = Base64Url.Decode(encoded); - - Assert.Equal(original, decoded); - } - - [Fact] - public void Decode_HandlesNoPadding() - { - // base64url typically omits padding - var encoded = "AQIDBA"; // No = padding - - var decoded = Base64Url.Decode(encoded); - - Assert.Equal(new byte[] { 1, 2, 3, 4 }, decoded); - } - - [Fact] - public void Decode_HandlesUrlSafeCharacters() - { - // Test decoding with - and _ (url-safe chars) - var encoded = "-_8"; // base64url for 0xfb, 0xff - - var decoded = Base64Url.Decode(encoded); - - Assert.Equal(new byte[] { 0xfb, 0xff }, decoded); - } - - [Fact] - public void Encode_MatchesWebAuthnCredentialIdFormat() - { - // WebAuthn credential IDs use base64url encoding - // This test verifies our encoding matches the expected format - var credentialId = new byte[] - { - 0x01, - 0x02, - 0x03, - 0x04, - 0x05, - 0x06, - 0x07, - 0x08, - 0x09, - 0x0a, - 0x0b, - 0x0c, - 0x0d, - 0x0e, - 0x0f, - 0x10, - }; - - var encoded = Base64Url.Encode(credentialId); - - // Should be AQIDBAUGBwgJCgsMDQ4PEA (no padding) - Assert.Equal("AQIDBAUGBwgJCgsMDQ4PEA", encoded); - - // Verify round-trip - var decoded = Base64Url.Decode(encoded); - Assert.Equal(credentialId, decoded); - } -} diff --git a/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs b/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs deleted file mode 100644 index aac8f2e4..00000000 --- a/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs +++ /dev/null @@ -1,646 +0,0 @@ -using System.Globalization; -using Npgsql; -using Outcome; - -namespace Gatekeeper.Api.Tests; - -/// -/// Integration tests for Gatekeeper authorization endpoints. -/// Tests RBAC permission checks, resource grants, and bulk evaluation. -/// -public sealed class AuthorizationTests : IClassFixture -{ - private readonly GatekeeperTestFixture _fixture; - - public AuthorizationTests(GatekeeperTestFixture fixture) => _fixture = fixture; - - [Fact] - public async Task Check_WithoutToken_ReturnsUnauthorized() - { - var client = _fixture.CreateClient(); - - var response = await client.GetAsync("/authz/check?permission=test:read"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Check_WithInvalidToken_ReturnsUnauthorized() - { - var client = _fixture.CreateClient(); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid-token"); - - var response = await client.GetAsync("/authz/check?permission=test:read"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Check_WithValidToken_UserHasDefaultPermissions_ReturnsAllowed() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateTestUserAndGetToken("authz-user-1@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Debug: Check what's in the database using DataProvider extensions - using var conn = _fixture.OpenConnection(); - var rolePermsResult = await conn.GetRolePermissionsAsync("role-user"); - var rolePerms = rolePermsResult switch - { - GetRolePermissionsOk ok => ok.Value.Select(p => $"role-user->{p.code}").ToList(), - GetRolePermissionsError err => [$"(error: {err.Value.Message})"], - }; - - // Default 'user' role has 'user:profile' permission - var response = await client.GetAsync("/authz/check?permission=user:profile"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - Assert.True( - doc.RootElement.GetProperty("Allowed").GetBoolean(), - $"Response: {content}, RolePerms: [{string.Join(", ", rolePerms)}]" - ); - Assert.Contains("user:profile", doc.RootElement.GetProperty("Reason").GetString()); - } - - [Fact] - public async Task Check_WithValidToken_UserLacksPermission_ReturnsDenied() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateTestUserAndGetToken("authz-user-2@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Default 'user' role does NOT have 'admin:users' permission - var response = await client.GetAsync("/authz/check?permission=admin:users"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - Assert.False(doc.RootElement.GetProperty("Allowed").GetBoolean()); - Assert.Equal("no matching permission", doc.RootElement.GetProperty("Reason").GetString()); - } - - [Fact] - public async Task Check_AdminWildcardPermission_MatchesSubPermissions() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateAdminUserAndGetToken("admin-wildcard@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Admin role has 'admin:*' which should match 'admin:users' - var response = await client.GetAsync("/authz/check?permission=admin:users"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - Assert.True(doc.RootElement.GetProperty("Allowed").GetBoolean()); - Assert.Contains("admin", doc.RootElement.GetProperty("Reason").GetString()); - } - - [Fact] - public async Task Check_AdminWildcardPermission_MatchesNestedSubPermissions() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateAdminUserAndGetToken("admin-nested@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Admin role has 'admin:*' which should match 'admin:users:create' - var response = await client.GetAsync("/authz/check?permission=admin:users:create"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - Assert.True(doc.RootElement.GetProperty("Allowed").GetBoolean()); - } - - [Fact] - public async Task Permissions_WithoutToken_ReturnsUnauthorized() - { - var client = _fixture.CreateClient(); - - var response = await client.GetAsync("/authz/permissions"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Permissions_WithValidToken_ReturnsUserPermissions() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateTestUserAndGetToken("authz-perms@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var response = await client.GetAsync("/authz/permissions"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - - Assert.True(doc.RootElement.TryGetProperty("Permissions", out var perms)); - Assert.Equal(JsonValueKind.Array, perms.ValueKind); - - // Default user role has 'user:profile' and 'user:credentials' - var permCodes = perms - .EnumerateArray() - .Select(p => p.GetProperty("code").GetString()) - .ToList(); - Assert.Contains("user:profile", permCodes); - Assert.Contains("user:credentials", permCodes); - } - - [Fact] - public async Task Permissions_AdminUser_ReturnsAdminPermissions() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateAdminUserAndGetToken("admin-perms@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var response = await client.GetAsync("/authz/permissions"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - - var perms = doc.RootElement.GetProperty("Permissions"); - var permCodes = perms - .EnumerateArray() - .Select(p => p.GetProperty("code").GetString()) - .ToList(); - Assert.Contains("admin:*", permCodes); - } - - [Fact] - public async Task Evaluate_WithoutToken_ReturnsUnauthorized() - { - var client = _fixture.CreateClient(); - - var request = new - { - Checks = new[] - { - new - { - Permission = "test:read", - ResourceType = (string?)null, - ResourceId = (string?)null, - }, - }, - }; - var response = await client.PostAsJsonAsync("/authz/evaluate", request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Evaluate_WithValidToken_ReturnsBulkResults() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateTestUserAndGetToken("authz-eval@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var request = new - { - Checks = new[] - { - new - { - Permission = "user:profile", - ResourceType = (string?)null, - ResourceId = (string?)null, - }, - new - { - Permission = "admin:users", - ResourceType = (string?)null, - ResourceId = (string?)null, - }, - new - { - Permission = "user:credentials", - ResourceType = (string?)null, - ResourceId = (string?)null, - }, - }, - }; - - var response = await client.PostAsJsonAsync("/authz/evaluate", request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - - Assert.True(doc.RootElement.TryGetProperty("Results", out var results)); - Assert.Equal(3, results.GetArrayLength()); - - var resultsList = results.EnumerateArray().ToList(); - - // user:profile - allowed - Assert.True(resultsList[0].GetProperty("Allowed").GetBoolean()); - - // admin:users - denied - Assert.False(resultsList[1].GetProperty("Allowed").GetBoolean()); - - // user:credentials - allowed - Assert.True(resultsList[2].GetProperty("Allowed").GetBoolean()); - } - - [Fact] - public async Task Evaluate_EmptyChecks_ReturnsEmptyResults() - { - var client = _fixture.CreateClient(); - var token = await _fixture.CreateTestUserAndGetToken("authz-empty@example.com"); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var request = new { Checks = Array.Empty() }; - - var response = await client.PostAsJsonAsync("/authz/evaluate", request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - - Assert.True(doc.RootElement.TryGetProperty("Results", out var results)); - Assert.Equal(0, results.GetArrayLength()); - } - - [Fact] - public async Task Check_WithResourceGrant_AllowsAccessToSpecificResource() - { - var client = _fixture.CreateClient(); - var (token, userId) = await _fixture.CreateTestUserAndGetTokenWithId( - "resource-grant@example.com" - ); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Grant access to a specific patient record - await _fixture.GrantResourceAccess(userId, "patient", "patient-123", "patient:read"); - - var response = await client.GetAsync( - "/authz/check?permission=patient:read&resourceType=patient&resourceId=patient-123" - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - Assert.True(doc.RootElement.GetProperty("Allowed").GetBoolean()); - Assert.Contains("resource-grant", doc.RootElement.GetProperty("Reason").GetString()); - } - - [Fact] - public async Task Check_WithResourceGrant_DeniesAccessToDifferentResource() - { - var client = _fixture.CreateClient(); - var (token, userId) = await _fixture.CreateTestUserAndGetTokenWithId( - "resource-deny@example.com" - ); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Grant access only to patient-123 - await _fixture.GrantResourceAccess(userId, "patient", "patient-123", "patient:read"); - - // Check access to patient-456 (should be denied) - var response = await client.GetAsync( - "/authz/check?permission=patient:read&resourceType=patient&resourceId=patient-456" - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - Assert.False(doc.RootElement.GetProperty("Allowed").GetBoolean()); - } - - [Fact] - public async Task Check_WithExpiredResourceGrant_DeniesAccess() - { - var client = _fixture.CreateClient(); - var (token, userId) = await _fixture.CreateTestUserAndGetTokenWithId( - "expired-grant@example.com" - ); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Grant access that's already expired - await _fixture.GrantResourceAccessExpired(userId, "order", "order-999", "order:read"); - - var response = await client.GetAsync( - "/authz/check?permission=order:read&resourceType=order&resourceId=order-999" - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(content); - Assert.False(doc.RootElement.GetProperty("Allowed").GetBoolean()); - } -} - -/// -/// Test fixture providing shared setup for Gatekeeper tests. -/// Creates test users and tokens without WebAuthn ceremony. -/// Uses PostgreSQL test database. -/// -public sealed class GatekeeperTestFixture : IDisposable -{ - private readonly WebApplicationFactory _factory; - private readonly byte[] _signingKey; - private readonly string _dbName; - private readonly string _connectionString; - - public GatekeeperTestFixture() - { - var baseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme"; - - _dbName = $"test_gatekeeper_{Guid.NewGuid():N}"; - _signingKey = new byte[32]; - - // Create test database - using (var adminConn = new NpgsqlConnection(baseConnectionString)) - { - adminConn.Open(); - using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {_dbName}"; - createCmd.ExecuteNonQuery(); - } - - // Build connection string for test database - _connectionString = baseConnectionString.Replace( - "Database=postgres", - $"Database={_dbName}" - ); - - _factory = new WebApplicationFactory().WithWebHostBuilder(builder => - { - builder.UseSetting("ConnectionStrings:Postgres", _connectionString); - builder.UseSetting("Jwt:SigningKey", Convert.ToBase64String(_signingKey)); - }); - - // Initialize database by making HTTP requests through the factory - // This ensures the app creates and seeds the database before we access it directly - using var client = _factory.CreateClient(); - // Make a request that forces full app initialization - _ = client.PostAsJsonAsync("/auth/login/begin", new { }).GetAwaiter().GetResult(); - } - - /// Creates a fresh HTTP client for testing. - public HttpClient CreateClient() => _factory.CreateClient(); - - /// Opens a database connection for direct data access. - public NpgsqlConnection OpenConnection() - { - var conn = new NpgsqlConnection(_connectionString); - conn.Open(); - return conn; - } - - /// - /// Creates a test user and returns a valid JWT token. - /// Bypasses WebAuthn by directly inserting user and generating token. - /// Uses DataProvider generated methods for data access. - /// - public async Task CreateTestUserAndGetToken(string email) - { - var (token, _) = await CreateTestUserAndGetTokenWithId(email).ConfigureAwait(false); - return token; - } - - /// - /// Creates a test user and returns both the token and user ID. - /// Uses DataProvider generated methods for data access. - /// - public async Task<(string Token, string UserId)> CreateTestUserAndGetTokenWithId(string email) - { - using var conn = OpenConnection(); - await using var tx = await conn.BeginTransactionAsync().ConfigureAwait(false); - - var userId = Guid.NewGuid().ToString(); - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - - // Insert user using DataProvider generated method - await tx.Insertgk_userAsync( - userId, - "Test User", - email, - now, - null, // last_login_at - true, // is_active - null // metadata - ) - .ConfigureAwait(false); - - // Link user to role using DataProvider generated method - await tx.Insertgk_user_roleAsync( - userId, - "role-user", - now, - null, // granted_by - null // expires_at - ) - .ConfigureAwait(false); - - await tx.CommitAsync().ConfigureAwait(false); - - var token = TokenService.CreateToken( - userId, - "Test User", - email, - ["user"], - _signingKey, - TimeSpan.FromHours(1) - ); - - return (token, userId); - } - - /// - /// Creates an admin user and returns a valid JWT token. - /// Uses DataProvider generated methods for data access. - /// - public async Task CreateAdminUserAndGetToken(string email) - { - using var conn = OpenConnection(); - await using var tx = await conn.BeginTransactionAsync().ConfigureAwait(false); - - var userId = Guid.NewGuid().ToString(); - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - - // Insert user using DataProvider generated method - await tx.Insertgk_userAsync( - userId, - "Admin User", - email, - now, - null, // last_login_at - true, // is_active - null // metadata - ) - .ConfigureAwait(false); - - // Link user to admin role using DataProvider generated method - await tx.Insertgk_user_roleAsync( - userId, - "role-admin", - now, - null, // granted_by - null // expires_at - ) - .ConfigureAwait(false); - - await tx.CommitAsync().ConfigureAwait(false); - - var token = TokenService.CreateToken( - userId, - "Admin User", - email, - ["admin"], - _signingKey, - TimeSpan.FromHours(1) - ); - - return token; - } - - /// - /// Grants resource-level access to a user. - /// Uses DataProvider generated methods for data access. - /// - public async Task GrantResourceAccess( - string userId, - string resourceType, - string resourceId, - string permissionCode - ) - { - using var conn = OpenConnection(); - - // Look up existing permission by code BEFORE starting transaction - var permLookupResult = await conn.GetPermissionByCodeAsync(permissionCode) - .ConfigureAwait(false); - var existingPerm = permLookupResult switch - { - GetPermissionByCodeOk ok => ok.Value.FirstOrDefault(), - GetPermissionByCodeError err => throw new InvalidOperationException( - $"Permission lookup failed: {err.Value.Message}, Exception: {err.Value.InnerException?.Message}" - ), - }; - - var permId = - existingPerm?.id - ?? throw new InvalidOperationException( - $"Permission '{permissionCode}' not found in seeded database" - ); - - await using var tx = await conn.BeginTransactionAsync().ConfigureAwait(false); - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - var grantId = Guid.NewGuid().ToString(); - - // Grant access using DataProvider generated method - var grantResult = await tx.Insertgk_resource_grantAsync( - grantId, - userId, - resourceType, - resourceId, - permId, - now, - null, // granted_by - null // expires_at - ) - .ConfigureAwait(false); - - if (grantResult is Result.Error grantErr) - { - throw new InvalidOperationException( - $"Failed to insert grant: {grantErr.Value.Message}" - ); - } - - await tx.CommitAsync().ConfigureAwait(false); - } - - /// - /// Grants resource-level access that has already expired. - /// Uses DataProvider generated methods for data access. - /// - public async Task GrantResourceAccessExpired( - string userId, - string resourceType, - string resourceId, - string permissionCode - ) - { - using var conn = OpenConnection(); - - // Look up existing permission by code BEFORE starting transaction - var permLookupResult = await conn.GetPermissionByCodeAsync(permissionCode) - .ConfigureAwait(false); - var existingPerm = permLookupResult switch - { - GetPermissionByCodeOk ok => ok.Value.FirstOrDefault(), - GetPermissionByCodeError err => throw new InvalidOperationException( - $"Permission lookup failed: {err.Value.Message}" - ), - }; - - var permId = - existingPerm?.id - ?? throw new InvalidOperationException( - $"Permission '{permissionCode}' not found in seeded database" - ); - - await using var tx = await conn.BeginTransactionAsync().ConfigureAwait(false); - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - var expired = DateTime.UtcNow.AddHours(-1).ToString("o", CultureInfo.InvariantCulture); - var grantId = Guid.NewGuid().ToString(); - - // Grant access with expired timestamp using DataProvider generated method - await tx.Insertgk_resource_grantAsync( - grantId, - userId, - resourceType, - resourceId, - permId, - now, - null, // granted_by - expired // expires_at - ) - .ConfigureAwait(false); - - await tx.CommitAsync().ConfigureAwait(false); - } - - /// Disposes the test fixture and cleans up test database. - public void Dispose() - { - _factory.Dispose(); - - var baseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme"; - - // Drop the test database - using var adminConn = new NpgsqlConnection(baseConnectionString); - adminConn.Open(); - - // Terminate any existing connections to the database - using var terminateCmd = adminConn.CreateCommand(); - terminateCmd.CommandText = - $"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{_dbName}'"; - terminateCmd.ExecuteNonQuery(); - - using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {_dbName}"; - dropCmd.ExecuteNonQuery(); - } -} diff --git a/Gatekeeper/Gatekeeper.Api.Tests/Gatekeeper.Api.Tests.csproj b/Gatekeeper/Gatekeeper.Api.Tests/Gatekeeper.Api.Tests.csproj deleted file mode 100644 index 79342674..00000000 --- a/Gatekeeper/Gatekeeper.Api.Tests/Gatekeeper.Api.Tests.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - Library - true - Gatekeeper.Api.Tests - CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859;CA1849;CA2234;CA1812;CA2007;CA2000;xUnit1030 - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - PreserveNewest - - - diff --git a/Gatekeeper/Gatekeeper.Api.Tests/GlobalUsings.cs b/Gatekeeper/Gatekeeper.Api.Tests/GlobalUsings.cs deleted file mode 100644 index 90439cda..00000000 --- a/Gatekeeper/Gatekeeper.Api.Tests/GlobalUsings.cs +++ /dev/null @@ -1,36 +0,0 @@ -#pragma warning disable IDE0005 // Using directive is unnecessary - -global using System.Net; -global using System.Net.Http.Json; -global using System.Text.Json; -global using Generated; -global using Microsoft.AspNetCore.Mvc.Testing; -global using Selecta; -global using Xunit; -global using GetPermissionByCodeError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using GetPermissionByCodeOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetRolePermissionsError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using GetRolePermissionsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetSessionRevokedError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using GetSessionRevokedOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; diff --git a/Gatekeeper/Gatekeeper.Api.Tests/TokenServiceTests.cs b/Gatekeeper/Gatekeeper.Api.Tests/TokenServiceTests.cs deleted file mode 100644 index b6b86cc2..00000000 --- a/Gatekeeper/Gatekeeper.Api.Tests/TokenServiceTests.cs +++ /dev/null @@ -1,597 +0,0 @@ -using System.Globalization; -using Migration; -using Migration.Postgres; -using Npgsql; - -namespace Gatekeeper.Api.Tests; - -/// -/// Unit tests for TokenService JWT creation, validation, and revocation. -/// -public sealed class TokenServiceTests -{ - private static readonly byte[] TestSigningKey = new byte[32]; - - [Fact] - public void CreateToken_ReturnsValidJwtFormat() - { - var token = TokenService.CreateToken( - "user-123", - "Test User", - "test@example.com", - ["user", "admin"], - TestSigningKey, - TimeSpan.FromHours(1) - ); - - // JWT has 3 parts separated by dots - var parts = token.Split('.'); - Assert.Equal(3, parts.Length); - - // All parts should be base64url encoded (no padding) - Assert.DoesNotContain("=", parts[0]); - Assert.DoesNotContain("=", parts[1]); - Assert.DoesNotContain("=", parts[2]); - } - - [Fact] - public void CreateToken_HeaderContainsCorrectAlgorithm() - { - var token = TokenService.CreateToken( - "user-123", - "Test User", - "test@example.com", - ["user"], - TestSigningKey, - TimeSpan.FromHours(1) - ); - - var parts = token.Split('.'); - var headerJson = Base64UrlDecode(parts[0]); - var header = JsonDocument.Parse(headerJson); - - Assert.Equal("HS256", header.RootElement.GetProperty("alg").GetString()); - Assert.Equal("JWT", header.RootElement.GetProperty("typ").GetString()); - } - - [Fact] - public void CreateToken_PayloadContainsAllClaims() - { - var token = TokenService.CreateToken( - "user-456", - "Jane Doe", - "jane@example.com", - ["admin", "manager"], - TestSigningKey, - TimeSpan.FromHours(2) - ); - - var parts = token.Split('.'); - var payloadJson = Base64UrlDecode(parts[1]); - var payload = JsonDocument.Parse(payloadJson); - - Assert.Equal("user-456", payload.RootElement.GetProperty("sub").GetString()); - Assert.Equal("Jane Doe", payload.RootElement.GetProperty("name").GetString()); - Assert.Equal("jane@example.com", payload.RootElement.GetProperty("email").GetString()); - - var roles = payload - .RootElement.GetProperty("roles") - .EnumerateArray() - .Select(e => e.GetString()) - .ToList(); - Assert.Contains("admin", roles); - Assert.Contains("manager", roles); - - Assert.True(payload.RootElement.TryGetProperty("jti", out var jti)); - Assert.False(string.IsNullOrEmpty(jti.GetString())); - - Assert.True(payload.RootElement.TryGetProperty("iat", out _)); - Assert.True(payload.RootElement.TryGetProperty("exp", out _)); - } - - [Fact] - public void CreateToken_ExpirationIsCorrect() - { - var beforeCreate = DateTimeOffset.UtcNow; - - var token = TokenService.CreateToken( - "user-789", - "Test", - "test@example.com", - [], - TestSigningKey, - TimeSpan.FromMinutes(30) - ); - - var parts = token.Split('.'); - var payloadJson = Base64UrlDecode(parts[1]); - var payload = JsonDocument.Parse(payloadJson); - - var exp = payload.RootElement.GetProperty("exp").GetInt64(); - var iat = payload.RootElement.GetProperty("iat").GetInt64(); - var expTime = DateTimeOffset.FromUnixTimeSeconds(exp); - var iatTime = DateTimeOffset.FromUnixTimeSeconds(iat); - - // exp should be ~30 minutes after iat - var diff = expTime - iatTime; - Assert.True(diff.TotalMinutes >= 29 && diff.TotalMinutes <= 31); - - // exp should be ~30 minutes from now - var expFromNow = expTime - beforeCreate; - Assert.True(expFromNow.TotalMinutes >= 29 && expFromNow.TotalMinutes <= 31); - } - - [Fact] - public async Task ValidateTokenAsync_ValidToken_ReturnsOk() - { - var (conn, dbPath) = CreateTestDb(); - try - { - var token = TokenService.CreateToken( - "user-valid", - "Valid User", - "valid@example.com", - ["user"], - TestSigningKey, - TimeSpan.FromHours(1) - ); - - var result = await TokenService.ValidateTokenAsync( - conn, - token, - TestSigningKey, - checkRevocation: false - ); - - Assert.IsType(result); - var ok = (TokenService.TokenValidationOk)result; - Assert.Equal("user-valid", ok.Claims.UserId); - Assert.Equal("Valid User", ok.Claims.DisplayName); - Assert.Equal("valid@example.com", ok.Claims.Email); - Assert.Contains("user", ok.Claims.Roles); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public async Task ValidateTokenAsync_InvalidFormat_ReturnsError() - { - var (conn, dbPath) = CreateTestDb(); - try - { - var result = await TokenService.ValidateTokenAsync( - conn, - "not-a-jwt", - TestSigningKey, - checkRevocation: false - ); - - Assert.IsType(result); - var error = (TokenService.TokenValidationError)result; - Assert.Equal("Invalid token format", error.Reason); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public async Task ValidateTokenAsync_TwoPartToken_ReturnsError() - { - var (conn, dbPath) = CreateTestDb(); - try - { - var result = await TokenService.ValidateTokenAsync( - conn, - "header.payload", - TestSigningKey, - checkRevocation: false - ); - - Assert.IsType(result); - var error = (TokenService.TokenValidationError)result; - Assert.Equal("Invalid token format", error.Reason); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public async Task ValidateTokenAsync_InvalidSignature_ReturnsError() - { - var (conn, dbPath) = CreateTestDb(); - try - { - var token = TokenService.CreateToken( - "user-sig", - "Sig User", - "sig@example.com", - [], - TestSigningKey, - TimeSpan.FromHours(1) - ); - - // Use different key for validation - var differentKey = new byte[32]; - differentKey[0] = 0xFF; - - var result = await TokenService.ValidateTokenAsync( - conn, - token, - differentKey, - checkRevocation: false - ); - - Assert.IsType(result); - var error = (TokenService.TokenValidationError)result; - Assert.Equal("Invalid signature", error.Reason); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public async Task ValidateTokenAsync_ExpiredToken_ReturnsError() - { - var (conn, dbPath) = CreateTestDb(); - try - { - // Create token that expired 1 hour ago - var token = TokenService.CreateToken( - "user-expired", - "Expired User", - "expired@example.com", - [], - TestSigningKey, - TimeSpan.FromHours(-2) // Negative = already expired - ); - - var result = await TokenService.ValidateTokenAsync( - conn, - token, - TestSigningKey, - checkRevocation: false - ); - - Assert.IsType(result); - var error = (TokenService.TokenValidationError)result; - Assert.Equal("Token expired", error.Reason); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public async Task ValidateTokenAsync_RevokedToken_ReturnsError() - { - var (conn, dbPath) = CreateTestDb(); - try - { - var token = TokenService.CreateToken( - "user-revoked", - "Revoked User", - "revoked@example.com", - [], - TestSigningKey, - TimeSpan.FromHours(1) - ); - - // Extract JTI and revoke - var parts = token.Split('.'); - var payloadJson = Base64UrlDecode(parts[1]); - var payload = JsonDocument.Parse(payloadJson); - var jti = payload.RootElement.GetProperty("jti").GetString()!; - - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - var exp = DateTime.UtcNow.AddHours(1).ToString("o", CultureInfo.InvariantCulture); - - // Insert user and revoked session using raw SQL (consistent with other tests) - using var tx = conn.BeginTransaction(); - - using var userCmd = conn.CreateCommand(); - userCmd.Transaction = tx; - userCmd.CommandText = - @"INSERT INTO gk_user (id, display_name, email, created_at, last_login_at, is_active, metadata) - VALUES (@id, @name, @email, @now, NULL, true, NULL)"; - userCmd.Parameters.AddWithValue("@id", "user-revoked"); - userCmd.Parameters.AddWithValue("@name", "Revoked User"); - userCmd.Parameters.AddWithValue("@email", DBNull.Value); - userCmd.Parameters.AddWithValue("@now", now); - await userCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - using var sessionCmd = conn.CreateCommand(); - sessionCmd.Transaction = tx; - sessionCmd.CommandText = - @"INSERT INTO gk_session (id, user_id, credential_id, created_at, expires_at, last_activity_at, ip_address, user_agent, is_revoked) - VALUES (@id, @user_id, NULL, @created, @expires, @activity, NULL, NULL, true)"; - sessionCmd.Parameters.AddWithValue("@id", jti); - sessionCmd.Parameters.AddWithValue("@user_id", "user-revoked"); - sessionCmd.Parameters.AddWithValue("@created", now); - sessionCmd.Parameters.AddWithValue("@expires", exp); - sessionCmd.Parameters.AddWithValue("@activity", now); - await sessionCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - tx.Commit(); - - var result = await TokenService.ValidateTokenAsync( - conn, - token, - TestSigningKey, - checkRevocation: true - ); - - Assert.IsType(result); - var error = (TokenService.TokenValidationError)result; - Assert.Equal("Token revoked", error.Reason); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public async Task ValidateTokenAsync_RevokedToken_IgnoredWhenCheckRevocationFalse() - { - var (conn, dbPath) = CreateTestDb(); - try - { - var token = TokenService.CreateToken( - "user-revoked2", - "Revoked User 2", - "revoked2@example.com", - [], - TestSigningKey, - TimeSpan.FromHours(1) - ); - - // Extract JTI and revoke - var parts = token.Split('.'); - var payloadJson = Base64UrlDecode(parts[1]); - var payload = JsonDocument.Parse(payloadJson); - var jti = payload.RootElement.GetProperty("jti").GetString()!; - - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - var exp = DateTime.UtcNow.AddHours(1).ToString("o", CultureInfo.InvariantCulture); - - // Insert user and revoked session using raw SQL (consistent with other tests) - using var tx = conn.BeginTransaction(); - - using var userCmd = conn.CreateCommand(); - userCmd.Transaction = tx; - userCmd.CommandText = - @"INSERT INTO gk_user (id, display_name, email, created_at, last_login_at, is_active, metadata) - VALUES (@id, @name, @email, @now, NULL, true, NULL)"; - userCmd.Parameters.AddWithValue("@id", "user-revoked2"); - userCmd.Parameters.AddWithValue("@name", "Revoked User 2"); - userCmd.Parameters.AddWithValue("@email", DBNull.Value); - userCmd.Parameters.AddWithValue("@now", now); - await userCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - using var sessionCmd = conn.CreateCommand(); - sessionCmd.Transaction = tx; - sessionCmd.CommandText = - @"INSERT INTO gk_session (id, user_id, credential_id, created_at, expires_at, last_activity_at, ip_address, user_agent, is_revoked) - VALUES (@id, @user_id, NULL, @created, @expires, @activity, NULL, NULL, true)"; - sessionCmd.Parameters.AddWithValue("@id", jti); - sessionCmd.Parameters.AddWithValue("@user_id", "user-revoked2"); - sessionCmd.Parameters.AddWithValue("@created", now); - sessionCmd.Parameters.AddWithValue("@expires", exp); - sessionCmd.Parameters.AddWithValue("@activity", now); - await sessionCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - tx.Commit(); - - // With checkRevocation: false, should still validate - var result = await TokenService.ValidateTokenAsync( - conn, - token, - TestSigningKey, - checkRevocation: false - ); - - Assert.IsType(result); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public async Task RevokeTokenAsync_SetsIsRevokedFlag() - { - var (conn, dbPath) = CreateTestDb(); - try - { - var jti = Guid.NewGuid().ToString(); - var userId = "user-test"; - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - var exp = DateTime.UtcNow.AddHours(1).ToString("o", CultureInfo.InvariantCulture); - - // Insert user and session using raw SQL (TEXT PK doesn't return rowid) - using var tx = conn.BeginTransaction(); - - using var userCmd = conn.CreateCommand(); - userCmd.Transaction = tx; - userCmd.CommandText = - @"INSERT INTO gk_user (id, display_name, email, created_at, last_login_at, is_active, metadata) - VALUES (@id, @name, @email, @now, NULL, true, NULL)"; - userCmd.Parameters.AddWithValue("@id", userId); - userCmd.Parameters.AddWithValue("@name", "Test User"); - userCmd.Parameters.AddWithValue("@email", DBNull.Value); - userCmd.Parameters.AddWithValue("@now", now); - await userCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - using var sessionCmd = conn.CreateCommand(); - sessionCmd.Transaction = tx; - sessionCmd.CommandText = - @"INSERT INTO gk_session (id, user_id, credential_id, created_at, expires_at, last_activity_at, ip_address, user_agent, is_revoked) - VALUES (@id, @user_id, NULL, @created, @expires, @activity, NULL, NULL, false)"; - sessionCmd.Parameters.AddWithValue("@id", jti); - sessionCmd.Parameters.AddWithValue("@user_id", userId); - sessionCmd.Parameters.AddWithValue("@created", now); - sessionCmd.Parameters.AddWithValue("@expires", exp); - sessionCmd.Parameters.AddWithValue("@activity", now); - await sessionCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - tx.Commit(); - - // Revoke - await TokenService.RevokeTokenAsync(conn, jti); - - // Verify using DataProvider generated method - var revokedResult = await conn.GetSessionRevokedAsync(jti); - var isRevoked = revokedResult switch - { - GetSessionRevokedOk ok => ok.Value.FirstOrDefault()?.is_revoked ?? false, - GetSessionRevokedError err => throw new InvalidOperationException( - $"GetSessionRevoked failed: {err.Value.Message}, {err.Value.InnerException?.Message}" - ), - }; - - Assert.True(isRevoked); - } - finally - { - CleanupTestDb(conn, dbPath); - } - } - - [Fact] - public void ExtractBearerToken_ValidHeader_ReturnsToken() - { - var token = TokenService.ExtractBearerToken("Bearer abc123xyz"); - - Assert.Equal("abc123xyz", token); - } - - [Fact] - public void ExtractBearerToken_NullHeader_ReturnsNull() - { - var token = TokenService.ExtractBearerToken(null); - - Assert.Null(token); - } - - [Fact] - public void ExtractBearerToken_EmptyHeader_ReturnsNull() - { - var token = TokenService.ExtractBearerToken(""); - - Assert.Null(token); - } - - [Fact] - public void ExtractBearerToken_NonBearerScheme_ReturnsNull() - { - var token = TokenService.ExtractBearerToken("Basic abc123xyz"); - - Assert.Null(token); - } - - [Fact] - public void ExtractBearerToken_BearerWithoutSpace_ReturnsNull() - { - var token = TokenService.ExtractBearerToken("Bearerabc123xyz"); - - Assert.Null(token); - } - - private static (NpgsqlConnection Connection, string DbName) CreateTestDb() - { - // Connect to PostgreSQL server - use environment variable or default to localhost - var baseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme"; - - var dbName = $"test_tokenservice_{Guid.NewGuid():N}"; - - // Create test database - using (var adminConn = new NpgsqlConnection(baseConnectionString)) - { - adminConn.Open(); - using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {dbName}"; - createCmd.ExecuteNonQuery(); - } - - // Connect to the new test database - var testConnectionString = baseConnectionString.Replace( - "Database=postgres", - $"Database={dbName}" - ); - var conn = new NpgsqlConnection(testConnectionString); - conn.Open(); - - // Use the YAML schema to create only the needed tables - // gk_credential is needed because gk_session has a FK to it - var yamlPath = Path.Combine(AppContext.BaseDirectory, "gatekeeper-schema.yaml"); - var schema = SchemaYamlSerializer.FromYamlFile(yamlPath); - var neededTables = new[] { "gk_user", "gk_credential", "gk_session" }; - - foreach (var table in schema.Tables.Where(t => neededTables.Contains(t.Name))) - { - var ddl = PostgresDdlGenerator.Generate(new CreateTableOperation(table)); - foreach ( - var statement in ddl.Split( - ';', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries - ) - ) - { - if (string.IsNullOrWhiteSpace(statement)) - { - continue; - } - using var cmd = conn.CreateCommand(); - cmd.CommandText = statement; - cmd.ExecuteNonQuery(); - } - } - - return (conn, dbName); - } - - private static void CleanupTestDb(NpgsqlConnection connection, string dbName) - { - var baseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme"; - - connection.Close(); - connection.Dispose(); - - // Drop the test database - using var adminConn = new NpgsqlConnection(baseConnectionString); - adminConn.Open(); - - // Terminate any existing connections to the database - using var terminateCmd = adminConn.CreateCommand(); - terminateCmd.CommandText = - $"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{dbName}'"; - terminateCmd.ExecuteNonQuery(); - - using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {dbName}"; - dropCmd.ExecuteNonQuery(); - } - - private static string Base64UrlDecode(string input) - { - var padded = input.Replace("-", "+").Replace("_", "/"); - var padding = (4 - (padded.Length % 4)) % 4; - padded += new string('=', padding); - return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(padded)); - } -} diff --git a/Gatekeeper/Gatekeeper.Api/AuthorizationService.cs b/Gatekeeper/Gatekeeper.Api/AuthorizationService.cs deleted file mode 100644 index 3b3f4f36..00000000 --- a/Gatekeeper/Gatekeeper.Api/AuthorizationService.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Text; - -namespace Gatekeeper.Api; - -/// -/// Service for evaluating authorization decisions. -/// -public static class AuthorizationService -{ - /// - /// Checks if a user has a specific permission, optionally scoped to a resource. - /// - public static async Task<(bool Allowed, string Reason)> CheckPermissionAsync( - NpgsqlConnection conn, - string userId, - string permissionCode, - string? resourceType, - string? resourceId, - string now - ) - { - // Step 1: Check resource-level grants first (most specific) - if (!string.IsNullOrEmpty(resourceType) && !string.IsNullOrEmpty(resourceId)) - { - var grantResult = await conn.CheckResourceGrantAsync( - now: now, - resource_id: resourceId, - user_id: userId, - resource_type: resourceType, - permission_code: permissionCode - ) - .ConfigureAwait(false); - - if (grantResult is CheckResourceGrantOk grantOk && grantOk.Value.Count > 0) - { - return (true, $"resource-grant:{resourceType}/{resourceId}"); - } - } - - // Step 2: Check user permissions (direct grants and role-based) - var permResult = await conn.GetUserPermissionsAsync(userId, now).ConfigureAwait(false); - var permissions = permResult is GetUserPermissionsOk ok ? ok.Value : []; - - foreach (var perm in permissions) - { - var matches = PermissionMatches(perm.code, permissionCode); - if (!matches) - { - continue; - } - - // Check scope - handle both string and byte[] types from generated code - var scopeType = ToStringValue(perm.scope_type); - var scopeValue = ToStringValue(perm.scope_value); - - var scopeMatches = scopeType switch - { - null or "" or "all" => true, - "record" => scopeValue == resourceId, - _ => false, - }; - - if (scopeMatches) - { - // source_type is role_id for role-based permissions, permission_id for direct grants - // source_name is role name for role-based, permission code for direct - var source = - perm.source_name != perm.code ? $"role:{perm.source_name}" : "direct-grant"; - return (true, $"{source} grants {perm.code}"); - } - } - - return (false, "no matching permission"); - } - - /// - /// Converts a value to string, handling byte[] from SQLite. - /// - private static string? ToStringValue(object? value) => - value switch - { - null => null, - string s => s, - byte[] bytes => Encoding.UTF8.GetString(bytes), - _ => value.ToString(), - }; - - /// - /// Checks if a permission code matches a target, supporting wildcards. - /// - private static bool PermissionMatches(string grantedCode, string targetCode) - { - if (grantedCode == targetCode) - { - return true; - } - - // Handle wildcards like "admin:*" matching "admin:users" - if (grantedCode.EndsWith(":*", StringComparison.Ordinal)) - { - var prefix = grantedCode[..^1]; // Remove "*" - return targetCode.StartsWith(prefix, StringComparison.Ordinal); - } - - // Handle global wildcard - if (grantedCode == "*:*" || grantedCode == "*") - { - return true; - } - - return false; - } -} diff --git a/Gatekeeper/Gatekeeper.Api/DataProvider.json b/Gatekeeper/Gatekeeper.Api/DataProvider.json deleted file mode 100644 index 5aa5ad0f..00000000 --- a/Gatekeeper/Gatekeeper.Api/DataProvider.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "queries": [ - { "name": "GetUserByEmail", "sqlFile": "Sql/GetUserByEmail.sql" }, - { "name": "GetUserById", "sqlFile": "Sql/GetUserById.sql" }, - { "name": "GetUserCredentials", "sqlFile": "Sql/GetUserCredentials.sql" }, - { "name": "GetCredentialById", "sqlFile": "Sql/GetCredentialById.sql" }, - { "name": "GetSessionById", "sqlFile": "Sql/GetSessionById.sql" }, - { "name": "GetChallengeById", "sqlFile": "Sql/GetChallengeById.sql" }, - { "name": "GetUserRoles", "sqlFile": "Sql/GetUserRoles.sql" }, - { "name": "GetUserPermissions", "sqlFile": "Sql/GetUserPermissions.sql" }, - { "name": "CheckResourceGrant", "sqlFile": "Sql/CheckResourceGrant.sql" }, - { "name": "GetActivePolicies", "sqlFile": "Sql/GetActivePolicies.sql" }, - { "name": "GetAllRoles", "sqlFile": "Sql/GetAllRoles.sql" }, - { "name": "GetAllPermissions", "sqlFile": "Sql/GetAllPermissions.sql" }, - { "name": "GetCredentialsByUserId", "sqlFile": "Sql/GetCredentialsByUserId.sql" }, - { "name": "GetRolePermissions", "sqlFile": "Sql/GetRolePermissions.sql" }, - { "name": "GetAllUsers", "sqlFile": "Sql/GetAllUsers.sql" }, - { "name": "CheckPermission", "sqlFile": "Sql/CheckPermission.sql" }, - { "name": "GetSessionRevoked", "sqlFile": "Sql/GetSessionRevoked.sql" }, - { "name": "GetSessionForRevoke", "sqlFile": "Sql/GetSessionForRevoke.sql" } - ], - "tables": [ - { "schema": "main", "name": "gk_user", "generateInsert": true, "excludeColumns": ["id"], "primaryKeyColumns": ["id"] }, - { "schema": "main", "name": "gk_credential", "generateInsert": true, "excludeColumns": ["id"], "primaryKeyColumns": ["id"] }, - { "schema": "main", "name": "gk_session", "generateInsert": true, "generateUpdate": true, "excludeColumns": ["id"], "primaryKeyColumns": ["id"] }, - { "schema": "main", "name": "gk_challenge", "generateInsert": true, "excludeColumns": ["id"], "primaryKeyColumns": ["id"] }, - { "schema": "main", "name": "gk_user_role", "generateInsert": true, "excludeColumns": [], "primaryKeyColumns": ["user_id", "role_id"] }, - { "schema": "main", "name": "gk_permission", "generateInsert": true, "excludeColumns": ["id"], "primaryKeyColumns": ["id"] }, - { "schema": "main", "name": "gk_resource_grant", "generateInsert": true, "excludeColumns": ["id"], "primaryKeyColumns": ["id"] }, - { "schema": "main", "name": "gk_role", "generateInsert": true, "excludeColumns": ["id"], "primaryKeyColumns": ["id"] }, - { "schema": "main", "name": "gk_role_permission", "generateInsert": true, "excludeColumns": [], "primaryKeyColumns": ["role_id", "permission_id"] } - ], - "connectionString": "Data Source=gatekeeper.db" -} diff --git a/Gatekeeper/Gatekeeper.Api/DatabaseSetup.cs b/Gatekeeper/Gatekeeper.Api/DatabaseSetup.cs deleted file mode 100644 index e2f3c18e..00000000 --- a/Gatekeeper/Gatekeeper.Api/DatabaseSetup.cs +++ /dev/null @@ -1,148 +0,0 @@ -using Migration; -using Migration.Postgres; -using InitError = Outcome.Result.Error; -using InitOk = Outcome.Result.Ok; -using InitResult = Outcome.Result; - -namespace Gatekeeper.Api; - -/// -/// Database initialization and seeding using Migration library. -/// -internal static class DatabaseSetup -{ - /// - /// Initializes the database schema and seeds default data. - /// - public static InitResult Initialize(NpgsqlConnection conn, ILogger logger) - { - var schemaResult = CreateSchemaFromMigration(conn, logger); - if (schemaResult is InitError) - return schemaResult; - - return SeedDefaultData(conn, logger); - } - - private static InitResult CreateSchemaFromMigration(NpgsqlConnection conn, ILogger logger) - { - logger.LogInformation("Creating database schema from gatekeeper-schema.yaml"); - - try - { - // Load schema from YAML (source of truth) - var yamlPath = Path.Combine(AppContext.BaseDirectory, "gatekeeper-schema.yaml"); - var schema = SchemaYamlSerializer.FromYamlFile(yamlPath); - - foreach (var table in schema.Tables) - { - var ddl = PostgresDdlGenerator.Generate(new CreateTableOperation(table)); - // DDL may contain multiple statements (CREATE TABLE + CREATE INDEX) - foreach ( - var statement in ddl.Split( - ';', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries - ) - ) - { - if (string.IsNullOrWhiteSpace(statement)) - { - continue; - } - using var cmd = conn.CreateCommand(); - cmd.CommandText = statement; - cmd.ExecuteNonQuery(); - } - logger.LogDebug("Created table {TableName}", table.Name); - } - - logger.LogInformation("Created Gatekeeper database schema from YAML"); - return new InitOk(true); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to create Gatekeeper database schema"); - return new InitError($"Failed to create Gatekeeper database schema: {ex.Message}"); - } - } - - private static InitResult SeedDefaultData(NpgsqlConnection conn, ILogger logger) - { - try - { - var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - - using var checkCmd = conn.CreateCommand(); - checkCmd.CommandText = "SELECT COUNT(*) FROM gk_role WHERE is_system = true"; - var count = Convert.ToInt64(checkCmd.ExecuteScalar(), CultureInfo.InvariantCulture); - - if (count > 0) - { - logger.LogInformation("Database already seeded, skipping"); - return new InitOk(true); - } - - logger.LogInformation("Seeding default roles and permissions"); - - ExecuteNonQuery( - conn, - """ - INSERT INTO gk_role (id, name, description, is_system, created_at) - VALUES ('role-admin', 'admin', 'Full system access', true, @now), - ('role-user', 'user', 'Basic authenticated user', true, @now) - """, - ("@now", now) - ); - - ExecuteNonQuery( - conn, - """ - INSERT INTO gk_permission (id, code, resource_type, action, description, created_at) - VALUES ('perm-admin-all', 'admin:*', 'admin', '*', 'Full admin access', @now), - ('perm-user-profile', 'user:profile', 'user', 'read', 'View own profile', @now), - ('perm-user-credentials', 'user:credentials', 'user', 'manage', 'Manage own passkeys', @now), - ('perm-patient-read', 'patient:read', 'patient', 'read', 'Read patient records', @now), - ('perm-order-read', 'order:read', 'order', 'read', 'Read order records', @now), - ('perm-sync-read', 'sync:read', 'sync', 'read', 'Read sync data', @now), - ('perm-sync-write', 'sync:write', 'sync', 'write', 'Write sync data', @now) - """, - ("@now", now) - ); - - ExecuteNonQuery( - conn, - """ - INSERT INTO gk_role_permission (role_id, permission_id, granted_at) - VALUES ('role-admin', 'perm-admin-all', @now), - ('role-admin', 'perm-sync-read', @now), - ('role-admin', 'perm-sync-write', @now), - ('role-user', 'perm-user-profile', @now), - ('role-user', 'perm-user-credentials', @now) - """, - ("@now", now) - ); - - logger.LogInformation("Default data seeded successfully"); - return new InitOk(true); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to seed Gatekeeper default data"); - return new InitError($"Failed to seed Gatekeeper default data: {ex.Message}"); - } - } - - private static void ExecuteNonQuery( - NpgsqlConnection conn, - string sql, - params (string name, object value)[] parameters - ) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = sql; - foreach (var (name, value) in parameters) - { - cmd.Parameters.AddWithValue(name, value); - } - cmd.ExecuteNonQuery(); - } -} diff --git a/Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs b/Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs deleted file mode 100644 index 8514a991..00000000 --- a/Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs +++ /dev/null @@ -1,109 +0,0 @@ -namespace Gatekeeper.Api; - -/// -/// Extension methods for adding file logging. -/// -public static class FileLoggingExtensions -{ - /// - /// Adds file logging to the logging builder. - /// - public static ILoggingBuilder AddFileLogging(this ILoggingBuilder builder, string path) - { - // CA2000: DI container takes ownership and disposes when application shuts down -#pragma warning disable CA2000 - builder.Services.AddSingleton(new FileLoggerProvider(path)); -#pragma warning restore CA2000 - return builder; - } -} - -/// -/// Simple file logger provider for writing logs to disk. -/// -public sealed class FileLoggerProvider : ILoggerProvider -{ - private readonly string _path; - private readonly object _lock = new(); - - /// - /// Initializes a new instance of FileLoggerProvider. - /// - public FileLoggerProvider(string path) - { - _path = path; - } - - /// - /// Creates a logger for the specified category. - /// - public ILogger CreateLogger(string categoryName) => new FileLogger(_path, categoryName, _lock); - - /// - /// Disposes the provider. - /// - public void Dispose() - { - // Nothing to dispose - singleton managed by DI container - } -} - -/// -/// Simple file logger that appends log entries to a file. -/// -public sealed class FileLogger : ILogger -{ - private readonly string _path; - private readonly string _category; - private readonly object _lock; - - /// - /// Initializes a new instance of FileLogger. - /// - public FileLogger(string path, string category, object lockObj) - { - _path = path; - _category = category; - _lock = lockObj; - } - - /// - /// Begins a logical operation scope. - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - /// Checks if the given log level is enabled. - /// - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - - /// - /// Writes a log entry to the file. - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter - ) - { - if (!IsEnabled(logLevel)) - { - return; - } - - var message = formatter(state, exception); - var line = $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {_category}: {message}"; - if (exception != null) - { - line += Environment.NewLine + exception; - } - - lock (_lock) - { - File.AppendAllText(_path, line + Environment.NewLine); - } - } -} diff --git a/Gatekeeper/Gatekeeper.Api/Gatekeeper.Api.csproj b/Gatekeeper/Gatekeeper.Api/Gatekeeper.Api.csproj deleted file mode 100644 index c0d484e7..00000000 --- a/Gatekeeper/Gatekeeper.Api/Gatekeeper.Api.csproj +++ /dev/null @@ -1,66 +0,0 @@ - - - Exe - CA1515;CA2100;RS1035;CA1508;CA2234;CA1819;CA2007;EPC12 - - - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - - - - - - - - - - - - - - - - diff --git a/Gatekeeper/Gatekeeper.Api/GlobalUsings.cs b/Gatekeeper/Gatekeeper.Api/GlobalUsings.cs deleted file mode 100644 index 60d2c0d4..00000000 --- a/Gatekeeper/Gatekeeper.Api/GlobalUsings.cs +++ /dev/null @@ -1,59 +0,0 @@ -#pragma warning disable IDE0005 // Using directive is unnecessary (some are unused but needed for tests) - -global using System; -global using System.Globalization; -global using System.Text.Json; -global using Fido2NetLib; -global using Fido2NetLib.Objects; -global using Generated; -global using Microsoft.Extensions.Logging; -global using Npgsql; -global using Outcome; -global using Selecta; -global using CheckResourceGrantOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -// Insert result type alias -global using GetChallengeByIdOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -// Additional query result type aliases -global using GetCredentialByIdOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetSessionRevokedError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using GetSessionRevokedOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -// Query result type aliases -global using GetUserByEmailOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetUserByIdOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetUserCredentialsError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -global using GetUserCredentialsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetUserPermissionsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetUserRolesOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; diff --git a/Gatekeeper/Gatekeeper.Api/Program.cs b/Gatekeeper/Gatekeeper.Api/Program.cs deleted file mode 100644 index 27ab8ccc..00000000 --- a/Gatekeeper/Gatekeeper.Api/Program.cs +++ /dev/null @@ -1,716 +0,0 @@ -#pragma warning disable IDE0037 // Use inferred member name - -using System.Text; -using Gatekeeper.Api; -using Microsoft.AspNetCore.Http.Json; -using InitError = Outcome.Result.Error; - -var builder = WebApplication.CreateBuilder(args); - -// File logging - use LOG_PATH env var or default to /tmp in containers -var logPath = - Environment.GetEnvironmentVariable("LOG_PATH") - ?? ( - Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true" - ? "/tmp/gatekeeper.log" - : Path.Combine(AppContext.BaseDirectory, "gatekeeper.log") - ); -builder.Logging.AddFileLogging(logPath); - -builder.Services.Configure(options => - options.SerializerOptions.PropertyNamingPolicy = null -); - -builder.Services.AddCors(options => - options.AddPolicy( - "Dashboard", - policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod() - ) -); - -var serverDomain = builder.Configuration["Fido2:ServerDomain"] ?? "localhost"; -var serverName = builder.Configuration["Fido2:ServerName"] ?? "Gatekeeper"; -var origin = builder.Configuration["Fido2:Origin"] ?? "http://localhost:5173"; - -builder.Services.AddFido2(options => -{ - options.ServerDomain = serverDomain; - options.ServerName = serverName; - options.Origins = new HashSet { origin }; - options.TimestampDriftTolerance = 300000; -}); - -var connectionString = - builder.Configuration.GetConnectionString("Postgres") - ?? throw new InvalidOperationException("PostgreSQL connection string 'Postgres' is required"); - -builder.Services.AddSingleton(new DbConfig(connectionString)); - -var signingKeyBase64 = builder.Configuration["Jwt:SigningKey"]; -var signingKey = string.IsNullOrEmpty(signingKeyBase64) - ? new byte[32] // Default dev key (32 zeros) - MUST match Clinical/Scheduling APIs - : Convert.FromBase64String(signingKeyBase64); -builder.Services.AddSingleton(new JwtConfig(signingKey, TimeSpan.FromHours(24))); - -var app = builder.Build(); - -using (var conn = new NpgsqlConnection(connectionString)) -{ - conn.Open(); - if (DatabaseSetup.Initialize(conn, app.Logger) is InitError initErr) - Environment.FailFast(initErr.Value); -} - -app.UseCors("Dashboard"); - -static string Now() => DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); - -static NpgsqlConnection OpenConnection(DbConfig db) -{ - var conn = new NpgsqlConnection(db.ConnectionString); - conn.Open(); - return conn; -} - -var authGroup = app.MapGroup("/auth").WithTags("Authentication"); - -authGroup.MapPost( - "/register/begin", - async (RegisterBeginRequest request, IFido2 fido2, DbConfig db, ILogger logger) => - { - try - { - using var conn = OpenConnection(db); - var now = Now(); - - var existingUser = await conn.GetUserByEmailAsync(request.Email).ConfigureAwait(false); - var isNewUser = existingUser is not GetUserByEmailOk { Value.Count: > 0 }; - var userId = isNewUser - ? Guid.NewGuid().ToString() - : ((GetUserByEmailOk)existingUser).Value[0].id; - - if (isNewUser) - { - await using var tx = await conn.BeginTransactionAsync().ConfigureAwait(false); - _ = await tx.Insertgk_userAsync( - userId, - request.DisplayName, - request.Email, - now, - null, - true, - null - ) - .ConfigureAwait(false); - await tx.CommitAsync().ConfigureAwait(false); - } - - var existingCredentials = await conn.GetUserCredentialsAsync(userId) - .ConfigureAwait(false); - var excludeCredentials = existingCredentials switch - { - GetUserCredentialsOk ok => ok - .Value.Select(c => new PublicKeyCredentialDescriptor(Base64Url.Decode(c.id))) - .ToList(), - GetUserCredentialsError _ => [], - }; - - var user = new Fido2User - { - Id = Encoding.UTF8.GetBytes(userId), - Name = request.Email, - DisplayName = request.DisplayName, - }; - // Don't restrict to platform authenticators only - allows security keys too - // Chrome on macOS can timeout with Platform-only restriction - var authSelector = new AuthenticatorSelection - { - ResidentKey = ResidentKeyRequirement.Required, - UserVerification = UserVerificationRequirement.Required, - }; - - var options = fido2.RequestNewCredential( - new RequestNewCredentialParams - { - User = user, - ExcludeCredentials = excludeCredentials, - AuthenticatorSelection = authSelector, - AttestationPreference = AttestationConveyancePreference.None, - } - ); - var challengeId = Guid.NewGuid().ToString(); - var challengeExpiry = DateTime - .UtcNow.AddMinutes(5) - .ToString("o", CultureInfo.InvariantCulture); - - await using var tx2 = await conn.BeginTransactionAsync().ConfigureAwait(false); - _ = await tx2.Insertgk_challengeAsync( - challengeId, - userId, - options.Challenge, - "registration", - now, - challengeExpiry - ) - .ConfigureAwait(false); - await tx2.CommitAsync().ConfigureAwait(false); - - return Results.Ok(new { ChallengeId = challengeId, OptionsJson = options.ToJson() }); - } - catch (Exception ex) - { - logger.LogError(ex, "Registration begin failed"); - return Results.Problem("Registration failed"); - } - } -); - -authGroup.MapPost( - "/login/begin", - async (IFido2 fido2, DbConfig db, ILogger logger) => - { - try - { - using var conn = OpenConnection(db); - var now = Now(); - - // Discoverable credentials: empty allowCredentials lets browser show all stored passkeys - // The credential contains userHandle which we use in /login/complete to identify the user - // See: https://webauthn.guide/ and fido2-net-lib docs - var options = fido2.GetAssertionOptions( - new GetAssertionOptionsParams - { - AllowedCredentials = [], // Empty = discoverable credentials - UserVerification = UserVerificationRequirement.Required, - } - ); - var challengeId = Guid.NewGuid().ToString(); - var challengeExpiry = DateTime - .UtcNow.AddMinutes(5) - .ToString("o", CultureInfo.InvariantCulture); - - await using var tx = await conn.BeginTransactionAsync().ConfigureAwait(false); - _ = await tx.Insertgk_challengeAsync( - challengeId, - null, // No user ID - discovered from credential in /login/complete - options.Challenge, - "authentication", - now, - challengeExpiry - ) - .ConfigureAwait(false); - await tx.CommitAsync().ConfigureAwait(false); - - return Results.Ok(new { ChallengeId = challengeId, OptionsJson = options.ToJson() }); - } - catch (Exception ex) - { - logger.LogError(ex, "Login begin failed"); - return Results.Problem("Login failed"); - } - } -); - -authGroup.MapPost( - "/register/complete", - async ( - RegisterCompleteRequest request, - IFido2 fido2, - DbConfig db, - JwtConfig jwtConfig, - ILogger logger - ) => - { - try - { - using var conn = OpenConnection(db); - var now = Now(); - - // Get the stored challenge - var challengeResult = await conn.GetChallengeByIdAsync(request.ChallengeId, now) - .ConfigureAwait(false); - if (challengeResult is not GetChallengeByIdOk { Value.Count: > 0 } challengeOk) - { - return Results.BadRequest(new { Error = "Challenge not found or expired" }); - } - - var storedChallenge = challengeOk.Value[0]; - if (string.IsNullOrEmpty(storedChallenge.user_id)) - { - return Results.BadRequest(new { Error = "Invalid challenge" }); - } - - // Parse the authenticator response - var options = CredentialCreateOptions.FromJson(request.OptionsJson); - - // Verify the attestation - var credentialResult = await fido2 - .MakeNewCredentialAsync( - new MakeNewCredentialParams - { - AttestationResponse = request.AttestationResponse, - OriginalOptions = options, - IsCredentialIdUniqueToUserCallback = async (args, ct) => - { - var existing = await conn.GetCredentialByIdAsync( - Base64Url.Encode(args.CredentialId) - ) - .ConfigureAwait(false); - return existing is not GetCredentialByIdOk { Value.Count: > 0 }; - }, - } - ) - .ConfigureAwait(false); - - var cred = credentialResult; - - // Store the credential - use base64url encoding to match WebAuthn spec - await using var tx = await conn.BeginTransactionAsync().ConfigureAwait(false); - _ = await tx.Insertgk_credentialAsync( - Base64Url.Encode(cred.Id), - storedChallenge.user_id, - cred.PublicKey, - cred.SignCount, - cred.AaGuid.ToString(), - cred.Type.ToString(), - cred.Transports != null ? string.Join(",", cred.Transports) : null, - cred.AttestationFormat, - now, - null, - request.DeviceName, - cred.IsBackupEligible, - cred.IsBackedUp - ) - .ConfigureAwait(false); - - // Assign default user role - _ = await tx.Insertgk_user_roleAsync( - storedChallenge.user_id, - "role-user", - now, - null, - null - ) - .ConfigureAwait(false); - - await tx.CommitAsync().ConfigureAwait(false); - - // Get user info for token - var userResult = await conn.GetUserByIdAsync(storedChallenge.user_id) - .ConfigureAwait(false); - var user = userResult is GetUserByIdOk { Value.Count: > 0 } userOk - ? userOk.Value[0] - : null; - - // Get user roles - var rolesResult = await conn.GetUserRolesAsync(storedChallenge.user_id, now) - .ConfigureAwait(false); - var roles = rolesResult is GetUserRolesOk rolesOk - ? rolesOk.Value.Select(r => r.name).ToList() - : []; - - // Generate JWT - var token = TokenService.CreateToken( - storedChallenge.user_id, - user?.display_name, - user?.email, - roles, - jwtConfig.SigningKey, - jwtConfig.TokenLifetime - ); - - return Results.Ok( - new - { - Token = token, - UserId = storedChallenge.user_id, - DisplayName = user?.display_name, - Email = user?.email, - Roles = roles, - } - ); - } - catch (Exception ex) - { - logger.LogError(ex, "Registration complete failed"); - return Results.Problem("Registration failed"); - } - } -); - -authGroup.MapPost( - "/login/complete", - async ( - LoginCompleteRequest request, - IFido2 fido2, - DbConfig db, - JwtConfig jwtConfig, - ILogger logger - ) => - { - try - { - using var conn = OpenConnection(db); - var now = Now(); - - // Get the stored challenge - var challengeResult = await conn.GetChallengeByIdAsync(request.ChallengeId, now) - .ConfigureAwait(false); - if (challengeResult is not GetChallengeByIdOk { Value.Count: > 0 } challengeOk) - { - return Results.BadRequest(new { Error = "Challenge not found or expired" }); - } - - var storedChallenge = challengeOk.Value[0]; - - var credentialId = request.AssertionResponse.Id; - logger.LogInformation("Login attempt - credential ID: {CredentialId}", credentialId); - var credResult = await conn.GetCredentialByIdAsync(credentialId).ConfigureAwait(false); - if (credResult is not GetCredentialByIdOk { Value.Count: > 0 } credOk) - { - logger.LogWarning("Credential not found for ID: {CredentialId}", credentialId); - return Results.BadRequest(new { Error = "Credential not found" }); - } - - var storedCred = credOk.Value[0]; - - // Parse the assertion options - var options = AssertionOptions.FromJson(request.OptionsJson); - - // Verify the assertion - var assertionResult = await fido2 - .MakeAssertionAsync( - new MakeAssertionParams - { - AssertionResponse = request.AssertionResponse, - OriginalOptions = options, - StoredPublicKey = storedCred.public_key, - StoredSignatureCounter = (uint)storedCred.sign_count, - IsUserHandleOwnerOfCredentialIdCallback = (args, _) => - { - var userIdFromHandle = Encoding.UTF8.GetString(args.UserHandle); - return Task.FromResult(storedCred.user_id == userIdFromHandle); - }, - } - ) - .ConfigureAwait(false); - - // Update sign count and last used - using var updateCmd = conn.CreateCommand(); - updateCmd.CommandText = - @" - UPDATE gk_credential - SET sign_count = @signCount, last_used_at = @now - WHERE id = @id"; - updateCmd.Parameters.AddWithValue("@signCount", (long)assertionResult.SignCount); - updateCmd.Parameters.AddWithValue("@now", now); - updateCmd.Parameters.AddWithValue("@id", credentialId); - await updateCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - // Update user last login - using var userUpdateCmd = conn.CreateCommand(); - userUpdateCmd.CommandText = "UPDATE gk_user SET last_login_at = @now WHERE id = @id"; - userUpdateCmd.Parameters.AddWithValue("@now", now); - userUpdateCmd.Parameters.AddWithValue("@id", storedCred.user_id); - await userUpdateCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - // Get user info for token - var userResult = await conn.GetUserByIdAsync(storedCred.user_id).ConfigureAwait(false); - var user = userResult is GetUserByIdOk { Value.Count: > 0 } userOk - ? userOk.Value[0] - : null; - - // Get user roles - var rolesResult = await conn.GetUserRolesAsync(storedCred.user_id, now) - .ConfigureAwait(false); - var roles = rolesResult is GetUserRolesOk rolesOk - ? rolesOk.Value.Select(r => r.name).ToList() - : []; - - // Generate JWT - var token = TokenService.CreateToken( - storedCred.user_id, - user?.display_name, - user?.email, - roles, - jwtConfig.SigningKey, - jwtConfig.TokenLifetime - ); - - return Results.Ok( - new - { - Token = token, - UserId = storedCred.user_id, - DisplayName = user?.display_name, - Email = user?.email, - Roles = roles, - } - ); - } - catch (Exception ex) - { - logger.LogError(ex, "Login complete failed"); - return Results.Problem("Login failed"); - } - } -); - -authGroup.MapGet( - "/session", - async (HttpContext ctx, DbConfig db, JwtConfig jwtConfig) => - { - var token = TokenService.ExtractBearerToken(ctx.Request.Headers.Authorization); - if (string.IsNullOrEmpty(token)) - { - return Results.Unauthorized(); - } - - using var conn = OpenConnection(db); - - var result = await TokenService - .ValidateTokenAsync(conn, token, jwtConfig.SigningKey, checkRevocation: true) - .ConfigureAwait(false); - if (result is not TokenService.TokenValidationOk ok) - { - return Results.Unauthorized(); - } - - return Results.Ok( - new - { - ok.Claims.UserId, - ok.Claims.DisplayName, - ok.Claims.Email, - ok.Claims.Roles, - ExpiresAt = DateTimeOffset - .FromUnixTimeSeconds(ok.Claims.Exp) - .ToString("o", CultureInfo.InvariantCulture), - } - ); - } -); - -authGroup.MapPost( - "/logout", - async (HttpContext ctx, DbConfig db, JwtConfig jwtConfig) => - { - var token = TokenService.ExtractBearerToken(ctx.Request.Headers.Authorization); - if (string.IsNullOrEmpty(token)) - { - return Results.Unauthorized(); - } - - using var conn = OpenConnection(db); - - var result = await TokenService - .ValidateTokenAsync(conn, token, jwtConfig.SigningKey, checkRevocation: false) - .ConfigureAwait(false); - if (result is TokenService.TokenValidationOk ok) - { - await TokenService.RevokeTokenAsync(conn, ok.Claims.Jti).ConfigureAwait(false); - } - - return Results.NoContent(); - } -); - -var authzGroup = app.MapGroup("/authz").WithTags("Authorization"); - -authzGroup.MapGet( - "/check", - async ( - string permission, - string? resourceType, - string? resourceId, - HttpContext ctx, - DbConfig db, - JwtConfig jwtConfig - ) => - { - var token = TokenService.ExtractBearerToken(ctx.Request.Headers.Authorization); - if (string.IsNullOrEmpty(token)) - { - return Results.Unauthorized(); - } - - using var conn = OpenConnection(db); - - var validateResult = await TokenService - .ValidateTokenAsync(conn, token, jwtConfig.SigningKey, checkRevocation: true) - .ConfigureAwait(false); - if (validateResult is not TokenService.TokenValidationOk ok) - { - return Results.Unauthorized(); - } - - var (allowed, reason) = await AuthorizationService - .CheckPermissionAsync( - conn, - ok.Claims.UserId, - permission, - resourceType, - resourceId, - Now() - ) - .ConfigureAwait(false); - return Results.Ok(new { Allowed = allowed, Reason = reason }); - } -); - -authzGroup.MapGet( - "/permissions", - async (HttpContext ctx, DbConfig db, JwtConfig jwtConfig) => - { - var token = TokenService.ExtractBearerToken(ctx.Request.Headers.Authorization); - if (string.IsNullOrEmpty(token)) - { - return Results.Unauthorized(); - } - - using var conn = OpenConnection(db); - - var validateResult = await TokenService - .ValidateTokenAsync(conn, token, jwtConfig.SigningKey, checkRevocation: true) - .ConfigureAwait(false); - if (validateResult is not TokenService.TokenValidationOk ok) - { - return Results.Unauthorized(); - } - - var permissionsResult = await conn.GetUserPermissionsAsync(ok.Claims.UserId, Now()) - .ConfigureAwait(false); - var permissions = permissionsResult is GetUserPermissionsOk permOk - ? permOk - .Value.Select(p => new - { - p.code, - p.source_name, - p.source_type, - p.scope_type, - p.scope_value, - }) - .ToList() - : []; - - return Results.Ok(new { Permissions = permissions }); - } -); - -authzGroup.MapPost( - "/evaluate", - async (EvaluateRequest request, HttpContext ctx, DbConfig db, JwtConfig jwtConfig) => - { - var token = TokenService.ExtractBearerToken(ctx.Request.Headers.Authorization); - if (string.IsNullOrEmpty(token)) - { - return Results.Unauthorized(); - } - - using var conn = OpenConnection(db); - - var validateResult = await TokenService - .ValidateTokenAsync(conn, token, jwtConfig.SigningKey, checkRevocation: true) - .ConfigureAwait(false); - if (validateResult is not TokenService.TokenValidationOk ok) - { - return Results.Unauthorized(); - } - - var now = Now(); - var results = new List(); - foreach (var check in request.Checks) - { - var (allowed, _) = await AuthorizationService - .CheckPermissionAsync( - conn, - ok.Claims.UserId, - check.Permission, - check.ResourceType, - check.ResourceId, - now - ) - .ConfigureAwait(false); - results.Add( - new - { - check.Permission, - check.ResourceId, - Allowed = allowed, - } - ); - } - - return Results.Ok(new { Results = results }); - } -); - -app.Run(); - -namespace Gatekeeper.Api -{ - /// - /// Program entry point marker for WebApplicationFactory. - /// - public partial class Program { } - - /// Database connection configuration. - public sealed record DbConfig(string ConnectionString); - - /// JWT signing configuration. - public sealed record JwtConfig(byte[] SigningKey, TimeSpan TokenLifetime); - - /// Request to begin passkey registration. - public sealed record RegisterBeginRequest(string Email, string DisplayName); - - /// Request to begin passkey login. - public sealed record LoginBeginRequest(string? Email); - - /// Request to evaluate multiple permissions. - public sealed record EvaluateRequest(List Checks); - - /// Single permission check. - public sealed record PermissionCheck( - string Permission, - string? ResourceType, - string? ResourceId - ); - - /// Request to complete passkey registration. - public sealed record RegisterCompleteRequest( - string ChallengeId, - string OptionsJson, - AuthenticatorAttestationRawResponse AttestationResponse, - string? DeviceName - ); - - /// Request to complete passkey login. - public sealed record LoginCompleteRequest( - string ChallengeId, - string OptionsJson, - AuthenticatorAssertionRawResponse AssertionResponse - ); - - /// Base64URL encoding utilities for WebAuthn credential IDs. - public static class Base64Url - { - /// Encodes bytes to base64url string. - public static string Encode(byte[] input) => - Convert - .ToBase64String(input) - .Replace("+", "-", StringComparison.Ordinal) - .Replace("/", "_", StringComparison.Ordinal) - .TrimEnd('='); - - /// Decodes base64url string to bytes. - public static byte[] Decode(string input) - { - var padded = input - .Replace("-", "+", StringComparison.Ordinal) - .Replace("_", "/", StringComparison.Ordinal); - var padding = (4 - (padded.Length % 4)) % 4; - padded += new string('=', padding); - return Convert.FromBase64String(padded); - } - } -} diff --git a/Gatekeeper/Gatekeeper.Api/Properties/launchSettings.json b/Gatekeeper/Gatekeeper.Api/Properties/launchSettings.json deleted file mode 100644 index 7b7463b0..00000000 --- a/Gatekeeper/Gatekeeper.Api/Properties/launchSettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "profiles": { - "Gatekeeper.Api": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5002", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ConnectionStrings__Postgres": "Host=localhost;Database=gatekeeper;Username=gatekeeper;Password=changeme" - } - } - } -} diff --git a/Gatekeeper/Gatekeeper.Api/Sql/CheckPermission.sql b/Gatekeeper/Gatekeeper.Api/Sql/CheckPermission.sql deleted file mode 100644 index 1af577b1..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/CheckPermission.sql +++ /dev/null @@ -1,24 +0,0 @@ --- name: CheckPermission --- Checks if user has a specific permission code (via roles or direct grant) -SELECT 1 AS has_permission -FROM gk_permission p -WHERE p.code = @permissionCode - AND ( - -- Check role permissions - EXISTS ( - SELECT 1 FROM gk_role_permission rp - JOIN gk_user_role ur ON rp.role_id = ur.role_id - WHERE rp.permission_id = p.id - AND ur.user_id = @userId - AND (ur.expires_at IS NULL OR ur.expires_at > @now) - ) - OR - -- Check direct permissions - EXISTS ( - SELECT 1 FROM gk_user_permission up - WHERE up.permission_id = p.id - AND up.user_id = @userId - AND (up.expires_at IS NULL OR up.expires_at > @now) - ) - ) -LIMIT 1; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/CheckResourceGrant.sql b/Gatekeeper/Gatekeeper.Api/Sql/CheckResourceGrant.sql deleted file mode 100644 index 1d5f24f3..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/CheckResourceGrant.sql +++ /dev/null @@ -1,10 +0,0 @@ --- name: CheckResourceGrant -SELECT rg.id, rg.user_id, rg.resource_type, rg.resource_id, rg.permission_id, - rg.granted_at, rg.granted_by, rg.expires_at, p.code as permission_code -FROM gk_resource_grant rg -JOIN gk_permission p ON rg.permission_id = p.id -WHERE rg.user_id = @user_id - AND rg.resource_type = @resource_type - AND rg.resource_id = @resource_id - AND p.code = @permission_code - AND (rg.expires_at IS NULL OR rg.expires_at > @now); diff --git a/Gatekeeper/Gatekeeper.Api/Sql/CountSystemRoles.sql b/Gatekeeper/Gatekeeper.Api/Sql/CountSystemRoles.sql deleted file mode 100644 index e1e5836a..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/CountSystemRoles.sql +++ /dev/null @@ -1,2 +0,0 @@ --- name: CountSystemRoles -SELECT COUNT(*) as cnt FROM gk_role WHERE is_system = true; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetActivePolicies.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetActivePolicies.sql deleted file mode 100644 index 2e7800b8..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetActivePolicies.sql +++ /dev/null @@ -1,7 +0,0 @@ --- name: GetActivePolicies -SELECT id, name, description, resource_type, action, condition, effect, priority -FROM gk_policy -WHERE is_active = true - AND (resource_type = @resource_type OR resource_type = '*') - AND (action = @action OR action = '*') -ORDER BY priority DESC; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetAllPermissions.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetAllPermissions.sql deleted file mode 100644 index a2753dae..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetAllPermissions.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: GetAllPermissions -SELECT id, code, resource_type, action, description, created_at -FROM gk_permission -ORDER BY resource_type, action; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetAllRoles.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetAllRoles.sql deleted file mode 100644 index 00a8e9b8..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetAllRoles.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: GetAllRoles -SELECT id, name, description, is_system, created_at, parent_role_id -FROM gk_role -ORDER BY name; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetAllUsers.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetAllUsers.sql deleted file mode 100644 index f3120c59..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetAllUsers.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: GetAllUsers -SELECT id, display_name, email, created_at, last_login_at, is_active -FROM gk_user -ORDER BY display_name; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetChallengeById.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetChallengeById.sql deleted file mode 100644 index ebb2cd01..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetChallengeById.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: GetChallengeById -SELECT id, user_id, challenge, type, created_at, expires_at -FROM gk_challenge -WHERE id = @id AND expires_at > @now; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetCredentialById.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetCredentialById.sql deleted file mode 100644 index 07106e6a..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetCredentialById.sql +++ /dev/null @@ -1,7 +0,0 @@ --- name: GetCredentialById -SELECT c.id, c.user_id, c.public_key, c.sign_count, c.aaguid, c.credential_type, c.transports, - c.attestation_format, c.created_at, c.last_used_at, c.device_name, c.is_backup_eligible, c.is_backed_up, - u.display_name, u.email -FROM gk_credential c -JOIN gk_user u ON c.user_id = u.id -WHERE c.id = @id AND u.is_active = true; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetCredentialsByUserId.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetCredentialsByUserId.sql deleted file mode 100644 index 2e4ccf22..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetCredentialsByUserId.sql +++ /dev/null @@ -1,6 +0,0 @@ --- name: GetCredentialsByUserId -SELECT id, user_id, public_key, sign_count, aaguid, credential_type, transports, - attestation_format, created_at, last_used_at, device_name, - is_backup_eligible, is_backed_up -FROM gk_credential -WHERE user_id = @userId; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetPermissionByCode.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetPermissionByCode.sql deleted file mode 100644 index cfd75b93..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetPermissionByCode.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: GetPermissionByCode -SELECT id, code, resource_type, action, description, created_at -FROM gk_permission -WHERE code = @code; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetRolePermissions.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetRolePermissions.sql deleted file mode 100644 index 6b80a6c6..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetRolePermissions.sql +++ /dev/null @@ -1,6 +0,0 @@ --- name: GetRolePermissions -SELECT p.id, p.code, p.resource_type, p.action, p.description, p.created_at, - rp.granted_at -FROM gk_permission p -JOIN gk_role_permission rp ON p.id = rp.permission_id -WHERE rp.role_id = @roleId; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetSessionById.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetSessionById.sql deleted file mode 100644 index 27cf52b5..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetSessionById.sql +++ /dev/null @@ -1,7 +0,0 @@ --- name: GetSessionById -SELECT s.id, s.user_id, s.credential_id, s.created_at, s.expires_at, s.last_activity_at, - s.ip_address, s.user_agent, s.is_revoked, - u.display_name, u.email -FROM gk_session s -JOIN gk_user u ON s.user_id = u.id -WHERE s.id = @id AND s.is_revoked = false AND s.expires_at > @now AND u.is_active = true; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetSessionForRevoke.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetSessionForRevoke.sql deleted file mode 100644 index 19e281e4..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetSessionForRevoke.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Gets a session for revocation (no filters) --- @jti: The session ID (JWT ID) to get -SELECT id, user_id, credential_id, created_at, expires_at, last_activity_at, - ip_address, user_agent, is_revoked -FROM gk_session -WHERE id = @jti; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetSessionRevoked.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetSessionRevoked.sql deleted file mode 100644 index 58f8e00c..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetSessionRevoked.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Gets the revocation status of a session --- @jti: The session ID (JWT ID) to check -SELECT is_revoked FROM gk_session WHERE id = @jti; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetUserByEmail.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetUserByEmail.sql deleted file mode 100644 index 3d2ed92b..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetUserByEmail.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: GetUserByEmail -SELECT id, display_name, email, created_at, last_login_at, is_active, metadata -FROM gk_user -WHERE email = @email AND is_active = true; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetUserById.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetUserById.sql deleted file mode 100644 index c442b586..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetUserById.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: GetUserById -SELECT id, display_name, email, created_at, last_login_at, is_active, metadata -FROM gk_user -WHERE id = @id; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetUserCredentials.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetUserCredentials.sql deleted file mode 100644 index d47001c1..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetUserCredentials.sql +++ /dev/null @@ -1,5 +0,0 @@ --- name: GetUserCredentials -SELECT id, user_id, public_key, sign_count, aaguid, credential_type, transports, - attestation_format, created_at, last_used_at, device_name, is_backup_eligible, is_backed_up -FROM gk_credential -WHERE user_id = @user_id; diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetUserPermissions.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetUserPermissions.sql deleted file mode 100644 index 249de396..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetUserPermissions.sql +++ /dev/null @@ -1,26 +0,0 @@ --- name: GetUserPermissions --- Returns all permissions for a user: from roles + direct grants --- Note: source_type column uses role name prefix to indicate source (role-based vs direct) -SELECT DISTINCT p.id, p.code, p.resource_type, p.action, p.description, - r.name as source_name, - ur.role_id as source_type, - NULL as scope_type, - NULL as scope_value -FROM gk_user_role ur -JOIN gk_role r ON ur.role_id = r.id -JOIN gk_role_permission rp ON r.id = rp.role_id -JOIN gk_permission p ON rp.permission_id = p.id -WHERE ur.user_id = @user_id - AND (ur.expires_at IS NULL OR ur.expires_at > @now) - -UNION ALL - -SELECT p.id, p.code, p.resource_type, p.action, p.description, - p.code as source_name, - up.permission_id as source_type, - COALESCE(up.scope_type, p.resource_type) as scope_type, - COALESCE(up.scope_value, p.action) as scope_value -FROM gk_user_permission up -JOIN gk_permission p ON up.permission_id = p.id -WHERE up.user_id = @user_id - AND (up.expires_at IS NULL OR up.expires_at > @now); diff --git a/Gatekeeper/Gatekeeper.Api/Sql/GetUserRoles.sql b/Gatekeeper/Gatekeeper.Api/Sql/GetUserRoles.sql deleted file mode 100644 index 63b6b881..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/GetUserRoles.sql +++ /dev/null @@ -1,6 +0,0 @@ --- name: GetUserRoles -SELECT r.id, r.name, r.description, r.is_system, ur.granted_at, ur.expires_at -FROM gk_user_role ur -JOIN gk_role r ON ur.role_id = r.id -WHERE ur.user_id = @user_id - AND (ur.expires_at IS NULL OR ur.expires_at > @now); diff --git a/Gatekeeper/Gatekeeper.Api/Sql/RevokeSession.sql b/Gatekeeper/Gatekeeper.Api/Sql/RevokeSession.sql deleted file mode 100644 index 71df552b..00000000 --- a/Gatekeeper/Gatekeeper.Api/Sql/RevokeSession.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Revokes a session by setting is_revoked = true --- @jti: The session ID (JWT ID) to revoke -UPDATE gk_session SET is_revoked = true WHERE id = @jti RETURNING id, is_revoked; diff --git a/Gatekeeper/Gatekeeper.Api/TokenService.cs b/Gatekeeper/Gatekeeper.Api/TokenService.cs deleted file mode 100644 index 29475824..00000000 --- a/Gatekeeper/Gatekeeper.Api/TokenService.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Gatekeeper.Api; - -/// -/// JWT token generation and validation service. -/// -public static class TokenService -{ - /// Token claims data. - public sealed record TokenClaims( - string UserId, - string? DisplayName, - string? Email, - IReadOnlyList Roles, - string Jti, - long Exp - ); - - /// Successful token validation result. - public sealed record TokenValidationOk(TokenClaims Claims); - - /// Failed token validation result. - public sealed record TokenValidationError(string Reason); - - /// - /// Extracts the token from a Bearer authorization header. - /// - public static string? ExtractBearerToken(string? authHeader) => - authHeader?.StartsWith("Bearer ", StringComparison.Ordinal) == true - ? authHeader["Bearer ".Length..] - : null; - - /// - /// Creates a JWT token for the given user. - /// - public static string CreateToken( - string userId, - string? displayName, - string? email, - IReadOnlyList roles, - byte[] signingKey, - TimeSpan lifetime - ) - { - var now = DateTimeOffset.UtcNow; - var exp = now.Add(lifetime); - var jti = Guid.NewGuid().ToString(); - - var header = Base64UrlEncode( - JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }) - ); - - var payload = Base64UrlEncode( - JsonSerializer.SerializeToUtf8Bytes( - new - { - sub = userId, - name = displayName, - email, - roles, - jti, - iat = now.ToUnixTimeSeconds(), - exp = exp.ToUnixTimeSeconds(), - } - ) - ); - - var signature = ComputeSignature(header, payload, signingKey); - return $"{header}.{payload}.{signature}"; - } - - /// - /// Validates a JWT token. - /// - public static async Task ValidateTokenAsync( - NpgsqlConnection conn, - string token, - byte[] signingKey, - bool checkRevocation, - ILogger? logger = null - ) - { - try - { - var parts = token.Split('.'); - if (parts.Length != 3) - { - return new TokenValidationError("Invalid token format"); - } - - var expectedSignature = ComputeSignature(parts[0], parts[1], signingKey); - if ( - !CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(expectedSignature), - Encoding.UTF8.GetBytes(parts[2]) - ) - ) - { - return new TokenValidationError("Invalid signature"); - } - - var payloadBytes = Base64UrlDecode(parts[1]); - using var doc = JsonDocument.Parse(payloadBytes); - var root = doc.RootElement; - - var exp = root.GetProperty("exp").GetInt64(); - if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp) - { - return new TokenValidationError("Token expired"); - } - - var jti = root.GetProperty("jti").GetString() ?? string.Empty; - - if (checkRevocation) - { - var isRevoked = await IsTokenRevokedAsync(conn, jti).ConfigureAwait(false); - if (isRevoked) - { - return new TokenValidationError("Token revoked"); - } - } - - var roles = root.TryGetProperty("roles", out var rolesElement) - ? rolesElement.EnumerateArray().Select(e => e.GetString() ?? string.Empty).ToList() - : []; - - var claims = new TokenClaims( - UserId: root.GetProperty("sub").GetString() ?? string.Empty, - DisplayName: root.TryGetProperty("name", out var nameElem) - ? nameElem.GetString() - : null, - Email: root.TryGetProperty("email", out var emailElem) - ? emailElem.GetString() - : null, - Roles: roles, - Jti: jti, - Exp: exp - ); - - return new TokenValidationOk(claims); - } - catch (Exception ex) - { - logger?.LogError(ex, "Token validation failed"); - return new TokenValidationError("Token validation failed"); - } - } - - /// - /// Revokes a token by JTI using DataProvider generated method. - /// - public static async Task RevokeTokenAsync(NpgsqlConnection conn, string jti) => - _ = await conn.RevokeSessionAsync(jti).ConfigureAwait(false); - - private static async Task IsTokenRevokedAsync(NpgsqlConnection conn, string jti) - { - var result = await conn.GetSessionRevokedAsync(jti).ConfigureAwait(false); - return result switch - { - GetSessionRevokedOk ok => ok.Value.FirstOrDefault()?.is_revoked == true, - GetSessionRevokedError => false, - }; - } - - private static string Base64UrlEncode(byte[] input) => - Convert - .ToBase64String(input) - .Replace("+", "-", StringComparison.Ordinal) - .Replace("/", "_", StringComparison.Ordinal) - .TrimEnd('='); - - private static byte[] Base64UrlDecode(string input) - { - var padded = input - .Replace("-", "+", StringComparison.Ordinal) - .Replace("_", "/", StringComparison.Ordinal); - var padding = (4 - (padded.Length % 4)) % 4; - padded += new string('=', padding); - return Convert.FromBase64String(padded); - } - - private static string ComputeSignature(string header, string payload, byte[] key) - { - var data = Encoding.UTF8.GetBytes($"{header}.{payload}"); - using var hmac = new HMACSHA256(key); - var hash = hmac.ComputeHash(data); - return Base64UrlEncode(hash); - } -} diff --git a/Gatekeeper/Gatekeeper.Api/gatekeeper-schema.yaml b/Gatekeeper/Gatekeeper.Api/gatekeeper-schema.yaml deleted file mode 100644 index 809eb19a..00000000 --- a/Gatekeeper/Gatekeeper.Api/gatekeeper-schema.yaml +++ /dev/null @@ -1,397 +0,0 @@ -name: gatekeeper -tables: -- name: gk_user - columns: - - name: id - type: Text - - name: display_name - type: Text - - name: email - type: Text - - name: created_at - type: Text - - name: last_login_at - type: Text - - name: is_active - type: Boolean - defaultValue: "true" - - name: metadata - type: Json - indexes: - - name: idx_user_email - columns: - - email - isUnique: true - primaryKey: - name: PK_gk_user - columns: - - id -- name: gk_credential - columns: - - name: id - type: Text - - name: user_id - type: Text - - name: public_key - type: Blob - - name: sign_count - type: Int - defaultValue: 0 - - name: aaguid - type: Text - - name: credential_type - type: Text - - name: transports - type: Json - - name: attestation_format - type: Text - - name: created_at - type: Text - - name: last_used_at - type: Text - - name: device_name - type: Text - - name: is_backup_eligible - type: Boolean - - name: is_backed_up - type: Boolean - indexes: - - name: idx_credential_user - columns: - - user_id - foreignKeys: - - name: FK_gk_credential_user_id - columns: - - user_id - referencedTable: gk_user - referencedColumns: - - id - onDelete: Cascade - primaryKey: - name: PK_gk_credential - columns: - - id -- name: gk_session - columns: - - name: id - type: Text - - name: user_id - type: Text - - name: credential_id - type: Text - - name: created_at - type: Text - - name: expires_at - type: Text - - name: last_activity_at - type: Text - - name: ip_address - type: Text - - name: user_agent - type: Text - - name: is_revoked - type: Boolean - defaultValue: "false" - indexes: - - name: idx_session_user - columns: - - user_id - - name: idx_session_expires - columns: - - expires_at - foreignKeys: - - name: FK_gk_session_user_id - columns: - - user_id - referencedTable: gk_user - referencedColumns: - - id - onDelete: Cascade - - name: FK_gk_session_credential_id - columns: - - credential_id - referencedTable: gk_credential - referencedColumns: - - id - primaryKey: - name: PK_gk_session - columns: - - id -- name: gk_challenge - columns: - - name: id - type: Text - - name: user_id - type: Text - - name: challenge - type: Blob - - name: type - type: Text - - name: created_at - type: Text - - name: expires_at - type: Text - primaryKey: - name: PK_gk_challenge - columns: - - id -- name: gk_role - columns: - - name: id - type: Text - - name: name - type: Text - - name: description - type: Text - - name: is_system - type: Boolean - defaultValue: "false" - - name: created_at - type: Text - - name: parent_role_id - type: Text - indexes: - - name: idx_role_name - columns: - - name - isUnique: true - foreignKeys: - - name: FK_gk_role_parent_role_id - columns: - - parent_role_id - referencedTable: gk_role - referencedColumns: - - id - primaryKey: - name: PK_gk_role - columns: - - id -- name: gk_user_role - columns: - - name: user_id - type: Text - - name: role_id - type: Text - - name: granted_at - type: Text - - name: granted_by - type: Text - - name: expires_at - type: Text - foreignKeys: - - name: FK_gk_user_role_user_id - columns: - - user_id - referencedTable: gk_user - referencedColumns: - - id - onDelete: Cascade - - name: FK_gk_user_role_role_id - columns: - - role_id - referencedTable: gk_role - referencedColumns: - - id - onDelete: Cascade - - name: FK_gk_user_role_granted_by - columns: - - granted_by - referencedTable: gk_user - referencedColumns: - - id - primaryKey: - name: PK_gk_user_role - columns: - - user_id - - role_id -- name: gk_permission - columns: - - name: id - type: Text - - name: code - type: Text - - name: resource_type - type: Text - - name: action - type: Text - - name: description - type: Text - - name: created_at - type: Text - indexes: - - name: idx_permission_code - columns: - - code - isUnique: true - - name: idx_permission_resource - columns: - - resource_type - primaryKey: - name: PK_gk_permission - columns: - - id -- name: gk_role_permission - columns: - - name: role_id - type: Text - - name: permission_id - type: Text - - name: granted_at - type: Text - foreignKeys: - - name: FK_gk_role_permission_role_id - columns: - - role_id - referencedTable: gk_role - referencedColumns: - - id - onDelete: Cascade - - name: FK_gk_role_permission_permission_id - columns: - - permission_id - referencedTable: gk_permission - referencedColumns: - - id - onDelete: Cascade - primaryKey: - name: PK_gk_role_permission - columns: - - role_id - - permission_id -- name: gk_user_permission - columns: - - name: user_id - type: Text - - name: permission_id - type: Text - - name: scope_type - type: Text - - name: scope_value - type: Text - - name: granted_at - type: Text - - name: granted_by - type: Text - - name: expires_at - type: Text - - name: reason - type: Text - indexes: - - name: idx_user_permission - columns: - - user_id - - permission_id - - scope_value - isUnique: true - foreignKeys: - - name: FK_gk_user_permission_user_id - columns: - - user_id - referencedTable: gk_user - referencedColumns: - - id - onDelete: Cascade - - name: FK_gk_user_permission_permission_id - columns: - - permission_id - referencedTable: gk_permission - referencedColumns: - - id - onDelete: Cascade - - name: FK_gk_user_permission_granted_by - columns: - - granted_by - referencedTable: gk_user - referencedColumns: - - id -- name: gk_resource_grant - columns: - - name: id - type: Text - - name: user_id - type: Text - - name: resource_type - type: Text - - name: resource_id - type: Text - - name: permission_id - type: Text - - name: granted_at - type: Text - - name: granted_by - type: Text - - name: expires_at - type: Text - indexes: - - name: idx_resource_grant_user - columns: - - user_id - - name: idx_resource_grant_resource - columns: - - resource_type - - resource_id - foreignKeys: - - name: FK_gk_resource_grant_user_id - columns: - - user_id - referencedTable: gk_user - referencedColumns: - - id - onDelete: Cascade - - name: FK_gk_resource_grant_permission_id - columns: - - permission_id - referencedTable: gk_permission - referencedColumns: - - id - - name: FK_gk_resource_grant_granted_by - columns: - - granted_by - referencedTable: gk_user - referencedColumns: - - id - primaryKey: - name: PK_gk_resource_grant - columns: - - id - uniqueConstraints: - - name: uq_resource_grant - columns: - - user_id - - resource_type - - resource_id - - permission_id -- name: gk_policy - columns: - - name: id - type: Text - - name: name - type: Text - - name: description - type: Text - - name: resource_type - type: Text - - name: action - type: Text - - name: condition - type: Json - - name: effect - type: Text - defaultValue: "'allow'" - - name: priority - type: Int - defaultValue: 0 - - name: is_active - type: Boolean - defaultValue: "true" - - name: created_at - type: Text - indexes: - - name: idx_policy_name - columns: - - name - isUnique: true - primaryKey: - name: PK_gk_policy - columns: - - id diff --git a/Gatekeeper/README.md b/Gatekeeper/README.md deleted file mode 100644 index 774bdf73..00000000 --- a/Gatekeeper/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# Gatekeeper - -An independent authentication and authorization microservice implementing passkey-only authentication (WebAuthn/FIDO2) and fine-grained role-based access control with record-level permissions. - -NOTE: There seems to be a mistake here. ABAC was not supposed to be part of this technology but was implemented anyway. We will need to remove this. - -## Overview - -Gatekeeper provides: - -- **Passwordless authentication** - WebAuthn/FIDO2 passkeys only, no passwords -- **Role-based access control (RBAC)** - Hierarchical roles with permission inheritance -- **Record-level permissions** - Fine-grained access to specific resources -- **JWT sessions** - Stateless session management with refresh tokens -- **Framework-agnostic** - REST API for integration with any system - -## Projects - -| Project | Description | -|---------|-------------| -| `Gatekeeper.Api` | REST API with WebAuthn and authorization endpoints | -| `Gatekeeper.Migration` | Database schema using DataProvider migrations | -| `Gatekeeper.Api.Tests` | Integration tests | - -## Getting Started - -### Prerequisites - -- .NET 9.0 SDK -- SQLite (default) or PostgreSQL - -### Run the API - -```bash -cd Gatekeeper/Gatekeeper.Api -dotnet run -``` - -The API starts on `http://localhost:5002`. - -### Database Setup - -The database is automatically created on first run. To reset: - -```bash -rm gatekeeper.db -dotnet run -``` - -## API Endpoints - -### Authentication - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/auth/register/begin` | POST | Start passkey registration | -| `/auth/register/complete` | POST | Complete passkey registration | -| `/auth/login/begin` | POST | Start passkey authentication | -| `/auth/login/complete` | POST | Complete authentication, returns JWT | -| `/auth/logout` | POST | Revoke current session | -| `/auth/session` | GET | Get current session info | - -### Authorization - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/authz/check` | GET | Check if user has permission | -| `/authz/permissions` | GET | List user's effective permissions | -| `/authz/evaluate` | POST | Bulk permission check | - -### Admin (requires admin role) - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/admin/users` | GET/POST | User management | -| `/admin/roles` | GET/POST | Role management | -| `/admin/permissions` | GET/POST | Permission management | - -## Usage Examples - -### Register a Passkey - -```bash -# 1. Begin registration -curl -X POST http://localhost:5002/auth/register/begin \ - -H "Content-Type: application/json" \ - -d '{"email": "user@example.com", "displayName": "John Doe"}' - -# Response contains WebAuthn options for the browser -# 2. Browser calls navigator.credentials.create() with options -# 3. Complete registration with authenticator response -curl -X POST http://localhost:5002/auth/register/complete \ - -H "Content-Type: application/json" \ - -d '{"challengeId": "...", "response": {...}}' -``` - -### Authenticate - -```bash -# 1. Begin login -curl -X POST http://localhost:5002/auth/login/begin \ - -H "Content-Type: application/json" \ - -d '{"email": "user@example.com"}' - -# 2. Browser calls navigator.credentials.get() with options -# 3. Complete login -curl -X POST http://localhost:5002/auth/login/complete \ - -H "Content-Type: application/json" \ - -d '{"challengeId": "...", "response": {...}}' - -# Response: {"token": "eyJ...", "expiresAt": "..."} -``` - -### Check Permission - -```bash -curl "http://localhost:5002/authz/check?resource=patient&action=read&resourceId=123" \ - -H "Authorization: Bearer eyJ..." - -# Response: {"allowed": true, "reason": "Role: physician"} -``` - -## Database Schema - -``` -gk_user ──┬── gk_credential (passkeys) - ├── gk_session (active sessions) - ├── gk_user_role ── gk_role ── gk_role_permission - ├── gk_user_permission (direct grants) - └── gk_resource_grant (record-level access) - │ - ▼ - gk_permission -``` - -### Key Tables - -| Table | Purpose | -|-------|---------| -| `gk_user` | User accounts (id, email, display_name) | -| `gk_credential` | WebAuthn credentials (public_key, sign_count) | -| `gk_session` | Active JWT sessions with revocation | -| `gk_role` | Roles with optional parent hierarchy | -| `gk_permission` | Permissions (resource_type + action) | -| `gk_resource_grant` | Record-level permission grants | - -## Permission Model - -### RBAC - -``` -admin (role) - └── user:manage (permission) - └── role:manage (permission) - -physician (role) - └── patient:read (permission) - └── patient:write (permission) -``` - -### Record-Level - -``` -User "dr.smith" has "patient:read" on Patient "patient-123" -``` - -## Configuration - -Environment variables: - -| Variable | Default | Description | -|----------|---------|-------------| -| `JWT_SECRET` | (generated) | Secret for JWT signing | -| `JWT_ISSUER` | `Gatekeeper` | JWT issuer claim | -| `JWT_AUDIENCE` | `GatekeeperClients` | JWT audience claim | -| `JWT_EXPIRY_MINUTES` | `60` | Token expiration | -| `DATABASE_PATH` | `gatekeeper.db` | SQLite database path | -| `WEBAUTHN_RP_ID` | `localhost` | WebAuthn Relying Party ID | -| `WEBAUTHN_RP_NAME` | `Gatekeeper` | WebAuthn Relying Party name | -| `WEBAUTHN_ORIGIN` | `http://localhost:5002` | Expected origin | - -## Testing - -```bash -# Run all Gatekeeper tests -dotnet test --filter "FullyQualifiedName~Gatekeeper" - -# Specific test class -dotnet test --filter "FullyQualifiedName~AuthorizationTests" -``` - -## Design Principles - -Following the repository's coding rules: - -- **No exceptions** - Returns `Result` types -- **No classes** - Uses records and static methods -- **No interfaces** - Uses `Func` for abstractions -- **Integration tests** - Real database, no mocks -- **DataProvider** - All SQL via generated extension methods - -## References - -### WebAuthn/FIDO2 -- [W3C WebAuthn Specification](https://www.w3.org/TR/webauthn-3/) -- [fido2-net-lib](https://github.com/passwordless-lib/fido2-net-lib) -- [SimpleWebAuthn](https://simplewebauthn.dev/docs/) - -### Access Control -- [NocoBase RBAC Guide](https://www.nocobase.com/en/blog/how-to-design-rbac-role-based-access-control-system) -- [Permify Fine-Grained Access](https://permify.co/post/fine-grained-access-control-where-rbac-falls-short/) - -## License - -See repository root for license information. diff --git a/Lql/Lql.Postgres/Lql.Postgres.csproj b/Lql/Lql.Postgres/Lql.Postgres.csproj deleted file mode 100644 index 91c01ecf..00000000 --- a/Lql/Lql.Postgres/Lql.Postgres.csproj +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/Lql/Lql.SQLite/Lql.SQLite.csproj b/Lql/Lql.SQLite/Lql.SQLite.csproj deleted file mode 100644 index 91c01ecf..00000000 --- a/Lql/Lql.SQLite/Lql.SQLite.csproj +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/Lql/Lql.SqlServer/Lql.SqlServer.csproj b/Lql/Lql.SqlServer/Lql.SqlServer.csproj deleted file mode 100644 index 91c01ecf..00000000 --- a/Lql/Lql.SqlServer/Lql.SqlServer.csproj +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/Lql/LqlExtension/.gitignore b/Lql/LqlExtension/.gitignore index 40b878db..a4b0e4b7 100644 --- a/Lql/LqlExtension/.gitignore +++ b/Lql/LqlExtension/.gitignore @@ -1 +1,3 @@ -node_modules/ \ No newline at end of file +node_modules/ +out-cov/ +.nyc_output/ \ No newline at end of file diff --git a/Lql/LqlExtension/.nycrc.json b/Lql/LqlExtension/.nycrc.json new file mode 100644 index 00000000..ef80406f --- /dev/null +++ b/Lql/LqlExtension/.nycrc.json @@ -0,0 +1,7 @@ +{ + "all": true, + "include": ["out/**/*.js"], + "exclude": ["out/test/**"], + "reporter": ["json-summary", "text"], + "temp-dir": ".nyc_output" +} diff --git a/Lql/LqlExtension/README.md b/Lql/LqlExtension/README.md index 02e8d92c..5824a2f4 100644 --- a/Lql/LqlExtension/README.md +++ b/Lql/LqlExtension/README.md @@ -1,337 +1,38 @@ -# Lambda Query Language (LQL) VS Code Extension +# LQL VS Code Extension -A VS Code extension providing full language support for Lambda Query Language (LQL) — powered by a Rust LSP server with ANTLR-generated parser, real database schema IntelliSense, and optional AI-powered completions. - -## Architecture +A VS Code extension providing full language support for Lambda Query Language (LQL) -- powered by a Rust LSP server with ANTLR-generated parser, real database schema IntelliSense, and optional AI-powered completions. ``` VS Code Extension (TypeScript) - │ - └── stdio JSON-RPC ──▶ lql-lsp (Rust binary) - │ - ├── lql-parser (ANTLR4 grammar → Rust) + | + └── stdio JSON-RPC --> lql-lsp (Rust binary) + | + ├── lql-parser (ANTLR4 grammar -> Rust) ├── lql-analyzer (completions, hover, diagnostics, schema) └── tokio-postgres (live database schema introspection) ``` -The LSP server is a native Rust binary communicating over stdio using the Language Server Protocol. The parser is generated from `Lql.g4` using ANTLR4 targeting Rust via the `antlr-rust` crate. - ## Features -### IntelliSense — Schema-Aware Completions - -Completions are context-aware and sourced from three layers: - -| Layer | Priority | What it provides | -|-------|----------|-----------------| -| **Schema (database)** | Columns: 0, Tables: 4 | Real table and column names from your database | -| **Language** | Pipeline: 1, Functions: 2, Keywords: 3 | `select`, `filter`, `join`, `count`, `sum`, etc. | -| **AI (optional)** | 6 | Custom model-generated suggestions | - -Trigger characters: `.` `|` `>` `(` `space` - -### IntelliPrompt — Dot-Triggered Column Completions - -Type a table name followed by `.` and the LSP returns real columns from the database schema: - -``` -customers. → id (uuid, PK, NOT NULL) - name (text, NOT NULL) - email (text, NOT NULL) - created_at (timestamp, NOT NULL) -``` - -Column completions include SQL type, nullability, and primary key indicators. Prefix filtering works — typing `customers.na` narrows to just `name`. - -### Hover - -- **Pipeline operations**: Rich Markdown with signature, description, and example -- **Aggregate/string/math functions**: Full documentation -- **Table names**: All columns with types displayed -- **Qualified names** (`Table.Column`): Column type, nullability, PK status from live schema -- **Unknown columns**: Shows available columns on the table - -### Diagnostics - -- Real-time syntax error detection from ANTLR parse -- Semantic analysis (unknown functions, pipeline validation) -- Errors include line/column ranges for inline squiggles - -### Document Symbols - -- Extracts `let` bindings as document symbols with correct source locations - -### Formatting - -- Automatic indentation of pipeline operators -- Bracket-aware indent/dedent - -## Database Connection - -The LSP connects to a real database to power schema-aware features. Connection is resolved in order: - -1. `initializationOptions.connectionString` (from VS Code settings) -2. `LQL_CONNECTION_STRING` environment variable -3. `DATABASE_URL` environment variable - -### Supported Connection String Formats - -**libpq** (native): -``` -host=localhost dbname=mydb user=postgres password=secret -``` - -**Npgsql** (.NET style — auto-converted): -``` -Host=localhost;Database=mydb;Username=postgres;Password=secret -``` - -**URI**: -``` -postgres://postgres:secret@localhost/mydb -``` - -### Schema Introspection - -On startup, the LSP queries `information_schema.columns` joined with primary key constraints to discover: -- All tables in the `public` schema -- Column names, SQL types, nullability -- Primary key membership - -Timeouts: 10s connection, 30s query. Schema is cached in memory using `Arc` for lock-free concurrent reads. - -### Graceful Degradation - -When no database is available, the LSP still provides full keyword, function, and pipeline completions. Schema-dependent features (table/column completions, qualified hover) are simply omitted. - -## AI Completion Provider - -The LSP has a pluggable AI completion integration with a **built-in Ollama provider** for local models and a trait for custom providers. - -### Built-in: Ollama Provider - -The LSP ships with a real Ollama-backed AI provider. Set `provider: "ollama"` and it calls your local Ollama instance with full context: - -- **LQL language reference** — compiled into the binary, sent as system context -- **Full database schema** — table names, column names, types, PK/nullability constraints -- **Current file content** — the full document being edited -- **Cursor context** — line, column, prefix being typed - -```json -{ - "initializationOptions": { - "connectionString": "host=localhost dbname=mydb user=postgres password=secret", - "aiProvider": { - "provider": "ollama", - "endpoint": "http://localhost:11434/api/generate", - "model": "qwen2.5-coder:1.5b", - "timeoutMs": 3000, - "enabled": true - } - } -} -``` - -#### Quick Setup - -```bash -cd Lql/lql-lsp-rust -./setup-ai.sh # Default: qwen2.5-coder:1.5b -./setup-ai.sh codellama:7b # Or pick your model -./setup-ai.sh deepseek-coder:1.3b # Lightweight alternative -``` - -The setup script: installs Ollama, pulls the model, smoke-tests it, builds the LSP, prints VS Code config. - -#### Schema Context Sent to Model - -The AI receives the full schema in compact form: - -``` -customers(id uuid PK NOT NULL, name text NOT NULL, email text NOT NULL, created_at timestamp NOT NULL) -orders(id uuid PK NOT NULL, customer_id uuid NOT NULL, total numeric NOT NULL, status text) -order_items(id uuid PK NOT NULL, order_id uuid NOT NULL, product_id uuid NOT NULL, quantity integer NOT NULL) -``` - -This means the model can suggest completions that reference real column names and types. - -#### On/Off Toggle - -Set `"enabled": false` to disable AI completions entirely. The LSP still provides full schema + keyword completions. - -### Custom Provider Trait - -For providers beyond Ollama, implement the trait: - -```rust -#[tower_lsp::async_trait] -pub trait AiCompletionProvider: Send + Sync { - async fn complete(&self, context: &AiCompletionContext) -> Vec; -} -``` - -### How It Works - -1. Configure via `initializationOptions.aiProvider` -2. The LSP calls `provider.complete()` on every completion request -3. AI results are **merged** with schema and keyword completions -4. A timeout (default 2000ms) ensures slow AI never blocks the editor -5. AI completions appear at priority 6 (after all schema/keyword items) - -### Configuration - -```json -{ - "initializationOptions": { - "connectionString": "host=localhost dbname=mydb user=postgres password=secret", - "aiProvider": { - "provider": "openai", - "endpoint": "https://api.openai.com/v1/completions", - "model": "gpt-4", - "apiKey": "sk-...", - "timeoutMs": 2000, - "enabled": true - } - } -} -``` - -| Field | Required | Description | -|-------|----------|-------------| -| `provider` | Yes | Provider identifier (`"openai"`, `"anthropic"`, `"ollama"`, `"custom"`, `"test"`) | -| `endpoint` | Yes | API endpoint URL | -| `model` | No | Model identifier (default: `"default"`) | -| `apiKey` | No | API key for authentication | -| `timeoutMs` | No | Max wait for AI response in ms (default: `2000`) | -| `enabled` | No | Enable/disable AI completions (default: `true`) | - -### Context Passed to AI - -The `AiCompletionContext` includes: -- `document_text` — full file content -- `line`, `column` — cursor position -- `line_prefix` — text before cursor on current line -- `word_prefix` — the word currently being typed -- `file_uri` — URI of the file -- `available_tables` — table names from the database schema (if loaded) -- `schema_description` — full schema with column types, PK, nullability (e.g., `customers(id uuid PK NOT NULL, name text NOT NULL)`) - -### LQL Reference Doc - -The file `crates/lql-reference.md` is compiled into the binary via `include_str!` and sent as system context to the Ollama provider. It contains the complete LQL grammar, all operations, functions, operators, and annotated examples — optimized for tight LLM context windows. - -### Built-in Test Providers - -For E2E testing, the LSP includes two built-in providers: - -- `provider: "test"` — Returns deterministic AI completions (`ai_suggest_filter`, `ai_suggest_join`, `ai_suggest_aggregate`) plus table-specific suggestions based on `available_tables`. Also emits `ai_schema_context` with the full schema description to prove schema flows through -- `provider: "test_slow"` — Sleeps longer than the configured timeout to prove timeout enforcement works - -### Recommended Models - -| Model | Size | Speed | Quality | Use Case | -|-------|------|-------|---------|----------| -| `qwen2.5-coder:1.5b` | 1.5B | Fast | Good | Default — best speed/quality tradeoff | -| `deepseek-coder:1.3b` | 1.3B | Fast | Good | Alternative lightweight model | -| `codellama:7b` | 7B | Medium | Better | When you have GPU and want higher quality | -| `qwen2.5-coder:7b` | 7B | Medium | Better | Larger Qwen for better understanding | +- Schema-aware completions (tables, columns from live database) +- Dot-triggered column completions with type info +- Hover documentation for operations, functions, tables, columns +- Real-time diagnostics from ANTLR parse +- Document symbols for `let` bindings +- Optional AI completions via Ollama ## Building -### Language Server (Rust) - ```bash -cd Lql/lql-lsp-rust -cargo build --release -# Binary: target/release/lql-lsp -``` - -### VS Code Extension (TypeScript) +# Rust LSP +cd Lql/lql-lsp-rust && cargo build --release -```bash -cd Lql/LqlExtension -npm install -npx tsc --project tsconfig.json +# VS Code extension +cd Lql/LqlExtension && npm install && npx tsc --project tsconfig.json ``` -## Testing - -76+ E2E tests verify the LSP via real stdio JSON-RPC protocol — zero mocks. - -```bash -cd Lql/LqlExtension -npx tsc --project tsconfig.json -npx mocha --timeout 30000 out/test/suite/lsp-protocol.test.js -``` - -### Test Breakdown - -| Suite | Count | What it proves | -|-------|-------|---------------| -| Core LSP | 37 | Completions, hover, diagnostics, symbols, formatting, shutdown | -| Schema-Aware | 10 | Real PostgreSQL: table/column completions, qualified hover, graceful degradation | -| AI Config | 4 | Config parsing, enabled/disabled logging, coexistence with keywords | -| AI Pipeline | 13 | Full pipeline: provider activation, AI items in results, snippet kinds, prefix filtering, timeout enforcement, schema+AI merge, schema context proof, consistency | -| **Real AI Model** | **12** | **Real Ollama + real PostgreSQL + real LSP: completions, schema-aware AI, join queries, syntax errors, valid queries, hover, full pipeline merge, on/off toggle, live editing** | - -### Running Schema Tests - -Schema tests require a local PostgreSQL instance with the `lql_test` database: - -```bash -# Start PostgreSQL -pg_ctlcluster 16 main start - -# Tests auto-detect via LQL_CONNECTION_STRING or DATABASE_URL -# The test suite passes connection strings via initializationOptions -``` - -### Running Real AI Model Tests - -These tests require Ollama running with a model pulled: - -```bash -# Set up Ollama (one time) -cd Lql/lql-lsp-rust -./setup-ai.sh - -# Run real AI tests -cd Lql/LqlExtension -LQL_TEST_REAL_AI=1 npx mocha --timeout 60000 out/test/suite/lsp-protocol.test.js - -# Or with a specific model -LQL_TEST_REAL_AI=1 OLLAMA_MODEL=codellama:7b npx mocha --timeout 60000 out/test/suite/lsp-protocol.test.js -``` - -The real AI tests prove: -- Real Ollama model returns LQL-aware completions -- Schema columns + AI suggestions coexist in results -- Multi-line join queries get real completions -- Syntax errors produce REAL error diagnostics with line/column -- Valid LQL produces zero diagnostics -- Hover on tables/columns returns real DB types -- Full pipeline: real DB schema + real AI model + real LSP merged -- `enabled: false` completely disables model calls -- Live editing triggers updated diagnostics in real time - -## Completion Priority Tiers - -| Priority | Kind | Source | -|----------|------|--------| -| 0 | Column | Database schema | -| 1 | Pipeline | `select`, `filter`, `join`, etc. | -| 2 | Function | `count`, `sum`, `avg`, `concat`, etc. | -| 3 | Keyword | `let`, `fn`, `as`, `distinct`, etc. | -| 4 | Table | Database schema | -| 5 | Variable | `let` bindings from scope | -| 6 | AI Snippet | AI provider suggestions | - -Completions are deduplicated by label and sorted by priority, then alphabetically within each tier. - -## File Extensions - -- `.lql` — Lambda Query Language files - -## License +## Documentation -MIT License - see LICENSE file for details. +- Parent README: [Lql/README.md](../README.md) +- LQL spec: [docs/specs/lql-spec.md](../../docs/specs/lql-spec.md) +- LQL reference: [lql-lsp-rust/crates/lql-reference.md](../lql-lsp-rust/crates/lql-reference.md) diff --git a/Lql/LqlExtension/package-lock.json b/Lql/LqlExtension/package-lock.json index ecb8885f..9cb20314 100644 --- a/Lql/LqlExtension/package-lock.json +++ b/Lql/LqlExtension/package-lock.json @@ -20,6 +20,7 @@ "eslint": "^9.0.0", "glob": "^8.0.3", "mocha": "^10.2.0", + "nyc": "^18.0.0", "prettier": "^3.0.0", "typescript": "^4.9.4", "typescript-eslint": "^8.0.0", @@ -29,6 +30,301 @@ "vscode": "^1.74.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -224,6 +520,177 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -593,6 +1060,20 @@ "node": ">= 14" } }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -660,12 +1141,32 @@ "node": ">= 8" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "license": "MIT" - }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -711,6 +1212,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -789,6 +1303,40 @@ "dev": true, "license": "ISC" }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -824,6 +1372,22 @@ "node": "*" } }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -878,6 +1442,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -984,6 +1569,16 @@ "dev": true, "license": "ISC" }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1077,6 +1672,13 @@ "node": ">= 6" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1084,6 +1686,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1206,6 +1815,22 @@ "dev": true, "license": "MIT" }, + "node_modules/default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -1300,6 +1925,13 @@ "node": ">= 0.4" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", @@ -1377,6 +2009,13 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1534,6 +2173,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -1647,6 +2300,24 @@ "node": ">=8" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1695,6 +2366,44 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1734,6 +2443,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1782,6 +2501,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -1886,6 +2615,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1909,6 +2645,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1945,6 +2698,13 @@ "node": ">=10" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", @@ -2084,6 +2844,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2189,6 +2959,26 @@ "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -2202,6 +2992,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2216,43 +3016,194 @@ "dev": true, "license": "ISC" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "argparse": "^2.0.1" + "append-transform": "^2.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=8" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "MIT" + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/istanbul-lib-processinfo": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz", + "integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^6.1.3", + "uuid": "^8.3.2" + }, + "engines": { + "node": "20 || >=22" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "MIT" - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", "dev": true, "license": "(MIT OR GPL-3.0-or-later)", @@ -2345,6 +3296,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2408,6 +3366,32 @@ "node": ">=10" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2520,6 +3504,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -2680,6 +3674,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2703,6 +3717,291 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nyc": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-18.0.0.tgz", + "integrity": "sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^3.3.0", + "get-package-type": "^0.1.0", + "glob": "^13.0.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^6.0.2", + "istanbul-lib-processinfo": "^3.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^6.1.3", + "signal-exit": "^3.0.2", + "spawn-wrap": "^3.0.0", + "test-exclude": "^8.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/nyc/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nyc/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nyc/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/nyc/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2858,6 +4157,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -2978,27 +4323,130 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" + "dependencies": { + "p-limit": "^2.2.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=8" } }, "node_modules/prebuild-install": { @@ -3061,6 +4509,19 @@ "dev": true, "license": "MIT" }, + "node_modules/process-on-spawn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", + "integrity": "sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -3176,6 +4637,19 @@ "node": ">=8.10.0" } }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "license": "ISC", + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3186,6 +4660,13 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3213,6 +4694,83 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -3256,6 +4814,13 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -3422,6 +4987,63 @@ "simple-concat": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-wrap": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-3.0.0.tgz", + "integrity": "sha512-z+s5vv4KzFPJVddGab0xX2n7kQPGMdNUX5l9T8EJqsXdKTWpcxmAqWHpsgHEXoC1taGBCc7b79bi62M5kdbrxQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "cross-spawn": "^7.0.6", + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^6.1.3", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/spawn-wrap/node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/spawn-wrap/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -3505,6 +5127,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3576,6 +5208,78 @@ "node": ">= 6" } }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3748,6 +5452,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -3760,6 +5474,16 @@ "underscore": "^1.12.1" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -4040,6 +5764,37 @@ "node": ">=20.18.1" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4064,6 +5819,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -4315,6 +6080,13 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4379,6 +6151,26 @@ "dev": true, "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", diff --git a/Lql/LqlExtension/package.json b/Lql/LqlExtension/package.json index 51f3d744..3ff5cbfe 100644 --- a/Lql/LqlExtension/package.json +++ b/Lql/LqlExtension/package.json @@ -91,7 +91,11 @@ }, "lql.ai.provider": { "type": "string", - "enum": ["", "ollama", "test"], + "enum": [ + "", + "ollama", + "test" + ], "default": "", "description": "AI completion provider. 'ollama' for local Ollama, 'test' for deterministic test completions." }, @@ -182,6 +186,7 @@ "fmt": "npx prettier --write .", "fmt:check": "npx prettier --check .", "test": "node ./out/test/runTest.js", + "test:coverage": "npm run compile && rm -rf out-cov && nyc instrument out out-cov && rm -rf out && mv out-cov out && node ./out/test/runTest.js; STATUS=$?; nyc report --reporter=json-summary --reporter=text; exit $STATUS", "test:lsp": "npm run compile && mocha ./out/test/suite/lsp-protocol.test.js --timeout 30000", "test:vsix": "npm run compile && npm run package && mocha ./out/test/suite/vsix-packaging.test.js --ui tdd --timeout 30000", "package": "vsce package", @@ -192,13 +197,14 @@ "@types/mocha": "^10.0.0", "@types/node": "16.x", "@types/vscode": "^1.74.0", - "typescript-eslint": "^8.0.0", "@vscode/test-electron": "^2.2.0", "eslint": "^9.0.0", - "prettier": "^3.0.0", "glob": "^8.0.3", "mocha": "^10.2.0", + "nyc": "^18.0.0", + "prettier": "^3.0.0", "typescript": "^4.9.4", + "typescript-eslint": "^8.0.0", "vsce": "^2.15.0" }, "dependencies": { diff --git a/Lql/LqlExtension/src/test/suite/index.ts b/Lql/LqlExtension/src/test/suite/index.ts index 0bd3e423..15fc46d0 100644 --- a/Lql/LqlExtension/src/test/suite/index.ts +++ b/Lql/LqlExtension/src/test/suite/index.ts @@ -1,5 +1,22 @@ import * as path from "path"; +import * as fs from "fs"; import * as glob from "glob"; +import Mocha from "mocha"; + +function writeCoverageData(): void { + const coverageData = ( + global as unknown as Record + ).__coverage__; + if (coverageData === undefined || coverageData === null) { + return; + } + const nycOutputDir = path.resolve(__dirname, "../../../.nyc_output"); + fs.mkdirSync(nycOutputDir, { recursive: true }); + fs.writeFileSync( + path.join(nycOutputDir, "coverage.json"), + JSON.stringify(coverageData), + ); +} export function run(): Promise { const mocha = new Mocha({ @@ -16,6 +33,7 @@ export function run(): Promise { return new Promise((resolve, reject) => { try { mocha.run((failures: number) => { + writeCoverageData(); if (failures > 0) { reject(new Error(`${String(failures)} tests failed.`)); } else { diff --git a/Lql/LqlWebsite-Eleventy/src/_data/navigation.json b/Lql/LqlWebsite-Eleventy/src/_data/navigation.json index a92ee94c..2774fab2 100644 --- a/Lql/LqlWebsite-Eleventy/src/_data/navigation.json +++ b/Lql/LqlWebsite-Eleventy/src/_data/navigation.json @@ -2,7 +2,7 @@ "main": [ { "text": "Docs", "url": "/docs/" }, { "text": "Playground", "url": "/playground/" }, - { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/DataProvider", "external": true } + { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core", "external": true } ], "docs": [ { @@ -38,7 +38,7 @@ "title": "Reference", "items": [ { "text": "SQL Dialects", "url": "/docs/sql-dialects/" }, - { "text": "DataProvider", "url": "https://dataprovider.dev", "external": true } + { "text": "Nimblesite.DataProvider.Core", "url": "https://dataprovider.dev", "external": true } ] } ], @@ -54,9 +54,9 @@ { "title": "Ecosystem", "items": [ - { "text": "DataProvider", "url": "https://dataprovider.dev" }, - { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/DataProvider" }, - { "text": "NuGet", "url": "https://www.nuget.org/packages/Lql" } + { "text": "Nimblesite.DataProvider.Core", "url": "https://dataprovider.dev" }, + { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core" }, + { "text": "NuGet", "url": "https://www.nuget.org/packages/Nimblesite.Lql.Core" } ] }, { diff --git a/Lql/LqlWebsite-Eleventy/src/_data/site.json b/Lql/LqlWebsite-Eleventy/src/_data/site.json index c3361fc7..cc613d1a 100644 --- a/Lql/LqlWebsite-Eleventy/src/_data/site.json +++ b/Lql/LqlWebsite-Eleventy/src/_data/site.json @@ -3,8 +3,8 @@ "title": "Lambda Query Language - Functional Data Querying", "description": "Functional programming meets data querying. Write elegant, composable queries with pipeline operators and lambda expressions.", "url": "https://lql.dev", - "author": "DataProvider team", + "author": "Nimblesite.DataProvider.Core team", "language": "en", "themeColor": "#FF4500", - "github": "https://github.com/MelbourneDeveloper/DataProvider" + "github": "https://github.com/MelbourneDeveloper/Nimblesite.DataProvider.Core" } diff --git a/Lql/Lql.Browser/App.axaml b/Lql/Nimblesite.Lql.Browser/App.axaml similarity index 90% rename from Lql/Lql.Browser/App.axaml rename to Lql/Nimblesite.Lql.Browser/App.axaml index 3567f261..221867ec 100644 --- a/Lql/Lql.Browser/App.axaml +++ b/Lql/Nimblesite.Lql.Browser/App.axaml @@ -1,7 +1,7 @@ diff --git a/Lql/Lql.Browser/App.axaml.cs b/Lql/Nimblesite.Lql.Browser/App.axaml.cs similarity index 92% rename from Lql/Lql.Browser/App.axaml.cs rename to Lql/Nimblesite.Lql.Browser/App.axaml.cs index ccdbd727..dc53609a 100644 --- a/Lql/Lql.Browser/App.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/App.axaml.cs @@ -2,10 +2,10 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; -using Lql.Browser.ViewModels; -using Lql.Browser.Views; +using Nimblesite.Lql.Browser.ViewModels; +using Nimblesite.Lql.Browser.Views; -namespace Lql.Browser; +namespace Nimblesite.Lql.Browser; public partial class App : Application { diff --git a/Lql/Lql.Browser/Assets/avalonia-logo.ico b/Lql/Nimblesite.Lql.Browser/Assets/avalonia-logo.ico similarity index 100% rename from Lql/Lql.Browser/Assets/avalonia-logo.ico rename to Lql/Nimblesite.Lql.Browser/Assets/avalonia-logo.ico diff --git a/Lql/Lql.Browser/Assets/lql-icon.png b/Lql/Nimblesite.Lql.Browser/Assets/lql-icon.png similarity index 100% rename from Lql/Lql.Browser/Assets/lql-icon.png rename to Lql/Nimblesite.Lql.Browser/Assets/lql-icon.png diff --git a/Lql/Lql.Browser/Converters/ConnectionStatusToBrushConverter.cs b/Lql/Nimblesite.Lql.Browser/Converters/ConnectionStatusToBrushConverter.cs similarity index 92% rename from Lql/Lql.Browser/Converters/ConnectionStatusToBrushConverter.cs rename to Lql/Nimblesite.Lql.Browser/Converters/ConnectionStatusToBrushConverter.cs index fce9420b..11ccdb62 100644 --- a/Lql/Lql.Browser/Converters/ConnectionStatusToBrushConverter.cs +++ b/Lql/Nimblesite.Lql.Browser/Converters/ConnectionStatusToBrushConverter.cs @@ -2,9 +2,9 @@ using Avalonia; using Avalonia.Data.Converters; using Avalonia.Media; -using Lql.Browser.ViewModels; +using Nimblesite.Lql.Browser.ViewModels; -namespace Lql.Browser.Converters; +namespace Nimblesite.Lql.Browser.Converters; public class ConnectionStatusToBrushConverter : IValueConverter { diff --git a/Lql/Nimblesite.Lql.Browser/GlobalUsings.cs b/Lql/Nimblesite.Lql.Browser/GlobalUsings.cs new file mode 100644 index 00000000..42b005d0 --- /dev/null +++ b/Lql/Nimblesite.Lql.Browser/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.Lql.Core; diff --git a/Lql/Lql.Browser/Models/DataExport.cs b/Lql/Nimblesite.Lql.Browser/Models/DataExport.cs similarity index 98% rename from Lql/Lql.Browser/Models/DataExport.cs rename to Lql/Nimblesite.Lql.Browser/Models/DataExport.cs index 06c731ff..2ab6bc16 100644 --- a/Lql/Lql.Browser/Models/DataExport.cs +++ b/Lql/Nimblesite.Lql.Browser/Models/DataExport.cs @@ -4,7 +4,7 @@ using Newtonsoft.Json; using Outcome; -namespace Lql.Browser.Models; +namespace Nimblesite.Lql.Browser.Models; /// /// Static methods for data export operations diff --git a/Lql/Lql.Browser/Models/FileOperations.cs b/Lql/Nimblesite.Lql.Browser/Models/FileOperations.cs similarity index 98% rename from Lql/Lql.Browser/Models/FileOperations.cs rename to Lql/Nimblesite.Lql.Browser/Models/FileOperations.cs index f64f0964..d34c79ed 100644 --- a/Lql/Lql.Browser/Models/FileOperations.cs +++ b/Lql/Nimblesite.Lql.Browser/Models/FileOperations.cs @@ -1,7 +1,7 @@ using System.Collections.ObjectModel; using Outcome; -namespace Lql.Browser.Models; +namespace Nimblesite.Lql.Browser.Models; /// /// File operation result types and static methods diff --git a/Lql/Lql.Browser/Models/FileTab.cs b/Lql/Nimblesite.Lql.Browser/Models/FileTab.cs similarity index 98% rename from Lql/Lql.Browser/Models/FileTab.cs rename to Lql/Nimblesite.Lql.Browser/Models/FileTab.cs index 6a6a6d64..0c8ad780 100644 --- a/Lql/Lql.Browser/Models/FileTab.cs +++ b/Lql/Nimblesite.Lql.Browser/Models/FileTab.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; -namespace Lql.Browser.Models; +namespace Nimblesite.Lql.Browser.Models; /// /// Represents a file tab in the editor diff --git a/Lql/Lql.Browser/Models/LqlRegistryOptions.cs b/Lql/Nimblesite.Lql.Browser/Models/LqlRegistryOptions.cs similarity index 93% rename from Lql/Lql.Browser/Models/LqlRegistryOptions.cs rename to Lql/Nimblesite.Lql.Browser/Models/LqlRegistryOptions.cs index 8b5dc4ee..caee0f8f 100644 --- a/Lql/Lql.Browser/Models/LqlRegistryOptions.cs +++ b/Lql/Nimblesite.Lql.Browser/Models/LqlRegistryOptions.cs @@ -1,6 +1,6 @@ using TextMateSharp.Grammars; -namespace Lql.Browser.Models; +namespace Nimblesite.Lql.Browser.Models; /// /// Helper class for creating registry options with LQL support diff --git a/Lql/Lql.Browser/Models/LqlTextMateSetup.cs b/Lql/Nimblesite.Lql.Browser/Models/LqlTextMateSetup.cs similarity index 94% rename from Lql/Lql.Browser/Models/LqlTextMateSetup.cs rename to Lql/Nimblesite.Lql.Browser/Models/LqlTextMateSetup.cs index 76628c12..14f810e0 100644 --- a/Lql/Lql.Browser/Models/LqlTextMateSetup.cs +++ b/Lql/Nimblesite.Lql.Browser/Models/LqlTextMateSetup.cs @@ -2,7 +2,7 @@ using AvaloniaEdit.TextMate; using TextMateSharp.Grammars; -namespace Lql.Browser.Models; +namespace Nimblesite.Lql.Browser.Models; /// /// Static methods for setting up LQL TextMate integration diff --git a/Lql/Lql.Browser/Models/QueryExecutionResult.cs b/Lql/Nimblesite.Lql.Browser/Models/QueryExecutionResult.cs similarity index 95% rename from Lql/Lql.Browser/Models/QueryExecutionResult.cs rename to Lql/Nimblesite.Lql.Browser/Models/QueryExecutionResult.cs index 37dbe828..05cafbc1 100644 --- a/Lql/Lql.Browser/Models/QueryExecutionResult.cs +++ b/Lql/Nimblesite.Lql.Browser/Models/QueryExecutionResult.cs @@ -1,7 +1,7 @@ using System.Collections.ObjectModel; using System.Data; -namespace Lql.Browser.Models; +namespace Nimblesite.Lql.Browser.Models; /// /// Result of query execution containing all necessary data diff --git a/Lql/Lql.Browser/Models/QueryResultRow.cs b/Lql/Nimblesite.Lql.Browser/Models/QueryResultRow.cs similarity index 96% rename from Lql/Lql.Browser/Models/QueryResultRow.cs rename to Lql/Nimblesite.Lql.Browser/Models/QueryResultRow.cs index d459830c..f0216a84 100644 --- a/Lql/Lql.Browser/Models/QueryResultRow.cs +++ b/Lql/Nimblesite.Lql.Browser/Models/QueryResultRow.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; -namespace Lql.Browser.Models; +namespace Nimblesite.Lql.Browser.Models; public class QueryResultRow : INotifyPropertyChanged { diff --git a/Lql/Lql.Browser/Lql.Browser.csproj b/Lql/Nimblesite.Lql.Browser/Nimblesite.Lql.Browser.csproj similarity index 82% rename from Lql/Lql.Browser/Lql.Browser.csproj rename to Lql/Nimblesite.Lql.Browser/Nimblesite.Lql.Browser.csproj index 6767adfb..4f9afdd0 100644 --- a/Lql/Lql.Browser/Lql.Browser.csproj +++ b/Lql/Nimblesite.Lql.Browser/Nimblesite.Lql.Browser.csproj @@ -1,7 +1,7 @@  WinExe - net10.0 + net9.0 enable true app.manifest @@ -29,15 +29,15 @@ - + - - - + + + diff --git a/Lql/Lql.Browser/Program.cs b/Lql/Nimblesite.Lql.Browser/Program.cs similarity index 94% rename from Lql/Lql.Browser/Program.cs rename to Lql/Nimblesite.Lql.Browser/Program.cs index 969ea402..1439a516 100644 --- a/Lql/Lql.Browser/Program.cs +++ b/Lql/Nimblesite.Lql.Browser/Program.cs @@ -1,6 +1,6 @@ using Avalonia; -namespace Lql.Browser; +namespace Nimblesite.Lql.Browser; sealed class Program { diff --git a/Lql/Lql.Browser/Properties/launchSettings.json b/Lql/Nimblesite.Lql.Browser/Properties/launchSettings.json similarity index 83% rename from Lql/Lql.Browser/Properties/launchSettings.json rename to Lql/Nimblesite.Lql.Browser/Properties/launchSettings.json index 84bb5b99..c791fb3e 100644 --- a/Lql/Lql.Browser/Properties/launchSettings.json +++ b/Lql/Nimblesite.Lql.Browser/Properties/launchSettings.json @@ -1,13 +1,13 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "Lql.Browser": { + "Nimblesite.Lql.Browser": { "commandName": "Project", "environmentVariables": { "DOTNET_ENVIRONMENT": "Development" } }, - "Lql.Browser (Debug)": { + "Nimblesite.Lql.Browser (Debug)": { "commandName": "Project", "environmentVariables": { "DOTNET_ENVIRONMENT": "Development", diff --git a/Lql/Lql.Browser/Resources/Colors.axaml b/Lql/Nimblesite.Lql.Browser/Resources/Colors.axaml similarity index 100% rename from Lql/Lql.Browser/Resources/Colors.axaml rename to Lql/Nimblesite.Lql.Browser/Resources/Colors.axaml diff --git a/Lql/Lql.Browser/Resources/Styles.axaml b/Lql/Nimblesite.Lql.Browser/Resources/Styles.axaml similarity index 100% rename from Lql/Lql.Browser/Resources/Styles.axaml rename to Lql/Nimblesite.Lql.Browser/Resources/Styles.axaml diff --git a/Lql/Lql.Browser/Services/DatabaseConnectionManager.cs b/Lql/Nimblesite.Lql.Browser/Services/DatabaseConnectionManager.cs similarity index 97% rename from Lql/Lql.Browser/Services/DatabaseConnectionManager.cs rename to Lql/Nimblesite.Lql.Browser/Services/DatabaseConnectionManager.cs index 55a7fe44..4bb712ec 100644 --- a/Lql/Lql.Browser/Services/DatabaseConnectionManager.cs +++ b/Lql/Nimblesite.Lql.Browser/Services/DatabaseConnectionManager.cs @@ -1,7 +1,7 @@ -using Lql.Browser.ViewModels; using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Browser.ViewModels; -namespace Lql.Browser.Services; +namespace Nimblesite.Lql.Browser.Services; /// /// Handles database connection and schema loading operations diff --git a/Lql/Lql.Browser/Services/DatabaseService.cs b/Lql/Nimblesite.Lql.Browser/Services/DatabaseService.cs similarity index 98% rename from Lql/Lql.Browser/Services/DatabaseService.cs rename to Lql/Nimblesite.Lql.Browser/Services/DatabaseService.cs index 6749879f..51b05dda 100644 --- a/Lql/Lql.Browser/Services/DatabaseService.cs +++ b/Lql/Nimblesite.Lql.Browser/Services/DatabaseService.cs @@ -2,7 +2,7 @@ using Microsoft.Data.Sqlite; using Outcome; -namespace Lql.Browser.Services; +namespace Nimblesite.Lql.Browser.Services; /// /// Service for managing database connections and schema operations diff --git a/Lql/Lql.Browser/Services/FileDialogService.cs b/Lql/Nimblesite.Lql.Browser/Services/FileDialogService.cs similarity index 99% rename from Lql/Lql.Browser/Services/FileDialogService.cs rename to Lql/Nimblesite.Lql.Browser/Services/FileDialogService.cs index 0af13853..68e3772f 100644 --- a/Lql/Lql.Browser/Services/FileDialogService.cs +++ b/Lql/Nimblesite.Lql.Browser/Services/FileDialogService.cs @@ -1,7 +1,7 @@ using Avalonia.Platform.Storage; using Outcome; -namespace Lql.Browser.Services; +namespace Nimblesite.Lql.Browser.Services; /// /// Service for handling file dialogs diff --git a/Lql/Lql.Browser/Services/LqlRegistryOptions.cs b/Lql/Nimblesite.Lql.Browser/Services/LqlRegistryOptions.cs similarity index 95% rename from Lql/Lql.Browser/Services/LqlRegistryOptions.cs rename to Lql/Nimblesite.Lql.Browser/Services/LqlRegistryOptions.cs index 88b0183b..5aef5f15 100644 --- a/Lql/Lql.Browser/Services/LqlRegistryOptions.cs +++ b/Lql/Nimblesite.Lql.Browser/Services/LqlRegistryOptions.cs @@ -5,7 +5,7 @@ using TextMateSharp.Registry; using TextMateSharp.Themes; -namespace Lql.Browser.Services; +namespace Nimblesite.Lql.Browser.Services; /// /// Custom registry options that provides LQL grammar support to TextMateSharp @@ -39,7 +39,7 @@ public IRawGrammar GetGrammar(string scopeName) try { var assembly = Assembly.GetExecutingAssembly(); - var resourceName = "Lql.Browser.TextMate.lql.tmLanguage.json"; + var resourceName = "Nimblesite.Lql.Browser.TextMate.lql.tmLanguage.json"; System.Diagnostics.Debug.WriteLine("=== LOADING LQL GRAMMAR ==="); System.Diagnostics.Debug.WriteLine($"Assembly: {assembly.FullName}"); diff --git a/Lql/Lql.Browser/Services/QueryExecutionService.cs b/Lql/Nimblesite.Lql.Browser/Services/QueryExecutionService.cs similarity index 97% rename from Lql/Lql.Browser/Services/QueryExecutionService.cs rename to Lql/Nimblesite.Lql.Browser/Services/QueryExecutionService.cs index f55814dd..88633dc8 100644 --- a/Lql/Lql.Browser/Services/QueryExecutionService.cs +++ b/Lql/Nimblesite.Lql.Browser/Services/QueryExecutionService.cs @@ -1,13 +1,13 @@ using System.Collections.ObjectModel; using System.Data; using System.Diagnostics; -using Lql.Browser.Models; -using Lql.SQLite; using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Browser.Models; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace Lql.Browser.Services; +namespace Nimblesite.Lql.Browser.Services; /// /// Service for executing queries and processing results diff --git a/Lql/Lql.Browser/Services/QueryExecutor.cs b/Lql/Nimblesite.Lql.Browser/Services/QueryExecutor.cs similarity index 97% rename from Lql/Lql.Browser/Services/QueryExecutor.cs rename to Lql/Nimblesite.Lql.Browser/Services/QueryExecutor.cs index c7bd4533..8106142b 100644 --- a/Lql/Lql.Browser/Services/QueryExecutor.cs +++ b/Lql/Nimblesite.Lql.Browser/Services/QueryExecutor.cs @@ -1,14 +1,14 @@ using System.Collections.ObjectModel; using System.Data; using System.Diagnostics; -using Lql.Browser.Models; -using Lql.Browser.ViewModels; -using Lql.SQLite; using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Browser.Models; +using Nimblesite.Lql.Browser.ViewModels; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace Lql.Browser.Services; +namespace Nimblesite.Lql.Browser.Services; /// /// Handles query execution and result processing diff --git a/Lql/Lql.Browser/TextMate/lql.tmLanguage.json b/Lql/Nimblesite.Lql.Browser/TextMate/lql.tmLanguage.json similarity index 100% rename from Lql/Lql.Browser/TextMate/lql.tmLanguage.json rename to Lql/Nimblesite.Lql.Browser/TextMate/lql.tmLanguage.json diff --git a/Lql/Lql.Browser/ViewLocator.cs b/Lql/Nimblesite.Lql.Browser/ViewLocator.cs similarity index 88% rename from Lql/Lql.Browser/ViewLocator.cs rename to Lql/Nimblesite.Lql.Browser/ViewLocator.cs index 4f9a10ec..a1360e76 100644 --- a/Lql/Lql.Browser/ViewLocator.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewLocator.cs @@ -1,8 +1,8 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; -using Lql.Browser.ViewModels; +using Nimblesite.Lql.Browser.ViewModels; -namespace Lql.Browser; +namespace Nimblesite.Lql.Browser; public class ViewLocator : IDataTemplate { diff --git a/Lql/Lql.Browser/ViewModels/FileTabsViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/FileTabsViewModel.cs similarity index 89% rename from Lql/Lql.Browser/ViewModels/FileTabsViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/FileTabsViewModel.cs index 9c992a2a..93cec574 100644 --- a/Lql/Lql.Browser/ViewModels/FileTabsViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/FileTabsViewModel.cs @@ -1,8 +1,8 @@ using System.Collections.ObjectModel; using System.Windows.Input; -using Lql.Browser.Models; +using Nimblesite.Lql.Browser.Models; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// ViewModel for the file tabs component diff --git a/Lql/Lql.Browser/ViewModels/MainWindowViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/MainWindowViewModel.cs similarity index 99% rename from Lql/Lql.Browser/ViewModels/MainWindowViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/MainWindowViewModel.cs index ab1228c9..2f6da78b 100644 --- a/Lql/Lql.Browser/ViewModels/MainWindowViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/MainWindowViewModel.cs @@ -4,13 +4,13 @@ using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Lql.Browser.Models; -using Lql.SQLite; using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Browser.Models; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; public partial class MainWindowViewModel : ViewModelBase, IDisposable { diff --git a/Lql/Lql.Browser/ViewModels/MessageTypeToColorConverter.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/MessageTypeToColorConverter.cs similarity index 97% rename from Lql/Lql.Browser/ViewModels/MessageTypeToColorConverter.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/MessageTypeToColorConverter.cs index fab979b0..b18f10af 100644 --- a/Lql/Lql.Browser/ViewModels/MessageTypeToColorConverter.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/MessageTypeToColorConverter.cs @@ -2,7 +2,7 @@ using Avalonia.Data.Converters; using Avalonia.Media; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// Converter that maps MessageType to appropriate brush colors diff --git a/Lql/Lql.Browser/ViewModels/MessagesPanelViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/MessagesPanelViewModel.cs similarity index 98% rename from Lql/Lql.Browser/ViewModels/MessagesPanelViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/MessagesPanelViewModel.cs index 7f517cf8..9da339f3 100644 --- a/Lql/Lql.Browser/ViewModels/MessagesPanelViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/MessagesPanelViewModel.cs @@ -3,7 +3,7 @@ using AvaloniaEdit.Document; using CommunityToolkit.Mvvm.ComponentModel; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// ViewModel for the messages panel that displays SQL output, errors, and execution information diff --git a/Lql/Lql.Browser/ViewModels/QueryEditorViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/QueryEditorViewModel.cs similarity index 84% rename from Lql/Lql.Browser/ViewModels/QueryEditorViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/QueryEditorViewModel.cs index 4bb9f72c..20a13669 100644 --- a/Lql/Lql.Browser/ViewModels/QueryEditorViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/QueryEditorViewModel.cs @@ -1,6 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// ViewModel for the query editor component diff --git a/Lql/Lql.Browser/ViewModels/ResultsGridViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/ResultsGridViewModel.cs similarity index 86% rename from Lql/Lql.Browser/ViewModels/ResultsGridViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/ResultsGridViewModel.cs index f0b54a8d..7132ae69 100644 --- a/Lql/Lql.Browser/ViewModels/ResultsGridViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/ResultsGridViewModel.cs @@ -1,8 +1,8 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; -using Lql.Browser.Models; +using Nimblesite.Lql.Browser.Models; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// ViewModel for the results grid component diff --git a/Lql/Lql.Browser/ViewModels/SchemaPanelViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/SchemaPanelViewModel.cs similarity index 96% rename from Lql/Lql.Browser/ViewModels/SchemaPanelViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/SchemaPanelViewModel.cs index 100e0efd..2560cc6e 100644 --- a/Lql/Lql.Browser/ViewModels/SchemaPanelViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/SchemaPanelViewModel.cs @@ -2,7 +2,7 @@ using System.Windows.Input; using CommunityToolkit.Mvvm.Input; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// ViewModel for the schema panel displaying database tables, views, and other objects diff --git a/Lql/Lql.Browser/ViewModels/StatusBarViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/StatusBarViewModel.cs similarity index 93% rename from Lql/Lql.Browser/ViewModels/StatusBarViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/StatusBarViewModel.cs index 052741b3..27189c15 100644 --- a/Lql/Lql.Browser/ViewModels/StatusBarViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/StatusBarViewModel.cs @@ -1,6 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// Connection status enumeration diff --git a/Lql/Lql.Browser/ViewModels/ToolbarViewModel.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/ToolbarViewModel.cs similarity index 95% rename from Lql/Lql.Browser/ViewModels/ToolbarViewModel.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/ToolbarViewModel.cs index ed7bba9f..3758ffc2 100644 --- a/Lql/Lql.Browser/ViewModels/ToolbarViewModel.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/ToolbarViewModel.cs @@ -1,7 +1,7 @@ using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; /// /// ViewModel for the toolbar component diff --git a/Lql/Lql.Browser/ViewModels/ViewModelBase.cs b/Lql/Nimblesite.Lql.Browser/ViewModels/ViewModelBase.cs similarity index 68% rename from Lql/Lql.Browser/ViewModels/ViewModelBase.cs rename to Lql/Nimblesite.Lql.Browser/ViewModels/ViewModelBase.cs index 2ee3908d..93d055fb 100644 --- a/Lql/Lql.Browser/ViewModels/ViewModelBase.cs +++ b/Lql/Nimblesite.Lql.Browser/ViewModels/ViewModelBase.cs @@ -1,5 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; -namespace Lql.Browser.ViewModels; +namespace Nimblesite.Lql.Browser.ViewModels; public class ViewModelBase : ObservableObject { } diff --git a/Lql/Lql.Browser/Views/FileTabs.axaml b/Lql/Nimblesite.Lql.Browser/Views/FileTabs.axaml similarity index 94% rename from Lql/Lql.Browser/Views/FileTabs.axaml rename to Lql/Nimblesite.Lql.Browser/Views/FileTabs.axaml index 5aec455b..b1d147f3 100644 --- a/Lql/Lql.Browser/Views/FileTabs.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/FileTabs.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:Lql.Browser.ViewModels" + xmlns:vm="using:Nimblesite.Lql.Browser.ViewModels" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="50" - x:Class="Lql.Browser.Views.FileTabs" + x:Class="Nimblesite.Lql.Browser.Views.FileTabs" x:DataType="vm:MainWindowViewModel"> diff --git a/Lql/Lql.Browser/Views/FileTabs.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/FileTabs.axaml.cs similarity index 77% rename from Lql/Lql.Browser/Views/FileTabs.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/FileTabs.axaml.cs index 383adcec..db5412e1 100644 --- a/Lql/Lql.Browser/Views/FileTabs.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/FileTabs.axaml.cs @@ -1,6 +1,6 @@ using Avalonia.Controls; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; public partial class FileTabs : UserControl { diff --git a/Lql/Lql.Browser/Views/MainWindow.axaml b/Lql/Nimblesite.Lql.Browser/Views/MainWindow.axaml similarity index 96% rename from Lql/Lql.Browser/Views/MainWindow.axaml rename to Lql/Nimblesite.Lql.Browser/Views/MainWindow.axaml index 5be4640b..ff56b71d 100644 --- a/Lql/Lql.Browser/Views/MainWindow.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/MainWindow.axaml @@ -1,12 +1,12 @@ diff --git a/Lql/Lql.Browser/Views/MessagesPanel.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/MessagesPanel.axaml.cs similarity index 86% rename from Lql/Lql.Browser/Views/MessagesPanel.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/MessagesPanel.axaml.cs index adf5d84d..16f101a1 100644 --- a/Lql/Lql.Browser/Views/MessagesPanel.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/MessagesPanel.axaml.cs @@ -1,6 +1,6 @@ using Avalonia.Controls; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; /// /// Messages panel view for displaying execution messages and transpiled SQL diff --git a/Lql/Lql.Browser/Views/QueryEditor.axaml b/Lql/Nimblesite.Lql.Browser/Views/QueryEditor.axaml similarity index 92% rename from Lql/Lql.Browser/Views/QueryEditor.axaml rename to Lql/Nimblesite.Lql.Browser/Views/QueryEditor.axaml index 62ab3abb..19d3409f 100644 --- a/Lql/Lql.Browser/Views/QueryEditor.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/QueryEditor.axaml @@ -2,10 +2,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:Lql.Browser.ViewModels" + xmlns:vm="using:Nimblesite.Lql.Browser.ViewModels" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="300" - x:Class="Lql.Browser.Views.QueryEditor" + x:Class="Nimblesite.Lql.Browser.Views.QueryEditor" x:DataType="vm:MainWindowViewModel"> diff --git a/Lql/Lql.Browser/Views/QueryEditor.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/QueryEditor.axaml.cs similarity index 97% rename from Lql/Lql.Browser/Views/QueryEditor.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/QueryEditor.axaml.cs index 7c008b84..d2311ff4 100644 --- a/Lql/Lql.Browser/Views/QueryEditor.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/QueryEditor.axaml.cs @@ -2,11 +2,11 @@ using Avalonia.Controls; using AvaloniaEdit; using AvaloniaEdit.TextMate; -using Lql.Browser.Services; -using Lql.Browser.ViewModels; +using Nimblesite.Lql.Browser.Services; +using Nimblesite.Lql.Browser.ViewModels; using TextMateSharp.Registry; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; public partial class QueryEditor : UserControl { diff --git a/Lql/Lql.Browser/Views/ResultsGrid.axaml b/Lql/Nimblesite.Lql.Browser/Views/ResultsGrid.axaml similarity index 95% rename from Lql/Lql.Browser/Views/ResultsGrid.axaml rename to Lql/Nimblesite.Lql.Browser/Views/ResultsGrid.axaml index 051d8f80..1897a552 100644 --- a/Lql/Lql.Browser/Views/ResultsGrid.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/ResultsGrid.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:Lql.Browser.ViewModels" + xmlns:vm="using:Nimblesite.Lql.Browser.ViewModels" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="300" - x:Class="Lql.Browser.Views.ResultsGrid" + x:Class="Nimblesite.Lql.Browser.Views.ResultsGrid" x:DataType="vm:ResultsGridViewModel"> diff --git a/Lql/Lql.Browser/Views/ResultsGrid.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/ResultsGrid.axaml.cs similarity index 95% rename from Lql/Lql.Browser/Views/ResultsGrid.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/ResultsGrid.axaml.cs index 52a92767..d1aa2681 100644 --- a/Lql/Lql.Browser/Views/ResultsGrid.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/ResultsGrid.axaml.cs @@ -1,9 +1,9 @@ using System.ComponentModel; using Avalonia.Controls; using Avalonia.Data; -using Lql.Browser.ViewModels; +using Nimblesite.Lql.Browser.ViewModels; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; public partial class ResultsGrid : UserControl { diff --git a/Lql/Lql.Browser/Views/SchemaPanel.axaml b/Lql/Nimblesite.Lql.Browser/Views/SchemaPanel.axaml similarity index 96% rename from Lql/Lql.Browser/Views/SchemaPanel.axaml rename to Lql/Nimblesite.Lql.Browser/Views/SchemaPanel.axaml index 0045b348..58bba5cc 100644 --- a/Lql/Lql.Browser/Views/SchemaPanel.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/SchemaPanel.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:Lql.Browser.ViewModels" + xmlns:vm="using:Nimblesite.Lql.Browser.ViewModels" mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="600" - x:Class="Lql.Browser.Views.SchemaPanel" + x:Class="Nimblesite.Lql.Browser.Views.SchemaPanel" x:DataType="vm:SchemaPanelViewModel"> diff --git a/Lql/Lql.Browser/Views/SchemaPanel.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/SchemaPanel.axaml.cs similarity index 78% rename from Lql/Lql.Browser/Views/SchemaPanel.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/SchemaPanel.axaml.cs index a1098b81..d856334e 100644 --- a/Lql/Lql.Browser/Views/SchemaPanel.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/SchemaPanel.axaml.cs @@ -1,6 +1,6 @@ using Avalonia.Controls; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; public partial class SchemaPanel : UserControl { diff --git a/Lql/Lql.Browser/Views/StatusBar.axaml b/Lql/Nimblesite.Lql.Browser/Views/StatusBar.axaml similarity index 88% rename from Lql/Lql.Browser/Views/StatusBar.axaml rename to Lql/Nimblesite.Lql.Browser/Views/StatusBar.axaml index 34589036..92e90a32 100644 --- a/Lql/Lql.Browser/Views/StatusBar.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/StatusBar.axaml @@ -2,10 +2,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:Lql.Browser.ViewModels" - xmlns:converters="using:Lql.Browser.Converters" + xmlns:vm="using:Nimblesite.Lql.Browser.ViewModels" + xmlns:converters="using:Nimblesite.Lql.Browser.Converters" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="40" - x:Class="Lql.Browser.Views.StatusBar" + x:Class="Nimblesite.Lql.Browser.Views.StatusBar" x:DataType="vm:StatusBarViewModel"> diff --git a/Lql/Lql.Browser/Views/StatusBar.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/StatusBar.axaml.cs similarity index 78% rename from Lql/Lql.Browser/Views/StatusBar.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/StatusBar.axaml.cs index 146429e5..b6f6f322 100644 --- a/Lql/Lql.Browser/Views/StatusBar.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/StatusBar.axaml.cs @@ -1,6 +1,6 @@ using Avalonia.Controls; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; public partial class StatusBar : UserControl { diff --git a/Lql/Lql.Browser/Views/Toolbar.axaml b/Lql/Nimblesite.Lql.Browser/Views/Toolbar.axaml similarity index 95% rename from Lql/Lql.Browser/Views/Toolbar.axaml rename to Lql/Nimblesite.Lql.Browser/Views/Toolbar.axaml index 83bb98be..951fb3ac 100644 --- a/Lql/Lql.Browser/Views/Toolbar.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/Toolbar.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:Lql.Browser.ViewModels" + xmlns:vm="using:Nimblesite.Lql.Browser.ViewModels" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="70" - x:Class="Lql.Browser.Views.Toolbar" + x:Class="Nimblesite.Lql.Browser.Views.Toolbar" x:DataType="vm:ToolbarViewModel"> diff --git a/Lql/Lql.Browser/Views/Toolbar.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/Toolbar.axaml.cs similarity index 77% rename from Lql/Lql.Browser/Views/Toolbar.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/Toolbar.axaml.cs index 7de98e44..0492b757 100644 --- a/Lql/Lql.Browser/Views/Toolbar.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/Toolbar.axaml.cs @@ -1,6 +1,6 @@ using Avalonia.Controls; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; public partial class Toolbar : UserControl { diff --git a/Lql/Lql.Browser/Views/TranspiledSqlPanel.axaml b/Lql/Nimblesite.Lql.Browser/Views/TranspiledSqlPanel.axaml similarity index 94% rename from Lql/Lql.Browser/Views/TranspiledSqlPanel.axaml rename to Lql/Nimblesite.Lql.Browser/Views/TranspiledSqlPanel.axaml index 9db82716..beb93ba4 100644 --- a/Lql/Lql.Browser/Views/TranspiledSqlPanel.axaml +++ b/Lql/Nimblesite.Lql.Browser/Views/TranspiledSqlPanel.axaml @@ -2,10 +2,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:Lql.Browser.ViewModels" + xmlns:vm="using:Nimblesite.Lql.Browser.ViewModels" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="300" - x:Class="Lql.Browser.Views.TranspiledSqlPanel" + x:Class="Nimblesite.Lql.Browser.Views.TranspiledSqlPanel" x:DataType="vm:MessagesPanelViewModel"> diff --git a/Lql/Lql.Browser/Views/TranspiledSqlPanel.axaml.cs b/Lql/Nimblesite.Lql.Browser/Views/TranspiledSqlPanel.axaml.cs similarity index 97% rename from Lql/Lql.Browser/Views/TranspiledSqlPanel.axaml.cs rename to Lql/Nimblesite.Lql.Browser/Views/TranspiledSqlPanel.axaml.cs index fdb13214..c966b09e 100644 --- a/Lql/Lql.Browser/Views/TranspiledSqlPanel.axaml.cs +++ b/Lql/Nimblesite.Lql.Browser/Views/TranspiledSqlPanel.axaml.cs @@ -3,7 +3,7 @@ using AvaloniaEdit.TextMate; using TextMateSharp.Grammars; -namespace Lql.Browser.Views; +namespace Nimblesite.Lql.Browser.Views; /// /// Transpiled SQL panel view for displaying the generated SQL from LQL queries diff --git a/Lql/Lql.Browser/app.manifest b/Lql/Nimblesite.Lql.Browser/app.manifest similarity index 100% rename from Lql/Lql.Browser/app.manifest rename to Lql/Nimblesite.Lql.Browser/app.manifest diff --git a/Lql/Lql.Browser/design.png b/Lql/Nimblesite.Lql.Browser/design.png similarity index 100% rename from Lql/Lql.Browser/design.png rename to Lql/Nimblesite.Lql.Browser/design.png diff --git a/Lql/LqlCli.SQLite.Tests/CliEndToEndTests.cs b/Lql/Nimblesite.Lql.Cli.SQLite.Tests/CliEndToEndTests.cs similarity index 99% rename from Lql/LqlCli.SQLite.Tests/CliEndToEndTests.cs rename to Lql/Nimblesite.Lql.Cli.SQLite.Tests/CliEndToEndTests.cs index 3931af50..db2fcc06 100644 --- a/Lql/LqlCli.SQLite.Tests/CliEndToEndTests.cs +++ b/Lql/Nimblesite.Lql.Cli.SQLite.Tests/CliEndToEndTests.cs @@ -5,7 +5,7 @@ #pragma warning disable CA1307 #pragma warning disable CA1707 -namespace LqlCli.SQLite.Tests; +namespace Nimblesite.Lql.Cli.SQLite.Tests; /// /// End-to-end tests for the LQL CLI tool diff --git a/Lql/LqlCli.SQLite.Tests/LqlCli.SQLite.Tests.csproj b/Lql/Nimblesite.Lql.Cli.SQLite.Tests/Nimblesite.Lql.Cli.SQLite.Tests.csproj similarity index 67% rename from Lql/LqlCli.SQLite.Tests/LqlCli.SQLite.Tests.csproj rename to Lql/Nimblesite.Lql.Cli.SQLite.Tests/Nimblesite.Lql.Cli.SQLite.Tests.csproj index de1d0937..012a27bb 100644 --- a/Lql/LqlCli.SQLite.Tests/LqlCli.SQLite.Tests.csproj +++ b/Lql/Nimblesite.Lql.Cli.SQLite.Tests/Nimblesite.Lql.Cli.SQLite.Tests.csproj @@ -12,9 +12,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - + diff --git a/Lql/LqlCli.SQLite.Tests/TestingRuleset.ruleset b/Lql/Nimblesite.Lql.Cli.SQLite.Tests/TestingRuleset.ruleset similarity index 100% rename from Lql/LqlCli.SQLite.Tests/TestingRuleset.ruleset rename to Lql/Nimblesite.Lql.Cli.SQLite.Tests/TestingRuleset.ruleset diff --git a/Lql/LqlCli.SQLite/LqlCli.SQLite.csproj b/Lql/Nimblesite.Lql.Cli.SQLite/Nimblesite.Lql.Cli.SQLite.csproj similarity index 80% rename from Lql/LqlCli.SQLite/LqlCli.SQLite.csproj rename to Lql/Nimblesite.Lql.Cli.SQLite/Nimblesite.Lql.Cli.SQLite.csproj index f3ce6ee4..e571519a 100644 --- a/Lql/LqlCli.SQLite/LqlCli.SQLite.csproj +++ b/Lql/Nimblesite.Lql.Cli.SQLite/Nimblesite.Lql.Cli.SQLite.csproj @@ -4,6 +4,7 @@ false true lql-sqlite + false $(NoWarn);RS1035 @@ -13,6 +14,6 @@ - + diff --git a/Lql/LqlCli.SQLite/Program.cs b/Lql/Nimblesite.Lql.Cli.SQLite/Program.cs similarity index 89% rename from Lql/LqlCli.SQLite/Program.cs rename to Lql/Nimblesite.Lql.Cli.SQLite/Program.cs index 3bbb621b..d97ee9a8 100644 --- a/Lql/LqlCli.SQLite/Program.cs +++ b/Lql/Nimblesite.Lql.Cli.SQLite/Program.cs @@ -1,19 +1,25 @@ using System.CommandLine; -using Lql; -using Lql.SQLite; -using Selecta; -using LqlStatementError = Outcome.Result.Error< - Lql.LqlStatement, - Selecta.SqlError +using Nimblesite.Lql.Core; +using Nimblesite.Lql.SQLite; +using Nimblesite.Sql.Model; +using LqlStatementError = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Error; +using LqlStatementOk = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Ok; +using StringSqlError = Outcome.Result.Error< + string, + Nimblesite.Sql.Model.SqlError >; -using LqlStatementOk = Outcome.Result.Ok< - Lql.LqlStatement, - Selecta.SqlError +using StringSqlOk = Outcome.Result.Ok< + string, + Nimblesite.Sql.Model.SqlError >; -using StringSqlError = Outcome.Result.Error; -using StringSqlOk = Outcome.Result.Ok; -namespace LqlCli.SQLite; +namespace Nimblesite.Lql.Cli.SQLite; /// /// LQL to SQLite CLI transpiler @@ -105,7 +111,7 @@ bool validate Console.WriteLine($"📖 Reading LQL from: {inputFile.FullName}"); - // Parse the LQL using Lql + // Parse the LQL using Nimblesite.Lql.Core var parseResult = LqlStatementConverter.ToStatement(lqlContent); return parseResult switch diff --git a/Lql/LqlCli.SQLite/README.md b/Lql/Nimblesite.Lql.Cli.SQLite/README.md similarity index 100% rename from Lql/LqlCli.SQLite/README.md rename to Lql/Nimblesite.Lql.Cli.SQLite/README.md diff --git a/Lql/Lql/.cursor/rules/LqlRules.mdc b/Lql/Nimblesite.Lql.Core/.cursor/rules/LqlRules.mdc similarity index 100% rename from Lql/Lql/.cursor/rules/LqlRules.mdc rename to Lql/Nimblesite.Lql.Core/.cursor/rules/LqlRules.mdc diff --git a/Lql/Lql/DetailedLexerErrorListener.cs b/Lql/Nimblesite.Lql.Core/DetailedLexerErrorListener.cs similarity index 94% rename from Lql/Lql/DetailedLexerErrorListener.cs rename to Lql/Nimblesite.Lql.Core/DetailedLexerErrorListener.cs index eefe32c1..dce5f46c 100644 --- a/Lql/Lql/DetailedLexerErrorListener.cs +++ b/Lql/Nimblesite.Lql.Core/DetailedLexerErrorListener.cs @@ -1,7 +1,7 @@ using Antlr4.Runtime; -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Custom error listener for capturing detailed parse errors with position information for lexer diff --git a/Lql/Lql/DetailedParserErrorListener.cs b/Lql/Nimblesite.Lql.Core/DetailedParserErrorListener.cs similarity index 94% rename from Lql/Lql/DetailedParserErrorListener.cs rename to Lql/Nimblesite.Lql.Core/DetailedParserErrorListener.cs index 1ad997e5..242dd527 100644 --- a/Lql/Lql/DetailedParserErrorListener.cs +++ b/Lql/Nimblesite.Lql.Core/DetailedParserErrorListener.cs @@ -1,7 +1,7 @@ using Antlr4.Runtime; -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Custom error listener for capturing detailed parse errors with position information for parser diff --git a/Lql/Lql/FilterStep.cs b/Lql/Nimblesite.Lql.Core/FilterStep.cs similarity index 81% rename from Lql/Lql/FilterStep.cs rename to Lql/Nimblesite.Lql.Core/FilterStep.cs index 46e6c9d1..ce715dd7 100644 --- a/Lql/Lql/FilterStep.cs +++ b/Lql/Nimblesite.Lql.Core/FilterStep.cs @@ -1,6 +1,6 @@ -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a filter (WHERE) operation. diff --git a/Lql/Lql/FunctionMapping/FunctionMapping.cs b/Lql/Nimblesite.Lql.Core/FunctionMapping/FunctionMapping.cs similarity index 84% rename from Lql/Lql/FunctionMapping/FunctionMapping.cs rename to Lql/Nimblesite.Lql.Core/FunctionMapping/FunctionMapping.cs index 4e59389c..f549e4ab 100644 --- a/Lql/Lql/FunctionMapping/FunctionMapping.cs +++ b/Lql/Nimblesite.Lql.Core/FunctionMapping/FunctionMapping.cs @@ -1,4 +1,4 @@ -namespace Lql.FunctionMapping; +namespace Nimblesite.Lql.Core.FunctionMapping; /// /// Represents a function mapping from LQL to SQL dialect diff --git a/Lql/Lql/FunctionMapping/FunctionMappingProviderBase.cs b/Lql/Nimblesite.Lql.Core/FunctionMapping/FunctionMappingProviderBase.cs similarity index 98% rename from Lql/Lql/FunctionMapping/FunctionMappingProviderBase.cs rename to Lql/Nimblesite.Lql.Core/FunctionMapping/FunctionMappingProviderBase.cs index 8dcd00ac..ea7e64e0 100644 --- a/Lql/Lql/FunctionMapping/FunctionMappingProviderBase.cs +++ b/Lql/Nimblesite.Lql.Core/FunctionMapping/FunctionMappingProviderBase.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace Lql.FunctionMapping; +namespace Nimblesite.Lql.Core.FunctionMapping; /// /// Base implementation for function mapping providers diff --git a/Lql/Lql/FunctionMapping/IFunctionMappingProvider.cs b/Lql/Nimblesite.Lql.Core/FunctionMapping/IFunctionMappingProvider.cs similarity index 97% rename from Lql/Lql/FunctionMapping/IFunctionMappingProvider.cs rename to Lql/Nimblesite.Lql.Core/FunctionMapping/IFunctionMappingProvider.cs index 608da5ae..88ebda70 100644 --- a/Lql/Lql/FunctionMapping/IFunctionMappingProvider.cs +++ b/Lql/Nimblesite.Lql.Core/FunctionMapping/IFunctionMappingProvider.cs @@ -1,4 +1,4 @@ -namespace Lql.FunctionMapping; +namespace Nimblesite.Lql.Core.FunctionMapping; /// /// Interface for providing platform-specific function mappings diff --git a/Lql/Lql/FunctionMapping/SqlSyntaxMapping.cs b/Lql/Nimblesite.Lql.Core/FunctionMapping/SqlSyntaxMapping.cs similarity index 88% rename from Lql/Lql/FunctionMapping/SqlSyntaxMapping.cs rename to Lql/Nimblesite.Lql.Core/FunctionMapping/SqlSyntaxMapping.cs index ae838613..ac01abcf 100644 --- a/Lql/Lql/FunctionMapping/SqlSyntaxMapping.cs +++ b/Lql/Nimblesite.Lql.Core/FunctionMapping/SqlSyntaxMapping.cs @@ -1,4 +1,4 @@ -namespace Lql.FunctionMapping; +namespace Nimblesite.Lql.Core.FunctionMapping; /// /// Represents a mapping of SQL operators and syntax differences diff --git a/Lql/Lql/GlobalAssemblyInfo.cs b/Lql/Nimblesite.Lql.Core/GlobalAssemblyInfo.cs similarity index 100% rename from Lql/Lql/GlobalAssemblyInfo.cs rename to Lql/Nimblesite.Lql.Core/GlobalAssemblyInfo.cs diff --git a/Lql/Lql/GroupByStep.cs b/Lql/Nimblesite.Lql.Core/GroupByStep.cs similarity index 94% rename from Lql/Lql/GroupByStep.cs rename to Lql/Nimblesite.Lql.Core/GroupByStep.cs index dc8d0e63..98c93b71 100644 --- a/Lql/Lql/GroupByStep.cs +++ b/Lql/Nimblesite.Lql.Core/GroupByStep.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a GROUP BY operation. diff --git a/Lql/Lql/HavingStep.cs b/Lql/Nimblesite.Lql.Core/HavingStep.cs similarity index 88% rename from Lql/Lql/HavingStep.cs rename to Lql/Nimblesite.Lql.Core/HavingStep.cs index eb68764b..a6dca92c 100644 --- a/Lql/Lql/HavingStep.cs +++ b/Lql/Nimblesite.Lql.Core/HavingStep.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a HAVING operation. diff --git a/Lql/Lql/INode.cs b/Lql/Nimblesite.Lql.Core/INode.cs similarity index 90% rename from Lql/Lql/INode.cs rename to Lql/Nimblesite.Lql.Core/INode.cs index f02b143e..7999926e 100644 --- a/Lql/Lql/INode.cs +++ b/Lql/Nimblesite.Lql.Core/INode.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Marker interface for AST nodes (Pipeline, Identifier, etc.). diff --git a/Lql/Lql/ISqlContext.cs b/Lql/Nimblesite.Lql.Core/ISqlContext.cs similarity index 97% rename from Lql/Lql/ISqlContext.cs rename to Lql/Nimblesite.Lql.Core/ISqlContext.cs index 832f9a15..27e12b69 100644 --- a/Lql/Lql/ISqlContext.cs +++ b/Lql/Nimblesite.Lql.Core/ISqlContext.cs @@ -1,6 +1,6 @@ -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Interface for SQL context implementations that generate dialect-specific SQL diff --git a/Lql/Lql/IStep.cs b/Lql/Nimblesite.Lql.Core/IStep.cs similarity index 86% rename from Lql/Lql/IStep.cs rename to Lql/Nimblesite.Lql.Core/IStep.cs index 99770991..459e3474 100644 --- a/Lql/Lql/IStep.cs +++ b/Lql/Nimblesite.Lql.Core/IStep.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a step in a pipeline. diff --git a/Lql/Lql/Identifier.cs b/Lql/Nimblesite.Lql.Core/Identifier.cs similarity index 86% rename from Lql/Lql/Identifier.cs rename to Lql/Nimblesite.Lql.Core/Identifier.cs index cba5c8e8..b1dff123 100644 --- a/Lql/Lql/Identifier.cs +++ b/Lql/Nimblesite.Lql.Core/Identifier.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents an identifier (table, column, or variable name). diff --git a/Lql/Lql/IdentityStep.cs b/Lql/Nimblesite.Lql.Core/IdentityStep.cs similarity index 82% rename from Lql/Lql/IdentityStep.cs rename to Lql/Nimblesite.Lql.Core/IdentityStep.cs index dbfb6802..3cd48448 100644 --- a/Lql/Lql/IdentityStep.cs +++ b/Lql/Nimblesite.Lql.Core/IdentityStep.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents an identity step that doesn't transform the input. diff --git a/Lql/Lql/InsertStep.cs b/Lql/Nimblesite.Lql.Core/InsertStep.cs similarity index 96% rename from Lql/Lql/InsertStep.cs rename to Lql/Nimblesite.Lql.Core/InsertStep.cs index 23543880..8460ed6c 100644 --- a/Lql/Lql/InsertStep.cs +++ b/Lql/Nimblesite.Lql.Core/InsertStep.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents an INSERT operation. diff --git a/Lql/Lql/JoinStep.cs b/Lql/Nimblesite.Lql.Core/JoinStep.cs similarity index 84% rename from Lql/Lql/JoinStep.cs rename to Lql/Nimblesite.Lql.Core/JoinStep.cs index bb66c876..13bfb425 100644 --- a/Lql/Lql/JoinStep.cs +++ b/Lql/Nimblesite.Lql.Core/JoinStep.cs @@ -1,6 +1,6 @@ -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a JOIN operation (INNER, LEFT, CROSS, etc.). diff --git a/Lql/Lql/LimitStep.cs b/Lql/Nimblesite.Lql.Core/LimitStep.cs similarity index 87% rename from Lql/Lql/LimitStep.cs rename to Lql/Nimblesite.Lql.Core/LimitStep.cs index 98b89729..227c3d40 100644 --- a/Lql/Lql/LimitStep.cs +++ b/Lql/Nimblesite.Lql.Core/LimitStep.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a LIMIT operation. diff --git a/Lql/Lql/LqlExtensions.cs b/Lql/Nimblesite.Lql.Core/LqlExtensions.cs similarity index 92% rename from Lql/Lql/LqlExtensions.cs rename to Lql/Nimblesite.Lql.Core/LqlExtensions.cs index f047c143..46810d37 100644 --- a/Lql/Lql/LqlExtensions.cs +++ b/Lql/Nimblesite.Lql.Core/LqlExtensions.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Extension methods for working with nodes and steps. diff --git a/Lql/Lql/LqlStatement.cs b/Lql/Nimblesite.Lql.Core/LqlStatement.cs similarity index 87% rename from Lql/Lql/LqlStatement.cs rename to Lql/Nimblesite.Lql.Core/LqlStatement.cs index d81445e1..da6a484e 100644 --- a/Lql/Lql/LqlStatement.cs +++ b/Lql/Nimblesite.Lql.Core/LqlStatement.cs @@ -1,6 +1,6 @@ -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a parsed LQL statement that can be converted to various SQL dialects diff --git a/Lql/Lql/LqlStatementConverter.cs b/Lql/Nimblesite.Lql.Core/LqlStatementConverter.cs similarity index 62% rename from Lql/Lql/LqlStatementConverter.cs rename to Lql/Nimblesite.Lql.Core/LqlStatementConverter.cs index cef4b9da..837c8ea2 100644 --- a/Lql/Lql/LqlStatementConverter.cs +++ b/Lql/Nimblesite.Lql.Core/LqlStatementConverter.cs @@ -1,16 +1,16 @@ -using Lql.Parsing; +using Nimblesite.Lql.Core.Parsing; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -using StatementError = Outcome.Result.Error< - Lql.LqlStatement, - Selecta.SqlError ->; -using StatementOk = Outcome.Result.Ok< - Lql.LqlStatement, - Selecta.SqlError ->; +using StatementError = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Error; +using StatementOk = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Ok; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Converts LQL code to LqlStatement and provides PostgreSQL generation. diff --git a/Lql/Lql/Lql.csproj b/Lql/Nimblesite.Lql.Core/Nimblesite.Lql.Core.csproj similarity index 76% rename from Lql/Lql/Lql.csproj rename to Lql/Nimblesite.Lql.Core/Nimblesite.Lql.Core.csproj index 2278770d..10e024ce 100644 --- a/Lql/Lql/Lql.csproj +++ b/Lql/Nimblesite.Lql.Core/Nimblesite.Lql.Core.csproj @@ -1,6 +1,7 @@  Library + Nimblesite.Lql.Core CA1515;CA1866;CA1310;CA1834;CS3021 @@ -11,6 +12,6 @@ - + diff --git a/Lql/Lql/OffsetStep.cs b/Lql/Nimblesite.Lql.Core/OffsetStep.cs similarity index 88% rename from Lql/Lql/OffsetStep.cs rename to Lql/Nimblesite.Lql.Core/OffsetStep.cs index 69f6af69..8026bb03 100644 --- a/Lql/Lql/OffsetStep.cs +++ b/Lql/Nimblesite.Lql.Core/OffsetStep.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents an OFFSET operation. diff --git a/Lql/Lql/OrderByStep.cs b/Lql/Nimblesite.Lql.Core/OrderByStep.cs similarity index 95% rename from Lql/Lql/OrderByStep.cs rename to Lql/Nimblesite.Lql.Core/OrderByStep.cs index 67cf4c91..44c2ea57 100644 --- a/Lql/Lql/OrderByStep.cs +++ b/Lql/Nimblesite.Lql.Core/OrderByStep.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents an ORDER BY operation. diff --git a/Lql/Lql/Parsing/Lql.g4 b/Lql/Nimblesite.Lql.Core/Parsing/Lql.g4 similarity index 100% rename from Lql/Lql/Parsing/Lql.g4 rename to Lql/Nimblesite.Lql.Core/Parsing/Lql.g4 diff --git a/Lql/Lql/Parsing/Lql.interp b/Lql/Nimblesite.Lql.Core/Parsing/Lql.interp similarity index 100% rename from Lql/Lql/Parsing/Lql.interp rename to Lql/Nimblesite.Lql.Core/Parsing/Lql.interp diff --git a/Lql/Lql/Parsing/Lql.tokens b/Lql/Nimblesite.Lql.Core/Parsing/Lql.tokens similarity index 100% rename from Lql/Lql/Parsing/Lql.tokens rename to Lql/Nimblesite.Lql.Core/Parsing/Lql.tokens diff --git a/Lql/Lql/Parsing/LqlBaseListener.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs similarity index 99% rename from Lql/Lql/Parsing/LqlBaseListener.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs index ff89381a..031a814e 100644 --- a/Lql/Lql/Parsing/LqlBaseListener.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Lql.g4 by ANTLR 4.13.1 +// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace Lql.Parsing { +namespace Nimblesite.Lql.Core.Parsing { using Antlr4.Runtime.Misc; using IErrorNode = Antlr4.Runtime.Tree.IErrorNode; @@ -410,4 +410,4 @@ public virtual void VisitTerminal([NotNull] ITerminalNode node) { } /// The default implementation does nothing. public virtual void VisitErrorNode([NotNull] IErrorNode node) { } } -} // namespace Lql.Parsing +} // namespace Nimblesite.Lql.Core.Parsing diff --git a/Lql/Lql/Parsing/LqlBaseVisitor.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs similarity index 99% rename from Lql/Lql/Parsing/LqlBaseVisitor.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs index bbb00274..24ee7e0f 100644 --- a/Lql/Lql/Parsing/LqlBaseVisitor.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Lql.g4 by ANTLR 4.13.1 +// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace Lql.Parsing { +namespace Nimblesite.Lql.Core.Parsing { using Antlr4.Runtime.Misc; using Antlr4.Runtime.Tree; using IToken = Antlr4.Runtime.IToken; @@ -336,4 +336,4 @@ public partial class LqlBaseVisitor : AbstractParseTreeVisitor, /// The visitor result. public virtual Result VisitComparisonOp([NotNull] LqlParser.ComparisonOpContext context) { return VisitChildren(context); } } -} // namespace Lql.Parsing +} // namespace Nimblesite.Lql.Core.Parsing diff --git a/Lql/Lql/Parsing/LqlCodeParser.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlCodeParser.cs similarity index 99% rename from Lql/Lql/Parsing/LqlCodeParser.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlCodeParser.cs index baa55d0e..678e9b70 100644 --- a/Lql/Lql/Parsing/LqlCodeParser.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlCodeParser.cs @@ -1,8 +1,8 @@ using Antlr4.Runtime; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace Lql.Parsing; +namespace Nimblesite.Lql.Core.Parsing; /// /// Utility class for parsing LQL code. diff --git a/Lql/Lql/Parsing/LqlLexer.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs similarity index 98% rename from Lql/Lql/Parsing/LqlLexer.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs index 0949fd03..2993e570 100644 --- a/Lql/Lql/Parsing/LqlLexer.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Lql.g4 by ANTLR 4.13.1 +// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace Lql.Parsing { +namespace Nimblesite.Lql.Core.Parsing { using System; using System.IO; using System.Text; @@ -99,7 +99,7 @@ public override IVocabulary Vocabulary } } - public override string GrammarFileName { get { return "Lql.g4"; } } + public override string GrammarFileName { get { return "Nimblesite.Lql.Core.g4"; } } public override string[] RuleNames { get { return ruleNames; } } @@ -286,4 +286,4 @@ static LqlLexer() { } -} // namespace Lql.Parsing +} // namespace Nimblesite.Lql.Core.Parsing diff --git a/Lql/Lql/Parsing/LqlLexer.interp b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.interp similarity index 100% rename from Lql/Lql/Parsing/LqlLexer.interp rename to Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.interp diff --git a/Lql/Lql/Parsing/LqlLexer.tokens b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.tokens similarity index 100% rename from Lql/Lql/Parsing/LqlLexer.tokens rename to Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.tokens diff --git a/Lql/Lql/Parsing/LqlListener.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs similarity index 99% rename from Lql/Lql/Parsing/LqlListener.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs index f82d6cc6..68ece03f 100644 --- a/Lql/Lql/Parsing/LqlListener.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Lql.g4 by ANTLR 4.13.1 +// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace Lql.Parsing { +namespace Nimblesite.Lql.Core.Parsing { using Antlr4.Runtime.Misc; using IParseTreeListener = Antlr4.Runtime.Tree.IParseTreeListener; using IToken = Antlr4.Runtime.IToken; @@ -332,4 +332,4 @@ public interface ILqlListener : IParseTreeListener { /// The parse tree. void ExitComparisonOp([NotNull] LqlParser.ComparisonOpContext context); } -} // namespace Lql.Parsing +} // namespace Nimblesite.Lql.Core.Parsing diff --git a/Lql/Lql/Parsing/LqlParser.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs similarity index 99% rename from Lql/Lql/Parsing/LqlParser.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs index 1788c01a..7bfc42a6 100644 --- a/Lql/Lql/Parsing/LqlParser.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Lql.g4 by ANTLR 4.13.1 +// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace Lql.Parsing { +namespace Nimblesite.Lql.Core.Parsing { using System; using System.IO; using System.Text; @@ -91,7 +91,7 @@ public override IVocabulary Vocabulary } } - public override string GrammarFileName { get { return "Lql.g4"; } } + public override string GrammarFileName { get { return "Nimblesite.Lql.Core.g4"; } } public override string[] RuleNames { get { return ruleNames; } } @@ -2984,4 +2984,4 @@ public ComparisonOpContext comparisonOp() { } -} // namespace Lql.Parsing +} // namespace Nimblesite.Lql.Core.Parsing diff --git a/Lql/Lql/Parsing/LqlToAstVisitor.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs similarity index 99% rename from Lql/Lql/Parsing/LqlToAstVisitor.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs index 5ddb6aac..908f1537 100644 --- a/Lql/Lql/Parsing/LqlToAstVisitor.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs @@ -2,9 +2,9 @@ using Antlr4.Runtime; using Antlr4.Runtime.Misc; using Antlr4.Runtime.Tree; -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql.Parsing; +namespace Nimblesite.Lql.Core.Parsing; /// /// Visitor that converts ANTLR parse tree to transpiler AST nodes. diff --git a/Lql/Lql/Parsing/LqlVisitor.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs similarity index 98% rename from Lql/Lql/Parsing/LqlVisitor.cs rename to Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs index deded7d7..c4da92a1 100644 --- a/Lql/Lql/Parsing/LqlVisitor.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Lql.g4 by ANTLR 4.13.1 +// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -19,7 +19,7 @@ // Ambiguous reference in cref attribute #pragma warning disable 419 -namespace Lql.Parsing { +namespace Nimblesite.Lql.Core.Parsing { using Antlr4.Runtime.Misc; using Antlr4.Runtime.Tree; using IToken = Antlr4.Runtime.IToken; @@ -213,4 +213,4 @@ public interface ILqlVisitor : IParseTreeVisitor { /// The visitor result. Result VisitComparisonOp([NotNull] LqlParser.ComparisonOpContext context); } -} // namespace Lql.Parsing +} // namespace Nimblesite.Lql.Core.Parsing diff --git a/Lql/Lql/Pipeline.cs b/Lql/Nimblesite.Lql.Core/Pipeline.cs similarity index 90% rename from Lql/Lql/Pipeline.cs rename to Lql/Nimblesite.Lql.Core/Pipeline.cs index d2b89499..dff715c2 100644 --- a/Lql/Lql/Pipeline.cs +++ b/Lql/Nimblesite.Lql.Core/Pipeline.cs @@ -1,6 +1,6 @@ using System.Collections.ObjectModel; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a pipeline of operations. diff --git a/Lql/Lql/PipelineProcessor.cs b/Lql/Nimblesite.Lql.Core/PipelineProcessor.cs similarity index 99% rename from Lql/Lql/PipelineProcessor.cs rename to Lql/Nimblesite.Lql.Core/PipelineProcessor.cs index 67f0b420..750732d6 100644 --- a/Lql/Lql/PipelineProcessor.cs +++ b/Lql/Nimblesite.Lql.Core/PipelineProcessor.cs @@ -1,6 +1,6 @@ -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Shared pipeline processor that converts pipelines to SQL using any ISqlContext implementation. diff --git a/Lql/Lql/SelectDistinctStep.cs b/Lql/Nimblesite.Lql.Core/SelectDistinctStep.cs similarity index 90% rename from Lql/Lql/SelectDistinctStep.cs rename to Lql/Nimblesite.Lql.Core/SelectDistinctStep.cs index 206dcec4..d88cf80a 100644 --- a/Lql/Lql/SelectDistinctStep.cs +++ b/Lql/Nimblesite.Lql.Core/SelectDistinctStep.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a SELECT DISTINCT operation. diff --git a/Lql/Lql/SelectStep.cs b/Lql/Nimblesite.Lql.Core/SelectStep.cs similarity index 90% rename from Lql/Lql/SelectStep.cs rename to Lql/Nimblesite.Lql.Core/SelectStep.cs index 5e96e2e9..916b94b0 100644 --- a/Lql/Lql/SelectStep.cs +++ b/Lql/Nimblesite.Lql.Core/SelectStep.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a SELECT operation. diff --git a/Lql/Lql/StepBase.cs b/Lql/Nimblesite.Lql.Core/StepBase.cs similarity index 88% rename from Lql/Lql/StepBase.cs rename to Lql/Nimblesite.Lql.Core/StepBase.cs index 7af9dede..a2db3f7b 100644 --- a/Lql/Lql/StepBase.cs +++ b/Lql/Nimblesite.Lql.Core/StepBase.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Base class for pipeline steps. diff --git a/Lql/Lql/UnionAllStep.cs b/Lql/Nimblesite.Lql.Core/UnionAllStep.cs similarity index 88% rename from Lql/Lql/UnionAllStep.cs rename to Lql/Nimblesite.Lql.Core/UnionAllStep.cs index 48ded0fb..98c58b9c 100644 --- a/Lql/Lql/UnionAllStep.cs +++ b/Lql/Nimblesite.Lql.Core/UnionAllStep.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a UNION ALL operation. diff --git a/Lql/Lql/UnionStep.cs b/Lql/Nimblesite.Lql.Core/UnionStep.cs similarity index 88% rename from Lql/Lql/UnionStep.cs rename to Lql/Nimblesite.Lql.Core/UnionStep.cs index bab0ee59..e2df6bc3 100644 --- a/Lql/Lql/UnionStep.cs +++ b/Lql/Nimblesite.Lql.Core/UnionStep.cs @@ -1,4 +1,4 @@ -namespace Lql; +namespace Nimblesite.Lql.Core; /// /// Represents a UNION operation. diff --git a/Lql/Nimblesite.Lql.Postgres/GlobalUsings.cs b/Lql/Nimblesite.Lql.Postgres/GlobalUsings.cs new file mode 100644 index 00000000..42b005d0 --- /dev/null +++ b/Lql/Nimblesite.Lql.Postgres/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.Lql.Core; diff --git a/Lql/Nimblesite.Lql.Postgres/Nimblesite.Lql.Postgres.csproj b/Lql/Nimblesite.Lql.Postgres/Nimblesite.Lql.Postgres.csproj new file mode 100644 index 00000000..3c167a82 --- /dev/null +++ b/Lql/Nimblesite.Lql.Postgres/Nimblesite.Lql.Postgres.csproj @@ -0,0 +1,8 @@ + + + Nimblesite.Lql.Postgres + + + + + diff --git a/Lql/Lql.Postgres/PostgreSqlContext.cs b/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs similarity index 99% rename from Lql/Lql.Postgres/PostgreSqlContext.cs rename to Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs index ee5eb758..2b31335b 100644 --- a/Lql/Lql.Postgres/PostgreSqlContext.cs +++ b/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs @@ -1,8 +1,8 @@ using System.Text; -using Lql.FunctionMapping; -using Selecta; +using Nimblesite.Lql.Core.FunctionMapping; +using Nimblesite.Sql.Model; -namespace Lql.Postgres; +namespace Nimblesite.Lql.Postgres; /// /// Context for building PostgreSQL queries with proper table aliases and structure diff --git a/Lql/Lql.Postgres/PostgreSqlFunctionMapping.cs b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMapping.cs similarity index 97% rename from Lql/Lql.Postgres/PostgreSqlFunctionMapping.cs rename to Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMapping.cs index c5fb150e..a3178c1c 100644 --- a/Lql/Lql.Postgres/PostgreSqlFunctionMapping.cs +++ b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMapping.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; -using Lql.FunctionMapping; +using Nimblesite.Lql.Core.FunctionMapping; -namespace Lql.Postgres; +namespace Nimblesite.Lql.Postgres; /// /// PostgreSQL-specific function mapping implementation diff --git a/Lql/Lql.Postgres/PostgreSqlFunctionMappingLocal.cs b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMappingLocal.cs similarity index 97% rename from Lql/Lql.Postgres/PostgreSqlFunctionMappingLocal.cs rename to Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMappingLocal.cs index 345bba6d..1c770fb7 100644 --- a/Lql/Lql.Postgres/PostgreSqlFunctionMappingLocal.cs +++ b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMappingLocal.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; -using Lql.FunctionMapping; +using Nimblesite.Lql.Core.FunctionMapping; -namespace Lql.Postgres; +namespace Nimblesite.Lql.Postgres; /// /// Local PostgreSQL-specific function mapping implementation for the Lql project diff --git a/Lql/Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs b/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs similarity index 98% rename from Lql/Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs rename to Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs index 8601aaf8..34496de6 100644 --- a/Lql/Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs +++ b/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace Lql.Postgres; +namespace Nimblesite.Lql.Postgres; /// /// PostgreSQL-specific extension methods for SelectStatement diff --git a/Lql/Nimblesite.Lql.SQLite/GlobalUsings.cs b/Lql/Nimblesite.Lql.SQLite/GlobalUsings.cs new file mode 100644 index 00000000..42b005d0 --- /dev/null +++ b/Lql/Nimblesite.Lql.SQLite/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.Lql.Core; diff --git a/Lql/Nimblesite.Lql.SQLite/Nimblesite.Lql.SQLite.csproj b/Lql/Nimblesite.Lql.SQLite/Nimblesite.Lql.SQLite.csproj new file mode 100644 index 00000000..f642d462 --- /dev/null +++ b/Lql/Nimblesite.Lql.SQLite/Nimblesite.Lql.SQLite.csproj @@ -0,0 +1,8 @@ + + + Nimblesite.Lql.SQLite + + + + + diff --git a/Lql/Lql.SQLite/SQLiteContext.cs b/Lql/Nimblesite.Lql.SQLite/SQLiteContext.cs similarity index 99% rename from Lql/Lql.SQLite/SQLiteContext.cs rename to Lql/Nimblesite.Lql.SQLite/SQLiteContext.cs index 828d2400..a361277c 100644 --- a/Lql/Lql.SQLite/SQLiteContext.cs +++ b/Lql/Nimblesite.Lql.SQLite/SQLiteContext.cs @@ -1,6 +1,6 @@ -using Selecta; +using Nimblesite.Sql.Model; -namespace Lql.SQLite; +namespace Nimblesite.Lql.SQLite; /// /// Context for building SQLite queries with proper table aliases and structure diff --git a/Lql/Lql.SQLite/SQLiteFunctionMappingLocal.cs b/Lql/Nimblesite.Lql.SQLite/SQLiteFunctionMappingLocal.cs similarity index 97% rename from Lql/Lql.SQLite/SQLiteFunctionMappingLocal.cs rename to Lql/Nimblesite.Lql.SQLite/SQLiteFunctionMappingLocal.cs index 57c2a744..2d6be9dd 100644 --- a/Lql/Lql.SQLite/SQLiteFunctionMappingLocal.cs +++ b/Lql/Nimblesite.Lql.SQLite/SQLiteFunctionMappingLocal.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; -using Lql.FunctionMapping; +using Nimblesite.Lql.Core.FunctionMapping; -namespace Lql.SQLite; +namespace Nimblesite.Lql.SQLite; /// /// SQLite-specific function mapping implementation diff --git a/Lql/Lql.SQLite/SqlStatementExtensionsSQLite.cs b/Lql/Nimblesite.Lql.SQLite/SqlStatementExtensionsSQLite.cs similarity index 94% rename from Lql/Lql.SQLite/SqlStatementExtensionsSQLite.cs rename to Lql/Nimblesite.Lql.SQLite/SqlStatementExtensionsSQLite.cs index 4e1c5812..ba2fe08c 100644 --- a/Lql/Lql.SQLite/SqlStatementExtensionsSQLite.cs +++ b/Lql/Nimblesite.Lql.SQLite/SqlStatementExtensionsSQLite.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace Lql.SQLite; +namespace Nimblesite.Lql.SQLite; /// /// SQLite-specific extension methods for SelectStatement @@ -50,7 +50,7 @@ public static Result ToSQLite(this LqlStatement statement) } /// - /// Converts a Selecta.SelectStatement to SQLite syntax + /// Converts a Nimblesite.Sql.Model.SelectStatement to SQLite syntax /// TODO: this should not return a result because it can't fail /// /// The SelectStatement to convert diff --git a/Lql/Nimblesite.Lql.SqlServer/GlobalUsings.cs b/Lql/Nimblesite.Lql.SqlServer/GlobalUsings.cs new file mode 100644 index 00000000..42b005d0 --- /dev/null +++ b/Lql/Nimblesite.Lql.SqlServer/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.Lql.Core; diff --git a/Lql/Nimblesite.Lql.SqlServer/Nimblesite.Lql.SqlServer.csproj b/Lql/Nimblesite.Lql.SqlServer/Nimblesite.Lql.SqlServer.csproj new file mode 100644 index 00000000..7567c8b4 --- /dev/null +++ b/Lql/Nimblesite.Lql.SqlServer/Nimblesite.Lql.SqlServer.csproj @@ -0,0 +1,8 @@ + + + Nimblesite.Lql.SqlServer + + + + + diff --git a/Lql/Lql.SqlServer/SqlServerContext.cs b/Lql/Nimblesite.Lql.SqlServer/SqlServerContext.cs similarity index 99% rename from Lql/Lql.SqlServer/SqlServerContext.cs rename to Lql/Nimblesite.Lql.SqlServer/SqlServerContext.cs index 7dcc0e09..5fd9614b 100644 --- a/Lql/Lql.SqlServer/SqlServerContext.cs +++ b/Lql/Nimblesite.Lql.SqlServer/SqlServerContext.cs @@ -1,7 +1,7 @@ -using Lql.FunctionMapping; -using Selecta; +using Nimblesite.Lql.Core.FunctionMapping; +using Nimblesite.Sql.Model; -namespace Lql.SqlServer; +namespace Nimblesite.Lql.SqlServer; /// /// SQL Server context implementation for generating SQL Server-specific SQL diff --git a/Lql/Lql.SqlServer/SqlServerFunctionMapping.cs b/Lql/Nimblesite.Lql.SqlServer/SqlServerFunctionMapping.cs similarity index 97% rename from Lql/Lql.SqlServer/SqlServerFunctionMapping.cs rename to Lql/Nimblesite.Lql.SqlServer/SqlServerFunctionMapping.cs index 3889a5e1..12db4843 100644 --- a/Lql/Lql.SqlServer/SqlServerFunctionMapping.cs +++ b/Lql/Nimblesite.Lql.SqlServer/SqlServerFunctionMapping.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; -using Lql.FunctionMapping; +using Nimblesite.Lql.Core.FunctionMapping; -namespace Lql.SqlServer; +namespace Nimblesite.Lql.SqlServer; /// /// SQL Server-specific function mapping implementation diff --git a/Lql/Lql.SqlServer/SqlStatementExtensionsSqlServer.cs b/Lql/Nimblesite.Lql.SqlServer/SqlStatementExtensionsSqlServer.cs similarity index 96% rename from Lql/Lql.SqlServer/SqlStatementExtensionsSqlServer.cs rename to Lql/Nimblesite.Lql.SqlServer/SqlStatementExtensionsSqlServer.cs index 709f6d26..ca5cb2ea 100644 --- a/Lql/Lql.SqlServer/SqlStatementExtensionsSqlServer.cs +++ b/Lql/Nimblesite.Lql.SqlServer/SqlStatementExtensionsSqlServer.cs @@ -1,7 +1,7 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; -namespace Lql.SqlServer; +namespace Nimblesite.Lql.SqlServer; /// /// SQL Server-specific extension methods for SelectStatement diff --git a/Lql/Lql.Tests/.cursor/rules/TestingRules.mdc b/Lql/Nimblesite.Lql.Tests/.cursor/rules/TestingRules.mdc similarity index 100% rename from Lql/Lql.Tests/.cursor/rules/TestingRules.mdc rename to Lql/Nimblesite.Lql.Tests/.cursor/rules/TestingRules.mdc diff --git a/Lql/Nimblesite.Lql.Tests/FunctionMappingE2ETests.cs b/Lql/Nimblesite.Lql.Tests/FunctionMappingE2ETests.cs new file mode 100644 index 00000000..1f3860b8 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/FunctionMappingE2ETests.cs @@ -0,0 +1,293 @@ +using Nimblesite.Lql.Core.FunctionMapping; +using Nimblesite.Lql.Postgres; +using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; +using Xunit; + +namespace Nimblesite.Lql.Tests; + +/// +/// E2E tests for function mapping providers across all SQL dialects. +/// Tests function lookup, transpilation, syntax mapping, and special handlers. +/// +public sealed class FunctionMappingE2ETests +{ + private static readonly IFunctionMappingProvider[] AllProviders = + [ + PostgreSqlFunctionMapping.Instance, + PostgreSqlFunctionMappingLocal.Instance, + SqlServerFunctionMapping.Instance, + SQLiteFunctionMappingLocal.Instance, + ]; + + [Theory] + [InlineData("count")] + [InlineData("sum")] + [InlineData("avg")] + [InlineData("min")] + [InlineData("max")] + [InlineData("coalesce")] + public void CoreAggregateFunctions_AllProviders_AreMapped(string functionName) + { + foreach (var provider in AllProviders) + { + var mapping = provider.GetFunctionMapping(functionName); + Assert.NotNull(mapping); + Assert.Equal(functionName, mapping.LqlFunction); + Assert.NotEmpty(mapping.SqlFunction); + } + } + + [Theory] + [InlineData("upper")] + [InlineData("lower")] + public void StringFunctions_AllProviders_AreMapped(string functionName) + { + foreach (var provider in AllProviders) + { + var mapping = provider.GetFunctionMapping(functionName); + Assert.NotNull(mapping); + Assert.NotEmpty(mapping.SqlFunction); + } + } + + [Fact] + public void CountStar_AllProviders_SpecialHandlerProducesCorrectSQL() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("count", "*"); + Assert.Equal("COUNT(*)", result); + } + } + + [Fact] + public void CountWithColumn_AllProviders_ProducesCorrectSQL() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("count", "user_id"); + Assert.Contains("COUNT", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("user_id", result, StringComparison.Ordinal); + } + } + + [Fact] + public void SumFunction_AllProviders_TranspilesCorrectly() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("sum", "amount"); + Assert.Contains("SUM", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("amount", result, StringComparison.Ordinal); + } + } + + [Fact] + public void UpperFunction_AllProviders_TranspilesCorrectly() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("upper", "name"); + Assert.Contains("UPPER", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name", result, StringComparison.Ordinal); + } + } + + [Fact] + public void LowerFunction_AllProviders_TranspilesCorrectly() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("lower", "email"); + Assert.Contains("LOWER", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("email", result, StringComparison.Ordinal); + } + } + + [Fact] + public void UnknownFunction_AllProviders_FallsBackToDefault() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("nonexistent_func", "arg1", "arg2"); + Assert.Contains("NONEXISTENT_FUNC", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("arg1", result, StringComparison.Ordinal); + Assert.Contains("arg2", result, StringComparison.Ordinal); + } + } + + [Fact] + public void GetFunctionMapping_NullInput_Throws() + { + foreach (var provider in AllProviders) + { + Assert.Throws(() => provider.GetFunctionMapping(null!)); + } + } + + [Fact] + public void TranspileFunction_NullFunctionName_Throws() + { + foreach (var provider in AllProviders) + { + Assert.Throws(() => provider.TranspileFunction(null!, "arg")); + } + } + + [Fact] + public void CurrentDate_SpecialHandler_ProducesNoParens() + { + var pgMapping = PostgreSqlFunctionMapping.Instance.GetFunctionMapping("current_date"); + Assert.NotNull(pgMapping); + Assert.True(pgMapping.RequiresSpecialHandling); + Assert.NotNull(pgMapping.SpecialHandler); + var result = pgMapping.SpecialHandler([]); + Assert.Equal("CURRENT_DATE", result); + + var pgLocalMapping = PostgreSqlFunctionMappingLocal.Instance.GetFunctionMapping( + "current_date" + ); + Assert.NotNull(pgLocalMapping); + Assert.True(pgLocalMapping.RequiresSpecialHandling); + } + + [Fact] + public void SyntaxMapping_AllProviders_HasValidValues() + { + foreach (var provider in AllProviders) + { + var syntax = provider.GetSyntaxMapping(); + Assert.NotNull(syntax); + Assert.NotEmpty(syntax.LimitClause); + Assert.NotEmpty(syntax.OffsetClause); + } + } + + [Fact] + public void FormatLimitClause_AllProviders_ProducesValidSQL() + { + foreach (var provider in AllProviders) + { + var result = provider.FormatLimitClause("10"); + Assert.NotEmpty(result); + Assert.Contains("10", result, StringComparison.Ordinal); + } + } + + [Fact] + public void FormatOffsetClause_AllProviders_ProducesValidSQL() + { + foreach (var provider in AllProviders) + { + var result = provider.FormatOffsetClause("20"); + Assert.NotEmpty(result); + Assert.Contains("20", result, StringComparison.Ordinal); + } + } + + [Fact] + public void FormatIdentifier_AllProviders_QuotesCorrectly() + { + foreach (var provider in AllProviders) + { + var result = provider.FormatIdentifier("column_name"); + Assert.Contains("column_name", result, StringComparison.Ordinal); + Assert.True(result.Length > "column_name".Length, "Should add quote characters"); + } + } + + [Fact] + public void PostgreSqlSpecificFunctions_AreMapped() + { + var pg = PostgreSqlFunctionMapping.Instance; + Assert.NotNull(pg.GetFunctionMapping("extract")); + Assert.NotNull(pg.GetFunctionMapping("date_trunc")); + Assert.NotNull(pg.GetFunctionMapping("coalesce")); + + var pgLocal = PostgreSqlFunctionMappingLocal.Instance; + Assert.NotNull(pgLocal.GetFunctionMapping("extract")); + Assert.NotNull(pgLocal.GetFunctionMapping("date_trunc")); + } + + [Fact] + public void SqlServerSpecificFunctions_AreMapped() + { + var ss = SqlServerFunctionMapping.Instance; + + // SQL Server maps extract to DATEPART + var extractMapping = ss.GetFunctionMapping("extract"); + Assert.NotNull(extractMapping); + Assert.True(extractMapping.RequiresSpecialHandling); + Assert.NotNull(extractMapping.SpecialHandler); + var datePartResult = extractMapping.SpecialHandler(["year", "order_date"]); + Assert.Contains("DATEPART", datePartResult, StringComparison.OrdinalIgnoreCase); + + // SQL Server maps date_trunc specially + var dateTruncMapping = ss.GetFunctionMapping("date_trunc"); + Assert.NotNull(dateTruncMapping); + } + + [Fact] + public void SQLiteSpecificFunctions_AreMapped() + { + var sl = SQLiteFunctionMappingLocal.Instance; + Assert.NotNull(sl.GetFunctionMapping("length")); + + // SQLite maps substring to SUBSTR + var substrMapping = sl.GetFunctionMapping("substring"); + Assert.NotNull(substrMapping); + Assert.Equal("SUBSTR", substrMapping.SqlFunction); + Assert.True(substrMapping.RequiresSpecialHandling); + } + + [Fact] + public void CoalesceFunction_AllProviders_TranspilesWithMultipleArgs() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("coalesce", "col1", "col2", "'default'"); + Assert.Contains("COALESCE", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("col1", result, StringComparison.Ordinal); + Assert.Contains("col2", result, StringComparison.Ordinal); + Assert.Contains("'default'", result, StringComparison.Ordinal); + } + } + + [Fact] + public void MinMaxFunctions_AllProviders_TranspileCorrectly() + { + foreach (var provider in AllProviders) + { + var minResult = provider.TranspileFunction("min", "price"); + Assert.Contains("MIN", minResult, StringComparison.OrdinalIgnoreCase); + + var maxResult = provider.TranspileFunction("max", "price"); + Assert.Contains("MAX", maxResult, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void AvgFunction_AllProviders_TranspilesCorrectly() + { + foreach (var provider in AllProviders) + { + var result = provider.TranspileFunction("avg", "rating"); + Assert.Contains("AVG", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("rating", result, StringComparison.Ordinal); + } + } + + [Fact] + public void FunctionNameCaseInsensitive_AllProviders_FindsMapping() + { + foreach (var provider in AllProviders) + { + Assert.NotNull(provider.GetFunctionMapping("COUNT")); + Assert.NotNull(provider.GetFunctionMapping("Count")); + Assert.NotNull(provider.GetFunctionMapping("count")); + Assert.NotNull(provider.GetFunctionMapping("SUM")); + Assert.NotNull(provider.GetFunctionMapping("Sum")); + } + } +} diff --git a/Lql/Nimblesite.Lql.Tests/GlobalUsings.cs b/Lql/Nimblesite.Lql.Tests/GlobalUsings.cs new file mode 100644 index 00000000..42b005d0 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.Lql.Core; diff --git a/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.cs b/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.cs new file mode 100644 index 00000000..20c919c8 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.cs @@ -0,0 +1,462 @@ +using Nimblesite.Lql.Postgres; +using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; +using Nimblesite.Sql.Model; +using Outcome; +using Xunit; + +namespace Nimblesite.Lql.Tests; + +/// +/// E2E tests: LQL parse -> convert to all 3 SQL dialects -> verify output. +/// Targets function mappings, DISTINCT, complex filters, and edge cases. +/// +public sealed class LqlDialectE2ETests +{ + private static (string PostgreSql, string SqlServer, string SQLite) ConvertToAllDialects( + string lqlCode + ) + { + var result = LqlStatementConverter.ToStatement(lqlCode); + if (result is not Result.Ok parseOk) + { + Assert.Fail( + $"Parse failed: {((Result.Error)result).Value.DetailedMessage}" + ); + return default; + } + + var stmt = parseOk.Value; + + var pg = stmt.ToPostgreSql(); + var ss = stmt.ToSqlServer(); + var sl = stmt.ToSQLite(); + + if (pg is not Result.Ok pgOk) + { + Assert.Fail("PostgreSql conversion failed"); + return default; + } + + if (ss is not Result.Ok ssOk) + { + Assert.Fail("SqlServer conversion failed"); + return default; + } + + if (sl is not Result.Ok slOk) + { + Assert.Fail("SQLite conversion failed"); + return default; + } + + return (pgOk.Value, ssOk.Value, slOk.Value); + } + + [Fact] + public void CountFunction_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + orders + |> group_by(orders.user_id) + |> select(orders.user_id, count(*) as order_count) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + Assert.Contains("COUNT(*)", pg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("COUNT(*)", ss, StringComparison.OrdinalIgnoreCase); + Assert.Contains("COUNT(*)", sl, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", pg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", ss, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", sl, StringComparison.OrdinalIgnoreCase); + Assert.Contains("order_count", pg, StringComparison.Ordinal); + Assert.Contains("order_count", ss, StringComparison.Ordinal); + Assert.Contains("order_count", sl, StringComparison.Ordinal); + } + + [Fact] + public void SumAndAvgFunctions_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + orders + |> group_by(orders.status) + |> select( + orders.status, + sum(orders.total) as total_amount, + avg(orders.total) as avg_amount + ) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("SUM", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AVG", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("total_amount", sql, StringComparison.Ordinal); + Assert.Contains("avg_amount", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void CountDistinctFunction_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + orders + |> group_by(orders.user_id) + |> select(orders.user_id, count(distinct orders.product_id) as unique_products) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("COUNT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DISTINCT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unique_products", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void UpperLowerFunctions_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + users + |> select(upper(users.name) as upper_name, lower(users.email) as lower_email) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("UPPER", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LOWER", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("upper_name", sql, StringComparison.Ordinal); + Assert.Contains("lower_email", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void SelectDistinct_AllDialects_GeneratesDistinctSQL() + { + var lql = """ + users |> select_distinct(users.country) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("SELECT DISTINCT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("country", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void OffsetAndLimit_AllDialects_GeneratesCorrectPagination() + { + var lql = """ + users + |> order_by(users.name asc) + |> offset(20) + |> limit(10) + |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + Assert.Contains("OFFSET", pg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", pg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("OFFSET", sl, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", sl, StringComparison.OrdinalIgnoreCase); + // SQL Server uses OFFSET...FETCH or TOP + Assert.Contains("OFFSET", ss, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void HavingClause_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + orders + |> group_by(orders.user_id) + |> having(fn(group) => count(*) > 5) + |> select(orders.user_id, count(*) as order_count) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("HAVING", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void LeftJoin_AllDialects_GeneratesLeftJoinSQL() + { + var lql = """ + users + |> left_join(orders, on = users.id = orders.user_id) + |> select(users.name, orders.total) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("LEFT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void MultipleJoins_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + users + |> join(orders, on = users.id = orders.user_id) + |> join(products, on = orders.product_id = products.id) + |> select(users.name, orders.total, products.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("users", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("orders", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("products", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void ComplexFilterWithAndOr_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + users + |> filter(fn(row) => row.users.age > 18 and row.users.country = 'US' or row.users.status = 'premium') + |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("OR", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void OrderByDescending_AllDialects_GeneratesDescSQL() + { + var lql = """ + users + |> order_by(users.age desc, users.name asc) + |> select(users.id, users.name, users.age) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DESC", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ASC", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void FilterWithStringLiteral_AllDialects_PreservesQuotes() + { + var lql = """ + users + |> filter(fn(row) => row.users.status = 'active') + |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("'active'", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void FilterWithNumericComparison_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + products + |> filter(fn(row) => row.products.price >= 100 and row.products.price <= 500) + |> select(products.id, products.name, products.price) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains(">=", sql, StringComparison.Ordinal); + Assert.Contains("<=", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void JoinWithFilterAndOrderBy_AllDialects_FullPipelineWorks() + { + var lql = """ + users + |> join(orders, on = users.id = orders.user_id) + |> filter(fn(row) => row.orders.total > 100) + |> order_by(orders.total desc) + |> limit(50) + |> select(users.name, orders.total, orders.status) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DESC", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void GroupByWithMultipleAggregates_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + orders + |> group_by(orders.status) + |> select( + orders.status, + count(*) as cnt, + sum(orders.total) as total, + avg(orders.total) as avg_val, + count(distinct orders.user_id) as unique_users + ) + |> order_by(total desc) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("COUNT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SUM", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AVG", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void SimpleTableReference_AllDialects_GeneratesSelectStar() + { + var lql = "users |> select(users.id, users.name)"; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FROM", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void ArithmeticExpressions_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + products + |> select( + products.id, + products.price * products.quantity as total_value, + products.price + 10 as price_plus_ten + ) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("*", sql, StringComparison.Ordinal); + Assert.Contains("+", sql, StringComparison.Ordinal); + Assert.Contains("total_value", sql, StringComparison.Ordinal); + Assert.Contains("price_plus_ten", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void CaseExpression_AllDialects_GeneratesCaseWhenSQL() + { + var lql = """ + orders + |> select( + orders.id, + case + when orders.total > 1000 then orders.total * 0.95 + when orders.total > 500 then orders.total * 0.97 + else orders.total + end as discounted + ) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("CASE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHEN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("THEN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ELSE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("END", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void FilterWithNotEquals_AllDialects_GeneratesCorrectOperator() + { + var lql = """ + users + |> filter(fn(row) => row.users.status != 'deleted') + |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.True( + sql.Contains("!=", StringComparison.Ordinal) + || sql.Contains("<>", StringComparison.Ordinal), + "Should contain != or <> operator" + ); + } + } + + [Fact] + public void SelectWithColumnAlias_AllDialects_GeneratesAliasedColumns() + { + var lql = """ + users + |> select(users.id, users.name as full_name, users.email as contact_email) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("full_name", sql, StringComparison.Ordinal); + Assert.Contains("contact_email", sql, StringComparison.Ordinal); + Assert.Contains("AS", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void LimitOnly_AllDialects_GeneratesLimitSQL() + { + var lql = """ + users |> limit(25) |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + Assert.Contains("LIMIT", pg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", sl, StringComparison.OrdinalIgnoreCase); + // SQL Server uses TOP + Assert.True( + ss.Contains("TOP", StringComparison.OrdinalIgnoreCase) + || ss.Contains("FETCH", StringComparison.OrdinalIgnoreCase), + "SQL Server should use TOP or FETCH for limit" + ); + } +} diff --git a/Lql/Lql.Tests/LqlErrorHandlingTests.cs b/Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs similarity index 99% rename from Lql/Lql.Tests/LqlErrorHandlingTests.cs rename to Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs index 37b77e24..e0c2836a 100644 --- a/Lql/Lql.Tests/LqlErrorHandlingTests.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs @@ -1,8 +1,8 @@ +using Nimblesite.Sql.Model; using Outcome; -using Selecta; using Xunit; -namespace Lql.Tests; +namespace Nimblesite.Lql.Tests; // TODO: THIS IS TOO VERBOSE!!! // Do something similar to the expected SQL in TestData/ExpectedSql diff --git a/Lql/Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs similarity index 96% rename from Lql/Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs rename to Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs index a180eae1..75a2556a 100644 --- a/Lql/Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Lql.Tests; +namespace Nimblesite.Lql.Tests; /// /// File-based tests for advanced LQL features - subqueries, CTEs, window functions, etc. diff --git a/Lql/Lql.Tests/LqlFileBasedTests.Aggregation.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Aggregation.cs similarity index 96% rename from Lql/Lql.Tests/LqlFileBasedTests.Aggregation.cs rename to Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Aggregation.cs index b8a70f72..4055837d 100644 --- a/Lql/Lql.Tests/LqlFileBasedTests.Aggregation.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Aggregation.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Lql.Tests; +namespace Nimblesite.Lql.Tests; /// /// File-based tests for LQL aggregation operations - group by, having, etc. diff --git a/Lql/Lql.Tests/LqlFileBasedTests.Arithmetic.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Arithmetic.cs similarity index 97% rename from Lql/Lql.Tests/LqlFileBasedTests.Arithmetic.cs rename to Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Arithmetic.cs index c19efdd0..82ea3e8e 100644 --- a/Lql/Lql.Tests/LqlFileBasedTests.Arithmetic.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Arithmetic.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Lql.Tests; +namespace Nimblesite.Lql.Tests; /// /// File-based tests for LQL arithmetic operations and expressions diff --git a/Lql/Lql.Tests/LqlFileBasedTests.BasicOperations.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.BasicOperations.cs similarity index 97% rename from Lql/Lql.Tests/LqlFileBasedTests.BasicOperations.cs rename to Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.BasicOperations.cs index 232c34f1..6816886d 100644 --- a/Lql/Lql.Tests/LqlFileBasedTests.BasicOperations.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.BasicOperations.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Lql.Tests; +namespace Nimblesite.Lql.Tests; /// /// File-based tests for basic LQL operations - select, filter, simple joins diff --git a/Lql/Lql.Tests/LqlFileBasedTests.Joins.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Joins.cs similarity index 97% rename from Lql/Lql.Tests/LqlFileBasedTests.Joins.cs rename to Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Joins.cs index 344db33f..e8fa1227 100644 --- a/Lql/Lql.Tests/LqlFileBasedTests.Joins.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Joins.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Lql.Tests; +namespace Nimblesite.Lql.Tests; /// /// File-based tests for LQL join operations diff --git a/Lql/Lql.Tests/LqlFileBasedTests.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.cs similarity index 98% rename from Lql/Lql.Tests/LqlFileBasedTests.cs rename to Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.cs index 0c273628..94ec442c 100644 --- a/Lql/Lql.Tests/LqlFileBasedTests.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.cs @@ -1,11 +1,11 @@ -using Lql.Postgres; -using Lql.SQLite; -using Lql.SqlServer; +using Nimblesite.Lql.Postgres; +using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; +using Nimblesite.Sql.Model; using Outcome; -using Selecta; using Xunit; -namespace Lql.Tests; +namespace Nimblesite.Lql.Tests; /// /// File-based tests for LQL to PostgreSQL transformation. diff --git a/Lql/Lql.Tests/Lql.Tests.csproj b/Lql/Nimblesite.Lql.Tests/Nimblesite.Lql.Tests.csproj similarity index 79% rename from Lql/Lql.Tests/Lql.Tests.csproj rename to Lql/Nimblesite.Lql.Tests/Nimblesite.Lql.Tests.csproj index f48396bf..436825ba 100644 --- a/Lql/Lql.Tests/Lql.Tests.csproj +++ b/Lql/Nimblesite.Lql.Tests/Nimblesite.Lql.Tests.csproj @@ -24,10 +24,10 @@ - - - - + + + + diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/aggregation_groupby.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/aggregation_groupby.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/aggregation_groupby.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/aggregation_groupby.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_basic.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_basic.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_basic.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_basic.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_brackets.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_brackets.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_brackets.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_brackets.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_case.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_case.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_case.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_case.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_comparisons.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_comparisons.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_comparisons.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_comparisons.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_complex_nested.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_complex_nested.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_complex_nested.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_complex_nested.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_functions.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_functions.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_functions.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_functions.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_simple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_simple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_simple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/arithmetic_simple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/cte_with.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/cte_with.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/cte_with.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/cte_with.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/exists_subquery.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/exists_subquery.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/exists_subquery.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/exists_subquery.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_complex_and_or.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_complex_and_or.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_complex_and_or.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_complex_and_or.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_like.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_like.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_like.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_like.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_multiple_conditions.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_multiple_conditions.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_multiple_conditions.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_multiple_conditions.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_simple_age.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_simple_age.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_simple_age.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/filter_simple_age.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/having_clause.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/having_clause.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/having_clause.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/having_clause.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/in_subquery.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/in_subquery.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/in_subquery.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/in_subquery.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/offset_with_limit.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/offset_with_limit.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/offset_with_limit.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/offset_with_limit.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/order_limit.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/order_limit.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/order_limit.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/order_limit.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/select_with_alias.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/select_with_alias.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/select_with_alias.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/select_with_alias.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_filter_and.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_filter_and.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_filter_and.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_filter_and.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_select.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_select.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_select.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/simple_select.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/subquery_nested.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/subquery_nested.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/subquery_nested.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/subquery_nested.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/window_function.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/window_function.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/PostgreSql/window_function.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/window_function.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/aggregation_groupby.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/aggregation_groupby.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/aggregation_groupby.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/aggregation_groupby.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_basic.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_basic.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_basic.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_basic.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_brackets.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_brackets.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_brackets.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_brackets.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_case.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_case.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_case.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_case.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_comparisons.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_comparisons.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_comparisons.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_comparisons.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_complex_nested.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_complex_nested.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_complex_nested.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_complex_nested.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_functions.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_functions.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_functions.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_functions.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_simple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_simple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_simple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/arithmetic_simple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/complex_join_union.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/complex_join_union.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/complex_join_union.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/complex_join_union.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_complex_and_or.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_complex_and_or.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_complex_and_or.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_complex_and_or.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_like.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_like.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_like.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_like.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_multiple_conditions.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_multiple_conditions.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_multiple_conditions.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_multiple_conditions.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_simple_age.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_simple_age.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/filter_simple_age.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/filter_simple_age.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/having_clause.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/having_clause.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/having_clause.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/having_clause.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/join_left.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/join_left.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/join_left.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/join_left.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/join_multiple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/join_multiple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/join_multiple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/join_multiple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/join_simple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/join_simple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/join_simple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/join_simple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/offset_with_limit.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/offset_with_limit.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/offset_with_limit.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/offset_with_limit.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/order_limit.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/order_limit.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/order_limit.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/order_limit.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/select_with_alias.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/select_with_alias.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/select_with_alias.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/select_with_alias.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/simple_filter_and.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/simple_filter_and.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/simple_filter_and.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/simple_filter_and.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/simple_select.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/simple_select.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/simple_select.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/simple_select.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SQLite/window_function.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/window_function.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SQLite/window_function.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/window_function.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/aggregation_groupby.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/aggregation_groupby.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/aggregation_groupby.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/aggregation_groupby.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_basic.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_basic.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_basic.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_basic.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_brackets.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_brackets.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_brackets.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_brackets.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_case.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_case.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_case.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_case.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_comparisons.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_comparisons.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_comparisons.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_comparisons.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_complex_nested.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_complex_nested.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_complex_nested.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_complex_nested.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_functions.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_functions.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_functions.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_functions.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_simple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_simple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_simple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/arithmetic_simple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/complex_join_union.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/complex_join_union.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/complex_join_union.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/complex_join_union.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/cte_with.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/cte_with.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/cte_with.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/cte_with.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/exists_subquery.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/exists_subquery.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/exists_subquery.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/exists_subquery.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_complex_and_or.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_complex_and_or.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_complex_and_or.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_complex_and_or.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_like.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_like.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_like.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_like.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_multiple_conditions.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_multiple_conditions.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_multiple_conditions.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_multiple_conditions.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_simple_age.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_simple_age.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/filter_simple_age.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/filter_simple_age.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/having_clause.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/having_clause.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/having_clause.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/having_clause.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/in_subquery.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/in_subquery.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/in_subquery.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/in_subquery.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/join_left.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/join_left.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/join_left.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/join_left.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/join_multiple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/join_multiple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/join_multiple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/join_multiple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/join_simple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/join_simple.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/join_simple.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/join_simple.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/offset_with_limit.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/offset_with_limit.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/offset_with_limit.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/offset_with_limit.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/order_limit.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/order_limit.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/order_limit.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/order_limit.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/select_with_alias.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/select_with_alias.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/select_with_alias.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/select_with_alias.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/simple_filter_and.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/simple_filter_and.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/simple_filter_and.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/simple_filter_and.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/simple_select.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/simple_select.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/simple_select.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/simple_select.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/subquery_nested.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/subquery_nested.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/subquery_nested.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/subquery_nested.sql diff --git a/Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/window_function.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/window_function.sql similarity index 100% rename from Lql/Lql.Tests/TestData/ExpectedSql/SqlServer/window_function.sql rename to Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SqlServer/window_function.sql diff --git a/Lql/Lql.Tests/TestData/Lql/aggregation_groupby.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/aggregation_groupby.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/aggregation_groupby.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/aggregation_groupby.lql diff --git a/Lql/Lql.Tests/TestData/Lql/arithmetic_basic.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_basic.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/arithmetic_basic.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_basic.lql diff --git a/Lql/Lql.Tests/TestData/Lql/arithmetic_brackets.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_brackets.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/arithmetic_brackets.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_brackets.lql diff --git a/Lql/Lql.Tests/TestData/Lql/arithmetic_case.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_case.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/arithmetic_case.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_case.lql diff --git a/Lql/Lql.Tests/TestData/Lql/arithmetic_comparisons.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_comparisons.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/arithmetic_comparisons.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_comparisons.lql diff --git a/Lql/Lql.Tests/TestData/Lql/arithmetic_complex_nested.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_complex_nested.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/arithmetic_complex_nested.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_complex_nested.lql diff --git a/Lql/Lql.Tests/TestData/Lql/arithmetic_functions.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_functions.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/arithmetic_functions.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_functions.lql diff --git a/Lql/Lql.Tests/TestData/Lql/arithmetic_simple.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_simple.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/arithmetic_simple.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/arithmetic_simple.lql diff --git a/Lql/Lql.Tests/TestData/Lql/complex_join_union.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/complex_join_union.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/complex_join_union.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/complex_join_union.lql diff --git a/Lql/Lql.Tests/TestData/Lql/cte_with.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/cte_with.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/cte_with.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/cte_with.lql diff --git a/Lql/Lql.Tests/TestData/Lql/exists_subquery.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/exists_subquery.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/exists_subquery.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/exists_subquery.lql diff --git a/Lql/Lql.Tests/TestData/Lql/filter_complex_and_or.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_complex_and_or.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/filter_complex_and_or.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_complex_and_or.lql diff --git a/Lql/Lql.Tests/TestData/Lql/filter_like.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_like.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/filter_like.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_like.lql diff --git a/Lql/Lql.Tests/TestData/Lql/filter_multiple_conditions.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_multiple_conditions.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/filter_multiple_conditions.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_multiple_conditions.lql diff --git a/Lql/Lql.Tests/TestData/Lql/filter_simple_age.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_simple_age.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/filter_simple_age.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/filter_simple_age.lql diff --git a/Lql/Lql.Tests/TestData/Lql/having_clause.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/having_clause.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/having_clause.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/having_clause.lql diff --git a/Lql/Lql.Tests/TestData/Lql/in_subquery.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/in_subquery.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/in_subquery.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/in_subquery.lql diff --git a/Lql/Lql.Tests/TestData/Lql/join_left.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/join_left.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/join_left.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/join_left.lql diff --git a/Lql/Lql.Tests/TestData/Lql/join_multiple.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/join_multiple.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/join_multiple.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/join_multiple.lql diff --git a/Lql/Lql.Tests/TestData/Lql/join_simple.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/join_simple.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/join_simple.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/join_simple.lql diff --git a/Lql/Lql.Tests/TestData/Lql/offset_with_limit.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/offset_with_limit.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/offset_with_limit.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/offset_with_limit.lql diff --git a/Lql/Lql.Tests/TestData/Lql/order_limit.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/order_limit.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/order_limit.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/order_limit.lql diff --git a/Lql/Lql.Tests/TestData/Lql/select_with_alias.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/select_with_alias.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/select_with_alias.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/select_with_alias.lql diff --git a/Lql/Lql.Tests/TestData/Lql/simple_filter_and.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/simple_filter_and.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/simple_filter_and.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/simple_filter_and.lql diff --git a/Lql/Lql.Tests/TestData/Lql/simple_select.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/simple_select.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/simple_select.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/simple_select.lql diff --git a/Lql/Lql.Tests/TestData/Lql/subquery_nested.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/subquery_nested.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/subquery_nested.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/subquery_nested.lql diff --git a/Lql/Lql.Tests/TestData/Lql/window_function.lql b/Lql/Nimblesite.Lql.Tests/TestData/Lql/window_function.lql similarity index 100% rename from Lql/Lql.Tests/TestData/Lql/window_function.lql rename to Lql/Nimblesite.Lql.Tests/TestData/Lql/window_function.lql diff --git a/Lql/Lql.Tests/Testing.ruleset b/Lql/Nimblesite.Lql.Tests/Testing.ruleset similarity index 63% rename from Lql/Lql.Tests/Testing.ruleset rename to Lql/Nimblesite.Lql.Tests/Testing.ruleset index dc3f03f1..833b4630 100644 --- a/Lql/Lql.Tests/Testing.ruleset +++ b/Lql/Nimblesite.Lql.Tests/Testing.ruleset @@ -1,5 +1,5 @@ - + diff --git a/Lql/Lql.TypeProvider.FSharp.Tests.Data/DataProvider.json b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/DataProvider.json similarity index 90% rename from Lql/Lql.TypeProvider.FSharp.Tests.Data/DataProvider.json rename to Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/DataProvider.json index 43eb1e3e..37218686 100644 --- a/Lql/Lql.TypeProvider.FSharp.Tests.Data/DataProvider.json +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/DataProvider.json @@ -34,5 +34,5 @@ "primaryKeyColumns": ["Id"] } ], - "connectionString": "Data Source=../Lql.TypeProvider.FSharp.Tests/typeprovider-test.db" + "connectionString": "Data Source=../Nimblesite.Lql.TypeProvider.FSharp.Tests/typeprovider-test.db" } diff --git a/Lql/Lql.TypeProvider.FSharp.Tests.Data/Lql.TypeProvider.FSharp.Tests.Data.csproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj similarity index 62% rename from Lql/Lql.TypeProvider.FSharp.Tests.Data/Lql.TypeProvider.FSharp.Tests.Data.csproj rename to Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj index ef99bcb4..f0cba5c4 100644 --- a/Lql/Lql.TypeProvider.FSharp.Tests.Data/Lql.TypeProvider.FSharp.Tests.Data.csproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj @@ -1,6 +1,6 @@ - net10.0 + net9.0 enable enable true @@ -13,12 +13,12 @@ - - + + - + @@ -27,23 +27,23 @@ - + PreserveNewest typeprovider-test-schema.yaml - + - + /// Seeds the test database with sample data using generated DataProvider extensions diff --git a/Lql/Lql.TypeProvider.FSharp.Tests/DataProvider.json b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/DataProvider.json similarity index 100% rename from Lql/Lql.TypeProvider.FSharp.Tests/DataProvider.json rename to Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/DataProvider.json diff --git a/Lql/Lql.TypeProvider.FSharp.Tests/Lql.TypeProvider.FSharp.Tests.fsproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj similarity index 60% rename from Lql/Lql.TypeProvider.FSharp.Tests/Lql.TypeProvider.FSharp.Tests.fsproj rename to Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj index 6c2d3a16..a62c6d48 100644 --- a/Lql/Lql.TypeProvider.FSharp.Tests/Lql.TypeProvider.FSharp.Tests.fsproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj @@ -1,7 +1,7 @@ - net10.0 + net9.0 preview false true @@ -30,11 +30,11 @@ - - - - - + + + + + @@ -44,9 +44,9 @@ - + - + diff --git a/Lql/Lql.TypeProvider.FSharp.Tests/TypeProviderE2ETests.fs b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/TypeProviderE2ETests.fs similarity index 95% rename from Lql/Lql.TypeProvider.FSharp.Tests/TypeProviderE2ETests.fs rename to Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/TypeProviderE2ETests.fs index e0d7a0fd..ee0287a1 100644 --- a/Lql/Lql.TypeProvider.FSharp.Tests/TypeProviderE2ETests.fs +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/TypeProviderE2ETests.fs @@ -1,12 +1,12 @@ -module Lql.TypeProvider.Tests +module Nimblesite.Lql.Core.TypeProvider.Tests open System open System.IO open Microsoft.Data.Sqlite open Xunit open Lql -open Lql.TypeProvider -open Lql.TypeProvider.FSharp.Tests.Data +open Nimblesite.Lql.Core.TypeProvider +open Nimblesite.Lql.TypeProvider.FSharp.Tests.Data // ============================================================================= // COMPILE-TIME VALIDATED LQL QUERIES @@ -46,11 +46,11 @@ type ArithmeticComplex = LqlCommand<"Orders |> select(Orders.Subtotal + Orders.T // ============================================================================= // E2E TEST FIXTURES - Test the type provider with REAL SQLite database file -// Schema is created by Migration.CLI from YAML - NO raw SQL for schema! +// Schema is created by Nimblesite.DataProvider.Migration.Core.CLI from YAML - NO raw SQL for schema! // ============================================================================= module TestFixtures = - /// Get the path to the test database file (created by Migration.CLI from YAML) + /// Get the path to the test database file (created by Nimblesite.DataProvider.Migration.Core.CLI from YAML) let getTestDbPath() = let baseDir = AppDomain.CurrentDomain.BaseDirectory // The database is created in the project directory by MSBuild target @@ -62,7 +62,7 @@ module TestFixtures = let openTestDatabase() = let dbPath = getTestDbPath() if not (File.Exists(dbPath)) then - failwithf "Test database not found at %s. Run 'dotnet build' first to create it via Migration.CLI." dbPath + failwithf "Test database not found at %s. Run 'dotnet build' first to create it via Nimblesite.DataProvider.Migration.Core.CLI." dbPath let conn = new SqliteConnection($"Data Source={dbPath}") conn.Open() conn @@ -73,9 +73,9 @@ module TestFixtures = use transaction = conn.BeginTransaction() let result = TestDataSeeder.SeedDataAsync(transaction).GetAwaiter().GetResult() match result with - | :? Outcome.Result.Ok -> + | :? Outcome.Result.Ok -> transaction.Commit() - | :? Outcome.Result.Error as err -> + | :? Outcome.Result.Error as err -> transaction.Rollback() failwithf "Failed to seed test data: %s" (err.Value.ToString()) | _ -> diff --git a/Lql/Lql.TypeProvider.FSharp.Tests/typeprovider-test-schema.yaml b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/typeprovider-test-schema.yaml similarity index 100% rename from Lql/Lql.TypeProvider.FSharp.Tests/typeprovider-test-schema.yaml rename to Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/typeprovider-test-schema.yaml diff --git a/Lql/Lql.TypeProvider.FSharp/LqlTypeProvider.fs b/Lql/Nimblesite.Lql.TypeProvider.FSharp/LqlTypeProvider.fs similarity index 96% rename from Lql/Lql.TypeProvider.FSharp/LqlTypeProvider.fs rename to Lql/Nimblesite.Lql.TypeProvider.FSharp/LqlTypeProvider.fs index a1a63ae0..d549ea73 100644 --- a/Lql/Lql.TypeProvider.FSharp/LqlTypeProvider.fs +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp/LqlTypeProvider.fs @@ -1,14 +1,14 @@ -namespace Lql.TypeProvider +namespace Nimblesite.Lql.Core.TypeProvider open System open System.Reflection open Microsoft.FSharp.Core.CompilerServices open Microsoft.FSharp.Quotations open ProviderImplementation.ProvidedTypes -open Lql -open Lql.SQLite +open Nimblesite.Lql.Core +open Nimblesite.Lql.SQLite open Outcome -open Selecta +open Nimblesite.Sql.Model [] type public LqlTypeProvider(config: TypeProviderConfig) as this = diff --git a/Lql/Lql.TypeProvider.FSharp/Lql.TypeProvider.FSharp.fsproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp/Nimblesite.Lql.TypeProvider.FSharp.fsproj similarity index 72% rename from Lql/Lql.TypeProvider.FSharp/Lql.TypeProvider.FSharp.fsproj rename to Lql/Nimblesite.Lql.TypeProvider.FSharp/Nimblesite.Lql.TypeProvider.FSharp.fsproj index 68b6fb7c..e7753329 100644 --- a/Lql/Lql.TypeProvider.FSharp/Lql.TypeProvider.FSharp.fsproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp/Nimblesite.Lql.TypeProvider.FSharp.fsproj @@ -1,7 +1,7 @@ - net10.0 + net9.0 true preview false @@ -24,9 +24,9 @@ - - - + + + \ No newline at end of file diff --git a/Lql/LqlWebsite/Components/App.razor b/Lql/Nimblesite.Lql.Website/Components/App.razor similarity index 100% rename from Lql/LqlWebsite/Components/App.razor rename to Lql/Nimblesite.Lql.Website/Components/App.razor diff --git a/Lql/LqlWebsite/Components/Layout/MainLayout.razor b/Lql/Nimblesite.Lql.Website/Components/Layout/MainLayout.razor similarity index 100% rename from Lql/LqlWebsite/Components/Layout/MainLayout.razor rename to Lql/Nimblesite.Lql.Website/Components/Layout/MainLayout.razor diff --git a/Lql/LqlWebsite/Components/Pages/Home.razor b/Lql/Nimblesite.Lql.Website/Components/Pages/Home.razor similarity index 99% rename from Lql/LqlWebsite/Components/Pages/Home.razor rename to Lql/Nimblesite.Lql.Website/Components/Pages/Home.razor index 50eaa3e1..ff95f9a1 100644 --- a/Lql/LqlWebsite/Components/Pages/Home.razor +++ b/Lql/Nimblesite.Lql.Website/Components/Pages/Home.razor @@ -1,10 +1,10 @@ @page "/" @using Microsoft.AspNetCore.Components.Web -@using Lql -@using Lql.Postgres -@using Lql.SqlServer +@using Nimblesite.Lql.Core +@using Nimblesite.Lql.Postgres +@using Nimblesite.Lql.SqlServer @using Outcome -@using Selecta +@using Nimblesite.Sql.Model Lambda Query Language (LQL) - Functional Data Querying diff --git a/Lql/LqlWebsite/Components/_Imports.razor b/Lql/Nimblesite.Lql.Website/Components/_Imports.razor similarity index 63% rename from Lql/LqlWebsite/Components/_Imports.razor rename to Lql/Nimblesite.Lql.Website/Components/_Imports.razor index e4df38e6..36637a5f 100644 --- a/Lql/LqlWebsite/Components/_Imports.razor +++ b/Lql/Nimblesite.Lql.Website/Components/_Imports.razor @@ -6,7 +6,8 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop -@using LqlWebsite -@using LqlWebsite.Components -@using LqlWebsite.Components.Layout -@using LqlWebsite.Components.Pages \ No newline at end of file +@using Nimblesite.Lql.Core +@using Nimblesite.Lql.Website +@using Nimblesite.Lql.Website.Components +@using Nimblesite.Lql.Website.Components.Layout +@using Nimblesite.Lql.Website.Components.Pages \ No newline at end of file diff --git a/Lql/Nimblesite.Lql.Website/GlobalUsings.cs b/Lql/Nimblesite.Lql.Website/GlobalUsings.cs new file mode 100644 index 00000000..08a38157 --- /dev/null +++ b/Lql/Nimblesite.Lql.Website/GlobalUsings.cs @@ -0,0 +1 @@ +// GlobalUsings intentionally empty — Lql.Core is imported via _Imports.razor diff --git a/Lql/LqlWebsite/LqlWebsite.csproj b/Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj similarity index 53% rename from Lql/LqlWebsite/LqlWebsite.csproj rename to Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj index 71581da8..3f8adeae 100644 --- a/Lql/LqlWebsite/LqlWebsite.csproj +++ b/Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj @@ -1,22 +1,22 @@ - net10.0 + net9.0 enable enable - + - - - + + + diff --git a/Lql/LqlWebsite/Program.cs b/Lql/Nimblesite.Lql.Website/Program.cs similarity index 91% rename from Lql/LqlWebsite/Program.cs rename to Lql/Nimblesite.Lql.Website/Program.cs index 4da9c5b8..b5016deb 100644 --- a/Lql/LqlWebsite/Program.cs +++ b/Lql/Nimblesite.Lql.Website/Program.cs @@ -1,6 +1,6 @@ -using LqlWebsite.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Nimblesite.Lql.Website.Components; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); diff --git a/Lql/LqlWebsite/Properties/launchSettings.json b/Lql/Nimblesite.Lql.Website/Properties/launchSettings.json similarity index 100% rename from Lql/LqlWebsite/Properties/launchSettings.json rename to Lql/Nimblesite.Lql.Website/Properties/launchSettings.json diff --git a/Lql/LqlWebsite/lql-icon.png b/Lql/Nimblesite.Lql.Website/lql-icon.png similarity index 100% rename from Lql/LqlWebsite/lql-icon.png rename to Lql/Nimblesite.Lql.Website/lql-icon.png diff --git a/Lql/LqlWebsite/wwwroot/css/site.css b/Lql/Nimblesite.Lql.Website/wwwroot/css/site.css similarity index 100% rename from Lql/LqlWebsite/wwwroot/css/site.css rename to Lql/Nimblesite.Lql.Website/wwwroot/css/site.css diff --git a/Lql/LqlWebsite/wwwroot/index.html b/Lql/Nimblesite.Lql.Website/wwwroot/index.html similarity index 100% rename from Lql/LqlWebsite/wwwroot/index.html rename to Lql/Nimblesite.Lql.Website/wwwroot/index.html diff --git a/Lql/LqlWebsite/wwwroot/lql-icon.png b/Lql/Nimblesite.Lql.Website/wwwroot/lql-icon.png similarity index 100% rename from Lql/LqlWebsite/wwwroot/lql-icon.png rename to Lql/Nimblesite.Lql.Website/wwwroot/lql-icon.png diff --git a/Lql/README.md b/Lql/README.md index 4f952ddd..ba31a646 100644 --- a/Lql/README.md +++ b/Lql/README.md @@ -1,171 +1,27 @@ # Lambda Query Language (LQL) -A functional pipeline-style DSL that transpiles to SQL. Write database logic once, run it anywhere. - -## The Problem - -SQL dialects differ. PostgreSQL, SQLite, and SQL Server each have their own quirks. This creates problems: - -- **Migrations** - Schema changes need different SQL for each database -- **Business Logic** - Triggers, stored procedures, and constraints vary by vendor -- **Sync Logic** - Offline-first apps need identical logic on client (SQLite) and server (Postgres) -- **Testing** - Running tests against SQLite while production uses Postgres - -## The Solution - -LQL is a single query language that transpiles to any SQL dialect. Write once, deploy everywhere. +A functional pipeline-style DSL that transpiles to SQL. Write database logic once, run it on PostgreSQL, SQLite, or SQL Server. ```lql Users |> filter(fn(row) => row.Age > 18 and row.Status = 'active') |> join(Orders, on = Users.Id = Orders.UserId) -|> group_by(Users.Id, Users.Name) |> select(Users.Name, sum(Orders.Total) as TotalSpent) -|> order_by(TotalSpent desc) -|> limit(10) -``` - -This transpiles to correct SQL for PostgreSQL, SQLite, or SQL Server. - -## Use Cases - -### Cross-Database Migrations -Define schema changes in LQL. Migration.CLI generates the right SQL for your target database. - -### Cross DB Platform Business Logic With Triggers -Write triggers and constraints in LQL. Deploy the same logic to any database. - -### Offline-First Sync -Sync framework uses LQL for conflict resolution. Same logic runs on mobile (SQLite) and server (Postgres). - -### Integration Testing -Test against SQLite locally, deploy to Postgres in production. Same queries, same results. - -## Quick Start - -### CLI Tool -```bash -dotnet tool install -g LqlCli.SQLite -lql --input query.lql --output query.sql -``` - -### NuGet Packages -```xml - - - -``` - -### Programmatic Usage -```csharp -using Lql; -using Lql.SQLite; - -var lql = "Users |> filter(fn(row) => row.Age > 21) |> select(Name, Email)"; -var sql = LqlCodeParser.Parse(lql).ToSql(new SQLiteContext()); -``` - -## F# Type Provider - -Validate LQL queries at compile time. Invalid queries cause compilation errors, not runtime errors. - -### Installation - -```xml - -``` - -### Basic Usage - -```fsharp -open Lql - -// Define types with validated LQL - errors caught at COMPILE TIME -type GetUsers = LqlCommand<"Users |> select(Users.Id, Users.Name, Users.Email)"> -type ActiveUsers = LqlCommand<"Users |> filter(fn(row) => row.Status = 'active') |> select(*)"> - -// Access generated SQL and original query -let sql = GetUsers.Sql // Generated SQL string -let query = GetUsers.Query // Original LQL string -``` - -### What Gets Validated - -The type provider validates your LQL at compile time and generates two properties: -- `Query` - The original LQL query string -- `Sql` - The generated SQL (SQLite dialect) - -### Query Examples - -```fsharp -// Select with columns -type SelectColumns = LqlCommand<"Users |> select(Users.Id, Users.Name, Users.Email)"> - -// Filtering with AND/OR -type FilterComplex = LqlCommand<"Users |> filter(fn(row) => row.Users.Age > 18 and row.Users.Status = 'active') |> select(*)"> - -// Joins -type JoinQuery = LqlCommand<"Users |> join(Orders, on = Users.Id = Orders.UserId) |> select(Users.Name, Orders.Total)"> -type LeftJoin = LqlCommand<"Users |> left_join(Orders, on = Users.Id = Orders.UserId) |> select(*)"> - -// Aggregations with GROUP BY and HAVING -type GroupBy = LqlCommand<"Orders |> group_by(Orders.UserId) |> select(Orders.UserId, count(*) as order_count)"> -type Having = LqlCommand<"Orders |> group_by(Orders.UserId) |> having(fn(g) => count(*) > 5) |> select(Orders.UserId, count(*) as cnt)"> - -// Order, limit, offset -type Pagination = LqlCommand<"Users |> order_by(Users.Name asc) |> limit(10) |> offset(20) |> select(*)"> - -// Arithmetic expressions -type Calculated = LqlCommand<"Products |> select(Products.Price * Products.Quantity as total)"> ``` -### Compile-Time Error Example - -Invalid LQL causes a build error with line/column position: - -```fsharp -// This FAILS to compile with: "Invalid LQL syntax at line 1, column 15" -type BadQuery = LqlCommand<"Users |> selectt(*)"> // typo: 'selectt' -``` - -### Executing Queries - -```fsharp -open Microsoft.Data.Sqlite - -let executeQuery() = - use conn = new SqliteConnection("Data Source=mydb.db") - conn.Open() - - // SQL is validated at compile time, safe to execute - use cmd = new SqliteCommand(GetUsers.Sql, conn) - use reader = cmd.ExecuteReader() - // ... process results -``` - -## Pipeline Operations - -| Operation | Description | -|-----------|-------------| -| `select(cols...)` | Choose columns | -| `filter(fn(row) => ...)` | Filter rows | -| `join(table, on = ...)` | Join tables | -| `left_join(table, on = ...)` | Left join | -| `group_by(cols...)` | Group rows | -| `having(fn(row) => ...)` | Filter groups | -| `order_by(col [asc/desc])` | Sort results | -| `limit(n)` / `offset(n)` | Pagination | -| `distinct()` | Unique rows | -| `union(query)` | Combine queries | - -## VS Code Extension - -Search for "LQL" in VS Code Extensions for syntax highlighting and IntelliSense. - -## Website +## Projects -Visit [lql.dev](https://lql.dev) for interactive playground. +| Project | Description | +|---------|-------------| +| `Nimblesite.Lql.Core` | Core transpiler library | +| `Nimblesite.Lql.Cli.SQLite` | CLI transpiler tool | +| `LqlExtension` | VS Code extension (TypeScript) | +| `lql-lsp-rust` | Language server (Rust) | +| `Nimblesite.Lql.TypeProvider.FSharp` | F# type provider for compile-time validation | -## License +## Documentation -MIT License +- LQL spec: [docs/specs/lql-spec.md](../docs/specs/lql-spec.md) +- LQL design system: [docs/specs/lql-design-system.md](../docs/specs/lql-design-system.md) +- LQL reference (compiled into LSP): [lql-lsp-rust/crates/lql-reference.md](lql-lsp-rust/crates/lql-reference.md) +- Website docs: [Website/src/docs/lql.md](../Website/src/docs/lql.md) diff --git a/Lql/lql-lsp-rust/crates/lql-reference.md b/Lql/lql-lsp-rust/crates/lql-reference.md index b8e2ba2c..7f97488d 100644 --- a/Lql/lql-lsp-rust/crates/lql-reference.md +++ b/Lql/lql-lsp-rust/crates/lql-reference.md @@ -13,16 +13,19 @@ table |> operation1(...) |> operation2(...) |> select(...) | Operation | SQL Equivalent | Syntax | |-----------|---------------|--------| | `select(cols...)` | `SELECT` | `select(name, email, age)` or `select(*)` | +| `select_distinct(cols...)` | `SELECT DISTINCT` | `select_distinct(name, email)` | | `filter(fn(row) => cond)` | `WHERE` | `filter(fn(row) => row.age > 18)` | -| `join(table, on = cond)` | `JOIN` | `join(orders, on = users.id = orders.user_id)` | +| `join(table, on = cond)` | `INNER JOIN` | `join(orders, on = users.id = orders.user_id)` | +| `left_join(table, on = cond)` | `LEFT JOIN` | `left_join(orders, on = users.id = orders.user_id)` | +| `cross_join(table)` | `CROSS JOIN` | `cross_join(dates)` | | `group_by(cols...)` | `GROUP BY` | `group_by(department)` | -| `order_by(col dir)` | `ORDER BY` | `order_by(name asc)` or `order_by(age desc)` | | `having(cond)` | `HAVING` | `having(count(*) > 5)` | +| `order_by(col dir)` | `ORDER BY` | `order_by(name asc)` or `order_by(age desc)` | | `limit(n)` | `LIMIT` | `limit(10)` | | `offset(n)` | `OFFSET` | `offset(20)` | | `union` | `UNION` | query1 `|> union |>` query2 | +| `union_all` | `UNION ALL` | query1 `|> union_all |>` query2 | | `insert(target)` | `INSERT INTO...SELECT` | `|> insert(archive_users)` | -| `distinct` | `DISTINCT` | `select(distinct name)` | ## Aggregate Functions @@ -36,6 +39,52 @@ table |> operation1(...) |> operation2(...) |> select(...) `round(col, decimals)`, `floor(col)`, `ceil(col)`, `abs(col)`, `sqrt(col)` +## Date/Time Functions + +`current_date()`, `extract(part, col)`, `date_trunc(part, col)` + +## Window Functions + +Window functions use the `OVER` clause with optional `PARTITION BY` and `ORDER BY`: + +```lql +employees +|> select( + name, + department, + salary, + row_number() over (partition by department order by salary desc) as rank +) +``` + +Available window functions: `row_number()`, `rank()`, `dense_rank()`, `lag(col)`, `lead(col)` + +## Other Functions + +`coalesce(a, b, ...)`, `exists(subquery)` + +## Subquery Operators + +### EXISTS + +```lql +customers +|> filter(fn(row) => exists( + orders |> filter(fn(o) => o.customer_id = row.id) +)) +|> select(*) +``` + +### IN + +```lql +users +|> filter(fn(row) => row.id in ( + orders |> select(user_id) +)) +|> select(*) +``` + ## Let Bindings ``` @@ -52,10 +101,11 @@ fn(row) => row.price * 0.1 ## Operators -Comparison: `=`, `!=`, `>`, `<`, `>=`, `<=` +Comparison: `=`, `!=`, `<>`, `>`, `<`, `>=`, `<=`, `like` Logical: `and`, `or`, `not` -Arithmetic: `+`, `-`, `*`, `/` -Other: `is null`, `is not null`, `in`, `like`, `exists` +Arithmetic: `+`, `-`, `*`, `/`, `%` +String: `||` (concatenation) +Other: `is null`, `is not null`, `in`, `exists` ## Case Expressions @@ -67,6 +117,12 @@ case when condition then value else other_value end Access columns via `table.column`: `users.name`, `orders.total` +## Column Aliases + +```lql +select(price * 1.1 as total, upper(name) as upper_name) +``` + ## Identifier Rules - Case-insensitive (transpiles to lowercase) @@ -86,6 +142,11 @@ orders |> select(customers.name, sum(orders.total) as total_spent) |> order_by(total_spent desc) +-- Left join +users +|> left_join(orders, on = users.id = orders.user_id) +|> select(users.name, orders.total) + -- Let binding with filter let vip = customers |> filter(fn(row) => row.tier = 'gold') in vip |> select(name, email) |> order_by(name asc) @@ -95,20 +156,45 @@ products |> select(name, price, price * 0.1 as tax, price * 1.1 as total) |> filter(fn(row) => row.total > 100) +-- Window function +employees +|> select( + name, + department, + salary, + row_number() over (partition by department order by salary desc) as dept_rank +) + +-- Union +active_users |> select(name, email) +|> union +|> archived_users |> select(name, email) + +-- Pagination +users |> order_by(name asc) |> limit(10) |> offset(20) |> select(*) + -- Subquery with insert users |> filter(fn(row) => row.last_login < '2024-01-01') |> select(id, name, email) |> insert(inactive_users) + +-- Exists subquery +customers +|> filter(fn(row) => exists( + orders |> filter(fn(o) => o.customer_id = row.id) +)) +|> select(name, email) ``` ## Completion Context When completing LQL code: -- After `|>`: suggest pipeline operations (select, filter, join, group_by, order_by, etc.) +- After `|>`: suggest pipeline operations (select, filter, join, left_join, cross_join, group_by, order_by, having, limit, offset, union, union_all, insert, select_distinct) - After `table.`: suggest column names for that table - Inside `fn(row) =>`: suggest row.column expressions -- Inside `select(...)`: suggest column names, aggregate functions, expressions +- Inside `select(...)`: suggest column names, aggregate functions, window functions, expressions - Inside `filter(...)`: suggest comparison expressions -- After `join(`: suggest table names +- After `join(` or `left_join(`: suggest table names - After `order_by(`: suggest column names, then `asc`/`desc` +- After `over (`: suggest `partition by` and `order by` diff --git a/Makefile b/Makefile index c51663dd..f8362920 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ # agent-pmo:d75d5c8 # ============================================================================= -# Standard Makefile — DataProvider +# Standard Makefile — Nimblesite.DataProvider.Core # Cross-platform: Linux, macOS, Windows (via GNU Make) # All targets are language-agnostic. Add language-specific helpers below. # ============================================================================= -.PHONY: build test lint fmt fmt-check clean check ci coverage coverage-check setup +.PHONY: build test lint fmt fmt-check clean check ci coverage setup # ----------------------------------------------------------------------------- # OS Detection — portable commands for Linux, macOS, and Windows @@ -23,8 +23,18 @@ else MKDIR = mkdir -p endif -# Coverage threshold (override in CI via env var or per-repo) -COVERAGE_THRESHOLD ?= 90 +# All .NET test projects (one per line for readability) +DOTNET_TEST_PROJECTS = \ + DataProvider/Nimblesite.DataProvider.Tests \ + DataProvider/Nimblesite.DataProvider.Example.Tests \ + Lql/Nimblesite.Lql.Tests \ + Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests \ + Migration/Nimblesite.DataProvider.Migration.Tests \ + Sync/Nimblesite.Sync.Tests \ + Sync/Nimblesite.Sync.SQLite.Tests \ + Sync/Nimblesite.Sync.Postgres.Tests \ + Sync/Nimblesite.Sync.Integration.Tests \ + Sync/Nimblesite.Sync.Http.Tests # ============================================================================= # PRIMARY TARGETS (uniform interface — do not rename) @@ -35,7 +45,7 @@ build: @echo "==> Building..." $(MAKE) _build -## test: Run full test suite with coverage +## test: Run full test suite with coverage enforcement test: @echo "==> Testing..." $(MAKE) _test @@ -66,16 +76,11 @@ check: lint test ## ci: lint + test + build (full CI simulation) ci: lint test build -## coverage: Generate coverage report +## coverage: Generate HTML coverage report (runs tests first) coverage: @echo "==> Coverage report..." $(MAKE) _coverage -## coverage-check: Assert thresholds (exits non-zero if below) -coverage-check: - @echo "==> Checking coverage thresholds..." - $(MAKE) _coverage_check - ## setup: Post-create dev environment setup (used by devcontainer) setup: @echo "==> Setting up development environment..." @@ -84,12 +89,11 @@ setup: # ============================================================================= # LANGUAGE-SPECIFIC IMPLEMENTATIONS -# DataProvider is a multi-language repo: C#/.NET (primary), Rust, TypeScript # ============================================================================= _build: _build_dotnet _build_rust _build_ts -_test: _test_dotnet _test_rust +_test: _test_dotnet _test_rust _test_ts _lint: _lint_dotnet _lint_rust _lint_ts @@ -101,20 +105,95 @@ _clean: _clean_dotnet _clean_rust _clean_ts _coverage: _coverage_dotnet -_coverage_check: _coverage_check_dotnet - _setup: _setup_dotnet _setup_ts +# ============================================================================= +# COVERAGE ENFORCEMENT (shared shell logic) +# ============================================================================= +# Each test target collects coverage, compares against coverage-thresholds.json, +# fails hard if below, and ratchets up if above. +# +# coverage-thresholds.json keys are SOURCE project paths, e.g.: +# "DataProvider/Nimblesite.DataProvider.Core": { "threshold": 88, "include": "..." } +# +# The SRC_KEY mapping converts test project paths -> source project keys. +# CI calls these same make targets — no duplication. +# ============================================================================= + # --- C#/.NET --- _build_dotnet: dotnet build DataProvider.sln --configuration Release _test_dotnet: - dotnet test DataProvider.sln --configuration Release \ - --settings coverlet.runsettings \ - --collect:"XPlat Code Coverage" \ - --results-directory TestResults \ - --verbosity normal + @for test_proj in $(DOTNET_TEST_PROJECTS); do \ + SRC_KEY=$$(echo "$$test_proj" | sed 's/\.Tests$$//'); \ + case "$$SRC_KEY" in \ + "DataProvider/Nimblesite.DataProvider") SRC_KEY="DataProvider/Nimblesite.DataProvider.Core" ;; \ + "DataProvider/Nimblesite.DataProvider.Example") ;; \ + "Lql/Nimblesite.Lql") SRC_KEY="Lql/Nimblesite.Lql.Core" ;; \ + "Lql/Nimblesite.Lql.TypeProvider.FSharp") ;; \ + "Migration/Nimblesite.DataProvider.Migration") SRC_KEY="Migration/Nimblesite.DataProvider.Migration.Core" ;; \ + "Sync/Nimblesite.Sync") SRC_KEY="Sync/Nimblesite.Sync.Core" ;; \ + "Sync/Nimblesite.Sync.SQLite") ;; \ + "Sync/Nimblesite.Sync.Postgres") ;; \ + "Sync/Nimblesite.Sync.Integration") ;; \ + "Sync/Nimblesite.Sync.Http") ;; \ + esac; \ + THRESHOLD=$$(jq -r ".projects[\"$$SRC_KEY\"].threshold // .default_threshold" coverage-thresholds.json); \ + INCLUDE=$$(jq -r ".projects[\"$$SRC_KEY\"].include // empty" coverage-thresholds.json); \ + echo ""; \ + echo "============================================================"; \ + echo "==> Testing $$SRC_KEY (via $$test_proj, threshold: $$THRESHOLD%)"; \ + if [ -n "$$INCLUDE" ]; then echo " Include filter: $$INCLUDE"; fi; \ + echo "============================================================"; \ + rm -rf "$$test_proj/TestResults"; \ + if [ -n "$$INCLUDE" ]; then \ + dotnet test "$$test_proj" --configuration Release \ + --settings coverlet.runsettings \ + --collect:"XPlat Code Coverage" \ + --results-directory "$$test_proj/TestResults" \ + --verbosity normal \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="$$INCLUDE"; \ + else \ + dotnet test "$$test_proj" --configuration Release \ + --settings coverlet.runsettings \ + --collect:"XPlat Code Coverage" \ + --results-directory "$$test_proj/TestResults" \ + --verbosity normal; \ + fi; \ + if [ $$? -ne 0 ]; then \ + echo "FAIL [$$SRC_KEY]: Tests failed ($$test_proj)"; \ + exit 1; \ + fi; \ + COBERTURA=$$(find "$$test_proj/TestResults" -name "coverage.cobertura.xml" -type f 2>/dev/null | head -1); \ + if [ -z "$$COBERTURA" ]; then \ + echo "FAIL [$$SRC_KEY]: No coverage file produced ($$test_proj)"; \ + exit 1; \ + fi; \ + LINE_RATE=$$(sed -n 's/.*line-rate="\([0-9.]*\)".*/\1/p' "$$COBERTURA" | head -1); \ + if [ -z "$$LINE_RATE" ]; then \ + echo "FAIL [$$SRC_KEY]: Could not parse line-rate from $$COBERTURA"; \ + exit 1; \ + fi; \ + COVERAGE=$$(echo "$$LINE_RATE * 100" | bc -l); \ + COVERAGE_FMT=$$(printf "%.2f" $$COVERAGE); \ + echo ""; \ + echo " [$$SRC_KEY] Coverage: $$COVERAGE_FMT% | Threshold: $$THRESHOLD%"; \ + BELOW=$$(echo "$$COVERAGE < $$THRESHOLD" | bc -l); \ + if [ "$$BELOW" = "1" ]; then \ + echo " FAIL [$$SRC_KEY]: $$COVERAGE_FMT% is BELOW threshold $$THRESHOLD%"; \ + exit 1; \ + fi; \ + ABOVE=$$(echo "$$COVERAGE > $$THRESHOLD" | bc -l); \ + if [ "$$ABOVE" = "1" ]; then \ + NEW=$$(echo "$$COVERAGE" | awk '{print int($$1)}'); \ + echo " Ratcheting threshold: $$THRESHOLD% -> $$NEW%"; \ + jq ".projects[\"$$SRC_KEY\"].threshold = $$NEW" coverage-thresholds.json > coverage-thresholds.json.tmp && mv coverage-thresholds.json.tmp coverage-thresholds.json; \ + fi; \ + echo " PASS [$$SRC_KEY]"; \ + done; \ + echo ""; \ + echo "==> All .NET test projects passed coverage thresholds." _lint_dotnet: dotnet build DataProvider.sln --configuration Release @@ -136,12 +215,8 @@ else endif _coverage_dotnet: - dotnet test DataProvider.sln --configuration Release \ - --settings coverlet.runsettings \ - --collect:"XPlat Code Coverage" \ - --results-directory TestResults \ - --verbosity normal - reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" \ + $(MAKE) _test_dotnet + reportgenerator -reports:"**/TestResults/**/coverage.cobertura.xml" \ -targetdir:coverage/html -reporttypes:Html ifeq ($(OS),Windows_NT) Start-Process coverage/html/index.html @@ -151,23 +226,6 @@ else xdg-open coverage/html/index.html endif -_coverage_check_dotnet: - @COVERAGE=$$(dotnet test DataProvider.sln --configuration Release \ - --settings coverlet.runsettings \ - --collect:"XPlat Code Coverage" \ - --results-directory TestResults \ - --verbosity quiet 2>/dev/null | grep -oP 'Line coverage: \K[0-9.]+' | tail -1); \ - THRESHOLD=$${COVERAGE_THRESHOLD:-80}; \ - if [ -z "$$COVERAGE" ]; then \ - echo "WARNING: Could not extract coverage percentage"; \ - else \ - echo "Coverage: $$COVERAGE% (threshold: $$THRESHOLD%)"; \ - if [ $$(echo "$$COVERAGE < $$THRESHOLD" | bc -l) -eq 1 ]; then \ - echo "FAIL: Coverage $$COVERAGE% is below threshold $$THRESHOLD%"; \ - exit 1; \ - fi; \ - fi - _setup_dotnet: dotnet restore dotnet tool restore @@ -177,7 +235,36 @@ _build_rust: cd Lql/lql-lsp-rust && cargo build --release _test_rust: - cd Lql/lql-lsp-rust && cargo test --workspace + @THRESHOLD=$$(jq -r '.projects["Lql/lql-lsp-rust"].threshold // .default_threshold' coverage-thresholds.json); \ + echo ""; \ + echo "============================================================"; \ + echo "==> Testing Lql/lql-lsp-rust (threshold: $$THRESHOLD%)"; \ + echo "============================================================"; \ + cd Lql/lql-lsp-rust && cargo tarpaulin --workspace --skip-clean 2>&1 | tee /tmp/_dp_tarpaulin_out.txt; \ + TARP_EXIT=$${PIPESTATUS[0]}; \ + if [ $$TARP_EXIT -ne 0 ]; then \ + echo "FAIL [Lql/lql-lsp-rust]: cargo tarpaulin failed"; \ + exit 1; \ + fi; \ + COVERAGE=$$(grep -oE '[0-9]+\.[0-9]+% coverage' /tmp/_dp_tarpaulin_out.txt | tail -1 | grep -oE '[0-9]+\.[0-9]+'); \ + if [ -z "$$COVERAGE" ]; then \ + echo "FAIL [Lql/lql-lsp-rust]: Could not parse coverage from tarpaulin output"; \ + exit 1; \ + fi; \ + echo ""; \ + echo " [Lql/lql-lsp-rust] Coverage: $$COVERAGE% | Threshold: $$THRESHOLD%"; \ + BELOW=$$(echo "$$COVERAGE < $$THRESHOLD" | bc -l); \ + if [ "$$BELOW" = "1" ]; then \ + echo " FAIL [Lql/lql-lsp-rust]: $$COVERAGE% is BELOW threshold $$THRESHOLD%"; \ + exit 1; \ + fi; \ + ABOVE=$$(echo "$$COVERAGE > $$THRESHOLD" | bc -l); \ + if [ "$$ABOVE" = "1" ]; then \ + NEW=$$(echo "$$COVERAGE" | awk '{print int($$1)}'); \ + echo " Ratcheting threshold: $$THRESHOLD% -> $$NEW%"; \ + cd "$(CURDIR)" && jq '.projects["Lql/lql-lsp-rust"].threshold = '"$$NEW" coverage-thresholds.json > coverage-thresholds.json.tmp && mv coverage-thresholds.json.tmp coverage-thresholds.json; \ + fi; \ + echo " PASS [Lql/lql-lsp-rust]" _lint_rust: cd Lql/lql-lsp-rust && cargo fmt --all --check @@ -196,6 +283,48 @@ _clean_rust: _build_ts: cd Lql/LqlExtension && npm install --no-audit --no-fund && npm run compile +_test_ts: + @THRESHOLD=$$(jq -r '.projects["Lql/LqlExtension"].threshold // .default_threshold' coverage-thresholds.json); \ + echo ""; \ + echo "============================================================"; \ + echo "==> Testing Lql/LqlExtension (threshold: $$THRESHOLD%)"; \ + echo "============================================================"; \ + cd Lql/LqlExtension && npm run compile && \ + rm -rf out-cov && npx nyc instrument out out-cov && rm -rf out && mv out-cov out && \ + if command -v xvfb-run >/dev/null 2>&1; then \ + xvfb-run -a node ./out/test/runTest.js; \ + else \ + node ./out/test/runTest.js; \ + fi && \ + npx nyc report --reporter=json-summary --reporter=text; \ + if [ $$? -ne 0 ]; then \ + echo "FAIL [Lql/LqlExtension]: Extension tests failed"; \ + exit 1; \ + fi; \ + SUMMARY="Lql/LqlExtension/coverage/coverage-summary.json"; \ + if [ ! -f "$$SUMMARY" ]; then \ + SUMMARY="Lql/LqlExtension/.nyc_output/coverage-summary.json"; \ + fi; \ + if [ ! -f "$$SUMMARY" ]; then \ + echo "FAIL [Lql/LqlExtension]: No coverage summary produced"; \ + exit 1; \ + fi; \ + COVERAGE=$$(jq -r '.total.lines.pct' "$$SUMMARY"); \ + echo ""; \ + echo " [Lql/LqlExtension] Coverage: $$COVERAGE% | Threshold: $$THRESHOLD%"; \ + BELOW=$$(echo "$$COVERAGE < $$THRESHOLD" | bc -l); \ + if [ "$$BELOW" = "1" ]; then \ + echo " FAIL [Lql/LqlExtension]: $$COVERAGE% is BELOW threshold $$THRESHOLD%"; \ + exit 1; \ + fi; \ + ABOVE=$$(echo "$$COVERAGE > $$THRESHOLD" | bc -l); \ + if [ "$$ABOVE" = "1" ]; then \ + NEW=$$(echo "$$COVERAGE" | awk '{print int($$1)}'); \ + echo " Ratcheting threshold: $$THRESHOLD% -> $$NEW%"; \ + jq '.projects["Lql/LqlExtension"].threshold = '"$$NEW" coverage-thresholds.json > coverage-thresholds.json.tmp && mv coverage-thresholds.json.tmp coverage-thresholds.json; \ + fi; \ + echo " PASS [Lql/LqlExtension]" + _lint_ts: cd Lql/LqlExtension && npm run lint @@ -211,13 +340,12 @@ _setup_ts: help: @echo "Available targets:" @echo " build - Compile/assemble all artifacts" - @echo " test - Run full test suite with coverage" + @echo " test - Run full test suite with coverage enforcement" @echo " lint - Run all linters (errors mode)" @echo " fmt - Format all code in-place" @echo " fmt-check - Check formatting (no modification)" @echo " clean - Remove build artifacts" @echo " check - lint + test (pre-commit)" @echo " ci - lint + test + build (full CI)" - @echo " coverage - Generate and open coverage report" - @echo " coverage-check - Assert coverage thresholds" + @echo " coverage - Generate and open HTML coverage report" @echo " setup - Post-create dev environment setup" diff --git a/Migration/Migration.Cli/Migration.Cli.csproj b/Migration/Migration.Cli/Migration.Cli.csproj deleted file mode 100644 index 49bd4006..00000000 --- a/Migration/Migration.Cli/Migration.Cli.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - Exe - Migration.Cli - $(NoWarn);CA2254;CA1515;RS1035;CA2100 - Migration.Cli - true - migration-cli - CLI tool for database schema migrations - - - - - - - - - - - - - diff --git a/Migration/Migration.Cli/Program.cs b/Migration/Migration.Cli/Program.cs deleted file mode 100644 index 83d69dd2..00000000 --- a/Migration/Migration.Cli/Program.cs +++ /dev/null @@ -1,302 +0,0 @@ -using Microsoft.Data.Sqlite; -using Migration.Postgres; -using Migration.SQLite; -using Npgsql; - -namespace Migration.Cli; - -/// -/// CLI tool to create databases from YAML schema definitions. -/// This is the ONLY canonical tool for database creation - all projects MUST use this. -/// -public static class Program -{ - /// - /// Entry point - creates database from YAML schema file. - /// Usage: dotnet run -- --schema path/to/schema.yaml --output path/to/database.db --provider [sqlite|postgres] - /// - public static int Main(string[] args) - { - var parseResult = ParseArguments(args); - - return parseResult switch - { - ParseResult.Success success => ExecuteMigration(success), - ParseResult.Failure failure => ShowError(failure), - ParseResult.HelpRequested => ShowUsage(), - }; - } - - private static int ExecuteMigration(ParseResult.Success args) - { - Console.WriteLine("Migration.Cli - Database Schema Tool"); - Console.WriteLine($" Schema: {args.SchemaPath}"); - Console.WriteLine($" Output: {args.OutputPath}"); - Console.WriteLine($" Provider: {args.Provider}"); - Console.WriteLine(); - - if (!File.Exists(args.SchemaPath)) - { - Console.WriteLine($"Error: Schema file not found: {args.SchemaPath}"); - return 1; - } - - SchemaDefinition schema; - try - { - var yamlContent = File.ReadAllText(args.SchemaPath); - schema = SchemaYamlSerializer.FromYaml(yamlContent); - Console.WriteLine($"Loaded schema '{schema.Name}' with {schema.Tables.Count} tables"); - } - catch (Exception ex) - { - Console.WriteLine($"Error: Failed to parse YAML schema: {ex}"); - return 1; - } - - return args.Provider.ToLowerInvariant() switch - { - "sqlite" => CreateSqliteDatabase(schema, args.OutputPath), - "postgres" => CreatePostgresDatabase(schema, args.OutputPath), - _ => ShowProviderError(args.Provider), - }; - } - - private static int CreateSqliteDatabase(SchemaDefinition schema, string outputPath) - { - try - { - // Delete existing file to start fresh - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - Console.WriteLine($"Deleted existing database: {outputPath}"); - } - - // Ensure directory exists - var directory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - var connectionString = $"Data Source={outputPath}"; - using var connection = new SqliteConnection(connectionString); - connection.Open(); - - var tablesCreated = 0; - foreach (var table in schema.Tables) - { - var ddl = SqliteDdlGenerator.Generate(new CreateTableOperation(table)); - using var cmd = connection.CreateCommand(); - cmd.CommandText = ddl; - cmd.ExecuteNonQuery(); - Console.WriteLine($" Created table: {table.Name}"); - tablesCreated++; - } - - Console.WriteLine(); - Console.WriteLine($"Successfully created SQLite database with {tablesCreated} tables"); - Console.WriteLine($" Output: {outputPath}"); - return 0; - } - catch (Exception ex) - { - Console.WriteLine($"Error: SQLite migration failed: {ex}"); - return 1; - } - } - - private static int CreatePostgresDatabase(SchemaDefinition schema, string connectionString) - { - try - { - using var connection = new NpgsqlConnection(connectionString); - connection.Open(); - - Console.WriteLine("Connected to PostgreSQL database"); - - var result = PostgresDdlGenerator.MigrateSchema( - connection: connection, - schema: schema, - onTableCreated: table => Console.WriteLine($" Created table: {table}"), - onTableFailed: (table, ex) => Console.WriteLine($" Failed table: {table} - {ex}") - ); - - Console.WriteLine(); - - if (result.Success) - { - Console.WriteLine( - $"Successfully created PostgreSQL database with {result.TablesCreated} tables" - ); - return 0; - } - else - { - Console.WriteLine("PostgreSQL migration completed with errors:"); - foreach (var error in result.Errors) - { - Console.WriteLine($" {error}"); - } - - return result.TablesCreated > 0 ? 0 : 1; - } - } - catch (Exception ex) - { - Console.WriteLine($"Error: PostgreSQL connection/migration failed: {ex}"); - return 1; - } - } - - private static int ShowProviderError(string provider) - { - Console.WriteLine($"Error: Unknown provider '{provider}'"); - Console.WriteLine("Valid providers: sqlite, postgres"); - return 1; - } - - private static int ShowError(ParseResult.Failure failure) - { - Console.WriteLine($"Error: {failure.Message}"); - Console.WriteLine(); - return ShowUsage(); - } - - private static int ShowUsage() - { - Console.WriteLine("Migration.Cli - Database Schema Tool"); - Console.WriteLine(); - Console.WriteLine("Usage:"); - Console.WriteLine( - " dotnet run --project Migration/Migration.Cli/Migration.Cli.csproj -- \\" - ); - Console.WriteLine(" --schema path/to/schema.yaml \\"); - Console.WriteLine(" --output path/to/database.db \\"); - Console.WriteLine(" --provider [sqlite|postgres]"); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --schema Path to YAML schema definition file (required)"); - Console.WriteLine( - " --output Path to output database file (SQLite) or connection string (Postgres)" - ); - Console.WriteLine(" --provider Database provider: sqlite or postgres (default: sqlite)"); - Console.WriteLine(); - Console.WriteLine("Examples:"); - Console.WriteLine(" # SQLite (file path)"); - Console.WriteLine( - " dotnet run -- --schema my-schema.yaml --output ./build.db --provider sqlite" - ); - Console.WriteLine(); - Console.WriteLine(" # PostgreSQL (connection string)"); - Console.WriteLine(" dotnet run -- --schema my-schema.yaml \\"); - Console.WriteLine( - " --output \"Host=localhost;Database=mydb;Username=user;Password=pass\" \\" - ); - Console.WriteLine(" --provider postgres"); - Console.WriteLine(); - Console.WriteLine("YAML Schema Format:"); - Console.WriteLine(" name: my_schema"); - Console.WriteLine(" tables:"); - Console.WriteLine(" - name: Users"); - Console.WriteLine(" columns:"); - Console.WriteLine(" - name: Id"); - Console.WriteLine(" type: Uuid"); - Console.WriteLine(" isNullable: false"); - Console.WriteLine(" - name: Email"); - Console.WriteLine(" type: VarChar(255)"); - Console.WriteLine(" isNullable: false"); - Console.WriteLine(" primaryKey:"); - Console.WriteLine(" columns: [Id]"); - return 1; - } - - private static ParseResult ParseArguments(string[] args) - { - string? schemaPath = null; - string? outputPath = null; - var provider = "sqlite"; - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - - switch (arg) - { - case "--schema" or "-s": - if (i + 1 >= args.Length) - { - return new ParseResult.Failure("--schema requires a path argument"); - } - - schemaPath = args[++i]; - break; - - case "--output" - or "-o": - if (i + 1 >= args.Length) - { - return new ParseResult.Failure("--output requires a path argument"); - } - - outputPath = args[++i]; - break; - - case "--provider" - or "-p": - if (i + 1 >= args.Length) - { - return new ParseResult.Failure( - "--provider requires an argument (sqlite or postgres)" - ); - } - - provider = args[++i]; - break; - - case "--help" - or "-h": - return new ParseResult.HelpRequested(); - - default: - if (arg.StartsWith('-')) - { - return new ParseResult.Failure($"Unknown option: {arg}"); - } - - break; - } - } - - if (string.IsNullOrEmpty(schemaPath)) - { - return new ParseResult.Failure("--schema is required"); - } - - if (string.IsNullOrEmpty(outputPath)) - { - return new ParseResult.Failure("--output is required"); - } - - return new ParseResult.Success(schemaPath, outputPath, provider); - } -} - -/// -/// Argument parsing result - closed type hierarchy. -/// -public abstract record ParseResult -{ - private ParseResult() { } - - /// Successfully parsed arguments. - public sealed record Success(string SchemaPath, string OutputPath, string Provider) - : ParseResult; - - /// Parse error. - public sealed record Failure(string Message) : ParseResult; - - /// Help requested. - public sealed record HelpRequested : ParseResult; -} diff --git a/Migration/Migration.Postgres/GlobalUsings.cs b/Migration/Migration.Postgres/GlobalUsings.cs deleted file mode 100644 index 604102ff..00000000 --- a/Migration/Migration.Postgres/GlobalUsings.cs +++ /dev/null @@ -1,7 +0,0 @@ -global using System.Data; -global using System.Text; -global using Microsoft.Extensions.Logging; -global using Npgsql; -// Type aliases -global using SchemaResult = Outcome.Result; -global using TableResult = Outcome.Result; diff --git a/Migration/Migration.Postgres/Migration.Postgres.csproj b/Migration/Migration.Postgres/Migration.Postgres.csproj deleted file mode 100644 index 3eea512d..00000000 --- a/Migration/Migration.Postgres/Migration.Postgres.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - Library - Migration.Postgres - Migration.Postgres - PostgreSQL DDL generator for Migration - $(NoWarn);CA2254;CA2100 - - - - - - - - - - - - - - - diff --git a/Migration/Migration.SQLite/GlobalUsings.cs b/Migration/Migration.SQLite/GlobalUsings.cs deleted file mode 100644 index 1bc74169..00000000 --- a/Migration/Migration.SQLite/GlobalUsings.cs +++ /dev/null @@ -1,7 +0,0 @@ -global using System.Data; -global using System.Text; -global using Microsoft.Data.Sqlite; -global using Microsoft.Extensions.Logging; -// Type aliases -global using SchemaResult = Outcome.Result; -global using TableResult = Outcome.Result; diff --git a/Migration/Migration.SQLite/Migration.SQLite.csproj b/Migration/Migration.SQLite/Migration.SQLite.csproj deleted file mode 100644 index bf1b4cba..00000000 --- a/Migration/Migration.SQLite/Migration.SQLite.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - Library - Migration.SQLite - Migration.SQLite - SQLite DDL generator for Migration - $(NoWarn);CA2254;CA2100 - - - - - - - - - - - - - - - diff --git a/Migration/Migration.Tests/GlobalUsings.cs b/Migration/Migration.Tests/GlobalUsings.cs deleted file mode 100644 index 876d9352..00000000 --- a/Migration/Migration.Tests/GlobalUsings.cs +++ /dev/null @@ -1,25 +0,0 @@ -global using Microsoft.Data.Sqlite; -global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Logging.Abstractions; -global using Migration.Postgres; -global using Migration.SQLite; -global using Npgsql; -global using Testcontainers.PostgreSql; -global using Xunit; -global using MigrationApplyResultError = Outcome.Result.Error< - bool, - Migration.MigrationError ->; -global using MigrationApplyResultOk = Outcome.Result.Ok< - bool, - Migration.MigrationError ->; -global using OperationsResultOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Migration.MigrationError ->.Ok, Migration.MigrationError>; -// Type aliases for Result types per CLAUDE.md -global using SchemaResultOk = Outcome.Result< - Migration.SchemaDefinition, - Migration.MigrationError ->.Ok; diff --git a/Migration/Migration/GlobalUsings.cs b/Migration/Migration/GlobalUsings.cs deleted file mode 100644 index bfa55d94..00000000 --- a/Migration/Migration/GlobalUsings.cs +++ /dev/null @@ -1,8 +0,0 @@ -global using System.Data; -global using Microsoft.Extensions.Logging; -global using MigrationApplyResult = Outcome.Result; -global using OperationsResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Migration.MigrationError ->; -// Type aliases for Result types per CLAUDE.md diff --git a/Migration/Nimblesite.DataProvider.Migration.Cli/Nimblesite.DataProvider.Migration.Cli.csproj b/Migration/Nimblesite.DataProvider.Migration.Cli/Nimblesite.DataProvider.Migration.Cli.csproj new file mode 100644 index 00000000..da02c785 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Cli/Nimblesite.DataProvider.Migration.Cli.csproj @@ -0,0 +1,22 @@ + + + Exe + Nimblesite.DataProvider.Migration.Cli + $(NoWarn);CA2254;CA1515;RS1035;CA2100 + Nimblesite.DataProvider.Migration.Cli + true + migration-cli + CLI tool for database schema migrations + + + + + + + + + + + + + diff --git a/Migration/Nimblesite.DataProvider.Migration.Cli/Program.cs b/Migration/Nimblesite.DataProvider.Migration.Cli/Program.cs new file mode 100644 index 00000000..33db966c --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Cli/Program.cs @@ -0,0 +1,543 @@ +using System.Reflection; +using Microsoft.Data.Sqlite; +using Nimblesite.DataProvider.Migration.Core; +using Nimblesite.DataProvider.Migration.Postgres; +using Nimblesite.DataProvider.Migration.SQLite; +using Npgsql; + +namespace Nimblesite.DataProvider.Migration.Cli; + +/// +/// CLI tool for database schema operations: migrate from YAML and export C# schemas to YAML. +/// This is the ONLY canonical tool for database creation - all projects MUST use this. +/// +public static class Program +{ + /// + /// Entry point - dispatches to migrate or export subcommand. + /// Usage: + /// migrate: dotnet run -- migrate --schema path/to/schema.yaml --output path/to/database.db --provider [sqlite|postgres] + /// export: dotnet run -- export --assembly path/to/assembly.dll --type Namespace.SchemaClass --output path/to/schema.yaml + /// + public static int Main(string[] args) + { + if (args.Length == 0 || args[0] is "--help" or "-h") + { + return ShowTopLevelUsage(); + } + + var command = args[0]; + var remainingArgs = args[1..]; + + return command switch + { + "migrate" => RunMigrate(remainingArgs), + "export" => RunExport(remainingArgs), + _ when command.StartsWith('-') => RunMigrate(args), // backwards compat: no subcommand = migrate + _ => ShowUnknownCommand(command), + }; + } + + private static int RunMigrate(string[] args) + { + var parseResult = ParseMigrateArguments(args); + + return parseResult switch + { + MigrateParseResult.Success success => ExecuteMigration(success), + MigrateParseResult.Failure failure => ShowMigrateError(failure), + MigrateParseResult.HelpRequested => ShowMigrateUsage(), + }; + } + + private static int RunExport(string[] args) + { + var parseResult = ParseExportArguments(args); + + return parseResult switch + { + ExportParseResult.Success success => ExecuteExport(success), + ExportParseResult.Failure failure => ShowExportError(failure), + ExportParseResult.HelpRequested => ShowExportUsage(), + }; + } + + // ── Migrate ────────────────────────────────────────────────────────── + + private static int ExecuteMigration(MigrateParseResult.Success args) + { + Console.WriteLine( + $""" + Nimblesite.DataProvider.Migration.Cli - Database Schema Tool + Schema: {args.SchemaPath} + Output: {args.OutputPath} + Provider: {args.Provider} + """ + ); + + if (!File.Exists(args.SchemaPath)) + { + Console.WriteLine($"Error: Schema file not found: {args.SchemaPath}"); + return 1; + } + + SchemaDefinition schema; + try + { + var yamlContent = File.ReadAllText(args.SchemaPath); + schema = SchemaYamlSerializer.FromYaml(yamlContent); + Console.WriteLine($"Loaded schema '{schema.Name}' with {schema.Tables.Count} tables"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: Failed to parse YAML schema: {ex}"); + return 1; + } + + return args.Provider.ToLowerInvariant() switch + { + "sqlite" => CreateSqliteDatabase(schema, args.OutputPath), + "postgres" => CreatePostgresDatabase(schema, args.OutputPath), + _ => ShowProviderError(args.Provider), + }; + } + + private static int CreateSqliteDatabase(SchemaDefinition schema, string outputPath) + { + try + { + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + Console.WriteLine($"Deleted existing database: {outputPath}"); + } + + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var connectionString = $"Data Source={outputPath}"; + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + var tablesCreated = 0; + foreach (var table in schema.Tables) + { + var ddl = SqliteDdlGenerator.Generate(new CreateTableOperation(table)); + using var cmd = connection.CreateCommand(); + cmd.CommandText = ddl; + cmd.ExecuteNonQuery(); + Console.WriteLine($" Created table: {table.Name}"); + tablesCreated++; + } + + Console.WriteLine( + $"\nSuccessfully created SQLite database with {tablesCreated} tables\n Output: {outputPath}" + ); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"Error: SQLite migration failed: {ex}"); + return 1; + } + } + + private static int CreatePostgresDatabase(SchemaDefinition schema, string connectionString) + { + try + { + using var connection = new NpgsqlConnection(connectionString); + connection.Open(); + + Console.WriteLine("Connected to PostgreSQL database"); + + var result = PostgresDdlGenerator.MigrateSchema( + connection: connection, + schema: schema, + onTableCreated: table => Console.WriteLine($" Created table: {table}"), + onTableFailed: (table, ex) => Console.WriteLine($" Failed table: {table} - {ex}") + ); + + if (result.Success) + { + Console.WriteLine( + $"\nSuccessfully created PostgreSQL database with {result.TablesCreated} tables" + ); + return 0; + } + + Console.WriteLine("PostgreSQL migration completed with errors:"); + foreach (var error in result.Errors) + { + Console.WriteLine($" {error}"); + } + + return result.TablesCreated > 0 ? 0 : 1; + } + catch (Exception ex) + { + Console.WriteLine($"Error: PostgreSQL connection/migration failed: {ex}"); + return 1; + } + } + + private static int ShowProviderError(string provider) + { + Console.WriteLine( + $"Error: Unknown provider '{provider}'\nValid providers: sqlite, postgres" + ); + return 1; + } + + // ── Export ──────────────────────────────────────────────────────────── + + private static int ExecuteExport(ExportParseResult.Success args) + { + Console.WriteLine( + $""" + Nimblesite.DataProvider.Migration.Cli - Export C# Schema to YAML + Assembly: {args.AssemblyPath} + Type: {args.TypeName} + Output: {args.OutputPath} + """ + ); + + if (!File.Exists(args.AssemblyPath)) + { + Console.WriteLine($"Error: Assembly not found: {args.AssemblyPath}"); + return 1; + } + + try + { + var assembly = Assembly.LoadFrom(args.AssemblyPath); + var schemaType = assembly.GetType(args.TypeName); + + if (schemaType is null) + { + Console.WriteLine($"Error: Type '{args.TypeName}' not found in assembly"); + return 1; + } + + var schema = GetSchemaDefinition(schemaType); + + if (schema is null) + { + Console.WriteLine( + $"Error: Could not get SchemaDefinition from type '{args.TypeName}'\n Expected: static property 'Definition' or static method 'Build()' returning SchemaDefinition" + ); + return 1; + } + + var directory = Path.GetDirectoryName(args.OutputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + SchemaYamlSerializer.ToYamlFile(schema, args.OutputPath); + Console.WriteLine( + $"Successfully exported schema '{schema.Name}' with {schema.Tables.Count} tables\n Output: {args.OutputPath}" + ); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex}"); + return 1; + } + } + + private static SchemaDefinition? GetSchemaDefinition(Type schemaType) + { + var definitionProp = schemaType.GetProperty( + "Definition", + BindingFlags.Public | BindingFlags.Static + ); + + if (definitionProp?.GetValue(null) is SchemaDefinition defFromProp) + { + return defFromProp; + } + + var buildMethod = schemaType.GetMethod( + "Build", + BindingFlags.Public | BindingFlags.Static, + Type.EmptyTypes + ); + + if (buildMethod?.Invoke(null, null) is SchemaDefinition defFromMethod) + { + return defFromMethod; + } + + return null; + } + + // ── Usage / Errors ─────────────────────────────────────────────────── + + private static int ShowTopLevelUsage() + { + Console.WriteLine( + """ + Nimblesite.DataProvider.Migration.Cli - Database Schema Tool + + Commands: + migrate Create database from YAML schema definition + export Export C# schema class to YAML file + + Usage: + migration-cli migrate --schema schema.yaml --output database.db [--provider sqlite|postgres] + migration-cli export --assembly assembly.dll --type Namespace.SchemaClass --output schema.yaml + + Run 'migration-cli --help' for command-specific options. + """ + ); + return 1; + } + + private static int ShowUnknownCommand(string command) + { + Console.WriteLine($"Error: Unknown command '{command}'\nValid commands: migrate, export"); + return 1; + } + + private static int ShowMigrateError(MigrateParseResult.Failure failure) + { + Console.WriteLine($"Error: {failure.Message}\n"); + return ShowMigrateUsage(); + } + + private static int ShowMigrateUsage() + { + Console.WriteLine( + """ + Usage: migration-cli migrate [options] + + Options: + --schema, -s Path to YAML schema definition file (required) + --output, -o Path to output database file (SQLite) or connection string (Postgres) + --provider, -p Database provider: sqlite or postgres (default: sqlite) + + Examples: + migration-cli migrate --schema my-schema.yaml --output ./build.db --provider sqlite + migration-cli migrate --schema my-schema.yaml --output "Host=localhost;Database=mydb;Username=user;Password=pass" --provider postgres + """ + ); + return 1; + } + + private static int ShowExportError(ExportParseResult.Failure failure) + { + Console.WriteLine($"Error: {failure.Message}\n"); + return ShowExportUsage(); + } + + private static int ShowExportUsage() + { + Console.WriteLine( + """ + Usage: migration-cli export [options] + + Options: + --assembly, -a Path to compiled assembly containing schema class (required) + --type, -t Fully qualified type name of schema class (required) + --output, -o Path to output YAML file (required) + + Examples: + migration-cli export -a bin/Debug/net10.0/MyProject.dll -t MyNamespace.MySchema -o schema.yaml + + Schema Class Requirements: + - Static property 'Definition' returning SchemaDefinition, OR + - Static method 'Build()' returning SchemaDefinition + """ + ); + return 1; + } + + // ── Argument Parsing ───────────────────────────────────────────────── + + private static MigrateParseResult ParseMigrateArguments(string[] args) + { + string? schemaPath = null; + string? outputPath = null; + var provider = "sqlite"; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + switch (arg) + { + case "--schema" or "-s": + if (i + 1 >= args.Length) + { + return new MigrateParseResult.Failure("--schema requires a path argument"); + } + + schemaPath = args[++i]; + break; + + case "--output" + or "-o": + if (i + 1 >= args.Length) + { + return new MigrateParseResult.Failure("--output requires a path argument"); + } + + outputPath = args[++i]; + break; + + case "--provider" + or "-p": + if (i + 1 >= args.Length) + { + return new MigrateParseResult.Failure( + "--provider requires an argument (sqlite or postgres)" + ); + } + + provider = args[++i]; + break; + + case "--help" + or "-h": + return new MigrateParseResult.HelpRequested(); + + default: + if (arg.StartsWith('-')) + { + return new MigrateParseResult.Failure($"Unknown option: {arg}"); + } + + break; + } + } + + if (string.IsNullOrEmpty(schemaPath)) + { + return new MigrateParseResult.Failure("--schema is required"); + } + + if (string.IsNullOrEmpty(outputPath)) + { + return new MigrateParseResult.Failure("--output is required"); + } + + return new MigrateParseResult.Success(schemaPath, outputPath, provider); + } + + private static ExportParseResult ParseExportArguments(string[] args) + { + string? assemblyPath = null; + string? typeName = null; + string? outputPath = null; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + switch (arg) + { + case "--assembly" or "-a": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure("--assembly requires a path argument"); + } + + assemblyPath = args[++i]; + break; + + case "--type" + or "-t": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure( + "--type requires a type name argument" + ); + } + + typeName = args[++i]; + break; + + case "--output" + or "-o": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure("--output requires a path argument"); + } + + outputPath = args[++i]; + break; + + case "--help" + or "-h": + return new ExportParseResult.HelpRequested(); + + default: + if (arg.StartsWith('-')) + { + return new ExportParseResult.Failure($"Unknown option: {arg}"); + } + + break; + } + } + + if (string.IsNullOrEmpty(assemblyPath)) + { + return new ExportParseResult.Failure("--assembly is required"); + } + + if (string.IsNullOrEmpty(typeName)) + { + return new ExportParseResult.Failure("--type is required"); + } + + if (string.IsNullOrEmpty(outputPath)) + { + return new ExportParseResult.Failure("--output is required"); + } + + return new ExportParseResult.Success(assemblyPath, typeName, outputPath); + } +} + +/// +/// Migrate subcommand argument parsing result. +/// +public abstract record MigrateParseResult +{ + private MigrateParseResult() { } + + /// Successfully parsed migrate arguments. + public sealed record Success(string SchemaPath, string OutputPath, string Provider) + : MigrateParseResult; + + /// Parse error. + public sealed record Failure(string Message) : MigrateParseResult; + + /// Help requested. + public sealed record HelpRequested : MigrateParseResult; +} + +/// +/// Export subcommand argument parsing result. +/// +public abstract record ExportParseResult +{ + private ExportParseResult() { } + + /// Successfully parsed export arguments. + public sealed record Success(string AssemblyPath, string TypeName, string OutputPath) + : ExportParseResult; + + /// Parse error. + public sealed record Failure(string Message) : ExportParseResult; + + /// Help requested. + public sealed record HelpRequested : ExportParseResult; +} diff --git a/Migration/Migration.Cli/example-schema.yaml b/Migration/Nimblesite.DataProvider.Migration.Cli/example-schema.yaml similarity index 100% rename from Migration/Migration.Cli/example-schema.yaml rename to Migration/Nimblesite.DataProvider.Migration.Cli/example-schema.yaml diff --git a/Migration/Migration/DdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Core/DdlGenerator.cs similarity index 98% rename from Migration/Migration/DdlGenerator.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/DdlGenerator.cs index b8cb5881..e0d5a95a 100644 --- a/Migration/Migration/DdlGenerator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/DdlGenerator.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Static helper for DDL generation delegates. diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.Core/GlobalUsings.cs new file mode 100644 index 00000000..e879122c --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/GlobalUsings.cs @@ -0,0 +1,11 @@ +global using System.Data; +global using Microsoft.Extensions.Logging; +global using MigrationApplyResult = Outcome.Result< + bool, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; +global using OperationsResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; +// Type aliases for Result types per CLAUDE.md diff --git a/Migration/Migration/LqlDefaultTranslator.cs b/Migration/Nimblesite.DataProvider.Migration.Core/LqlDefaultTranslator.cs similarity index 92% rename from Migration/Migration/LqlDefaultTranslator.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/LqlDefaultTranslator.cs index 68a85522..821aba24 100644 --- a/Migration/Migration/LqlDefaultTranslator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/LqlDefaultTranslator.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text.RegularExpressions; -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Translates LQL default expressions to platform-specific SQL. @@ -18,7 +18,7 @@ public static string ToPostgres(string lqlExpression) { ArgumentNullException.ThrowIfNull(lqlExpression); - var normalized = lqlExpression.Trim().ToLowerInvariant(); + var normalized = lqlExpression.Trim().ToUpperInvariant(); // Handle common LQL functions return normalized switch @@ -58,24 +58,24 @@ public static string ToSqlite(string lqlExpression) { ArgumentNullException.ThrowIfNull(lqlExpression); - var normalized = lqlExpression.Trim().ToLowerInvariant(); + var normalized = lqlExpression.Trim().ToUpperInvariant(); // Handle common LQL functions return normalized switch { // Timestamp functions - SQLite uses datetime/date/time functions - "now()" => "(datetime('now'))", - "current_timestamp()" => "CURRENT_TIMESTAMP", - "current_date()" => "(date('now'))", - "current_time()" => "(time('now'))", + "NOW()" => "(datetime('now'))", + "CURRENT_TIMESTAMP()" => "CURRENT_TIMESTAMP", + "CURRENT_DATE()" => "(date('now'))", + "CURRENT_TIME()" => "(time('now'))", // UUID generation - SQLite needs manual UUID v4 construction "gen_uuid()" => UuidV4SqliteExpression, "uuid()" => UuidV4SqliteExpression, // Boolean literals - SQLite uses 0/1 - "true" => "1", - "false" => "0", + "TRUE" => "1", + "FALSE" => "0", // Numeric literals (pass through) var n when int.TryParse(n, out _) => n, @@ -109,7 +109,7 @@ Func functionTranslator return expression; } - var functionName = match.Groups["func"].Value.ToLowerInvariant(); + var functionName = match.Groups["func"].Value.ToUpperInvariant(); var argsString = match.Groups["args"].Value; // Parse arguments (simple split, doesn't handle nested functions with commas) diff --git a/Migration/Migration/MigrationError.cs b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationError.cs similarity index 94% rename from Migration/Migration/MigrationError.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/MigrationError.cs index c25f6952..8b98daf6 100644 --- a/Migration/Migration/MigrationError.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationError.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Migration error with message and optional inner exception. diff --git a/Migration/Migration/MigrationOptions.cs b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationOptions.cs similarity index 95% rename from Migration/Migration/MigrationOptions.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/MigrationOptions.cs index 80d9fc6d..936c738e 100644 --- a/Migration/Migration/MigrationOptions.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationOptions.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Options for migration execution. diff --git a/Migration/Migration/Migration.csproj b/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj similarity index 73% rename from Migration/Migration/Migration.csproj rename to Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj index 724ebbe7..0a9836a3 100644 --- a/Migration/Migration/Migration.csproj +++ b/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj @@ -1,15 +1,15 @@ Library - Migration - Migration + Nimblesite.DataProvider.Migration.Core + Nimblesite.DataProvider.Migration.Core YAML-based database schema migration library $(NoWarn);CA2254;CA1720;CA1724;RS1035 - + diff --git a/Migration/Migration/PortableDefaults.cs b/Migration/Nimblesite.DataProvider.Migration.Core/PortableDefaults.cs similarity index 99% rename from Migration/Migration/PortableDefaults.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/PortableDefaults.cs index b1693cbe..5d4a195b 100644 --- a/Migration/Migration/PortableDefaults.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/PortableDefaults.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Platform-independent default value expression. diff --git a/Migration/Migration/PortableTypes.cs b/Migration/Nimblesite.DataProvider.Migration.Core/PortableTypes.cs similarity index 99% rename from Migration/Migration/PortableTypes.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/PortableTypes.cs index 5836d74a..a7d50ba8 100644 --- a/Migration/Migration/PortableTypes.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/PortableTypes.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Database-agnostic type definition. Base sealed type for discriminated union. diff --git a/Migration/Migration/SchemaBuilder.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaBuilder.cs similarity index 99% rename from Migration/Migration/SchemaBuilder.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/SchemaBuilder.cs index 6d2fb56f..5fd4ec4a 100644 --- a/Migration/Migration/SchemaBuilder.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaBuilder.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Fluent builder for schema definitions. diff --git a/Migration/Migration/SchemaDefinition.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs similarity index 99% rename from Migration/Migration/SchemaDefinition.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs index 4b61e768..f0769461 100644 --- a/Migration/Migration/SchemaDefinition.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Complete database schema definition. diff --git a/Migration/Migration/SchemaDiff.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs similarity index 99% rename from Migration/Migration/SchemaDiff.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs index 97e732eb..7a117b56 100644 --- a/Migration/Migration/SchemaDiff.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Calculates the difference between two schema definitions. diff --git a/Migration/Migration/SchemaOperation.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs similarity index 98% rename from Migration/Migration/SchemaOperation.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs index dee42988..b131f1e2 100644 --- a/Migration/Migration/SchemaOperation.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs @@ -1,4 +1,4 @@ -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Base type for schema migration operations. diff --git a/Migration/Migration/SchemaSerializer.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs similarity index 99% rename from Migration/Migration/SchemaSerializer.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs index 22c807f3..5a1c44bb 100644 --- a/Migration/Migration/SchemaSerializer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Serializes and deserializes schema definitions to/from JSON and YAML. diff --git a/Migration/Migration/SchemaYamlSerializer.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs similarity index 99% rename from Migration/Migration/SchemaYamlSerializer.cs rename to Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs index 38a5ec1f..a09f239b 100644 --- a/Migration/Migration/SchemaYamlSerializer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs @@ -5,7 +5,7 @@ using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.Serialization.ObjectGraphVisitors; -namespace Migration; +namespace Nimblesite.DataProvider.Migration.Core; /// /// Serializes and deserializes schema definitions to/from YAML. diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/GlobalUsings.cs new file mode 100644 index 00000000..295e9638 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using System.Data; +global using System.Text; +global using Microsoft.Extensions.Logging; +global using Nimblesite.DataProvider.Migration.Core; +global using Npgsql; +// Type aliases +global using SchemaResult = Outcome.Result< + Nimblesite.DataProvider.Migration.Core.SchemaDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; +global using TableResult = Outcome.Result< + Nimblesite.DataProvider.Migration.Core.TableDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/Nimblesite.DataProvider.Migration.Postgres.csproj b/Migration/Nimblesite.DataProvider.Migration.Postgres/Nimblesite.DataProvider.Migration.Postgres.csproj new file mode 100644 index 00000000..e79949f7 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/Nimblesite.DataProvider.Migration.Postgres.csproj @@ -0,0 +1,22 @@ + + + Library + Nimblesite.DataProvider.Migration.Postgres + Nimblesite.DataProvider.Migration.Postgres + PostgreSQL DDL generator for Nimblesite.DataProvider.Migration.Core + $(NoWarn);CA2254;CA2100 + + + + + + + + + + + + + + + diff --git a/Migration/Migration.Postgres/PostgresDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs similarity index 99% rename from Migration/Migration.Postgres/PostgresDdlGenerator.cs rename to Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs index 14cf97e6..68e2930c 100644 --- a/Migration/Migration.Postgres/PostgresDdlGenerator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace Migration.Postgres; +namespace Nimblesite.DataProvider.Migration.Postgres; /// /// Result of a schema migration operation. diff --git a/Migration/Migration.Postgres/PostgresSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs similarity index 99% rename from Migration/Migration.Postgres/PostgresSchemaInspector.cs rename to Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs index 268297f9..ff8918da 100644 --- a/Migration/Migration.Postgres/PostgresSchemaInspector.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs @@ -1,4 +1,4 @@ -namespace Migration.Postgres; +namespace Nimblesite.DataProvider.Migration.Postgres; /// /// Inspects PostgreSQL database schema and returns a SchemaDefinition. diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/GlobalUsings.cs new file mode 100644 index 00000000..f38d7963 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using System.Data; +global using System.Text; +global using Microsoft.Data.Sqlite; +global using Microsoft.Extensions.Logging; +global using Nimblesite.DataProvider.Migration.Core; +// Type aliases +global using SchemaResult = Outcome.Result< + Nimblesite.DataProvider.Migration.Core.SchemaDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; +global using TableResult = Outcome.Result< + Nimblesite.DataProvider.Migration.Core.TableDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/Nimblesite.DataProvider.Migration.SQLite.csproj b/Migration/Nimblesite.DataProvider.Migration.SQLite/Nimblesite.DataProvider.Migration.SQLite.csproj new file mode 100644 index 00000000..3b2212f2 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/Nimblesite.DataProvider.Migration.SQLite.csproj @@ -0,0 +1,22 @@ + + + Library + Nimblesite.DataProvider.Migration.SQLite + Nimblesite.DataProvider.Migration.SQLite + SQLite DDL generator for Nimblesite.DataProvider.Migration.Core + $(NoWarn);CA2254;CA2100 + + + + + + + + + + + + + + + diff --git a/Migration/Migration.SQLite/SqliteDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteDdlGenerator.cs similarity index 99% rename from Migration/Migration.SQLite/SqliteDdlGenerator.cs rename to Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteDdlGenerator.cs index 4ff3a18c..a1e46bc8 100644 --- a/Migration/Migration.SQLite/SqliteDdlGenerator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteDdlGenerator.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace Migration.SQLite; +namespace Nimblesite.DataProvider.Migration.SQLite; /// /// SQLite DDL generator for schema operations. diff --git a/Migration/Migration.SQLite/SqliteSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaInspector.cs similarity index 99% rename from Migration/Migration.SQLite/SqliteSchemaInspector.cs rename to Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaInspector.cs index 370d61e8..b859ba66 100644 --- a/Migration/Migration.SQLite/SqliteSchemaInspector.cs +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaInspector.cs @@ -1,4 +1,4 @@ -namespace Migration.SQLite; +namespace Nimblesite.DataProvider.Migration.SQLite; /// /// Inspects SQLite database schema and returns a SchemaDefinition. diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs new file mode 100644 index 00000000..2849b990 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs @@ -0,0 +1,32 @@ +global using Microsoft.Data.Sqlite; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; +global using Nimblesite.DataProvider.Migration.Core; +global using Nimblesite.DataProvider.Migration.Postgres; +global using Nimblesite.DataProvider.Migration.SQLite; +global using Npgsql; +global using Testcontainers.PostgreSql; +global using Xunit; +global using MigrationApplyResultError = Outcome.Result< + bool, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Error; +global using MigrationApplyResultOk = Outcome.Result< + bool, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok; +global using OperationsResultOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; +// Type aliases for Result types per CLAUDE.md +global using SchemaResultOk = Outcome.Result< + Nimblesite.DataProvider.Migration.Core.SchemaDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok< + Nimblesite.DataProvider.Migration.Core.SchemaDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; diff --git a/Migration/Migration.Tests/LqlDefaultTranslatorTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultTranslatorTests.cs similarity index 99% rename from Migration/Migration.Tests/LqlDefaultTranslatorTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultTranslatorTests.cs index 3f9f9e67..9c6b03a4 100644 --- a/Migration/Migration.Tests/LqlDefaultTranslatorTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultTranslatorTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// Unit tests for LqlDefaultTranslator covering all code paths. diff --git a/Migration/Migration.Tests/LqlDefaultsTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs similarity index 99% rename from Migration/Migration.Tests/LqlDefaultsTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs index 6f23ea41..7a1a8a8b 100644 --- a/Migration/Migration.Tests/LqlDefaultsTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// E2E tests proving LQL (Language Query Language) default values are TRULY platform-independent. diff --git a/Migration/Migration.Tests/MigrateSchemaTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrateSchemaTests.cs similarity index 99% rename from Migration/Migration.Tests/MigrateSchemaTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/MigrateSchemaTests.cs index 944fd528..0e2fac03 100644 --- a/Migration/Migration.Tests/MigrateSchemaTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrateSchemaTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// Tests for PostgresDdlGenerator.MigrateSchema() method. diff --git a/Migration/Migration.Tests/MigrationCornerCaseTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationCornerCaseTests.cs similarity index 99% rename from Migration/Migration.Tests/MigrationCornerCaseTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/MigrationCornerCaseTests.cs index cf2902ad..348852a8 100644 --- a/Migration/Migration.Tests/MigrationCornerCaseTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationCornerCaseTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// Corner case and edge case tests for migrations. diff --git a/Migration/Migration.Tests/Migration.Tests.csproj b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj similarity index 65% rename from Migration/Migration.Tests/Migration.Tests.csproj rename to Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj index 203d7384..536a0fcd 100644 --- a/Migration/Migration.Tests/Migration.Tests.csproj +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj @@ -2,7 +2,7 @@ Library true - Migration.Tests + Nimblesite.DataProvider.Migration.Tests CS1591;CA1707;CA1307;CA1062;CA1515;CA1001;CA2100 @@ -13,10 +13,10 @@ all runtime; build; native; contentfiles; analyzers - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -24,8 +24,8 @@ - - - + + + diff --git a/Migration/Migration.Tests/PostgresEdgeCaseTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresEdgeCaseTests.cs similarity index 99% rename from Migration/Migration.Tests/PostgresEdgeCaseTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/PostgresEdgeCaseTests.cs index 6b674d26..c9fc80de 100644 --- a/Migration/Migration.Tests/PostgresEdgeCaseTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresEdgeCaseTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// PostgreSQL-specific edge case tests for migrations. diff --git a/Migration/Migration.Tests/PostgresMigrationTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs similarity index 99% rename from Migration/Migration.Tests/PostgresMigrationTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs index 5be91c5d..a72528be 100644 --- a/Migration/Migration.Tests/PostgresMigrationTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// E2E tests for PostgreSQL migrations using Testcontainers. diff --git a/Migration/Migration.Tests/SchemaDiffTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffTests.cs similarity index 99% rename from Migration/Migration.Tests/SchemaDiffTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffTests.cs index d6141af9..b32493c2 100644 --- a/Migration/Migration.Tests/SchemaDiffTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// Tests for SchemaDiff.Calculate() method. diff --git a/Migration/Migration.Tests/SchemaVerifier.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaVerifier.cs similarity index 99% rename from Migration/Migration.Tests/SchemaVerifier.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/SchemaVerifier.cs index c4bda605..ea943d70 100644 --- a/Migration/Migration.Tests/SchemaVerifier.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaVerifier.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// Reusable schema verification utilities for comprehensive E2E testing. diff --git a/Migration/Migration.Tests/SchemaYamlSerializerTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaYamlSerializerTests.cs similarity index 99% rename from Migration/Migration.Tests/SchemaYamlSerializerTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/SchemaYamlSerializerTests.cs index c195ef56..2a8670c3 100644 --- a/Migration/Migration.Tests/SchemaYamlSerializerTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaYamlSerializerTests.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// E2E tests for YAML schema serialization and deserialization. diff --git a/Migration/Migration.Tests/SqliteMigrationTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteMigrationTests.cs similarity index 99% rename from Migration/Migration.Tests/SqliteMigrationTests.cs rename to Migration/Nimblesite.DataProvider.Migration.Tests/SqliteMigrationTests.cs index a7d9458f..45d193f7 100644 --- a/Migration/Migration.Tests/SqliteMigrationTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteMigrationTests.cs @@ -1,4 +1,4 @@ -namespace Migration.Tests; +namespace Nimblesite.DataProvider.Migration.Tests; /// /// E2E tests for SQLite migrations - greenfield and upgrades. diff --git a/Migration/Schema.Export.Cli/Program.cs b/Migration/Schema.Export.Cli/Program.cs deleted file mode 100644 index c60283e0..00000000 --- a/Migration/Schema.Export.Cli/Program.cs +++ /dev/null @@ -1,239 +0,0 @@ -using System.Reflection; -using Migration; - -namespace Schema.Export.Cli; - -/// -/// CLI tool to export C# schema definitions to YAML files. -/// Usage: dotnet run -- --assembly path/to/assembly.dll --type Namespace.SchemaClass --output path/to/schema.yaml -/// -public static class Program -{ - /// - /// Entry point - loads assembly, finds schema, exports to YAML. - /// - public static int Main(string[] args) - { - var parseResult = ParseArguments(args); - - return parseResult switch - { - ParseResult.Success success => ExportSchema(success), - ParseResult.ParseError error => ShowError(error), - ParseResult.Help => ShowUsage(), - }; - } - - private static int ExportSchema(ParseResult.Success args) - { - Console.WriteLine($"Schema.Export.Cli - Export C# Schema to YAML"); - Console.WriteLine($" Assembly: {args.AssemblyPath}"); - Console.WriteLine($" Type: {args.TypeName}"); - Console.WriteLine($" Output: {args.OutputPath}"); - Console.WriteLine(); - - if (!File.Exists(args.AssemblyPath)) - { - Console.WriteLine($"Error: Assembly not found: {args.AssemblyPath}"); - return 1; - } - - try - { - var assembly = Assembly.LoadFrom(args.AssemblyPath); - var schemaType = assembly.GetType(args.TypeName); - - if (schemaType is null) - { - Console.WriteLine($"Error: Type '{args.TypeName}' not found in assembly"); - return 1; - } - - // Try to find a static property or method that returns SchemaDefinition - var schema = GetSchemaDefinition(schemaType); - - if (schema is null) - { - Console.WriteLine( - $"Error: Could not get SchemaDefinition from type '{args.TypeName}'" - ); - Console.WriteLine( - " Expected: static property 'Definition' or static method 'Build()' returning SchemaDefinition" - ); - return 1; - } - - // Export to YAML - var directory = Path.GetDirectoryName(args.OutputPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - SchemaYamlSerializer.ToYamlFile(schema, args.OutputPath); - Console.WriteLine( - $"Successfully exported schema '{schema.Name}' with {schema.Tables.Count} tables" - ); - Console.WriteLine($" Output: {args.OutputPath}"); - return 0; - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex}"); - return 1; - } - } - - private static SchemaDefinition? GetSchemaDefinition(Type schemaType) - { - // Try static property named "Definition" - var definitionProp = schemaType.GetProperty( - "Definition", - BindingFlags.Public | BindingFlags.Static - ); - - if (definitionProp?.GetValue(null) is SchemaDefinition defFromProp) - { - return defFromProp; - } - - // Try static method named "Build" - var buildMethod = schemaType.GetMethod( - "Build", - BindingFlags.Public | BindingFlags.Static, - Type.EmptyTypes - ); - - if (buildMethod?.Invoke(null, null) is SchemaDefinition defFromMethod) - { - return defFromMethod; - } - - return null; - } - - private static int ShowError(ParseResult.ParseError error) - { - Console.WriteLine($"Error: {error.Message}"); - Console.WriteLine(); - return ShowUsage(); - } - - private static int ShowUsage() - { - Console.WriteLine("Schema.Export.Cli - Export C# Schema to YAML"); - Console.WriteLine(); - Console.WriteLine("Usage:"); - Console.WriteLine( - " dotnet run --project Migration/Schema.Export.Cli/Schema.Export.Cli.csproj -- \\" - ); - Console.WriteLine(" --assembly path/to/assembly.dll \\"); - Console.WriteLine(" --type Namespace.SchemaClass \\"); - Console.WriteLine(" --output path/to/schema.yaml"); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --assembly, -a Path to compiled assembly containing schema class"); - Console.WriteLine(" --type, -t Fully qualified type name of schema class"); - Console.WriteLine(" --output, -o Path to output YAML file"); - Console.WriteLine(); - Console.WriteLine("Examples:"); - Console.WriteLine(" # Export ExampleSchema"); - Console.WriteLine(" dotnet run -- -a bin/Debug/net9.0/DataProvider.Example.dll \\"); - Console.WriteLine(" -t DataProvider.Example.ExampleSchema \\"); - Console.WriteLine(" -o example-schema.yaml"); - Console.WriteLine(); - Console.WriteLine("Schema Class Requirements:"); - Console.WriteLine(" - Static property 'Definition' returning SchemaDefinition, OR"); - Console.WriteLine(" - Static method 'Build()' returning SchemaDefinition"); - return 1; - } - - private static ParseResult ParseArguments(string[] args) - { - string? assemblyPath = null; - string? typeName = null; - string? outputPath = null; - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - - switch (arg) - { - case "--assembly" or "-a": - if (i + 1 >= args.Length) - { - return new ParseResult.ParseError("--assembly requires a path argument"); - } - - assemblyPath = args[++i]; - break; - - case "--type" - or "-t": - if (i + 1 >= args.Length) - { - return new ParseResult.ParseError("--type requires a type name argument"); - } - - typeName = args[++i]; - break; - - case "--output" - or "-o": - if (i + 1 >= args.Length) - { - return new ParseResult.ParseError("--output requires a path argument"); - } - - outputPath = args[++i]; - break; - - case "--help" - or "-h": - return new ParseResult.Help(); - - default: - if (arg.StartsWith('-')) - { - return new ParseResult.ParseError($"Unknown option: {arg}"); - } - - break; - } - } - - if (string.IsNullOrEmpty(assemblyPath)) - { - return new ParseResult.ParseError("--assembly is required"); - } - - if (string.IsNullOrEmpty(typeName)) - { - return new ParseResult.ParseError("--type is required"); - } - - if (string.IsNullOrEmpty(outputPath)) - { - return new ParseResult.ParseError("--output is required"); - } - - return new ParseResult.Success(assemblyPath, typeName, outputPath); - } -} - -/// Argument parsing result base. -public abstract record ParseResult -{ - private ParseResult() { } - - /// Successfully parsed arguments. - public sealed record Success(string AssemblyPath, string TypeName, string OutputPath) - : ParseResult; - - /// Parse error. - public sealed record ParseError(string Message) : ParseResult; - - /// Help requested. - public sealed record Help : ParseResult; -} diff --git a/Migration/Schema.Export.Cli/Schema.Export.Cli.csproj b/Migration/Schema.Export.Cli/Schema.Export.Cli.csproj deleted file mode 100644 index 4e69ac8d..00000000 --- a/Migration/Schema.Export.Cli/Schema.Export.Cli.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - Exe - Schema.Export.Cli - $(NoWarn);CA1515;RS1035;CA2007 - - - - - - diff --git a/Other/Selecta/ColumnInfo.cs b/Other/Nimblesite.Sql.Model/ColumnInfo.cs similarity index 97% rename from Other/Selecta/ColumnInfo.cs rename to Other/Nimblesite.Sql.Model/ColumnInfo.cs index af8b1158..fa8d4fd9 100644 --- a/Other/Selecta/ColumnInfo.cs +++ b/Other/Nimblesite.Sql.Model/ColumnInfo.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a column in the SELECT list - a closed type hierarchy for different column types diff --git a/Other/Selecta/ComparisonOperator.cs b/Other/Nimblesite.Sql.Model/ComparisonOperator.cs similarity index 99% rename from Other/Selecta/ComparisonOperator.cs rename to Other/Nimblesite.Sql.Model/ComparisonOperator.cs index 71401c0d..89f5cf07 100644 --- a/Other/Selecta/ComparisonOperator.cs +++ b/Other/Nimblesite.Sql.Model/ComparisonOperator.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; // Algebraic data type. Closed Hierarchy. diff --git a/Other/Selecta/ExpressionColumn.cs b/Other/Nimblesite.Sql.Model/ExpressionColumn.cs similarity index 92% rename from Other/Selecta/ExpressionColumn.cs rename to Other/Nimblesite.Sql.Model/ExpressionColumn.cs index c0d06fae..aa11cbe7 100644 --- a/Other/Selecta/ExpressionColumn.cs +++ b/Other/Nimblesite.Sql.Model/ExpressionColumn.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents an expression column (calculations, functions, literals) diff --git a/Other/Selecta/ISqlParser.cs b/Other/Nimblesite.Sql.Model/ISqlParser.cs similarity index 94% rename from Other/Selecta/ISqlParser.cs rename to Other/Nimblesite.Sql.Model/ISqlParser.cs index 5cd57d1f..70107289 100644 --- a/Other/Selecta/ISqlParser.cs +++ b/Other/Nimblesite.Sql.Model/ISqlParser.cs @@ -1,6 +1,6 @@ using Outcome; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Abstraction for parsing SQL and extracting comprehensive metadata diff --git a/Other/Selecta/JoinGraph.cs b/Other/Nimblesite.Sql.Model/JoinGraph.cs similarity index 97% rename from Other/Selecta/JoinGraph.cs rename to Other/Nimblesite.Sql.Model/JoinGraph.cs index 65264f76..7e157088 100644 --- a/Other/Selecta/JoinGraph.cs +++ b/Other/Nimblesite.Sql.Model/JoinGraph.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents the join relationships for one-to-many scenarios that need grouping diff --git a/Other/Selecta/JoinRelationship.cs b/Other/Nimblesite.Sql.Model/JoinRelationship.cs similarity index 89% rename from Other/Selecta/JoinRelationship.cs rename to Other/Nimblesite.Sql.Model/JoinRelationship.cs index 9ec0ad5e..bc722858 100644 --- a/Other/Selecta/JoinRelationship.cs +++ b/Other/Nimblesite.Sql.Model/JoinRelationship.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a single join relationship between two tables (for one-to-many relationships) diff --git a/Other/Selecta/LogicalOperator.cs b/Other/Nimblesite.Sql.Model/LogicalOperator.cs similarity index 97% rename from Other/Selecta/LogicalOperator.cs rename to Other/Nimblesite.Sql.Model/LogicalOperator.cs index bb97e74b..f5894922 100644 --- a/Other/Selecta/LogicalOperator.cs +++ b/Other/Nimblesite.Sql.Model/LogicalOperator.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; // ALGEBRAIC DATA TYPE!! Closed Hierarchy! diff --git a/Other/Selecta/NamedColumn.cs b/Other/Nimblesite.Sql.Model/NamedColumn.cs similarity index 94% rename from Other/Selecta/NamedColumn.cs rename to Other/Nimblesite.Sql.Model/NamedColumn.cs index ec5f27af..ea20f65d 100644 --- a/Other/Selecta/NamedColumn.cs +++ b/Other/Nimblesite.Sql.Model/NamedColumn.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a named column with optional table qualifier diff --git a/Other/Nimblesite.Sql.Model/Nimblesite.Sql.Model.csproj b/Other/Nimblesite.Sql.Model/Nimblesite.Sql.Model.csproj new file mode 100644 index 00000000..8d82545f --- /dev/null +++ b/Other/Nimblesite.Sql.Model/Nimblesite.Sql.Model.csproj @@ -0,0 +1,8 @@ + + + Nimblesite.Sql.Model + Nimblesite.Sql.Model + Nimblesite.Sql.Model + Library for representing SQL queries as an object model + + diff --git a/Other/Selecta/OrderByItem.cs b/Other/Nimblesite.Sql.Model/OrderByItem.cs similarity index 82% rename from Other/Selecta/OrderByItem.cs rename to Other/Nimblesite.Sql.Model/OrderByItem.cs index 19f41bd2..e63ce83f 100644 --- a/Other/Selecta/OrderByItem.cs +++ b/Other/Nimblesite.Sql.Model/OrderByItem.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents an ORDER BY item with column and direction diff --git a/Other/Selecta/ParameterInfo.cs b/Other/Nimblesite.Sql.Model/ParameterInfo.cs similarity index 82% rename from Other/Selecta/ParameterInfo.cs rename to Other/Nimblesite.Sql.Model/ParameterInfo.cs index 8f5dee2a..6f0bba77 100644 --- a/Other/Selecta/ParameterInfo.cs +++ b/Other/Nimblesite.Sql.Model/ParameterInfo.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a parameter in the SQL query diff --git a/Other/Selecta/PredicateBuilder.cs b/Other/Nimblesite.Sql.Model/PredicateBuilder.cs similarity index 99% rename from Other/Selecta/PredicateBuilder.cs rename to Other/Nimblesite.Sql.Model/PredicateBuilder.cs index f7b5da31..26a017f9 100644 --- a/Other/Selecta/PredicateBuilder.cs +++ b/Other/Nimblesite.Sql.Model/PredicateBuilder.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// PredicateBuilder enables dynamic construction of Expression trees for LINQ predicates. diff --git a/Other/Selecta/Readme.md b/Other/Nimblesite.Sql.Model/Readme.md similarity index 100% rename from Other/Selecta/Readme.md rename to Other/Nimblesite.Sql.Model/Readme.md diff --git a/Other/Selecta/SelectQueryable.cs b/Other/Nimblesite.Sql.Model/SelectQueryable.cs similarity index 98% rename from Other/Selecta/SelectQueryable.cs rename to Other/Nimblesite.Sql.Model/SelectQueryable.cs index adc35ca8..c9460eaa 100644 --- a/Other/Selecta/SelectQueryable.cs +++ b/Other/Nimblesite.Sql.Model/SelectQueryable.cs @@ -1,7 +1,7 @@ using System.Collections; using System.Linq.Expressions; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a SQL query that can be built using LINQ query expressions diff --git a/Other/Selecta/SelectQueryableExtensions.cs b/Other/Nimblesite.Sql.Model/SelectQueryableExtensions.cs similarity index 95% rename from Other/Selecta/SelectQueryableExtensions.cs rename to Other/Nimblesite.Sql.Model/SelectQueryableExtensions.cs index 8631c4ee..e212a88d 100644 --- a/Other/Selecta/SelectQueryableExtensions.cs +++ b/Other/Nimblesite.Sql.Model/SelectQueryableExtensions.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Extension methods to avoid casting when using ToSqlStatement diff --git a/Other/Selecta/SelectStatement.cs b/Other/Nimblesite.Sql.Model/SelectStatement.cs similarity index 98% rename from Other/Selecta/SelectStatement.cs rename to Other/Nimblesite.Sql.Model/SelectStatement.cs index a3f820f4..01a06237 100644 --- a/Other/Selecta/SelectStatement.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatement.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a parsed SQL SELECT statement with extracted metadata that is generic for all SQL flavors diff --git a/Other/Selecta/SelectStatementBuilder.cs b/Other/Nimblesite.Sql.Model/SelectStatementBuilder.cs similarity index 99% rename from Other/Selecta/SelectStatementBuilder.cs rename to Other/Nimblesite.Sql.Model/SelectStatementBuilder.cs index b2ea2d24..4f3b8155 100644 --- a/Other/Selecta/SelectStatementBuilder.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementBuilder.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Builder for constructing SelectStatement instances diff --git a/Other/Selecta/SelectStatementLinqExtensions.cs b/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs similarity index 99% rename from Other/Selecta/SelectStatementLinqExtensions.cs rename to Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs index 7868aedd..9662ed54 100644 --- a/Other/Selecta/SelectStatementLinqExtensions.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Linq.Expressions; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// LINQ expression extensions for building SQL statements diff --git a/Other/Selecta/SelectStatementProvider.cs b/Other/Nimblesite.Sql.Model/SelectStatementProvider.cs similarity index 98% rename from Other/Selecta/SelectStatementProvider.cs rename to Other/Nimblesite.Sql.Model/SelectStatementProvider.cs index 35cf7572..0f62e210 100644 --- a/Other/Selecta/SelectStatementProvider.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementProvider.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Query provider that builds SelectStatement from LINQ expressions diff --git a/Other/Selecta/SelectStatementVisitor.cs b/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs similarity index 99% rename from Other/Selecta/SelectStatementVisitor.cs rename to Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs index 88f004d1..32b0164c 100644 --- a/Other/Selecta/SelectStatementVisitor.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Linq.Expressions; -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Expression visitor that builds SQL from LINQ expressions diff --git a/Other/Selecta/SqlError.cs b/Other/Nimblesite.Sql.Model/SqlError.cs similarity index 99% rename from Other/Selecta/SqlError.cs rename to Other/Nimblesite.Sql.Model/SqlError.cs index 2c06d2fb..3a07ccfb 100644 --- a/Other/Selecta/SqlError.cs +++ b/Other/Nimblesite.Sql.Model/SqlError.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a position in source code diff --git a/Other/Selecta/SqlErrorException.cs b/Other/Nimblesite.Sql.Model/SqlErrorException.cs similarity index 98% rename from Other/Selecta/SqlErrorException.cs rename to Other/Nimblesite.Sql.Model/SqlErrorException.cs index 555fc7b7..73dcfe3f 100644 --- a/Other/Selecta/SqlErrorException.cs +++ b/Other/Nimblesite.Sql.Model/SqlErrorException.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Exception that wraps a SqlError for proper error propagation diff --git a/Other/Selecta/SubQueryColumn.cs b/Other/Nimblesite.Sql.Model/SubQueryColumn.cs similarity index 92% rename from Other/Selecta/SubQueryColumn.cs rename to Other/Nimblesite.Sql.Model/SubQueryColumn.cs index 85ca623f..38ee5481 100644 --- a/Other/Selecta/SubQueryColumn.cs +++ b/Other/Nimblesite.Sql.Model/SubQueryColumn.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a subquery column (subselect) diff --git a/Other/Selecta/TableInfo.cs b/Other/Nimblesite.Sql.Model/TableInfo.cs similarity index 80% rename from Other/Selecta/TableInfo.cs rename to Other/Nimblesite.Sql.Model/TableInfo.cs index 32e6cb89..f8d53886 100644 --- a/Other/Selecta/TableInfo.cs +++ b/Other/Nimblesite.Sql.Model/TableInfo.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a table in the FROM clause diff --git a/Other/Selecta/UnionOperation.cs b/Other/Nimblesite.Sql.Model/UnionOperation.cs similarity index 81% rename from Other/Selecta/UnionOperation.cs rename to Other/Nimblesite.Sql.Model/UnionOperation.cs index 2cef46c6..9f19dffe 100644 --- a/Other/Selecta/UnionOperation.cs +++ b/Other/Nimblesite.Sql.Model/UnionOperation.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a UNION or UNION ALL operation diff --git a/Other/Selecta/WhereCondition.cs b/Other/Nimblesite.Sql.Model/WhereCondition.cs similarity index 98% rename from Other/Selecta/WhereCondition.cs rename to Other/Nimblesite.Sql.Model/WhereCondition.cs index ca1fbde7..90f56306 100644 --- a/Other/Selecta/WhereCondition.cs +++ b/Other/Nimblesite.Sql.Model/WhereCondition.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; // ALGEBRAIC DATA TYPE!!! diff --git a/Other/Selecta/WildcardColumn.cs b/Other/Nimblesite.Sql.Model/WildcardColumn.cs similarity index 92% rename from Other/Selecta/WildcardColumn.cs rename to Other/Nimblesite.Sql.Model/WildcardColumn.cs index f106636b..5dd17473 100644 --- a/Other/Selecta/WildcardColumn.cs +++ b/Other/Nimblesite.Sql.Model/WildcardColumn.cs @@ -1,4 +1,4 @@ -namespace Selecta; +namespace Nimblesite.Sql.Model; /// /// Represents a wildcard column (*) that selects all columns diff --git a/Other/Selecta/Selecta.csproj b/Other/Selecta/Selecta.csproj deleted file mode 100644 index 566ffa8e..00000000 --- a/Other/Selecta/Selecta.csproj +++ /dev/null @@ -1,6 +0,0 @@ - - - Selecta - Utility library for SQL result selection and mapping - - diff --git a/Samples/.editorconfig b/Samples/.editorconfig deleted file mode 100644 index 1ea7230c..00000000 --- a/Samples/.editorconfig +++ /dev/null @@ -1,10 +0,0 @@ -# Sample projects - less strict analysis -root = false - -[*.cs] -# Suppress problematic analyzers in sample code -dotnet_diagnostic.RS1035.severity = none -dotnet_diagnostic.EPC12.severity = none -dotnet_diagnostic.CA2100.severity = none -dotnet_diagnostic.CA1826.severity = none -dotnet_diagnostic.IDE0037.severity = none diff --git a/Samples/Clinical/Clinical.Api.Tests/AuthorizationTests.cs b/Samples/Clinical/Clinical.Api.Tests/AuthorizationTests.cs deleted file mode 100644 index 5e46bb9c..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/AuthorizationTests.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; - -namespace Clinical.Api.Tests; - -/// -/// Authorization tests for Clinical.Api endpoints. -/// Tests that endpoints require proper authentication and permissions. -/// -public sealed class AuthorizationTests : IClassFixture -{ - private readonly HttpClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// Shared factory instance. - public AuthorizationTests(ClinicalApiFactory factory) => _client = factory.CreateClient(); - - [Fact] - public async Task GetPatients_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/fhir/Patient/"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPatients_WithInvalidToken_ReturnsUnauthorized() - { - using var request = new HttpRequestMessage(HttpMethod.Get, "/fhir/Patient/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); - - var response = await _client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPatients_WithExpiredToken_ReturnsUnauthorized() - { - using var request = new HttpRequestMessage(HttpMethod.Get, "/fhir/Patient/"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", - TestTokenHelper.GenerateExpiredToken() - ); - - var response = await _client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPatients_WithValidToken_SucceedsInDevMode() - { - // In dev mode (default signing key is all zeros), Gatekeeper permission checks - // are bypassed to allow E2E testing without requiring Gatekeeper setup. - // Valid tokens pass through after local JWT validation. - using var request = new HttpRequestMessage(HttpMethod.Get, "/fhir/Patient/"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", - TestTokenHelper.GenerateNoRoleToken() - ); - - var response = await _client.SendAsync(request); - - // In dev mode, valid tokens succeed without permission checks - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task CreatePatient_WithoutToken_ReturnsUnauthorized() - { - var patient = new - { - Active = true, - GivenName = "Test", - FamilyName = "Patient", - Gender = "male", - }; - - var response = await _client.PostAsJsonAsync("/fhir/Patient/", patient); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetEncounters_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/fhir/Patient/test-patient/Encounter/"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetConditions_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/fhir/Patient/test-patient/Condition/"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetMedicationRequests_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/fhir/Patient/test-patient/MedicationRequest/"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncChanges_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/changes"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncOrigin_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/origin"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncStatus_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/status"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncRecords_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/records"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncRetry_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.PostAsync("/sync/records/test-id/retry", null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task PatientSearch_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/fhir/Patient/_search?q=test"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPatientById_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/fhir/Patient/test-patient-id"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task UpdatePatient_WithoutToken_ReturnsUnauthorized() - { - var patient = new - { - Active = true, - GivenName = "Updated", - FamilyName = "Patient", - Gender = "male", - }; - - var response = await _client.PutAsJsonAsync("/fhir/Patient/test-id", patient); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task CreateEncounter_WithoutToken_ReturnsUnauthorized() - { - var encounter = new - { - Status = "planned", - Class = "outpatient", - PractitionerId = "pract-1", - ServiceType = "General", - ReasonCode = "Checkup", - PeriodStart = "2024-01-01T10:00:00Z", - PeriodEnd = "2024-01-01T11:00:00Z", - Notes = "Test", - }; - - var response = await _client.PostAsJsonAsync( - "/fhir/Patient/test-patient/Encounter/", - encounter - ); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task CreateCondition_WithoutToken_ReturnsUnauthorized() - { - var condition = new - { - ClinicalStatus = "active", - VerificationStatus = "confirmed", - Category = "encounter-diagnosis", - Severity = "moderate", - CodeSystem = "http://snomed.info/sct", - CodeValue = "123456", - CodeDisplay = "Test Condition", - }; - - var response = await _client.PostAsJsonAsync( - "/fhir/Patient/test-patient/Condition/", - condition - ); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task CreateMedicationRequest_WithoutToken_ReturnsUnauthorized() - { - var medication = new - { - Status = "active", - Intent = "order", - PractitionerId = "pract-1", - EncounterId = "enc-1", - MedicationCode = "12345", - MedicationDisplay = "Test Medication", - DosageInstruction = "Take once daily", - Quantity = 30, - Unit = "tablets", - Refills = 2, - }; - - var response = await _client.PostAsJsonAsync( - "/fhir/Patient/test-patient/MedicationRequest/", - medication - ); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } -} diff --git a/Samples/Clinical/Clinical.Api.Tests/ClinicalApiFactory.cs b/Samples/Clinical/Clinical.Api.Tests/ClinicalApiFactory.cs deleted file mode 100644 index fc831ae6..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/ClinicalApiFactory.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Npgsql; - -namespace Clinical.Api.Tests; - -/// -/// WebApplicationFactory for Clinical.Api e2e testing. -/// Creates an isolated PostgreSQL test database per factory instance. -/// -public sealed class ClinicalApiFactory : WebApplicationFactory -{ - private readonly string _dbName; - private readonly string _connectionString; - - private static readonly string BaseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme"; - - /// - /// Creates a new instance with an isolated PostgreSQL test database. - /// - public ClinicalApiFactory() - { - _dbName = $"test_clinical_{Guid.NewGuid():N}"; - - using (var adminConn = new NpgsqlConnection(BaseConnectionString)) - { - adminConn.Open(); - using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {_dbName}"; - createCmd.ExecuteNonQuery(); - } - - _connectionString = BaseConnectionString.Replace( - "Database=postgres", - $"Database={_dbName}" - ); - } - - /// - /// Gets the connection string for direct access in tests if needed. - /// - public string ConnectionString => _connectionString; - - /// - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseSetting("ConnectionStrings:Postgres", _connectionString); - builder.UseEnvironment("Development"); - - var clinicalApiAssembly = typeof(Program).Assembly; - var contentRoot = Path.GetDirectoryName(clinicalApiAssembly.Location)!; - builder.UseContentRoot(contentRoot); - } - - /// - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing) - { - try - { - using var adminConn = new NpgsqlConnection(BaseConnectionString); - adminConn.Open(); - - using var terminateCmd = adminConn.CreateCommand(); - terminateCmd.CommandText = - $"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{_dbName}'"; - terminateCmd.ExecuteNonQuery(); - - using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {_dbName}"; - dropCmd.ExecuteNonQuery(); - } - catch - { - // Ignore cleanup errors - } - } - } -} diff --git a/Samples/Clinical/Clinical.Api.Tests/ConditionEndpointTests.cs b/Samples/Clinical/Clinical.Api.Tests/ConditionEndpointTests.cs deleted file mode 100644 index 4ebd60cb..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/ConditionEndpointTests.cs +++ /dev/null @@ -1,335 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Clinical.Api.Tests; - -/// -/// E2E tests for Condition FHIR endpoints - REAL database, NO mocks. -/// Each test creates its own isolated factory and database. -/// -public sealed class ConditionEndpointTests -{ - private static readonly string AuthToken = TestTokenHelper.GenerateClinicianToken(); - - private static HttpClient CreateAuthenticatedClient(ClinicalApiFactory factory) - { - var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - AuthToken - ); - return client; - } - - private static async Task CreateTestPatientAsync(HttpClient client) - { - var patient = new - { - Active = true, - GivenName = "Condition", - FamilyName = "TestPatient", - Gender = "female", - }; - - var response = await client.PostAsJsonAsync("/fhir/Patient/", patient); - var created = await response.Content.ReadFromJsonAsync(); - return created.GetProperty("Id").GetString()!; - } - - [Fact] - public async Task GetConditionsByPatient_ReturnsEmptyList_WhenNoConditions() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - - var response = await client.GetAsync($"/fhir/Patient/{patientId}/Condition/"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[]", content); - } - - [Fact] - public async Task CreateCondition_ReturnsCreated_WithValidData() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - ClinicalStatus = "active", - VerificationStatus = "confirmed", - Category = "problem-list-item", - Severity = "moderate", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "J06.9", - CodeDisplay = "Acute upper respiratory infection, unspecified", - OnsetDateTime = "2024-01-10T00:00:00Z", - NoteText = "Patient presents with cold symptoms", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var condition = await response.Content.ReadFromJsonAsync(); - Assert.Equal("active", condition.GetProperty("ClinicalStatus").GetString()); - Assert.Equal("J06.9", condition.GetProperty("CodeValue").GetString()); - Assert.Equal(patientId, condition.GetProperty("SubjectReference").GetString()); - Assert.NotNull(condition.GetProperty("Id").GetString()); - } - - [Fact] - public async Task CreateCondition_WithAllClinicalStatuses() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var statuses = new[] - { - "active", - "recurrence", - "relapse", - "inactive", - "remission", - "resolved", - }; - - foreach (var status in statuses) - { - var patientId = await CreateTestPatientAsync(client); - var request = new - { - ClinicalStatus = status, - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "Z00.00", - CodeDisplay = "General examination", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var condition = await response.Content.ReadFromJsonAsync(); - Assert.Equal(status, condition.GetProperty("ClinicalStatus").GetString()); - } - } - - [Fact] - public async Task CreateCondition_WithAllSeverities() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var severities = new[] { "mild", "moderate", "severe" }; - - foreach (var severity in severities) - { - var patientId = await CreateTestPatientAsync(client); - var request = new - { - ClinicalStatus = "active", - Severity = severity, - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "R51", - CodeDisplay = "Headache", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var condition = await response.Content.ReadFromJsonAsync(); - Assert.Equal(severity, condition.GetProperty("Severity").GetString()); - } - } - - [Fact] - public async Task CreateCondition_WithVerificationStatuses() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var statuses = new[] - { - "unconfirmed", - "provisional", - "differential", - "confirmed", - "refuted", - }; - - foreach (var status in statuses) - { - var patientId = await CreateTestPatientAsync(client); - var request = new - { - ClinicalStatus = "active", - VerificationStatus = status, - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "M54.5", - CodeDisplay = "Low back pain", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var condition = await response.Content.ReadFromJsonAsync(); - Assert.Equal(status, condition.GetProperty("VerificationStatus").GetString()); - } - } - - [Fact] - public async Task GetConditionsByPatient_ReturnsConditions_WhenExist() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request1 = new - { - ClinicalStatus = "active", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "E11.9", - CodeDisplay = "Type 2 diabetes mellitus", - }; - var request2 = new - { - ClinicalStatus = "resolved", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "J02.9", - CodeDisplay = "Acute pharyngitis, unspecified", - }; - - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/Condition/", request1); - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/Condition/", request2); - - var response = await client.GetAsync($"/fhir/Patient/{patientId}/Condition/"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var conditions = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(conditions); - Assert.True(conditions.Length >= 2); - } - - [Fact] - public async Task CreateCondition_SetsRecordedDate() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - ClinicalStatus = "active", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "I10", - CodeDisplay = "Essential hypertension", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - request - ); - var condition = await response.Content.ReadFromJsonAsync(); - - var recordedDate = condition.GetProperty("RecordedDate").GetString(); - Assert.NotNull(recordedDate); - Assert.Matches(@"\d{4}-\d{2}-\d{2}", recordedDate); - } - - [Fact] - public async Task CreateCondition_SetsVersionIdToOne() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - ClinicalStatus = "active", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "K21.0", - CodeDisplay = "GERD", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - request - ); - var condition = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(1L, condition.GetProperty("VersionId").GetInt64()); - } - - [Fact] - public async Task CreateCondition_WithEncounterReference() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - - var encounterRequest = new - { - Status = "finished", - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - }; - var encounterResponse = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - encounterRequest - ); - var encounter = await encounterResponse.Content.ReadFromJsonAsync(); - var encounterId = encounter.GetProperty("Id").GetString(); - - var conditionRequest = new - { - ClinicalStatus = "active", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "J18.9", - CodeDisplay = "Pneumonia, unspecified organism", - EncounterReference = encounterId, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - conditionRequest - ); - var condition = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(encounterId, condition.GetProperty("EncounterReference").GetString()); - } - - [Fact] - public async Task CreateCondition_WithNotes() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - ClinicalStatus = "active", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "F32.1", - CodeDisplay = "Major depressive disorder, single episode, moderate", - NoteText = "Patient started on SSRI therapy. Follow up in 4 weeks.", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Condition/", - request - ); - var condition = await response.Content.ReadFromJsonAsync(); - - Assert.Equal( - "Patient started on SSRI therapy. Follow up in 4 weeks.", - condition.GetProperty("NoteText").GetString() - ); - } -} diff --git a/Samples/Clinical/Clinical.Api.Tests/DashboardIntegrationTests.cs b/Samples/Clinical/Clinical.Api.Tests/DashboardIntegrationTests.cs deleted file mode 100644 index 9f92dd78..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/DashboardIntegrationTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Net.Http.Headers; - -namespace Clinical.Api.Tests; - -/// -/// Tests that verify the Dashboard can actually connect to Clinical API. -/// These tests MUST FAIL if: -/// 1. Dashboard hardcoded URL doesn't match actual API URL -/// 2. CORS is not configured for Dashboard origin -/// -public sealed class DashboardIntegrationTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly string _authToken = TestTokenHelper.GenerateClinicianToken(); - - /// - /// The actual URL where Dashboard runs (for CORS origin testing). - /// - private const string DashboardOrigin = "http://localhost:5173"; - - /// - /// Initializes a new instance of the class. - /// - /// Shared factory instance. - public DashboardIntegrationTests(ClinicalApiFactory factory) - { - _client = factory.CreateClient(); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - _authToken - ); - } - - #region URL Configuration Tests - - [Fact] - public void Dashboard_ClinicalApiUrl_MatchesActualPort() - { - // The Dashboard's index.html has this hardcoded: - // const CLINICAL_API = window.dashboardConfig?.CLINICAL_API_URL || 'http://localhost:5000'; - // - // But Clinical API runs on port 5080 (see start.sh and launchSettings.json) - // - // This test verifies the Dashboard is configured to hit the CORRECT port. - // If this fails, the Dashboard cannot connect to the API. - - const string dashboardHardcodedUrl = "http://localhost:5080"; // What Dashboard actually uses - const string clinicalApiActualUrl = "http://localhost:5080"; // Where API actually runs - - Assert.Equal( - clinicalApiActualUrl, - dashboardHardcodedUrl // Dashboard now uses correct port! - ); - } - - #endregion - - #region CORS Tests - - [Fact] - public async Task ClinicalApi_Returns_CorsHeaders_ForDashboardOrigin() - { - // The Dashboard runs on localhost:5173 and makes fetch() calls to Clinical API. - // Browser enforces CORS - without proper headers, the request is blocked. - // - // This test verifies Clinical API returns Access-Control-Allow-Origin header - // for the Dashboard's origin. - - var request = new HttpRequestMessage(HttpMethod.Get, "/fhir/Patient"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authToken); - - var response = await _client.SendAsync(request); - - // API should return CORS header allowing Dashboard origin - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Clinical API must return Access-Control-Allow-Origin header for Dashboard to work" - ); - - var allowedOrigin = response - .Headers.GetValues("Access-Control-Allow-Origin") - .FirstOrDefault(); - Assert.True( - allowedOrigin == DashboardOrigin || allowedOrigin == "*", - $"Access-Control-Allow-Origin must be '{DashboardOrigin}' or '*', but was '{allowedOrigin}'" - ); - } - - [Fact] - public async Task ClinicalApi_Handles_PreflightRequest_ForDashboardOrigin() - { - // Before making actual requests, browsers send OPTIONS preflight request. - // API must respond with correct CORS headers. - - var request = new HttpRequestMessage(HttpMethod.Options, "/fhir/Patient"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Access-Control-Request-Method", "GET"); - request.Headers.Add("Access-Control-Request-Headers", "Accept"); - - var response = await _client.SendAsync(request); - - // Preflight should succeed (200 or 204) - Assert.True( - response.IsSuccessStatusCode, - $"Preflight OPTIONS request failed with {response.StatusCode}" - ); - - // Must have CORS headers - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Preflight response must include Access-Control-Allow-Origin" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Methods"), - "Preflight response must include Access-Control-Allow-Methods" - ); - } - - #endregion -} diff --git a/Samples/Clinical/Clinical.Api.Tests/EncounterEndpointTests.cs b/Samples/Clinical/Clinical.Api.Tests/EncounterEndpointTests.cs deleted file mode 100644 index 610de404..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/EncounterEndpointTests.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Clinical.Api.Tests; - -/// -/// E2E tests for Encounter FHIR endpoints - REAL database, NO mocks. -/// Each test creates its own isolated factory and database. -/// -public sealed class EncounterEndpointTests -{ - private static readonly string AuthToken = TestTokenHelper.GenerateClinicianToken(); - - private static HttpClient CreateAuthenticatedClient(ClinicalApiFactory factory) - { - var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - AuthToken - ); - return client; - } - - private static async Task CreateTestPatientAsync(HttpClient client) - { - var patient = new - { - Active = true, - GivenName = "Encounter", - FamilyName = "TestPatient", - Gender = "male", - }; - - var response = await client.PostAsJsonAsync("/fhir/Patient/", patient); - var created = await response.Content.ReadFromJsonAsync(); - return created.GetProperty("Id").GetString()!; - } - - [Fact] - public async Task GetEncountersByPatient_ReturnsEmptyList_WhenNoEncounters() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - - var response = await client.GetAsync($"/fhir/Patient/{patientId}/Encounter/"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[]", content); - } - - [Fact] - public async Task CreateEncounter_ReturnsCreated_WithValidData() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "planned", - Class = "ambulatory", - PractitionerId = "practitioner-123", - ServiceType = "General Practice", - ReasonCode = "Annual checkup", - PeriodStart = "2024-01-15T09:00:00Z", - PeriodEnd = "2024-01-15T09:30:00Z", - Notes = "Routine visit", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var encounter = await response.Content.ReadFromJsonAsync(); - Assert.Equal("planned", encounter.GetProperty("Status").GetString()); - Assert.Equal("ambulatory", encounter.GetProperty("Class").GetString()); - Assert.Equal(patientId, encounter.GetProperty("PatientId").GetString()); - Assert.NotNull(encounter.GetProperty("Id").GetString()); - } - - [Fact] - public async Task CreateEncounter_WithAllStatuses() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var statuses = new[] - { - "planned", - "arrived", - "triaged", - "in-progress", - "onleave", - "finished", - "cancelled", - }; - - foreach (var status in statuses) - { - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = status, - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var encounter = await response.Content.ReadFromJsonAsync(); - Assert.Equal(status, encounter.GetProperty("Status").GetString()); - } - } - - [Fact] - public async Task CreateEncounter_WithAllClasses() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var classes = new[] { "ambulatory", "emergency", "inpatient", "observation", "virtual" }; - - foreach (var encounterClass in classes) - { - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "planned", - Class = encounterClass, - PeriodStart = "2024-01-15T09:00:00Z", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var encounter = await response.Content.ReadFromJsonAsync(); - Assert.Equal(encounterClass, encounter.GetProperty("Class").GetString()); - } - } - - [Fact] - public async Task GetEncountersByPatient_ReturnsEncounters_WhenExist() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request1 = new - { - Status = "planned", - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - }; - var request2 = new - { - Status = "finished", - Class = "inpatient", - PeriodStart = "2024-01-16T10:00:00Z", - }; - - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/Encounter/", request1); - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/Encounter/", request2); - - var response = await client.GetAsync($"/fhir/Patient/{patientId}/Encounter/"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var encounters = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(encounters); - Assert.True(encounters.Length >= 2); - } - - [Fact] - public async Task CreateEncounter_SetsVersionIdToOne() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "planned", - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - request - ); - var encounter = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(1L, encounter.GetProperty("VersionId").GetInt64()); - } - - [Fact] - public async Task CreateEncounter_SetsLastUpdatedTimestamp() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "planned", - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - request - ); - var encounter = await response.Content.ReadFromJsonAsync(); - - var lastUpdated = encounter.GetProperty("LastUpdated").GetString(); - Assert.NotNull(lastUpdated); - Assert.Matches(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", lastUpdated); - } - - [Fact] - public async Task CreateEncounter_WithNotes() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "planned", - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - Notes = "Patient reported mild headache. Follow up in 2 weeks.", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - request - ); - var encounter = await response.Content.ReadFromJsonAsync(); - - Assert.Equal( - "Patient reported mild headache. Follow up in 2 weeks.", - encounter.GetProperty("Notes").GetString() - ); - } - - [Fact] - public async Task CreateEncounter_WithPeriodEndTime() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "finished", - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - PeriodEnd = "2024-01-15T09:45:00Z", - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - request - ); - var encounter = await response.Content.ReadFromJsonAsync(); - - Assert.Equal("2024-01-15T09:00:00Z", encounter.GetProperty("PeriodStart").GetString()); - Assert.Equal("2024-01-15T09:45:00Z", encounter.GetProperty("PeriodEnd").GetString()); - } -} diff --git a/Samples/Clinical/Clinical.Api.Tests/GlobalUsings.cs b/Samples/Clinical/Clinical.Api.Tests/GlobalUsings.cs deleted file mode 100644 index f68c2477..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Samples.Authorization; -global using Xunit; diff --git a/Samples/Clinical/Clinical.Api.Tests/MedicationRequestEndpointTests.cs b/Samples/Clinical/Clinical.Api.Tests/MedicationRequestEndpointTests.cs deleted file mode 100644 index 857c143e..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/MedicationRequestEndpointTests.cs +++ /dev/null @@ -1,376 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Clinical.Api.Tests; - -/// -/// E2E tests for MedicationRequest FHIR endpoints - REAL database, NO mocks. -/// Each test creates its own isolated factory and database. -/// -public sealed class MedicationRequestEndpointTests -{ - private static readonly string AuthToken = TestTokenHelper.GenerateClinicianToken(); - - private static HttpClient CreateAuthenticatedClient(ClinicalApiFactory factory) - { - var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - AuthToken - ); - return client; - } - - private static async Task CreateTestPatientAsync(HttpClient client) - { - var patient = new - { - Active = true, - GivenName = "Medication", - FamilyName = "TestPatient", - Gender = "male", - }; - - var response = await client.PostAsJsonAsync("/fhir/Patient/", patient); - var created = await response.Content.ReadFromJsonAsync(); - return created.GetProperty("Id").GetString()!; - } - - [Fact] - public async Task GetMedicationsByPatient_ReturnsEmptyList_WhenNoMedications() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - - var response = await client.GetAsync($"/fhir/Patient/{patientId}/MedicationRequest/"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[]", content); - } - - [Fact] - public async Task CreateMedicationRequest_ReturnsCreated_WithValidData() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "active", - Intent = "order", - PractitionerId = "practitioner-456", - MedicationCode = "197361", - MedicationDisplay = "Lisinopril 10 MG Oral Tablet", - DosageInstruction = "Take 1 tablet by mouth once daily", - Quantity = 30.0, - Unit = "tablet", - Refills = 3, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var medication = await response.Content.ReadFromJsonAsync(); - Assert.Equal("active", medication.GetProperty("Status").GetString()); - Assert.Equal("order", medication.GetProperty("Intent").GetString()); - Assert.Equal( - "Lisinopril 10 MG Oral Tablet", - medication.GetProperty("MedicationDisplay").GetString() - ); - Assert.Equal(patientId, medication.GetProperty("PatientId").GetString()); - Assert.NotNull(medication.GetProperty("Id").GetString()); - } - - [Fact] - public async Task CreateMedicationRequest_WithAllStatuses() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var statuses = new[] { "active", "on-hold", "cancelled", "completed", "stopped", "draft" }; - - foreach (var status in statuses) - { - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = status, - Intent = "order", - PractitionerId = "practitioner-789", - MedicationCode = "123456", - MedicationDisplay = "Test Medication", - Refills = 0, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var medication = await response.Content.ReadFromJsonAsync(); - Assert.Equal(status, medication.GetProperty("Status").GetString()); - } - } - - [Fact] - public async Task CreateMedicationRequest_WithAllIntents() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var intents = new[] - { - "proposal", - "plan", - "order", - "original-order", - "reflex-order", - "filler-order", - "instance-order", - "option", - }; - - foreach (var intent in intents) - { - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "active", - Intent = intent, - PractitionerId = "practitioner-abc", - MedicationCode = "654321", - MedicationDisplay = "Test Med", - Refills = 1, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var medication = await response.Content.ReadFromJsonAsync(); - Assert.Equal(intent, medication.GetProperty("Intent").GetString()); - } - } - - [Fact] - public async Task GetMedicationsByPatient_ReturnsMedications_WhenExist() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request1 = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-1", - MedicationCode = "311354", - MedicationDisplay = "Metformin 500 MG Oral Tablet", - Refills = 5, - }; - var request2 = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-1", - MedicationCode = "197361", - MedicationDisplay = "Lisinopril 10 MG Oral Tablet", - Refills = 3, - }; - - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/MedicationRequest/", request1); - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/MedicationRequest/", request2); - - var response = await client.GetAsync($"/fhir/Patient/{patientId}/MedicationRequest/"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var medications = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(medications); - Assert.True(medications.Length >= 2); - } - - [Fact] - public async Task CreateMedicationRequest_SetsVersionIdToOne() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-2", - MedicationCode = "849727", - MedicationDisplay = "Atorvastatin 20 MG Oral Tablet", - Refills = 6, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - var medication = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(1L, medication.GetProperty("VersionId").GetInt64()); - } - - [Fact] - public async Task CreateMedicationRequest_SetsAuthoredOn() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-3", - MedicationCode = "310429", - MedicationDisplay = "Amlodipine 5 MG Oral Tablet", - Refills = 3, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - var medication = await response.Content.ReadFromJsonAsync(); - - var authoredOn = medication.GetProperty("AuthoredOn").GetString(); - Assert.NotNull(authoredOn); - Assert.Matches(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", authoredOn); - } - - [Fact] - public async Task CreateMedicationRequest_WithQuantityAndUnit() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-4", - MedicationCode = "1049621", - MedicationDisplay = "Omeprazole 20 MG Delayed Release Oral Capsule", - Quantity = 90.0, - Unit = "capsule", - Refills = 2, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - var medication = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(90.0, medication.GetProperty("Quantity").GetDouble()); - Assert.Equal("capsule", medication.GetProperty("Unit").GetString()); - } - - [Fact] - public async Task CreateMedicationRequest_WithDosageInstruction() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-5", - MedicationCode = "1049621", - MedicationDisplay = "Omeprazole 20 MG Capsule", - DosageInstruction = "Take 1 capsule by mouth 30 minutes before breakfast", - Refills = 2, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - var medication = await response.Content.ReadFromJsonAsync(); - - Assert.Equal( - "Take 1 capsule by mouth 30 minutes before breakfast", - medication.GetProperty("DosageInstruction").GetString() - ); - } - - [Fact] - public async Task CreateMedicationRequest_WithEncounterId() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - - var encounterRequest = new - { - Status = "finished", - Class = "ambulatory", - PeriodStart = "2024-01-15T09:00:00Z", - }; - var encounterResponse = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/Encounter/", - encounterRequest - ); - var encounter = await encounterResponse.Content.ReadFromJsonAsync(); - var encounterId = encounter.GetProperty("Id").GetString(); - - var medicationRequest = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-6", - EncounterId = encounterId, - MedicationCode = "308136", - MedicationDisplay = "Amoxicillin 500 MG Oral Capsule", - DosageInstruction = "Take 1 capsule by mouth three times daily for 10 days", - Quantity = 30.0, - Unit = "capsule", - Refills = 0, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - medicationRequest - ); - var medication = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(encounterId, medication.GetProperty("EncounterId").GetString()); - } - - [Fact] - public async Task CreateMedicationRequest_WithZeroRefills() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientId = await CreateTestPatientAsync(client); - var request = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-7", - MedicationCode = "562251", - MedicationDisplay = "Prednisone 10 MG Oral Tablet", - DosageInstruction = "Taper as directed", - Refills = 0, - }; - - var response = await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - request - ); - var medication = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(0, medication.GetProperty("Refills").GetInt32()); - } -} diff --git a/Samples/Clinical/Clinical.Api.Tests/PatientEndpointTests.cs b/Samples/Clinical/Clinical.Api.Tests/PatientEndpointTests.cs deleted file mode 100644 index 7356510c..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/PatientEndpointTests.cs +++ /dev/null @@ -1,293 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Clinical.Api.Tests; - -/// -/// E2E tests for Patient FHIR endpoints - REAL database, NO mocks. -/// Uses shared factory for all tests - starts once, runs all tests, shuts down. -/// -public sealed class PatientEndpointTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly string _authToken = TestTokenHelper.GenerateClinicianToken(); - - /// - /// Initializes a new instance of the class. - /// - /// Shared factory instance. - public PatientEndpointTests(ClinicalApiFactory factory) - { - _client = factory.CreateClient(); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - _authToken - ); - } - - [Fact] - public async Task GetPatients_ReturnsOk() - { - var response = await _client.GetAsync("/fhir/Patient/"); - var body = await response.Content.ReadAsStringAsync(); - - Assert.True( - response.IsSuccessStatusCode, - $"Expected OK but got {response.StatusCode}: {body}" - ); - } - - [Fact] - public async Task CreatePatient_ReturnsCreated_WithValidData() - { - var request = new - { - Active = true, - GivenName = "John", - FamilyName = "Doe", - BirthDate = "1990-01-15", - Gender = "male", - Phone = "555-1234", - Email = "john.doe@test.com", - AddressLine = "123 Main St", - City = "Springfield", - State = "IL", - PostalCode = "62701", - Country = "USA", - }; - - var response = await _client.PostAsJsonAsync("/fhir/Patient/", request); - var content = await response.Content.ReadAsStringAsync(); - - Assert.True( - response.IsSuccessStatusCode, - $"Expected success. Got {response.StatusCode}: {content}" - ); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var json = JsonSerializer.Deserialize(content); - Assert.True( - json.TryGetProperty("GivenName", out var givenName), - $"Missing GivenName in: {content}" - ); - Assert.Equal("John", givenName.GetString()); - Assert.True( - json.TryGetProperty("FamilyName", out var familyName), - $"Missing FamilyName in: {content}" - ); - Assert.Equal("Doe", familyName.GetString()); - Assert.True(json.TryGetProperty("Gender", out var gender), $"Missing Gender in: {content}"); - Assert.Equal("male", gender.GetString()); - Assert.True(json.TryGetProperty("Id", out var id), $"Missing Id in: {content}"); - Assert.NotNull(id.GetString()); - } - - [Fact] - public async Task GetPatientById_ReturnsPatient_WhenExists() - { - var createRequest = new - { - Active = true, - GivenName = "Jane", - FamilyName = "Smith", - BirthDate = "1985-06-20", - Gender = "female", - }; - - var createResponse = await _client.PostAsJsonAsync("/fhir/Patient/", createRequest); - var created = await createResponse.Content.ReadFromJsonAsync(); - var patientId = created.GetProperty("Id").GetString(); - - var response = await _client.GetAsync($"/fhir/Patient/{patientId}"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var patient = await response.Content.ReadFromJsonAsync(); - Assert.Equal("Jane", patient.GetProperty("GivenName").GetString()); - Assert.Equal("Smith", patient.GetProperty("FamilyName").GetString()); - } - - [Fact] - public async Task GetPatientById_ReturnsNotFound_WhenNotExists() - { - var response = await _client.GetAsync("/fhir/Patient/nonexistent-id-12345"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task SearchPatients_FindsPatientsByName() - { - var request = new - { - Active = true, - GivenName = "SearchTest", - FamilyName = "UniqueLastName", - Gender = "other", - }; - - await _client.PostAsJsonAsync("/fhir/Patient/", request); - - var response = await _client.GetAsync("/fhir/Patient/_search?q=UniqueLastName"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var patients = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(patients); - Assert.Contains(patients, p => p.GetProperty("FamilyName").GetString() == "UniqueLastName"); - } - - [Fact] - public async Task GetPatients_FiltersByActiveStatus() - { - var activePatient = new - { - Active = true, - GivenName = "Active", - FamilyName = "PatientFilter", - Gender = "male", - }; - - var inactivePatient = new - { - Active = false, - GivenName = "Inactive", - FamilyName = "PatientFilter", - Gender = "female", - }; - - await _client.PostAsJsonAsync("/fhir/Patient/", activePatient); - await _client.PostAsJsonAsync("/fhir/Patient/", inactivePatient); - - var activeResponse = await _client.GetAsync("/fhir/Patient/?active=true"); - Assert.Equal(HttpStatusCode.OK, activeResponse.StatusCode); - var activePatients = await activeResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(activePatients); - Assert.All(activePatients, p => Assert.Equal(1L, p.GetProperty("Active").GetInt64())); - } - - [Fact] - public async Task GetPatients_FiltersByFamilyName() - { - var patient = new - { - Active = true, - GivenName = "FilterTest", - FamilyName = "FilterFamilyName", - Gender = "unknown", - }; - - await _client.PostAsJsonAsync("/fhir/Patient/", patient); - - var response = await _client.GetAsync("/fhir/Patient/?familyName=FilterFamilyName"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var patients = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(patients); - Assert.Contains( - patients, - p => p.GetProperty("FamilyName").GetString() == "FilterFamilyName" - ); - } - - [Fact] - public async Task GetPatients_FiltersByGivenName() - { - var patient = new - { - Active = true, - GivenName = "UniqueGivenName", - FamilyName = "TestFamily", - Gender = "male", - }; - - await _client.PostAsJsonAsync("/fhir/Patient/", patient); - - var response = await _client.GetAsync("/fhir/Patient/?givenName=UniqueGivenName"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var patients = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(patients); - Assert.Contains(patients, p => p.GetProperty("GivenName").GetString() == "UniqueGivenName"); - } - - [Fact] - public async Task GetPatients_FiltersByGender() - { - var malePatient = new - { - Active = true, - GivenName = "GenderTest", - FamilyName = "Male", - Gender = "male", - }; - - await _client.PostAsJsonAsync("/fhir/Patient/", malePatient); - - var response = await _client.GetAsync("/fhir/Patient/?gender=male"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var patients = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(patients); - Assert.All(patients, p => Assert.Equal("male", p.GetProperty("Gender").GetString())); - } - - [Fact] - public async Task CreatePatient_GeneratesUniqueIds() - { - var request = new - { - Active = true, - GivenName = "IdTest", - FamilyName = "Patient", - Gender = "other", - }; - - var response1 = await _client.PostAsJsonAsync("/fhir/Patient/", request); - var response2 = await _client.PostAsJsonAsync("/fhir/Patient/", request); - - var patient1 = await response1.Content.ReadFromJsonAsync(); - var patient2 = await response2.Content.ReadFromJsonAsync(); - - Assert.NotEqual( - patient1.GetProperty("Id").GetString(), - patient2.GetProperty("Id").GetString() - ); - } - - [Fact] - public async Task CreatePatient_SetsVersionIdToOne() - { - var request = new - { - Active = true, - GivenName = "Version", - FamilyName = "Test", - Gender = "male", - }; - - var response = await _client.PostAsJsonAsync("/fhir/Patient/", request); - var patient = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(1L, patient.GetProperty("VersionId").GetInt64()); - } - - [Fact] - public async Task CreatePatient_SetsLastUpdatedTimestamp() - { - var request = new - { - Active = true, - GivenName = "Timestamp", - FamilyName = "Test", - Gender = "female", - }; - - var response = await _client.PostAsJsonAsync("/fhir/Patient/", request); - var patient = await response.Content.ReadFromJsonAsync(); - - var lastUpdated = patient.GetProperty("LastUpdated").GetString(); - Assert.NotNull(lastUpdated); - Assert.Matches(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", lastUpdated); - } -} diff --git a/Samples/Clinical/Clinical.Api.Tests/SyncEndpointTests.cs b/Samples/Clinical/Clinical.Api.Tests/SyncEndpointTests.cs deleted file mode 100644 index 8757e53d..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/SyncEndpointTests.cs +++ /dev/null @@ -1,419 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Clinical.Api.Tests; - -/// -/// E2E tests for Sync endpoints - REAL database, NO mocks. -/// Tests sync log generation and origin tracking. -/// Each test creates its own isolated factory and database. -/// -public sealed class SyncEndpointTests -{ - private static readonly string AuthToken = TestTokenHelper.GenerateClinicianToken(); - - private static HttpClient CreateAuthenticatedClient(ClinicalApiFactory factory) - { - var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - AuthToken - ); - return client; - } - - [Fact] - public async Task GetSyncOrigin_ReturnsOriginId() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/sync/origin"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - var originId = result.GetProperty("originId").GetString(); - Assert.NotNull(originId); - Assert.NotEmpty(originId); - } - - [Fact] - public async Task GetSyncChanges_ReturnsEmptyList_WhenNoChanges() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/sync/changes?fromVersion=999999"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[]", content); - } - - [Fact] - public async Task GetSyncChanges_ReturnChanges_AfterPatientCreated() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientRequest = new - { - Active = true, - GivenName = "Sync", - FamilyName = "TestPatient", - Gender = "male", - }; - - await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var changes = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(changes); - Assert.True(changes.Length > 0); - } - - [Fact] - public async Task GetSyncChanges_RespectsLimitParameter() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - for (var i = 0; i < 5; i++) - { - var patientRequest = new - { - Active = true, - GivenName = $"SyncLimit{i}", - FamilyName = "TestPatient", - Gender = "other", - }; - await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - } - - var response = await client.GetAsync("/sync/changes?fromVersion=0&limit=2"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var changes = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(changes); - Assert.True(changes.Length <= 2); - } - - [Fact] - public async Task GetSyncChanges_ContainsTableName() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientRequest = new - { - Active = true, - GivenName = "SyncTable", - FamilyName = "TestPatient", - Gender = "male", - }; - await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains(changes, c => c.GetProperty("TableName").GetString() == "fhir_patient"); - } - - [Fact] - public async Task GetSyncChanges_ContainsOperation() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientRequest = new - { - Active = true, - GivenName = "SyncOp", - FamilyName = "TestPatient", - Gender = "female", - }; - await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains( - changes, - c => - { - // Operation is serialized as integer (0=Insert, 1=Update, 2=Delete) - var opValue = c.GetProperty("Operation").GetInt32(); - return opValue >= 0 && opValue <= 2; - } - ); - } - - [Fact] - public async Task GetSyncChanges_TracksEncounterChanges() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientRequest = new - { - Active = true, - GivenName = "SyncEncounter", - FamilyName = "TestPatient", - Gender = "male", - }; - var patientResponse = await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - var patient = await patientResponse.Content.ReadFromJsonAsync(); - var patientId = patient.GetProperty("Id").GetString(); - - var encounterRequest = new - { - Status = "planned", - Class = "ambulatory", - PeriodStart = "2024-02-01T10:00:00Z", - }; - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/Encounter/", encounterRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains(changes, c => c.GetProperty("TableName").GetString() == "fhir_encounter"); - } - - [Fact] - public async Task GetSyncChanges_TracksConditionChanges() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientRequest = new - { - Active = true, - GivenName = "SyncCondition", - FamilyName = "TestPatient", - Gender = "female", - }; - var patientResponse = await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - var patient = await patientResponse.Content.ReadFromJsonAsync(); - var patientId = patient.GetProperty("Id").GetString(); - - var conditionRequest = new - { - ClinicalStatus = "active", - CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", - CodeValue = "J06.9", - CodeDisplay = "URI", - }; - await client.PostAsJsonAsync($"/fhir/Patient/{patientId}/Condition/", conditionRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains(changes, c => c.GetProperty("TableName").GetString() == "fhir_condition"); - } - - [Fact] - public async Task GetSyncChanges_TracksMedicationRequestChanges() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - var patientRequest = new - { - Active = true, - GivenName = "SyncMedication", - FamilyName = "TestPatient", - Gender = "male", - }; - var patientResponse = await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - var patient = await patientResponse.Content.ReadFromJsonAsync(); - var patientId = patient.GetProperty("Id").GetString(); - - var medicationRequest = new - { - Status = "active", - Intent = "order", - PractitionerId = "doc-sync", - MedicationCode = "123", - MedicationDisplay = "Test Med", - Refills = 0, - }; - await client.PostAsJsonAsync( - $"/fhir/Patient/{patientId}/MedicationRequest/", - medicationRequest - ); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains( - changes, - c => c.GetProperty("TableName").GetString() == "fhir_medicationrequest" - ); - } - - // ========== SYNC DASHBOARD ENDPOINT TESTS ========== - // These tests verify the endpoints required by the Sync Dashboard UI. - // They should FAIL until the endpoints are implemented. - - /// - /// Tests GET /sync/status endpoint - returns service sync health status. - /// REQUIRED BY: Sync Dashboard service status cards. - /// - [Fact] - public async Task GetSyncStatus_ReturnsServiceStatus() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/sync/status"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - - // Should return service health info - Assert.True(result.TryGetProperty("service", out var service)); - Assert.Equal("Clinical.Api", service.GetString()); - - Assert.True(result.TryGetProperty("status", out var status)); - var statusValue = status.GetString(); - Assert.True( - statusValue == "healthy" || statusValue == "degraded" || statusValue == "unhealthy", - $"Status should be healthy, degraded, or unhealthy but was '{statusValue}'" - ); - - Assert.True(result.TryGetProperty("lastSyncTime", out _)); - Assert.True(result.TryGetProperty("totalRecords", out _)); - } - - /// - /// Tests GET /sync/records endpoint - returns paginated sync records. - /// REQUIRED BY: Sync Dashboard sync records table. - /// - [Fact] - public async Task GetSyncRecords_ReturnsPaginatedRecords() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - - // Create some data to generate sync records - var patientRequest = new - { - Active = true, - GivenName = "SyncRecordTest", - FamilyName = "TestPatient", - Gender = "male", - }; - await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - - var response = await client.GetAsync("/sync/records"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - - // Should return paginated response - Assert.True(result.TryGetProperty("records", out var records)); - Assert.True(records.GetArrayLength() > 0); - - Assert.True(result.TryGetProperty("total", out _)); - Assert.True(result.TryGetProperty("page", out _)); - Assert.True(result.TryGetProperty("pageSize", out _)); - } - - /// - /// Tests GET /sync/records with search query. - /// REQUIRED BY: Sync Dashboard search input. - /// - [Fact] - public async Task GetSyncRecords_SearchByEntityId() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - - // Create a patient with known ID pattern - var patientRequest = new - { - Active = true, - GivenName = "SearchSyncTest", - FamilyName = "UniquePatient", - Gender = "female", - }; - var createResponse = await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - var patient = await createResponse.Content.ReadFromJsonAsync(); - var patientId = patient.GetProperty("Id").GetString(); - - var response = await client.GetAsync($"/sync/records?search={patientId}"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - - // Should find records matching the patient ID - var records = result.GetProperty("records"); - Assert.True(records.GetArrayLength() > 0); - } - - /// - /// Tests POST /sync/records/{id}/retry endpoint - retries failed sync. - /// REQUIRED BY: Sync Dashboard retry button. - /// - [Fact] - public async Task PostSyncRetry_RetriesFailedRecord() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - - // First we need a failed sync record to retry - // For now, test that the endpoint exists and accepts the request - var response = await client.PostAsync("/sync/records/test-record-id/retry", null); - - // Should return 200 OK or 404 Not Found (if record doesn't exist) - // NOT 404 Method Not Found (which would mean endpoint doesn't exist) - Assert.True( - response.StatusCode == HttpStatusCode.OK - || response.StatusCode == HttpStatusCode.NotFound - || response.StatusCode == HttpStatusCode.Accepted, - $"Expected OK, NotFound, or Accepted but got {response.StatusCode}" - ); - } - - /// - /// Tests that sync records include required fields for dashboard display. - /// REQUIRED BY: Sync Dashboard table columns. - /// - [Fact] - public async Task GetSyncRecords_ContainsRequiredFields() - { - using var factory = new ClinicalApiFactory(); - var client = CreateAuthenticatedClient(factory); - - // Create data to generate sync records - var patientRequest = new - { - Active = true, - GivenName = "FieldTest", - FamilyName = "SyncPatient", - Gender = "other", - }; - await client.PostAsJsonAsync("/fhir/Patient/", patientRequest); - - var response = await client.GetAsync("/sync/records"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var result = await response.Content.ReadFromJsonAsync(); - var records = result.GetProperty("records"); - Assert.True(records.GetArrayLength() > 0); - - var firstRecord = records[0]; - - // Required fields for Sync Dashboard UI - Assert.True(firstRecord.TryGetProperty("id", out _), "Missing 'id' field"); - Assert.True(firstRecord.TryGetProperty("entityType", out _), "Missing 'entityType' field"); - Assert.True(firstRecord.TryGetProperty("entityId", out _), "Missing 'entityId' field"); - Assert.True(firstRecord.TryGetProperty("operation", out _), "Missing 'operation' field"); - Assert.True( - firstRecord.TryGetProperty("lastAttempt", out _), - "Missing 'lastAttempt' field" - ); - } -} diff --git a/Samples/Clinical/Clinical.Api.Tests/SyncWorkerFaultToleranceTests.cs b/Samples/Clinical/Clinical.Api.Tests/SyncWorkerFaultToleranceTests.cs deleted file mode 100644 index 3ed32bc6..00000000 --- a/Samples/Clinical/Clinical.Api.Tests/SyncWorkerFaultToleranceTests.cs +++ /dev/null @@ -1,440 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Clinical.Api.Tests; - -/// -/// Tests proving sync worker fault tolerance behavior. -/// These tests verify that sync workers: -/// 1. NEVER crash when APIs are unavailable -/// 2. Retry with exponential backoff -/// 3. Log appropriately at different failure levels -/// 4. Recover gracefully when APIs become available -/// -public sealed class SyncWorkerFaultToleranceTests -{ - /// - /// Proves that sync worker handles HttpRequestException without crashing. - /// Simulates API being completely unreachable. - /// - [Fact] - public async Task SyncWorker_HandlesHttpRequestException_WithoutCrashing() - { - // Arrange - var logMessages = new List<(LogLevel Level, string Message)>(); - var logger = new TestLogger(logMessages); - var cancellationTokenSource = new CancellationTokenSource(); - var failureCount = 0; - - // Simulate API that always fails with connection refused - Func> performSync = () => - { - failureCount++; - if (failureCount >= 3) - { - cancellationTokenSource.Cancel(); - } - throw new HttpRequestException("Connection refused (localhost:5001)"); - }; - - var worker = new FaultTolerantSyncWorker(logger, performSync); - - // Act - Run the worker until it handles 3 failures - await worker.ExecuteAsync(cancellationTokenSource.Token); - - // Assert - Worker should have handled multiple failures without crashing - Assert.True(failureCount >= 3, "Worker should have retried at least 3 times"); - Assert.Contains( - logMessages, - m => m.Message.Contains("[SYNC-RETRY]") || m.Message.Contains("[SYNC-FAULT]") - ); - Assert.Contains(logMessages, m => m.Message.Contains("Connection refused")); - } - - /// - /// Proves that sync worker uses exponential backoff when retrying. - /// - [Fact] - public async Task SyncWorker_UsesExponentialBackoff_OnConsecutiveFailures() - { - // Arrange - var logMessages = new List<(LogLevel Level, string Message)>(); - var logger = new TestLogger(logMessages); - var cancellationTokenSource = new CancellationTokenSource(); - var retryDelays = new List(); - var failureCount = 0; - - Func> performSync = () => - { - failureCount++; - if (failureCount >= 5) - { - cancellationTokenSource.Cancel(); - } - throw new HttpRequestException("Connection refused"); - }; - - var worker = new FaultTolerantSyncWorker(logger, performSync, retryDelays.Add); - - // Act - await worker.ExecuteAsync(cancellationTokenSource.Token); - - // Assert - Delays should increase (exponential backoff) - Assert.True(retryDelays.Count >= 4, "Should have recorded multiple retry delays"); - for (var i = 1; i < retryDelays.Count; i++) - { - Assert.True( - retryDelays[i] >= retryDelays[i - 1], - $"Delay should increase or stay same. Delay[{i - 1}]={retryDelays[i - 1]}, Delay[{i}]={retryDelays[i]}" - ); - } - } - - /// - /// Proves that sync worker escalates log level after multiple consecutive failures. - /// - [Fact] - public async Task SyncWorker_EscalatesLogLevel_AfterMultipleFailures() - { - // Arrange - var logMessages = new List<(LogLevel Level, string Message)>(); - var logger = new TestLogger(logMessages); - var cancellationTokenSource = new CancellationTokenSource(); - var failureCount = 0; - - Func> performSync = () => - { - failureCount++; - if (failureCount >= 5) - { - cancellationTokenSource.Cancel(); - } - throw new HttpRequestException("Connection refused"); - }; - - var worker = new FaultTolerantSyncWorker(logger, performSync); - - // Act - await worker.ExecuteAsync(cancellationTokenSource.Token); - - // Assert - Early failures should be Info, later ones should be Warning - var infoLogs = logMessages.Where(m => m.Level == LogLevel.Information).ToList(); - var warningLogs = logMessages.Where(m => m.Level == LogLevel.Warning).ToList(); - - Assert.True(infoLogs.Count > 0, "Should have info-level logs for early retries"); - Assert.True( - warningLogs.Count > 0, - "Should have warning-level logs after multiple failures" - ); - } - - /// - /// Proves that sync worker recovers and resets failure counter on success. - /// - [Fact] - public async Task SyncWorker_ResetsFailureCounter_OnSuccess() - { - // Arrange - var logMessages = new List<(LogLevel Level, string Message)>(); - var logger = new TestLogger(logMessages); - var cancellationTokenSource = new CancellationTokenSource(); - var callCount = 0; - - Func> performSync = () => - { - callCount++; - return callCount switch - { - 1 or 2 => throw new HttpRequestException("Connection refused"), // First 2 calls fail - 3 => Task.FromResult(true), // Third call succeeds - 4 => throw new HttpRequestException("Connection refused again"), // Fourth fails - _ => Task.FromException(new OperationCanceledException()), // Stop - }; - }; - - var worker = new FaultTolerantSyncWorker(logger, performSync); - - // Act - try - { - cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(5)); - await worker.ExecuteAsync(cancellationTokenSource.Token); - } - catch (OperationCanceledException) - { - // Expected - } - - // Assert - Should have logged recovery message - Assert.Contains(logMessages, m => m.Message.Contains("[SYNC-RECOVERED]")); - } - - /// - /// Proves that sync worker handles unexpected exceptions without crashing. - /// - [Fact] - public async Task SyncWorker_HandlesUnexpectedException_WithoutCrashing() - { - // Arrange - var logMessages = new List<(LogLevel Level, string Message)>(); - var logger = new TestLogger(logMessages); - var cancellationTokenSource = new CancellationTokenSource(); - var failureCount = 0; - - Func> performSync = () => - { - failureCount++; - if (failureCount >= 3) - { - cancellationTokenSource.Cancel(); - } - throw new InvalidOperationException("Unexpected database error"); - }; - - var worker = new FaultTolerantSyncWorker(logger, performSync); - - // Act - await worker.ExecuteAsync(cancellationTokenSource.Token); - - // Assert - Worker should have handled unexpected exceptions - Assert.True(failureCount >= 3, "Worker should have retried after unexpected exceptions"); - Assert.Contains(logMessages, m => m.Level == LogLevel.Error); - Assert.Contains(logMessages, m => m.Message.Contains("[SYNC-ERROR]")); - } - - /// - /// Proves that sync worker shuts down gracefully on cancellation. - /// - [Fact] - public async Task SyncWorker_ShutsDownGracefully_OnCancellation() - { - // Arrange - var logMessages = new List<(LogLevel Level, string Message)>(); - var logger = new TestLogger(logMessages); - var cancellationTokenSource = new CancellationTokenSource(); - - Func> performSync = async () => - { - await Task.Delay(100); - return true; - }; - - var worker = new FaultTolerantSyncWorker(logger, performSync); - - // Act - Cancel immediately - cancellationTokenSource.Cancel(); - await worker.ExecuteAsync(cancellationTokenSource.Token); - - // Assert - Should have logged shutdown message - Assert.Contains( - logMessages, - m => m.Message.Contains("[SYNC-SHUTDOWN]") || m.Message.Contains("[SYNC-EXIT]") - ); - } - - /// - /// Proves that backoff is capped at maximum value (30 seconds for HTTP errors). - /// - [Fact] - public async Task SyncWorker_CapsBackoff_AtMaximumValue() - { - // Arrange - var logMessages = new List<(LogLevel Level, string Message)>(); - var logger = new TestLogger(logMessages); - var cancellationTokenSource = new CancellationTokenSource(); - var retryDelays = new List(); - var failureCount = 0; - - Func> performSync = () => - { - failureCount++; - if (failureCount >= 10) - { - cancellationTokenSource.Cancel(); - } - throw new HttpRequestException("Connection refused"); - }; - - var worker = new FaultTolerantSyncWorker(logger, performSync, retryDelays.Add); - - // Act - await worker.ExecuteAsync(cancellationTokenSource.Token); - - // Assert - All delays should be capped at 30 seconds - Assert.True(retryDelays.All(d => d <= 30), "All delays should be capped at 30 seconds"); - // After enough failures, delays should hit the cap - Assert.Contains(retryDelays, d => d == 30); - } -} - -/// -/// Test implementation of fault-tolerant sync worker behavior. -/// Mirrors the actual SyncWorker fault tolerance patterns. -/// -internal sealed class FaultTolerantSyncWorker -{ - private readonly ILogger _logger; - private readonly Func> _performSync; - private readonly Action? _onRetryDelay; - - /// - /// Creates a fault-tolerant sync worker for testing. - /// - public FaultTolerantSyncWorker( - ILogger logger, - Func> performSync, - Action? onRetryDelay = null - ) - { - _logger = logger; - _performSync = performSync; - _onRetryDelay = onRetryDelay; - } - - /// - /// Executes the sync worker with fault tolerance. - /// NEVER crashes - handles all errors gracefully. - /// - public async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.Log(LogLevel.Information, "[SYNC-START] Fault tolerant sync worker starting"); - - var consecutiveFailures = 0; - const int maxConsecutiveFailuresBeforeWarning = 3; - - while (!stoppingToken.IsCancellationRequested) - { - try - { - await _performSync().ConfigureAwait(false); - - if (consecutiveFailures > 0) - { - _logger.Log( - LogLevel.Information, - "[SYNC-RECOVERED] Sync recovered after {Count} consecutive failures", - consecutiveFailures - ); - consecutiveFailures = 0; - } - - try - { - await Task.Delay(TimeSpan.FromMilliseconds(10), stoppingToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - } - catch (HttpRequestException ex) - { - consecutiveFailures++; - var retryDelay = Math.Min(5 * consecutiveFailures, 30); - _onRetryDelay?.Invoke(retryDelay); - - if (consecutiveFailures >= maxConsecutiveFailuresBeforeWarning) - { - _logger.Log( - LogLevel.Warning, - "[SYNC-FAULT] API unreachable for {Count} consecutive attempts. Error: {Message}. Retrying in {Delay}s...", - consecutiveFailures, - ex.Message, - retryDelay - ); - } - else - { - _logger.Log( - LogLevel.Information, - "[SYNC-RETRY] API not reachable ({Message}). Attempt {Count}, retrying in {Delay}s...", - ex.Message, - consecutiveFailures, - retryDelay - ); - } - - try - { - await Task.Delay(TimeSpan.FromMilliseconds(retryDelay), stoppingToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - } - catch (TaskCanceledException) when (stoppingToken.IsCancellationRequested) - { - _logger.Log( - LogLevel.Information, - "[SYNC-SHUTDOWN] Sync worker shutting down gracefully" - ); - break; - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - _logger.Log( - LogLevel.Information, - "[SYNC-SHUTDOWN] Sync worker shutting down gracefully" - ); - break; - } - catch (Exception ex) - { - consecutiveFailures++; - var retryDelay = Math.Min(10 * consecutiveFailures, 60); - _onRetryDelay?.Invoke(retryDelay); - - _logger.Log( - LogLevel.Error, - "[SYNC-ERROR] Unexpected error during sync (attempt {Count}). Retrying in {Delay}s. Error: {Message}", - consecutiveFailures, - retryDelay, - ex.Message - ); - - try - { - await Task.Delay(TimeSpan.FromMilliseconds(retryDelay), stoppingToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - } - } - - _logger.Log(LogLevel.Information, "[SYNC-EXIT] Sync worker exited"); - } -} - -/// -/// Test logger that captures log messages for assertion. -/// -internal sealed class TestLogger : ILogger -{ - private readonly List<(LogLevel Level, string Message)> _messages; - - /// - /// Creates a test logger that captures messages. - /// - public TestLogger(List<(LogLevel Level, string Message)> messages) => _messages = messages; - - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter - ) => _messages.Add((logLevel, formatter(state, exception))); -} diff --git a/Samples/Clinical/Clinical.Api/.editorconfig b/Samples/Clinical/Clinical.Api/.editorconfig deleted file mode 100644 index 9501334c..00000000 --- a/Samples/Clinical/Clinical.Api/.editorconfig +++ /dev/null @@ -1,10 +0,0 @@ -root = false - -[*.cs] -# Relax analyzer rules for sample code -dotnet_diagnostic.CA1515.severity = none -dotnet_diagnostic.CA2100.severity = none -dotnet_diagnostic.RS1035.severity = none -dotnet_diagnostic.CA1508.severity = none -dotnet_diagnostic.CA2234.severity = none -dotnet_diagnostic.IDE0037.severity = none diff --git a/Samples/Clinical/Clinical.Api/Clinical.Api.csproj b/Samples/Clinical/Clinical.Api/Clinical.Api.csproj deleted file mode 100644 index 92fa08ed..00000000 --- a/Samples/Clinical/Clinical.Api/Clinical.Api.csproj +++ /dev/null @@ -1,80 +0,0 @@ - - - Exe - CA1515;CA2100;RS1035;CA1508;CA2234 - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/Clinical/Clinical.Api/DataProvider.json b/Samples/Clinical/Clinical.Api/DataProvider.json deleted file mode 100644 index 3c6fe7d7..00000000 --- a/Samples/Clinical/Clinical.Api/DataProvider.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "queries": [ - { - "name": "GetPatients", - "sqlFile": "Queries/GetPatients.generated.sql" - }, - { - "name": "GetPatientById", - "sqlFile": "Queries/GetPatientById.generated.sql" - }, - { - "name": "SearchPatients", - "sqlFile": "Queries/SearchPatients.generated.sql" - }, - { - "name": "GetEncountersByPatient", - "sqlFile": "Queries/GetEncountersByPatient.generated.sql" - }, - { - "name": "GetConditionsByPatient", - "sqlFile": "Queries/GetConditionsByPatient.generated.sql" - }, - { - "name": "GetMedicationsByPatient", - "sqlFile": "Queries/GetMedicationsByPatient.generated.sql" - } - ], - "tables": [ - { - "schema": "main", - "name": "fhir_Patient", - "generateInsert": true, - "generateUpdate": true, - "generateDelete": true, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "fhir_Encounter", - "generateInsert": true, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "fhir_Condition", - "generateInsert": true, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "fhir_MedicationRequest", - "generateInsert": true, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - } - ], - "connectionString": "Data Source=clinical.db" -} diff --git a/Samples/Clinical/Clinical.Api/DatabaseSetup.cs b/Samples/Clinical/Clinical.Api/DatabaseSetup.cs deleted file mode 100644 index d9235601..00000000 --- a/Samples/Clinical/Clinical.Api/DatabaseSetup.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Migration; -using Migration.Postgres; -using InitError = Outcome.Result.Error; -using InitOk = Outcome.Result.Ok; -using InitResult = Outcome.Result; - -namespace Clinical.Api; - -/// -/// Database initialization for Clinical.Api using Migration tool. -/// -internal static class DatabaseSetup -{ - /// - /// Creates the database schema and sync infrastructure using Migration. - /// - public static InitResult Initialize(NpgsqlConnection connection, ILogger logger) - { - var schemaResult = PostgresSyncSchema.CreateSchema(connection); - var originResult = PostgresSyncSchema.SetOriginId(connection, Guid.NewGuid().ToString()); - - if (schemaResult is Result.Error schemaErr) - { - var msg = SyncHelpers.ToMessage(schemaErr.Value); - logger.Log(LogLevel.Error, "Failed to create sync schema: {Message}", msg); - return new InitError($"Failed to create sync schema: {msg}"); - } - - if (originResult is Result.Error originErr) - { - var msg = SyncHelpers.ToMessage(originErr.Value); - logger.Log(LogLevel.Error, "Failed to set origin ID: {Message}", msg); - return new InitError($"Failed to set origin ID: {msg}"); - } - - // Use Migration tool to create schema from YAML (source of truth) - try - { - var yamlPath = Path.Combine(AppContext.BaseDirectory, "clinical-schema.yaml"); - var schema = SchemaYamlSerializer.FromYamlFile(yamlPath); - - foreach (var table in schema.Tables) - { - var ddl = PostgresDdlGenerator.Generate(new CreateTableOperation(table)); - using var cmd = connection.CreateCommand(); - cmd.CommandText = ddl; - cmd.ExecuteNonQuery(); - logger.Log(LogLevel.Debug, "Created table {TableName}", table.Name); - } - - logger.Log(LogLevel.Information, "Created Clinical database schema from YAML"); - } - catch (Exception ex) - { - logger.Log(LogLevel.Error, ex, "Failed to create Clinical database schema"); - return new InitError($"Failed to create Clinical database schema: {ex.Message}"); - } - - var triggerTables = new[] - { - "fhir_patient", - "fhir_encounter", - "fhir_condition", - "fhir_medicationrequest", - }; - foreach (var table in triggerTables) - { - var triggerResult = PostgresTriggerGenerator.CreateTriggers(connection, table, logger); - if (triggerResult is Result.Error triggerErr) - { - logger.Log( - LogLevel.Error, - "Failed to create triggers for {Table}: {Message}", - table, - SyncHelpers.ToMessage(triggerErr.Value) - ); - } - } - - logger.Log(LogLevel.Information, "Clinical.Api database initialized with sync triggers"); - return new InitOk(true); - } -} diff --git a/Samples/Clinical/Clinical.Api/FileLoggerProvider.cs b/Samples/Clinical/Clinical.Api/FileLoggerProvider.cs deleted file mode 100644 index 74cc9a9e..00000000 --- a/Samples/Clinical/Clinical.Api/FileLoggerProvider.cs +++ /dev/null @@ -1,109 +0,0 @@ -namespace Clinical.Api; - -/// -/// Extension methods for adding file logging. -/// -public static class FileLoggingExtensions -{ - /// - /// Adds file logging to the logging builder. - /// - public static ILoggingBuilder AddFileLogging(this ILoggingBuilder builder, string path) - { - // CA2000: DI container takes ownership and disposes when application shuts down -#pragma warning disable CA2000 - builder.Services.AddSingleton(new FileLoggerProvider(path)); -#pragma warning restore CA2000 - return builder; - } -} - -/// -/// Simple file logger provider for writing logs to disk. -/// -public sealed class FileLoggerProvider : ILoggerProvider -{ - private readonly string _path; - private readonly object _lock = new(); - - /// - /// Initializes a new instance of FileLoggerProvider. - /// - public FileLoggerProvider(string path) - { - _path = path; - } - - /// - /// Creates a logger for the specified category. - /// - public ILogger CreateLogger(string categoryName) => new FileLogger(_path, categoryName, _lock); - - /// - /// Disposes the provider. - /// - public void Dispose() - { - // Nothing to dispose - singleton managed by DI container - } -} - -/// -/// Simple file logger that appends log entries to a file. -/// -public sealed class FileLogger : ILogger -{ - private readonly string _path; - private readonly string _category; - private readonly object _lock; - - /// - /// Initializes a new instance of FileLogger. - /// - public FileLogger(string path, string category, object lockObj) - { - _path = path; - _category = category; - _lock = lockObj; - } - - /// - /// Begins a logical operation scope. - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - /// Checks if the given log level is enabled. - /// - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - - /// - /// Writes a log entry to the file. - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter - ) - { - if (!IsEnabled(logLevel)) - { - return; - } - - var message = formatter(state, exception); - var line = $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {_category}: {message}"; - if (exception != null) - { - line += Environment.NewLine + exception; - } - - lock (_lock) - { - File.AppendAllText(_path, line + Environment.NewLine); - } - } -} diff --git a/Samples/Clinical/Clinical.Api/GlobalUsings.cs b/Samples/Clinical/Clinical.Api/GlobalUsings.cs deleted file mode 100644 index 3f63934c..00000000 --- a/Samples/Clinical/Clinical.Api/GlobalUsings.cs +++ /dev/null @@ -1,95 +0,0 @@ -global using System; -global using Generated; -global using Microsoft.Extensions.Logging; -global using Npgsql; -global using Outcome; -global using Sync; -global using Sync.Postgres; -global using GetConditionsError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetConditionsByPatient query result type aliases -global using GetConditionsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using GetEncountersError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetEncountersByPatient query result type aliases -global using GetEncountersOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using GetMedicationsError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetMedicationsByPatient query result type aliases -global using GetMedicationsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using GetPatientByIdError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetPatientById query result type aliases -global using GetPatientByIdOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetPatientsError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetPatients query result type aliases -global using GetPatientsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using InsertError = Outcome.Result.Error; -// Insert result type aliases -global using InsertOk = Outcome.Result.Ok; -global using SearchPatientsError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// SearchPatients query result type aliases -global using SearchPatientsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -// Sync result type aliases -global using StringSyncError = Outcome.Result.Error; -global using StringSyncOk = Outcome.Result.Ok; -global using SyncLogListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -// Update result type aliases -global using UpdateOk = Outcome.Result.Ok; diff --git a/Samples/Clinical/Clinical.Api/Program.cs b/Samples/Clinical/Clinical.Api/Program.cs deleted file mode 100644 index db9bb2a5..00000000 --- a/Samples/Clinical/Clinical.Api/Program.cs +++ /dev/null @@ -1,865 +0,0 @@ -#pragma warning disable IDE0037 // Use inferred member name - prefer explicit for clarity in API responses - -using System.Collections.Immutable; -using System.Globalization; -using Clinical.Api; -using Microsoft.AspNetCore.Http.Json; -using Samples.Authorization; -using InitError = Outcome.Result.Error; - -var builder = WebApplication.CreateBuilder(args); - -// File logging - use LOG_PATH env var or default to /tmp in containers -var logPath = - Environment.GetEnvironmentVariable("LOG_PATH") - ?? ( - Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true" - ? "/tmp/clinical.log" - : Path.Combine(AppContext.BaseDirectory, "clinical.log") - ); -builder.Logging.AddFileLogging(logPath); - -// Configure JSON to use PascalCase property names -builder.Services.Configure(options => -{ - options.SerializerOptions.PropertyNamingPolicy = null; -}); - -// Add CORS for dashboard - allow any origin for testing -builder.Services.AddCors(options => -{ - options.AddPolicy( - "Dashboard", - policy => - { - policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); - } - ); -}); - -var connectionString = - builder.Configuration.GetConnectionString("Postgres") - ?? throw new InvalidOperationException("PostgreSQL connection string 'Postgres' is required"); - -builder.Services.AddSingleton(() => -{ - var conn = new NpgsqlConnection(connectionString); - conn.Open(); - return conn; -}); - -// Gatekeeper configuration for authorization -var gatekeeperUrl = builder.Configuration["Gatekeeper:BaseUrl"] ?? "http://localhost:5002"; -var signingKeyBase64 = builder.Configuration["Jwt:SigningKey"]; -var signingKey = string.IsNullOrEmpty(signingKeyBase64) - ? ImmutableArray.Create(new byte[32]) // Default empty key for development (MUST configure in production) - : ImmutableArray.Create(Convert.FromBase64String(signingKeyBase64)); - -builder.Services.AddHttpClient( - "Gatekeeper", - client => - { - client.BaseAddress = new Uri(gatekeeperUrl); - client.Timeout = TimeSpan.FromSeconds(5); - } -); - -var app = builder.Build(); - -using (var conn = new NpgsqlConnection(connectionString)) -{ - conn.Open(); - if (DatabaseSetup.Initialize(conn, app.Logger) is InitError initErr) - Environment.FailFast(initErr.Value); -} - -// Enable CORS -app.UseCors("Dashboard"); - -// Get HttpClientFactory for auth filters -var httpClientFactory = app.Services.GetRequiredService(); -Func getGatekeeperClient = () => httpClientFactory.CreateClient("Gatekeeper"); - -var patientGroup = app.MapGroup("/fhir/Patient").WithTags("Patient"); - -patientGroup - .MapGet( - "/", - async ( - bool? active, - string? familyName, - string? givenName, - string? gender, - Func getConn - ) => - { - using var conn = getConn(); - var result = await conn.GetPatientsAsync( - active.HasValue - ? active.Value - ? 1 - : 0 - : DBNull.Value, - familyName ?? (object)DBNull.Value, - givenName ?? (object)DBNull.Value, - gender ?? (object)DBNull.Value - ) - .ConfigureAwait(false); - return result switch - { - GetPatientsOk(var patients) => Results.Ok(patients), - GetPatientsError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PatientRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -patientGroup - .MapGet( - "/{id}", - async (string id, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetPatientByIdAsync(id).ConfigureAwait(false); - return result switch - { - GetPatientByIdOk(var patients) when patients.Count > 0 => Results.Ok(patients[0]), - GetPatientByIdOk => Results.NotFound(), - GetPatientByIdError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequireResourcePermission( - FhirPermissions.PatientRead, - signingKey, - getGatekeeperClient, - app.Logger, - idParamName: "id" - ) - ); - -patientGroup - .MapPost( - "/", - async (CreatePatientRequest request, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - var id = Guid.NewGuid().ToString(); - var now = DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ); - - var result = await transaction - .Insertfhir_PatientAsync( - id, - request.Active ? 1 : 0, - request.GivenName, - request.FamilyName, - request.BirthDate, - request.Gender, - request.Phone, - request.Email, - request.AddressLine, - request.City, - request.State, - request.PostalCode, - request.Country, - now, - 1 - ) - .ConfigureAwait(false); - - if (result is InsertOk) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Created( - $"/fhir/Patient/{id}", - new - { - Id = id, - Active = request.Active, - GivenName = request.GivenName, - FamilyName = request.FamilyName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Phone = request.Phone, - Email = request.Email, - AddressLine = request.AddressLine, - City = request.City, - State = request.State, - PostalCode = request.PostalCode, - Country = request.Country, - LastUpdated = now, - VersionId = 1, - } - ); - } - - return result.Match( - _ => Results.Problem("Unexpected state"), - err => Results.Problem(err.Message) - ); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PatientCreate, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -patientGroup - .MapPut( - "/{id}", - async (string id, UpdatePatientRequest request, Func getConn) => - { - using var conn = getConn(); - - // First verify the patient exists - var existingResult = await conn.GetPatientByIdAsync(id).ConfigureAwait(false); - if (existingResult is GetPatientByIdOk(var patients) && patients.Count == 0) - { - return Results.NotFound(); - } - - if (existingResult is GetPatientByIdError(var fetchErr)) - { - return Results.Problem(fetchErr.Message); - } - - var existingPatient = ((GetPatientByIdOk)existingResult).Value[0]; - var newVersionId = existingPatient.VersionId + 1; - - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - var now = DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ); - - var result = await transaction - .Updatefhir_PatientAsync( - id, - request.Active ? 1 : 0, - request.GivenName, - request.FamilyName, - request.BirthDate ?? string.Empty, - request.Gender ?? string.Empty, - request.Phone ?? string.Empty, - request.Email ?? string.Empty, - request.AddressLine ?? string.Empty, - request.City ?? string.Empty, - request.State ?? string.Empty, - request.PostalCode ?? string.Empty, - request.Country ?? string.Empty, - now, - newVersionId - ) - .ConfigureAwait(false); - - if (result is UpdateOk) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Ok( - new - { - Id = id, - Active = request.Active, - GivenName = request.GivenName, - FamilyName = request.FamilyName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Phone = request.Phone, - Email = request.Email, - AddressLine = request.AddressLine, - City = request.City, - State = request.State, - PostalCode = request.PostalCode, - Country = request.Country, - LastUpdated = now, - VersionId = newVersionId, - } - ); - } - - return result.Match( - _ => Results.Problem("Unexpected state"), - err => Results.Problem(err.Message) - ); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequireResourcePermission( - FhirPermissions.PatientUpdate, - signingKey, - getGatekeeperClient, - app.Logger, - idParamName: "id" - ) - ); - -patientGroup - .MapGet( - "/_search", - async (string q, Func getConn) => - { - using var conn = getConn(); - var result = await conn.SearchPatientsAsync($"%{q}%").ConfigureAwait(false); - return result switch - { - SearchPatientsOk(var patients) => Results.Ok(patients), - SearchPatientsError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PatientRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -var encounterGroup = patientGroup.MapGroup("/{patientId}/Encounter").WithTags("Encounter"); - -encounterGroup - .MapGet( - "/", - async (string patientId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetEncountersByPatientAsync(patientId).ConfigureAwait(false); - return result switch - { - GetEncountersOk(var encounters) => Results.Ok(encounters), - GetEncountersError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePatientPermission( - FhirPermissions.EncounterRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -encounterGroup - .MapPost( - "/", - async (string patientId, CreateEncounterRequest request, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - var id = Guid.NewGuid().ToString(); - var now = DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ); - - var result = await transaction - .Insertfhir_EncounterAsync( - id, - request.Status, - request.Class, - patientId, - request.PractitionerId, - request.ServiceType, - request.ReasonCode, - request.PeriodStart, - request.PeriodEnd, - request.Notes, - now, - 1 - ) - .ConfigureAwait(false); - - if (result is InsertOk) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Created( - $"/fhir/Patient/{patientId}/Encounter/{id}", - new - { - Id = id, - Status = request.Status, - Class = request.Class, - PatientId = patientId, - PractitionerId = request.PractitionerId, - ServiceType = request.ServiceType, - ReasonCode = request.ReasonCode, - PeriodStart = request.PeriodStart, - PeriodEnd = request.PeriodEnd, - Notes = request.Notes, - LastUpdated = now, - VersionId = 1, - } - ); - } - - return result switch - { - InsertOk => Results.Problem("Unexpected state"), - InsertError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePatientPermission( - FhirPermissions.EncounterCreate, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -var conditionGroup = patientGroup.MapGroup("/{patientId}/Condition").WithTags("Condition"); - -conditionGroup - .MapGet( - "/", - async (string patientId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetConditionsByPatientAsync(patientId).ConfigureAwait(false); - return result switch - { - GetConditionsOk(var conditions) => Results.Ok(conditions), - GetConditionsError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePatientPermission( - FhirPermissions.ConditionRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -conditionGroup - .MapPost( - "/", - async (string patientId, CreateConditionRequest request, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - var id = Guid.NewGuid().ToString(); - var now = DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ); - var recordedDate = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - - var result = await transaction - .Insertfhir_ConditionAsync( - id: id, - clinicalstatus: request.ClinicalStatus, - verificationstatus: request.VerificationStatus, - category: request.Category, - severity: request.Severity, - codesystem: request.CodeSystem, - codevalue: request.CodeValue, - codedisplay: request.CodeDisplay, - subjectreference: patientId, - encounterreference: request.EncounterReference, - onsetdatetime: request.OnsetDateTime, - recordeddate: recordedDate, - recorderreference: request.RecorderReference, - notetext: request.NoteText, - lastupdated: now, - versionid: 1 - ) - .ConfigureAwait(false); - - if (result is InsertOk) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Created( - $"/fhir/Patient/{patientId}/Condition/{id}", - new - { - Id = id, - ClinicalStatus = request.ClinicalStatus, - VerificationStatus = request.VerificationStatus, - Category = request.Category, - Severity = request.Severity, - CodeSystem = request.CodeSystem, - CodeValue = request.CodeValue, - CodeDisplay = request.CodeDisplay, - SubjectReference = patientId, - EncounterReference = request.EncounterReference, - OnsetDateTime = request.OnsetDateTime, - RecordedDate = recordedDate, - RecorderReference = request.RecorderReference, - NoteText = request.NoteText, - LastUpdated = now, - VersionId = 1, - } - ); - } - - return result switch - { - InsertOk => Results.Problem("Unexpected state"), - InsertError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePatientPermission( - FhirPermissions.ConditionCreate, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -var medicationGroup = patientGroup - .MapGroup("/{patientId}/MedicationRequest") - .WithTags("MedicationRequest"); - -medicationGroup - .MapGet( - "/", - async (string patientId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetMedicationsByPatientAsync(patientId).ConfigureAwait(false); - return result switch - { - GetMedicationsOk(var medications) => Results.Ok(medications), - GetMedicationsError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePatientPermission( - FhirPermissions.MedicationRequestRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -medicationGroup - .MapPost( - "/", - async ( - string patientId, - CreateMedicationRequestRequest request, - Func getConn - ) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - var id = Guid.NewGuid().ToString(); - var now = DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ); - - var result = await transaction - .Insertfhir_MedicationRequestAsync( - id, - request.Status, - request.Intent, - patientId, - request.PractitionerId, - request.EncounterId, - request.MedicationCode, - request.MedicationDisplay, - request.DosageInstruction, - request.Quantity, - request.Unit, - request.Refills, - now, - now, - 1 - ) - .ConfigureAwait(false); - - if (result is InsertOk) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Created( - $"/fhir/Patient/{patientId}/MedicationRequest/{id}", - new - { - Id = id, - Status = request.Status, - Intent = request.Intent, - PatientId = patientId, - PractitionerId = request.PractitionerId, - EncounterId = request.EncounterId, - MedicationCode = request.MedicationCode, - MedicationDisplay = request.MedicationDisplay, - DosageInstruction = request.DosageInstruction, - Quantity = request.Quantity, - Unit = request.Unit, - Refills = request.Refills, - AuthoredOn = now, - LastUpdated = now, - VersionId = 1, - } - ); - } - - return result switch - { - InsertOk => Results.Problem("Unexpected state"), - InsertError(var err) => Results.Problem(err.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePatientPermission( - FhirPermissions.MedicationRequestCreate, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/changes", - (long? fromVersion, int? limit, Func getConn) => - { - using var conn = getConn(); - var result = PostgresSyncLogRepository.FetchChanges( - conn, - fromVersion ?? 0, - limit ?? 100 - ); - return result switch - { - SyncLogListOk(var logs) => Results.Ok(logs), - SyncLogListError(var err) => Results.Problem(SyncHelpers.ToMessage(err)), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/origin", - (Func getConn) => - { - using var conn = getConn(); - var result = PostgresSyncSchema.GetOriginId(conn); - return result switch - { - StringSyncOk(var originId) => Results.Ok(new { originId }), - StringSyncError(var err) => Results.Problem(SyncHelpers.ToMessage(err)), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/status", - (Func getConn) => - { - using var conn = getConn(); - var changesResult = PostgresSyncLogRepository.FetchChanges(conn, 0, 1000); - - var (totalCount, lastSyncTime) = changesResult switch - { - SyncLogListOk(var logs) => ( - logs.Count, - logs.Count > 0 - ? logs.Max(l => l.Timestamp) - : DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ) - ), - SyncLogListError => ( - 0, - DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ) - ), - }; - - return Results.Ok( - new - { - service = "Clinical.Api", - status = "healthy", - lastSyncTime, - totalRecords = totalCount, - failedCount = 0, - } - ); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/records", - (string? search, int? page, int? pageSize, Func getConn) => - { - using var conn = getConn(); - var currentPage = page ?? 1; - var size = pageSize ?? 50; - var changesResult = PostgresSyncLogRepository.FetchChanges(conn, 0, 1000); - - return changesResult switch - { - SyncLogListOk(var logs) => Results.Ok( - BuildSyncRecordsResponse(logs, search, currentPage, size) - ), - SyncLogListError(var err) => Results.Problem(SyncHelpers.ToMessage(err)), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapPost( - "/sync/records/{id}/retry", - (string id) => - { - // For now, just acknowledge the retry request - // Real implementation would mark the record for re-sync - return Results.Accepted(); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncWrite, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/providers", - (Func getConn) => - { - using var conn = getConn(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = - "SELECT ProviderId, FirstName, LastName, Specialty, SyncedAt FROM sync_Provider"; - using var reader = cmd.ExecuteReader(); - var providers = new List(); - while (reader.Read()) - { - providers.Add( - new - { - ProviderId = reader.GetString(0), - FirstName = reader.IsDBNull(1) ? null : reader.GetString(1), - LastName = reader.IsDBNull(2) ? null : reader.GetString(2), - Specialty = reader.IsDBNull(3) ? null : reader.GetString(3), - SyncedAt = reader.IsDBNull(4) ? null : reader.GetString(4), - } - ); - } - return Results.Ok(providers); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.Run(); - -static object BuildSyncRecordsResponse( - IReadOnlyList logs, - string? search, - int page, - int pageSize -) -{ - // Records in _sync_log are captured changes ready for clients to pull. - // Clients track their own sync position via fromVersion parameter. - var records = logs.Select(l => new - { - id = l.Version.ToString(CultureInfo.InvariantCulture), - entityType = l.TableName, - entityId = l.PkValue, - lastAttempt = l.Timestamp, - operation = l.Operation, - }); - - if (!string.IsNullOrEmpty(search)) - { - records = records.Where(r => - r.entityId.Contains(search, StringComparison.OrdinalIgnoreCase) - ); - } - - var recordList = records.ToList(); - var total = recordList.Count; - var pagedRecords = recordList.Skip((page - 1) * pageSize).Take(pageSize).ToList(); - - return new - { - records = pagedRecords, - total, - page, - pageSize, - }; -} - -namespace Clinical.Api -{ - /// - /// Program entry point marker for WebApplicationFactory. - /// - public partial class Program { } -} diff --git a/Samples/Clinical/Clinical.Api/Properties/launchSettings.json b/Samples/Clinical/Clinical.Api/Properties/launchSettings.json deleted file mode 100644 index 4912a810..00000000 --- a/Samples/Clinical/Clinical.Api/Properties/launchSettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "profiles": { - "Clinical.Api": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5080", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ConnectionStrings__Postgres": "Host=localhost;Database=clinical;Username=clinical;Password=changeme" - } - } - } -} diff --git a/Samples/Clinical/Clinical.Api/Queries/GetConditionsByPatient.lql b/Samples/Clinical/Clinical.Api/Queries/GetConditionsByPatient.lql deleted file mode 100644 index 6b88fa65..00000000 --- a/Samples/Clinical/Clinical.Api/Queries/GetConditionsByPatient.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Get conditions for a patient --- Parameters: @patientId -fhir_Condition -|> filter(fn(row) => row.fhir_Condition.SubjectReference = @patientId) -|> select(fhir_Condition.Id, fhir_Condition.ClinicalStatus, fhir_Condition.VerificationStatus, fhir_Condition.Category, fhir_Condition.Severity, fhir_Condition.CodeSystem, fhir_Condition.CodeValue, fhir_Condition.CodeDisplay, fhir_Condition.SubjectReference, fhir_Condition.EncounterReference, fhir_Condition.OnsetDateTime, fhir_Condition.RecordedDate, fhir_Condition.RecorderReference, fhir_Condition.NoteText, fhir_Condition.LastUpdated, fhir_Condition.VersionId) -|> order_by(fhir_Condition.RecordedDate desc) diff --git a/Samples/Clinical/Clinical.Api/Queries/GetEncountersByPatient.lql b/Samples/Clinical/Clinical.Api/Queries/GetEncountersByPatient.lql deleted file mode 100644 index 2f6530f4..00000000 --- a/Samples/Clinical/Clinical.Api/Queries/GetEncountersByPatient.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Get encounters for a patient --- Parameters: @patientId -fhir_Encounter -|> filter(fn(row) => row.fhir_Encounter.PatientId = @patientId) -|> select(fhir_Encounter.Id, fhir_Encounter.Status, fhir_Encounter.Class, fhir_Encounter.PatientId, fhir_Encounter.PractitionerId, fhir_Encounter.ServiceType, fhir_Encounter.ReasonCode, fhir_Encounter.PeriodStart, fhir_Encounter.PeriodEnd, fhir_Encounter.Notes, fhir_Encounter.LastUpdated, fhir_Encounter.VersionId) -|> order_by(fhir_Encounter.PeriodStart desc) diff --git a/Samples/Clinical/Clinical.Api/Queries/GetMedicationsByPatient.lql b/Samples/Clinical/Clinical.Api/Queries/GetMedicationsByPatient.lql deleted file mode 100644 index b7e53d35..00000000 --- a/Samples/Clinical/Clinical.Api/Queries/GetMedicationsByPatient.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Get medication requests for a patient --- Parameters: @patientId -fhir_MedicationRequest -|> filter(fn(row) => row.fhir_MedicationRequest.PatientId = @patientId) -|> select(fhir_MedicationRequest.Id, fhir_MedicationRequest.Status, fhir_MedicationRequest.Intent, fhir_MedicationRequest.PatientId, fhir_MedicationRequest.PractitionerId, fhir_MedicationRequest.EncounterId, fhir_MedicationRequest.MedicationCode, fhir_MedicationRequest.MedicationDisplay, fhir_MedicationRequest.DosageInstruction, fhir_MedicationRequest.Quantity, fhir_MedicationRequest.Unit, fhir_MedicationRequest.Refills, fhir_MedicationRequest.AuthoredOn, fhir_MedicationRequest.LastUpdated, fhir_MedicationRequest.VersionId) -|> order_by(fhir_MedicationRequest.AuthoredOn desc) diff --git a/Samples/Clinical/Clinical.Api/Queries/GetPatientById.lql b/Samples/Clinical/Clinical.Api/Queries/GetPatientById.lql deleted file mode 100644 index 250e0ee9..00000000 --- a/Samples/Clinical/Clinical.Api/Queries/GetPatientById.lql +++ /dev/null @@ -1,5 +0,0 @@ --- Get patient by ID --- Parameters: @id -fhir_Patient -|> filter(fn(row) => row.fhir_Patient.Id = @id) -|> select(fhir_Patient.Id, fhir_Patient.Active, fhir_Patient.GivenName, fhir_Patient.FamilyName, fhir_Patient.BirthDate, fhir_Patient.Gender, fhir_Patient.Phone, fhir_Patient.Email, fhir_Patient.AddressLine, fhir_Patient.City, fhir_Patient.State, fhir_Patient.PostalCode, fhir_Patient.Country, fhir_Patient.LastUpdated, fhir_Patient.VersionId) diff --git a/Samples/Clinical/Clinical.Api/Queries/GetPatients.lql b/Samples/Clinical/Clinical.Api/Queries/GetPatients.lql deleted file mode 100644 index 6d47e4c3..00000000 --- a/Samples/Clinical/Clinical.Api/Queries/GetPatients.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Get patients with optional FHIR search parameters --- Parameters: @active, @familyName, @givenName, @gender -fhir_Patient -|> filter(fn(p) => (@active is null or p.fhir_Patient.Active = @active) and (@familyName is null or p.fhir_Patient.FamilyName like '%' || @familyName || '%') and (@givenName is null or p.fhir_Patient.GivenName like '%' || @givenName || '%') and (@gender is null or p.fhir_Patient.Gender = @gender)) -|> select(fhir_Patient.Id, fhir_Patient.Active, fhir_Patient.GivenName, fhir_Patient.FamilyName, fhir_Patient.BirthDate, fhir_Patient.Gender, fhir_Patient.Phone, fhir_Patient.Email, fhir_Patient.AddressLine, fhir_Patient.City, fhir_Patient.State, fhir_Patient.PostalCode, fhir_Patient.Country, fhir_Patient.LastUpdated, fhir_Patient.VersionId) -|> order_by(fhir_Patient.FamilyName, fhir_Patient.GivenName) diff --git a/Samples/Clinical/Clinical.Api/Queries/SearchPatients.lql b/Samples/Clinical/Clinical.Api/Queries/SearchPatients.lql deleted file mode 100644 index 8a256b16..00000000 --- a/Samples/Clinical/Clinical.Api/Queries/SearchPatients.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Search patients by name or email --- Parameters: @term -fhir_Patient -|> filter(fn(row) => row.fhir_Patient.GivenName like @term or row.fhir_Patient.FamilyName like @term or row.fhir_Patient.Email like @term) -|> select(fhir_Patient.Id, fhir_Patient.Active, fhir_Patient.GivenName, fhir_Patient.FamilyName, fhir_Patient.BirthDate, fhir_Patient.Gender, fhir_Patient.Phone, fhir_Patient.Email, fhir_Patient.AddressLine, fhir_Patient.City, fhir_Patient.State, fhir_Patient.PostalCode, fhir_Patient.Country, fhir_Patient.LastUpdated, fhir_Patient.VersionId) -|> order_by(fhir_Patient.FamilyName, fhir_Patient.GivenName) diff --git a/Samples/Clinical/Clinical.Api/Requests.cs b/Samples/Clinical/Clinical.Api/Requests.cs deleted file mode 100644 index ff326ce8..00000000 --- a/Samples/Clinical/Clinical.Api/Requests.cs +++ /dev/null @@ -1,84 +0,0 @@ -namespace Clinical.Api; - -/// -/// Patient creation request DTO. -/// -public sealed record CreatePatientRequest( - bool Active, - string GivenName, - string FamilyName, - string? BirthDate, - string? Gender, - string? Phone, - string? Email, - string? AddressLine, - string? City, - string? State, - string? PostalCode, - string? Country -); - -/// -/// Patient update request DTO. -/// -public sealed record UpdatePatientRequest( - bool Active, - string GivenName, - string FamilyName, - string? BirthDate, - string? Gender, - string? Phone, - string? Email, - string? AddressLine, - string? City, - string? State, - string? PostalCode, - string? Country -); - -/// -/// Encounter creation request DTO. -/// -public sealed record CreateEncounterRequest( - string Status, - string Class, - string? PractitionerId, - string? ServiceType, - string? ReasonCode, - string PeriodStart, - string? PeriodEnd, - string? Notes -); - -/// -/// Condition creation request DTO. -/// -public sealed record CreateConditionRequest( - string ClinicalStatus, - string? VerificationStatus, - string? Category, - string? Severity, - string CodeSystem, - string CodeValue, - string CodeDisplay, - string? EncounterReference, - string? OnsetDateTime, - string? RecorderReference, - string? NoteText -); - -/// -/// MedicationRequest creation request DTO. -/// -public sealed record CreateMedicationRequestRequest( - string Status, - string Intent, - string PractitionerId, - string? EncounterId, - string MedicationCode, - string MedicationDisplay, - string? DosageInstruction, - double? Quantity, - string? Unit, - int Refills -); diff --git a/Samples/Clinical/Clinical.Api/SyncHelpers.cs b/Samples/Clinical/Clinical.Api/SyncHelpers.cs deleted file mode 100644 index 382b7859..00000000 --- a/Samples/Clinical/Clinical.Api/SyncHelpers.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Clinical.Api; - -/// -/// Helper methods for sync operations. -/// -public static class SyncHelpers -{ - /// - /// Converts a SyncError to a displayable error message. - /// - public static string ToMessage(SyncError error) => - error switch - { - SyncErrorDatabase db => db.Message, - SyncErrorForeignKeyViolation fk => $"FK violation in {fk.TableName}: {fk.Details}", - SyncErrorHashMismatch hash => - $"Hash mismatch: expected {hash.ExpectedHash}, got {hash.ActualHash}", - SyncErrorFullResyncRequired resync => - $"Full resync required: client at {resync.ClientVersion}, oldest available {resync.OldestAvailableVersion}", - SyncErrorDeferredChangeFailed deferred => $"Deferred change failed: {deferred.Reason}", - SyncErrorUnresolvedConflict => "Unresolved conflict detected", - _ => "Unknown sync error", - }; -} diff --git a/Samples/Clinical/Clinical.Api/clinical-schema.yaml b/Samples/Clinical/Clinical.Api/clinical-schema.yaml deleted file mode 100644 index 379c4eec..00000000 --- a/Samples/Clinical/Clinical.Api/clinical-schema.yaml +++ /dev/null @@ -1,227 +0,0 @@ -name: clinical -tables: -- name: fhir_Patient - columns: - - name: Id - type: Text - - name: Active - type: Int - defaultValue: 1 - - name: GivenName - type: Text - - name: FamilyName - type: Text - - name: BirthDate - type: Text - - name: Gender - type: Text - checkConstraint: 'Gender IN (''male'', ''female'', ''other'', ''unknown'')' - - name: Phone - type: Text - - name: Email - type: Text - - name: AddressLine - type: Text - - name: City - type: Text - - name: State - type: Text - - name: PostalCode - type: Text - - name: Country - type: Text - - name: LastUpdated - type: Text - defaultValue: CURRENT_TIMESTAMP - - name: VersionId - type: Int - defaultValue: 1 - indexes: - - name: idx_fhir_patient_family - columns: - - FamilyName - - name: idx_fhir_patient_given - columns: - - GivenName - primaryKey: - name: PK_fhir_Patient - columns: - - Id -- name: fhir_Encounter - columns: - - name: Id - type: Text - - name: Status - type: Text - checkConstraint: 'Status IN (''planned'', ''arrived'', ''triaged'', ''in-progress'', ''onleave'', ''finished'', ''cancelled'', ''entered-in-error'')' - - name: Class - type: Text - checkConstraint: 'Class IN (''ambulatory'', ''emergency'', ''inpatient'', ''observation'', ''virtual'')' - - name: PatientId - type: Text - - name: PractitionerId - type: Text - - name: ServiceType - type: Text - - name: ReasonCode - type: Text - - name: PeriodStart - type: Text - - name: PeriodEnd - type: Text - - name: Notes - type: Text - - name: LastUpdated - type: Text - defaultValue: CURRENT_TIMESTAMP - - name: VersionId - type: Int - defaultValue: 1 - indexes: - - name: idx_fhir_encounter_patient - columns: - - PatientId - foreignKeys: - - name: FK_fhir_Encounter_PatientId - columns: - - PatientId - referencedTable: fhir_Patient - referencedColumns: - - Id - primaryKey: - name: PK_fhir_Encounter - columns: - - Id -- name: fhir_Condition - columns: - - name: Id - type: Text - - name: ClinicalStatus - type: Text - checkConstraint: 'ClinicalStatus IN (''active'', ''recurrence'', ''relapse'', ''inactive'', ''remission'', ''resolved'')' - - name: VerificationStatus - type: Text - checkConstraint: 'VerificationStatus IN (''unconfirmed'', ''provisional'', ''differential'', ''confirmed'', ''refuted'', ''entered-in-error'')' - - name: Category - type: Text - defaultValue: "'problem-list-item'" - - name: Severity - type: Text - checkConstraint: 'Severity IN (''mild'', ''moderate'', ''severe'')' - - name: CodeSystem - type: Text - defaultValue: "'http://hl7.org/fhir/sid/icd-10-cm'" - - name: CodeValue - type: Text - - name: CodeDisplay - type: Text - - name: SubjectReference - type: Text - - name: EncounterReference - type: Text - - name: OnsetDateTime - type: Text - - name: RecordedDate - type: Text - defaultValue: CURRENT_DATE - - name: RecorderReference - type: Text - - name: NoteText - type: Text - - name: LastUpdated - type: Text - defaultValue: CURRENT_TIMESTAMP - - name: VersionId - type: Int - defaultValue: 1 - indexes: - - name: idx_fhir_condition_patient - columns: - - SubjectReference - foreignKeys: - - name: FK_fhir_Condition_SubjectReference - columns: - - SubjectReference - referencedTable: fhir_Patient - referencedColumns: - - Id - primaryKey: - name: PK_fhir_Condition - columns: - - Id -- name: fhir_MedicationRequest - columns: - - name: Id - type: Text - - name: Status - type: Text - checkConstraint: 'Status IN (''active'', ''on-hold'', ''cancelled'', ''completed'', ''entered-in-error'', ''stopped'', ''draft'')' - - name: Intent - type: Text - checkConstraint: 'Intent IN (''proposal'', ''plan'', ''order'', ''original-order'', ''reflex-order'', ''filler-order'', ''instance-order'', ''option'')' - - name: PatientId - type: Text - - name: PractitionerId - type: Text - - name: EncounterId - type: Text - - name: MedicationCode - type: Text - - name: MedicationDisplay - type: Text - - name: DosageInstruction - type: Text - - name: Quantity - type: Double - - name: Unit - type: Text - - name: Refills - type: Int - defaultValue: 0 - - name: AuthoredOn - type: Text - defaultValue: CURRENT_TIMESTAMP - - name: LastUpdated - type: Text - defaultValue: CURRENT_TIMESTAMP - - name: VersionId - type: Int - defaultValue: 1 - indexes: - - name: idx_fhir_medication_patient - columns: - - PatientId - foreignKeys: - - name: FK_fhir_MedicationRequest_PatientId - columns: - - PatientId - referencedTable: fhir_Patient - referencedColumns: - - Id - - name: FK_fhir_MedicationRequest_EncounterId - columns: - - EncounterId - referencedTable: fhir_Encounter - referencedColumns: - - Id - primaryKey: - name: PK_fhir_MedicationRequest - columns: - - Id -- name: sync_Provider - columns: - - name: ProviderId - type: Text - - name: FirstName - type: Text - - name: LastName - type: Text - - name: Specialty - type: Text - - name: SyncedAt - type: Text - defaultValue: CURRENT_TIMESTAMP - primaryKey: - name: PK_sync_Provider - columns: - - ProviderId diff --git a/Samples/Clinical/Clinical.Sync/Clinical.Sync.csproj b/Samples/Clinical/Clinical.Sync/Clinical.Sync.csproj deleted file mode 100644 index 27b56812..00000000 --- a/Samples/Clinical/Clinical.Sync/Clinical.Sync.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - Exe - CA1515;CA1050;CA1054;CA1849;CA2007;CA2234 - - - - - - - - - - - - - - - PreserveNewest - - - diff --git a/Samples/Clinical/Clinical.Sync/GlobalUsings.cs b/Samples/Clinical/Clinical.Sync/GlobalUsings.cs deleted file mode 100644 index e08608ce..00000000 --- a/Samples/Clinical/Clinical.Sync/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Npgsql; diff --git a/Samples/Clinical/Clinical.Sync/Program.cs b/Samples/Clinical/Clinical.Sync/Program.cs deleted file mode 100644 index bef2cadc..00000000 --- a/Samples/Clinical/Clinical.Sync/Program.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Clinical.Sync; - -internal static class Program -{ - internal static async Task Main(string[] args) - { - var builder = Host.CreateApplicationBuilder(args); - - var connectionString = - Environment.GetEnvironmentVariable("CLINICAL_CONNECTION_STRING") - ?? builder.Configuration.GetConnectionString("Postgres") - ?? throw new InvalidOperationException("PostgreSQL connection string required"); - var schedulingApiUrl = - Environment.GetEnvironmentVariable("SCHEDULING_API_URL") ?? "http://localhost:5001"; - - Console.WriteLine($"[Clinical.Sync] Scheduling API URL: {schedulingApiUrl}"); - - builder.Services.AddSingleton>(_ => - () => - { - var conn = new NpgsqlConnection(connectionString); - conn.Open(); - return conn; - } - ); - - builder.Services.AddHostedService(sp => - { - var logger = sp.GetRequiredService>(); - var getConn = sp.GetRequiredService>(); - return new SyncWorker(logger, getConn, schedulingApiUrl); - }); - - var host = builder.Build(); - await host.RunAsync().ConfigureAwait(false); - } -} - -/// -/// Sync change record from remote API. -/// Matches the SyncLogEntry schema returned by /sync/changes endpoint. -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage( - "Performance", - "CA1812:Avoid uninstantiated internal classes", - Justification = "Used for JSON deserialization" -)] -internal sealed record SyncChange( - long Version, - string TableName, - string PkValue, - int Operation, - string? Payload, - string Origin, - string Timestamp -) -{ - /// Insert operation (0). - public const int Insert = 0; - - /// Update operation (1). - public const int Update = 1; - - /// Delete operation (2). - public const int Delete = 2; -} diff --git a/Samples/Clinical/Clinical.Sync/SyncMappings.json b/Samples/Clinical/Clinical.Sync/SyncMappings.json deleted file mode 100644 index b5cefb23..00000000 --- a/Samples/Clinical/Clinical.Sync/SyncMappings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "mappings": [ - { - "source_table": "fhir_Practitioner", - "target_table": "sync_Provider", - "column_mappings": [ - { - "source": "Id", - "target": "ProviderId" - }, - { - "source": "NameGiven", - "target": "FirstName" - }, - { - "source": "NameFamily", - "target": "LastName" - }, - { - "source": "Specialty", - "target": "Specialty" - } - ], - "filter": "Active = 1" - } - ] -} diff --git a/Samples/Clinical/Clinical.Sync/SyncWorker.cs b/Samples/Clinical/Clinical.Sync/SyncWorker.cs deleted file mode 100644 index 307ce41a..00000000 --- a/Samples/Clinical/Clinical.Sync/SyncWorker.cs +++ /dev/null @@ -1,356 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Clinical.Sync; - -/// -/// Background service that pulls Practitioner data from Scheduling.Api and maps to sync_Provider. -/// -internal sealed class SyncWorker : BackgroundService -{ - private readonly ILogger _logger; - private readonly Func _getConnection; - private readonly string _schedulingApiUrl; - - /// - /// Initializes a new instance of the SyncWorker class. - /// - public SyncWorker( - ILogger logger, - Func getConnection, - string schedulingApiUrl - ) - { - _logger = logger; - _getConnection = getConnection; - _schedulingApiUrl = schedulingApiUrl; - } - - /// - /// Executes the sync worker background service. - /// FAULT TOLERANT: This worker NEVER crashes. It handles all errors gracefully and retries indefinitely. - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.Log( - LogLevel.Information, - "[SYNC-START] Clinical.Sync worker starting at {Time}. Target: {Url}", - DateTimeOffset.Now, - _schedulingApiUrl - ); - - var consecutiveFailures = 0; - const int maxConsecutiveFailuresBeforeWarning = 3; - - // Main sync loop - NEVER exits except on cancellation - while (!stoppingToken.IsCancellationRequested) - { - try - { - await PerformSync(stoppingToken).ConfigureAwait(false); - - // Reset failure counter on success - if (consecutiveFailures > 0) - { - _logger.Log( - LogLevel.Information, - "[SYNC-RECOVERED] Sync recovered after {Count} consecutive failures", - consecutiveFailures - ); - consecutiveFailures = 0; - } - - await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false); - } - catch (HttpRequestException ex) - { - consecutiveFailures++; - var retryDelay = Math.Min(5 * consecutiveFailures, 30); // Exponential backoff up to 30s - - if (consecutiveFailures >= maxConsecutiveFailuresBeforeWarning) - { - _logger.Log( - LogLevel.Warning, - "[SYNC-FAULT] Scheduling.Api unreachable for {Count} consecutive attempts. Error: {Message}. Retrying in {Delay}s...", - consecutiveFailures, - ex.Message, - retryDelay - ); - } - else - { - _logger.Log( - LogLevel.Information, - "[SYNC-RETRY] Scheduling.Api not reachable ({Message}). Attempt {Count}, retrying in {Delay}s...", - ex.Message, - consecutiveFailures, - retryDelay - ); - } - - await Task.Delay(TimeSpan.FromSeconds(retryDelay), stoppingToken) - .ConfigureAwait(false); - } - catch (TaskCanceledException) when (stoppingToken.IsCancellationRequested) - { - _logger.Log( - LogLevel.Information, - "[SYNC-SHUTDOWN] Sync worker shutting down gracefully" - ); - break; - } - catch (Exception ex) - { - consecutiveFailures++; - var retryDelay = Math.Min(10 * consecutiveFailures, 60); // Longer backoff for unknown errors - - _logger.Log( - LogLevel.Error, - ex, - "[SYNC-ERROR] Unexpected error during sync (attempt {Count}). Retrying in {Delay}s. Error type: {Type}", - consecutiveFailures, - retryDelay, - ex.GetType().Name - ); - - await Task.Delay(TimeSpan.FromSeconds(retryDelay), stoppingToken) - .ConfigureAwait(false); - } - } - - _logger.Log( - LogLevel.Information, - "[SYNC-EXIT] Clinical.Sync worker exited at {Time}", - DateTimeOffset.Now - ); - } - - private async Task PerformSync(CancellationToken cancellationToken) - { - _logger.Log( - LogLevel.Information, - "Starting sync from Scheduling.Api at {Time}", - DateTimeOffset.Now - ); - - using var conn = _getConnection(); - - // Get last sync version - var lastVersion = GetLastSyncVersion(conn); - - using var httpClient = new HttpClient { BaseAddress = new Uri(_schedulingApiUrl) }; - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", GenerateSyncToken()); - - var changesResponse = await httpClient - .GetAsync($"/sync/changes?fromVersion={lastVersion}&limit=100", cancellationToken) - .ConfigureAwait(false); - if (!changesResponse.IsSuccessStatusCode) - { - _logger.Log( - LogLevel.Warning, - "Failed to fetch changes from Scheduling.Api: {StatusCode}", - changesResponse.StatusCode - ); - return; - } - - var changesJson = await changesResponse - .Content.ReadAsStringAsync(cancellationToken) - .ConfigureAwait(false); - var changes = JsonSerializer.Deserialize>(changesJson); - - if (changes == null || changes.Count == 0) - { - _logger.Log(LogLevel.Information, "No changes to sync"); - return; - } - - _logger.Log(LogLevel.Information, "Processing {Count} changes", changes.Count); - - await using var transaction = await conn.BeginTransactionAsync(cancellationToken) - .ConfigureAwait(false); - - try - { - var practitionerChanges = changes - .Where(c => c.TableName == "fhir_practitioner") - .ToList(); - - foreach (var change in practitionerChanges) - { - ApplyMappedChange(conn, transaction, change); - } - - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); - - // Update last sync version to the maximum version we processed - var maxVersion = changes.Max(c => c.Version); - UpdateLastSyncVersion(conn, maxVersion); - - _logger.Log( - LogLevel.Information, - "Successfully synced {Count} provider changes, updated version to {Version}", - practitionerChanges.Count, - maxVersion - ); - } - catch (Exception ex) - { - _logger.Log( - LogLevel.Error, - ex, - "Error applying sync changes, rolling back transaction" - ); - await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); - } - } - - private void ApplyMappedChange( - NpgsqlConnection conn, - System.Data.Common.DbTransaction transaction, - SyncChange change - ) - { - // Extract the ID from PkValue which is JSON like {"id":"uuid-here"} - var pkData = JsonSerializer.Deserialize>(change.PkValue); - var rowId = pkData?.GetValueOrDefault("id").GetString() ?? change.PkValue; - - if (change.Operation == SyncChange.Delete) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = (NpgsqlTransaction)transaction; - cmd.CommandText = "DELETE FROM sync_Provider WHERE ProviderId = @id"; - cmd.Parameters.AddWithValue("@id", rowId); - cmd.ExecuteNonQuery(); - _logger.Log(LogLevel.Debug, "Deleted provider {ProviderId}", rowId); - return; - } - - if (change.Payload == null) - { - return; - } - - var data = JsonSerializer.Deserialize>(change.Payload); - if (data == null) - { - return; - } - - using var upsertCmd = conn.CreateCommand(); - upsertCmd.Transaction = (NpgsqlTransaction)transaction; - upsertCmd.CommandText = """ - INSERT INTO sync_Provider (ProviderId, FirstName, LastName, Specialty, SyncedAt) - VALUES (@providerId, @firstName, @lastName, @specialty, @syncedAt) - ON CONFLICT(ProviderId) DO UPDATE SET - FirstName = @firstName, - LastName = @lastName, - Specialty = @specialty, - SyncedAt = @syncedAt - """; - - upsertCmd.Parameters.AddWithValue( - "@providerId", - data.GetValueOrDefault("id").GetString() ?? string.Empty - ); - upsertCmd.Parameters.AddWithValue( - "@firstName", - data.GetValueOrDefault("namegiven").GetString() ?? string.Empty - ); - upsertCmd.Parameters.AddWithValue( - "@lastName", - data.GetValueOrDefault("namefamily").GetString() ?? string.Empty - ); - upsertCmd.Parameters.AddWithValue( - "@specialty", - data.GetValueOrDefault("specialty").GetString() ?? string.Empty - ); - upsertCmd.Parameters.AddWithValue("@syncedAt", DateTime.UtcNow.ToString("o")); - - upsertCmd.ExecuteNonQuery(); - _logger.Log( - LogLevel.Debug, - "Upserted provider {ProviderId}", - data.GetValueOrDefault("id").GetString() - ); - } - - private static long GetLastSyncVersion(NpgsqlConnection connection) - { - // Ensure _sync_state table exists - using var createCmd = connection.CreateCommand(); - createCmd.CommandText = """ - CREATE TABLE IF NOT EXISTS _sync_state ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """; - createCmd.ExecuteNonQuery(); - - using var cmd = connection.CreateCommand(); - cmd.CommandText = - "SELECT value FROM _sync_state WHERE key = 'last_scheduling_sync_version'"; - - var result = cmd.ExecuteScalar(); - return result is string str && long.TryParse(str, out var version) ? version : 0; - } - - private static void UpdateLastSyncVersion(NpgsqlConnection connection, long version) - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO _sync_state (key, value) VALUES ('last_scheduling_sync_version', @version) - ON CONFLICT (key) DO UPDATE SET value = excluded.value - """; - cmd.Parameters.AddWithValue( - "@version", - version.ToString(System.Globalization.CultureInfo.InvariantCulture) - ); - cmd.ExecuteNonQuery(); - } - - private static readonly string[] SyncRoles = ["sync-client", "clinician", "scheduler", "admin"]; - - /// - /// Generates a JWT token for sync worker authentication. - /// Uses the dev mode signing key (32 zeros) for E2E testing. - /// - private static string GenerateSyncToken() - { - var signingKey = new byte[32]; // 32 zeros = dev mode key - var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"HS256","typ":"JWT"}""")); - var expiration = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); - var payload = Base64UrlEncode( - Encoding.UTF8.GetBytes( - JsonSerializer.Serialize( - new - { - sub = "clinical-sync-worker", - name = "Clinical Sync Worker", - email = "sync@clinical.local", - jti = Guid.NewGuid().ToString(), - exp = expiration, - roles = SyncRoles, - } - ) - ) - ); - var signature = ComputeHmacSignature(header, payload, signingKey); - return $"{header}.{payload}.{signature}"; - } - - private static string Base64UrlEncode(byte[] input) => - Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - private static string ComputeHmacSignature(string header, string payload, byte[] key) - { - var data = Encoding.UTF8.GetBytes($"{header}.{payload}"); - using var hmac = new HMACSHA256(key); - var hash = hmac.ComputeHash(data); - return Base64UrlEncode(hash); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/AppointmentE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/AppointmentE2ETests.cs deleted file mode 100644 index 1432be33..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/AppointmentE2ETests.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for appointment-related functionality. -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class AppointmentE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public AppointmentE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Dashboard loads and displays appointment data from Scheduling API. - /// - [Fact] - public async Task Dashboard_DisplaysAppointmentData_FromSchedulingApi() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Appointments"); - await page.WaitForSelectorAsync( - "text=Checkup", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Checkup", content); - - await page.CloseAsync(); - } - - /// - /// Add Appointment button opens modal and creates appointment via API. - /// - [Fact] - public async Task AddAppointmentButton_OpensModal_AndCreatesAppointment() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Appointments"); - await page.WaitForSelectorAsync( - "[data-testid='add-appointment-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.ClickAsync("[data-testid='add-appointment-btn']"); - await page.WaitForSelectorAsync( - ".modal", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var uniqueServiceType = $"E2EConsult{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='appointment-service-type']", uniqueServiceType); - await page.ClickAsync("[data-testid='submit-appointment']"); - - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - using var client = E2EFixture.CreateAuthenticatedClient(); - var response = await client.GetStringAsync($"{E2EFixture.SchedulingUrl}/Appointment"); - Assert.Contains(uniqueServiceType, response); - - await page.CloseAsync(); - } - - /// - /// View Schedule button navigates to appointments view. - /// - [Fact] - public async Task ViewScheduleButton_NavigatesToAppointments() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=View Schedule"); - await page.WaitForSelectorAsync( - "text=Appointments", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.WaitForSelectorAsync( - "text=Checkup", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Checkup", content); - - await page.CloseAsync(); - } - - /// - /// Edit Appointment button opens edit page and updates appointment via API. - /// - [Fact] - public async Task EditAppointmentButton_OpensEditPage_AndUpdatesAppointment() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueServiceType = $"EditApptTest{DateTime.UtcNow.Ticks % 100000}"; - var startTime = DateTime.UtcNow.AddDays(7).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var endTime = DateTime - .UtcNow.AddDays(7) - .AddMinutes(30) - .ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Appointment", - new StringContent( - $$$"""{"ServiceCategory": "General", "ServiceType": "{{{uniqueServiceType}}}", "Priority": "routine", "Start": "{{{startTime}}}", "End": "{{{endTime}}}", "PatientReference": "Patient/1", "PractitionerReference": "Practitioner/1"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdAppointmentJson = await createResponse.Content.ReadAsStringAsync(); - - var appointmentIdMatch = Regex.Match(createdAppointmentJson, "\"Id\"\\s*:\\s*\"([^\"]+)\""); - Assert.True(appointmentIdMatch.Success); - var appointmentId = appointmentIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Appointments"); - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var editButton = await page.QuerySelectorAsync( - $"tr:has-text('{uniqueServiceType}') .btn-secondary" - ); - Assert.NotNull(editButton); - await editButton.ClickAsync(); - - await page.WaitForSelectorAsync( - "text=Edit Appointment", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var newServiceType = $"Edited{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("#appointment-service-type", newServiceType); - await page.ClickAsync("button:has-text('Save Changes')"); - - await page.WaitForSelectorAsync( - "text=Appointment updated successfully", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var updatedAppointmentJson = await client.GetStringAsync( - $"{E2EFixture.SchedulingUrl}/Appointment/{appointmentId}" - ); - Assert.Contains(newServiceType, updatedAppointmentJson); - - await page.CloseAsync(); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/AuthE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/AuthE2ETests.cs deleted file mode 100644 index 84a5458d..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/AuthE2ETests.cs +++ /dev/null @@ -1,393 +0,0 @@ -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for authentication (login, logout, WebAuthn). -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class AuthE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public AuthE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Login page uses discoverable credentials (no email required). - /// - [Fact] - public async Task LoginPage_DoesNotRequireEmailForSignIn() - { - var page = await _fixture.Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.GotoAsync(E2EFixture.DashboardUrl); - await page.EvaluateAsync( - "() => { localStorage.removeItem('gatekeeper_token'); localStorage.removeItem('gatekeeper_user'); }" - ); - await page.ReloadAsync(); - await page.WaitForSelectorAsync( - ".login-card", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - var pageContent = await page.ContentAsync(); - Assert.Contains("Healthcare Dashboard", pageContent); - Assert.Contains("Sign in with your passkey", pageContent); - - var emailInputVisible = await page.IsVisibleAsync("input[type='email']"); - Assert.False(emailInputVisible, "Login mode should NOT show email field"); - - var signInButton = page.Locator("button:has-text('Sign in with Passkey')"); - await signInButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 }); - Assert.True(await signInButton.IsVisibleAsync()); - - await page.CloseAsync(); - } - - /// - /// Registration page requires email and display name. - /// - [Fact] - public async Task LoginPage_RegistrationRequiresEmailAndDisplayName() - { - var page = await _fixture.Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.GotoAsync(E2EFixture.DashboardUrl); - await page.EvaluateAsync( - "() => { localStorage.removeItem('gatekeeper_token'); localStorage.removeItem('gatekeeper_user'); }" - ); - await page.ReloadAsync(); - await page.WaitForSelectorAsync( - ".login-card", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - await page.ClickAsync("button:has-text('Register')"); - await Task.Delay(500); - - var pageContent = await page.ContentAsync(); - Assert.Contains("Create your account", pageContent); - - var emailInput = page.Locator("input[type='email']"); - var displayNameInput = page.Locator("input#displayName"); - - Assert.True(await emailInput.IsVisibleAsync()); - Assert.True(await displayNameInput.IsVisibleAsync()); - - await page.CloseAsync(); - } - - /// - /// Gatekeeper API /auth/login/begin returns valid response for discoverable credentials. - /// - [Fact] - public async Task GatekeeperApi_LoginBegin_ReturnsValidDiscoverableCredentialOptions() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var response = await client.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/login/begin", - new StringContent("{}", System.Text.Encoding.UTF8, "application/json") - ); - - Assert.True(response.IsSuccessStatusCode); - - var json = await response.Content.ReadAsStringAsync(); - using var doc = System.Text.Json.JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.True(root.TryGetProperty("ChallengeId", out var challengeId)); - Assert.False(string.IsNullOrEmpty(challengeId.GetString())); - - Assert.True(root.TryGetProperty("OptionsJson", out var optionsJson)); - var optionsJsonStr = optionsJson.GetString(); - Assert.False(string.IsNullOrEmpty(optionsJsonStr)); - - using var optionsDoc = System.Text.Json.JsonDocument.Parse(optionsJsonStr!); - var options = optionsDoc.RootElement; - Assert.True(options.TryGetProperty("challenge", out _)); - Assert.True(options.TryGetProperty("rpId", out _)); - } - - /// - /// Gatekeeper API /auth/register/begin returns valid response. - /// - [Fact] - public async Task GatekeeperApi_RegisterBegin_ReturnsValidOptions() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var response = await client.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/register/begin", - new StringContent( - """{"Email": "test-e2e@example.com", "DisplayName": "E2E Test User"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - - Assert.True(response.IsSuccessStatusCode); - - var json = await response.Content.ReadAsStringAsync(); - using var doc = System.Text.Json.JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.True(root.TryGetProperty("ChallengeId", out _)); - Assert.True(root.TryGetProperty("OptionsJson", out var optionsJson)); - - using var optionsDoc = System.Text.Json.JsonDocument.Parse(optionsJson.GetString()!); - var options = optionsDoc.RootElement; - Assert.True(options.TryGetProperty("challenge", out _)); - Assert.True(options.TryGetProperty("rp", out _)); - Assert.True(options.TryGetProperty("user", out _)); - } - - /// - /// Dashboard sign-in flow calls API and handles response correctly. - /// - [Fact] - public async Task LoginPage_SignInButton_CallsApiWithoutJsonErrors() - { - var page = await _fixture.Browser!.NewPageAsync(); - var consoleErrors = new List(); - var networkRequests = new List(); - - page.Console += (_, msg) => - { - Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - if (msg.Type == "error") - consoleErrors.Add(msg.Text); - }; - - page.Request += (_, request) => - { - if (request.Url.Contains("/auth/")) - networkRequests.Add($"{request.Method} {request.Url}"); - }; - - await page.GotoAsync(E2EFixture.DashboardUrl); - await page.EvaluateAsync( - "() => { localStorage.removeItem('gatekeeper_token'); localStorage.removeItem('gatekeeper_user'); }" - ); - await page.ReloadAsync(); - await page.WaitForSelectorAsync( - ".login-card", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - await page.ClickAsync("button:has-text('Sign in with Passkey')"); - await Task.Delay(3000); - - Assert.Contains(networkRequests, r => r.Contains("/auth/login/begin")); - - var hasJsonParseError = consoleErrors.Any(e => - e.Contains("undefined") || e.Contains("is not valid JSON") || e.Contains("SyntaxError") - ); - Assert.False(hasJsonParseError); - - await page.CloseAsync(); - } - - /// - /// User menu click shows dropdown with Sign Out. - /// - [Fact] - public async Task UserMenu_ClickShowsDropdownWithSignOut() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - var userMenuButton = await page.QuerySelectorAsync("[data-testid='user-menu-button']"); - Assert.NotNull(userMenuButton); - await userMenuButton.ClickAsync(); - - await page.WaitForSelectorAsync( - "[data-testid='user-dropdown']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var signOutButton = await page.QuerySelectorAsync("[data-testid='logout-button']"); - Assert.NotNull(signOutButton); - Assert.True(await signOutButton.IsVisibleAsync()); - - await page.CloseAsync(); - } - - /// - /// Sign Out button click shows login page. - /// - [Fact] - public async Task SignOutButton_ClickShowsLoginPage() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - await page.ClickAsync("[data-testid='user-menu-button']"); - await page.WaitForSelectorAsync( - "[data-testid='user-dropdown']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.ClickAsync("[data-testid='logout-button']"); - - await page.WaitForSelectorAsync( - "[data-testid='login-page']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var tokenAfterLogout = await page.EvaluateAsync( - "() => localStorage.getItem('gatekeeper_token')" - ); - Assert.Null(tokenAfterLogout); - - await page.CloseAsync(); - } - - /// - /// Gatekeeper API logout revokes token. - /// - [Fact] - public async Task GatekeeperApi_Logout_RevokesToken() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var logoutResponse = await client.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/logout", - new StringContent("{}", System.Text.Encoding.UTF8, "application/json") - ); - Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); - - using var unauthClient = new HttpClient(); - var unauthResponse = await unauthClient.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/logout", - new StringContent("{}", System.Text.Encoding.UTF8, "application/json") - ); - Assert.Equal(HttpStatusCode.Unauthorized, unauthResponse.StatusCode); - } - - /// - /// User menu displays user initials and name in dropdown. - /// - [Fact] - public async Task UserMenu_DisplaysUserInitialsAndNameInDropdown() - { - var page = await _fixture.Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - // Generate valid test token for custom user - var testToken = E2EFixture.GenerateTestToken( - userId: "test-user", - displayName: "Alice Smith", - email: "alice@example.com" - ); - - // Set custom user data BEFORE loading - await page.GotoAsync(E2EFixture.DashboardUrl); - await page.EvaluateAsync( - $@"() => {{ - localStorage.setItem('gatekeeper_token', '{testToken}'); - localStorage.setItem('gatekeeper_user', JSON.stringify({{ - userId: 'test-user', displayName: 'Alice Smith', email: 'alice@example.com' - }})); - }}" - ); - // Reload to pick up custom user data - await page.ReloadAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - var avatarText = await page.TextContentAsync("[data-testid='user-menu-button']"); - Assert.Equal("AS", avatarText?.Trim()); - - await page.ClickAsync("[data-testid='user-menu-button']"); - await page.WaitForSelectorAsync( - "[data-testid='user-dropdown']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var userNameText = await page.TextContentAsync(".user-dropdown-name"); - Assert.Contains("Alice Smith", userNameText); - - var emailText = await page.TextContentAsync(".user-dropdown-email"); - Assert.Contains("alice@example.com", emailText); - - await page.CloseAsync(); - } - - /// - /// First-time sign-in must work WITHOUT browser refresh. - /// - [Fact] - public async Task FirstTimeSignIn_TransitionsToDashboard_WithoutRefresh() - { - var page = await _fixture.Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.GotoAsync(E2EFixture.DashboardUrl); - await page.EvaluateAsync( - "() => { localStorage.removeItem('gatekeeper_token'); localStorage.removeItem('gatekeeper_user'); }" - ); - await page.ReloadAsync(); - await page.WaitForSelectorAsync( - "[data-testid='login-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Wait for React to mount and set the __triggerLogin hook - await page.WaitForFunctionAsync( - "() => typeof window.__triggerLogin === 'function'", - new PageWaitForFunctionOptions { Timeout = 10000 } - ); - - // Generate a valid test token - this token is accepted by the APIs - var devToken = E2EFixture.GenerateTestToken( - userId: "test-user-123", - displayName: "Test User", - email: "test@example.com" - ); - await page.EvaluateAsync( - $@"() => {{ - console.log('[TEST] Setting token and triggering login'); - localStorage.setItem('gatekeeper_token', '{devToken}'); - localStorage.setItem('gatekeeper_user', JSON.stringify({{ - userId: 'test-user-123', displayName: 'Test User', email: 'test@example.com' - }})); - window.__triggerLogin({{ userId: 'test-user-123', displayName: 'Test User', email: 'test@example.com' }}); - console.log('[TEST] Login triggered, waiting for React state update'); - }}" - ); - - // Wait longer for React state update and re-render - await Task.Delay(2000); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - var loginPageStillVisible = await page.IsVisibleAsync("[data-testid='login-page']"); - Assert.False(loginPageStillVisible, "Login page should be hidden after successful login"); - Assert.True( - await page.IsVisibleAsync(".sidebar"), - "Sidebar should be visible after successful login" - ); - - await page.CloseAsync(); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/CalendarE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/CalendarE2ETests.cs deleted file mode 100644 index 73cd66c4..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/CalendarE2ETests.cs +++ /dev/null @@ -1,288 +0,0 @@ -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for calendar-related functionality. -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class CalendarE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public CalendarE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Calendar page displays appointments in calendar grid. - /// - [Fact] - public async Task CalendarPage_DisplaysAppointmentsInCalendarGrid() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#calendar" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER {msg.Type}] {msg.Text}"); - page.PageError += (_, err) => Console.WriteLine($"[PAGE ERROR] {err}"); - - // Debug: Check auth state - var hasToken = await page.EvaluateAsync( - "() => !!localStorage.getItem('gatekeeper_token')" - ); - var hasUser = await page.EvaluateAsync( - "() => !!localStorage.getItem('gatekeeper_user')" - ); - var currentUrl = page.Url; - Console.WriteLine( - $"[DEBUG] Auth state - hasToken: {hasToken}, hasUser: {hasUser}, URL: {currentUrl}" - ); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - await page.WaitForSelectorAsync( - ".calendar-grid-container", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("calendar-grid", content); - Assert.Contains("Sun", content); - Assert.Contains("Mon", content); - Assert.Contains("Today", content); - - await page.CloseAsync(); - } - - /// - /// Calendar page allows clicking on a day to view appointments. - /// - [Fact] - public async Task CalendarPage_ClickOnDay_ShowsAppointmentDetails() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var today = DateTime.Now; - var startTime = new DateTime( - today.Year, - today.Month, - today.Day, - 14, - 0, - 0, - DateTimeKind.Local - ).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var endTime = new DateTime( - today.Year, - today.Month, - today.Day, - 14, - 30, - 0, - DateTimeKind.Local - ).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var uniqueServiceType = $"CalTest{DateTime.Now.Ticks % 100000}"; - - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Appointment", - new StringContent( - $$$"""{"ServiceCategory": "General", "ServiceType": "{{{uniqueServiceType}}}", "Priority": "routine", "Start": "{{{startTime}}}", "End": "{{{endTime}}}", "PatientReference": "Patient/1", "PractitionerReference": "Practitioner/1"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Schedule"); - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.WaitForSelectorAsync( - ".calendar-cell.today.has-appointments", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var todayCell = page.Locator(".calendar-cell.today").First; - await todayCell.ClickAsync(); - - await page.WaitForSelectorAsync( - ".calendar-details-panel h4", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains(uniqueServiceType, content); - - await page.CloseAsync(); - } - - /// - /// Calendar page Edit button opens edit appointment page. - /// - [Fact] - public async Task CalendarPage_EditButton_OpensEditAppointmentPage() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var today = DateTime.Now; - var startTime = new DateTime( - today.Year, - today.Month, - today.Day, - 15, - 0, - 0, - DateTimeKind.Local - ).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var endTime = new DateTime( - today.Year, - today.Month, - today.Day, - 15, - 30, - 0, - DateTimeKind.Local - ).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var uniqueServiceType = $"CalEdit{DateTime.Now.Ticks % 100000}"; - - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Appointment", - new StringContent( - $$$"""{"ServiceCategory": "General", "ServiceType": "{{{uniqueServiceType}}}", "Priority": "routine", "Start": "{{{startTime}}}", "End": "{{{endTime}}}", "PatientReference": "Patient/1", "PractitionerReference": "Practitioner/1"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Schedule"); - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.WaitForSelectorAsync( - ".calendar-cell.today.has-appointments", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var todayCell = page.Locator(".calendar-cell.today").First; - await todayCell.ClickAsync(); - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var editButton = await page.QuerySelectorAsync( - $".calendar-appointment-item:has-text('{uniqueServiceType}') button:has-text('Edit')" - ); - Assert.NotNull(editButton); - await editButton.ClickAsync(); - - await page.WaitForSelectorAsync( - "text=Edit Appointment", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Edit Appointment", content); - - await page.CloseAsync(); - } - - /// - /// Calendar navigation (previous/next month) works. - /// - [Fact] - public async Task CalendarPage_NavigationButtons_ChangeMonth() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Schedule"); - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var currentMonthYear = await page.TextContentAsync(".text-lg.font-semibold"); - Assert.NotNull(currentMonthYear); - - var headerControls = page.Locator(".page-header .flex.items-center.gap-4"); - var nextButton = headerControls.Locator("button.btn-secondary").Nth(1); - await nextButton.ClickAsync(); - await Task.Delay(300); - - var newMonthYear = await page.TextContentAsync(".text-lg.font-semibold"); - Assert.NotEqual(currentMonthYear, newMonthYear); - - var prevButton = headerControls.Locator("button.btn-secondary").First; - await prevButton.ClickAsync(); - await Task.Delay(300); - await prevButton.ClickAsync(); - await Task.Delay(300); - - await page.ClickAsync("button:has-text('Today')"); - await Task.Delay(500); - - var todayContent = await page.ContentAsync(); - Assert.Contains("today", todayContent); - - await page.CloseAsync(); - } - - /// - /// Deep linking to calendar page works. - /// - [Fact] - public async Task CalendarPage_DeepLinkingWorks() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#calendar" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER {msg.Type}] {msg.Text}"); - page.PageError += (_, err) => Console.WriteLine($"[PAGE ERROR] {err}"); - - // Debug: Check auth state and hash - var hasToken = await page.EvaluateAsync( - "() => !!localStorage.getItem('gatekeeper_token')" - ); - var currentHash = await page.EvaluateAsync("() => window.location.hash"); - Console.WriteLine($"[DEBUG] hasToken: {hasToken}, hash: {currentHash}, URL: {page.Url}"); - - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Schedule", content); - Assert.Contains("calendar-grid", content); - - await page.CloseAsync(); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/Dashboard.Integration.Tests.csproj b/Samples/Dashboard/Dashboard.Integration.Tests/Dashboard.Integration.Tests.csproj deleted file mode 100644 index aa00cc11..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/Dashboard.Integration.Tests.csproj +++ /dev/null @@ -1,47 +0,0 @@ - - - net10.0 - Library - true - enable - enable - Dashboard.Integration.Tests - CS1591;CA1707;CA1307;CA1062;CA1515;CA2100 - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/DashboardApiCorsTests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/DashboardApiCorsTests.cs deleted file mode 100644 index bdfbd7da..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/DashboardApiCorsTests.cs +++ /dev/null @@ -1,505 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Hosting; -using Npgsql; - -namespace Dashboard.Integration.Tests; - -/// -/// WebApplicationFactory for Clinical.Api that creates an isolated PostgreSQL test database. -/// -public sealed class ClinicalApiTestFactory : WebApplicationFactory -{ - private readonly string _dbName = $"test_dashboard_clinical_{Guid.NewGuid():N}"; - private readonly string _connectionString; - - private static readonly string BaseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme;Timeout=5;Command Timeout=5"; - - public ClinicalApiTestFactory() - { - using (var adminConn = new NpgsqlConnection(BaseConnectionString)) - { - adminConn.Open(); - using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {_dbName}"; - createCmd.ExecuteNonQuery(); - } - - _connectionString = BaseConnectionString.Replace( - "Database=postgres", - $"Database={_dbName}" - ); - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseSetting("ConnectionStrings:Postgres", _connectionString); - - var clinicalApiAssembly = typeof(Clinical.Api.Program).Assembly; - var contentRoot = Path.GetDirectoryName(clinicalApiAssembly.Location)!; - builder.UseContentRoot(contentRoot); - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - if (disposing) - { - try - { - using var adminConn = new NpgsqlConnection(BaseConnectionString); - adminConn.Open(); - - using var terminateCmd = adminConn.CreateCommand(); - terminateCmd.CommandText = - $"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{_dbName}'"; - terminateCmd.ExecuteNonQuery(); - - using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {_dbName}"; - dropCmd.ExecuteNonQuery(); - } - catch - { /* ignore */ - } - } - } -} - -/// -/// WebApplicationFactory for Scheduling.Api that creates an isolated PostgreSQL test database. -/// -public sealed class SchedulingApiTestFactory : WebApplicationFactory -{ - private readonly string _dbName = $"test_dashboard_scheduling_{Guid.NewGuid():N}"; - private readonly string _connectionString; - - private static readonly string BaseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme;Timeout=5;Command Timeout=5"; - - public SchedulingApiTestFactory() - { - using (var adminConn = new NpgsqlConnection(BaseConnectionString)) - { - adminConn.Open(); - using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {_dbName}"; - createCmd.ExecuteNonQuery(); - } - - _connectionString = BaseConnectionString.Replace( - "Database=postgres", - $"Database={_dbName}" - ); - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) => - builder.UseSetting("ConnectionStrings:Postgres", _connectionString); - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - if (disposing) - { - try - { - using var adminConn = new NpgsqlConnection(BaseConnectionString); - adminConn.Open(); - - using var terminateCmd = adminConn.CreateCommand(); - terminateCmd.CommandText = - $"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{_dbName}'"; - terminateCmd.ExecuteNonQuery(); - - using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {_dbName}"; - dropCmd.ExecuteNonQuery(); - } - catch - { /* ignore */ - } - } - } -} - -/// -/// Tests that verify the Dashboard frontend can communicate with backend APIs. -/// These tests simulate browser requests with CORS headers to ensure the APIs -/// are properly configured for cross-origin requests from the Dashboard. -/// -[Collection("E2E Tests")] -public sealed class DashboardApiCorsTests : IAsyncLifetime -{ - private readonly ClinicalApiTestFactory _clinicalFactory; - private readonly SchedulingApiTestFactory _schedulingFactory; - private HttpClient _clinicalClient = null!; - private HttpClient _schedulingClient = null!; - - // Dashboard origin - this is where the frontend runs - private const string DashboardOrigin = "http://localhost:5173"; - - public DashboardApiCorsTests() - { - _clinicalFactory = new ClinicalApiTestFactory(); - _schedulingFactory = new SchedulingApiTestFactory(); - } - - public Task InitializeAsync() - { - _clinicalClient = _clinicalFactory.CreateClient(); - _schedulingClient = _schedulingFactory.CreateClient(); - - // Add auth headers for all requests (uses dev mode signing key - 32 zeros) - var token = GenerateTestToken(); - _clinicalClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - _schedulingClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - return Task.CompletedTask; - } - - private static readonly string[] TestRoles = ["admin", "user"]; - - private static string GenerateTestToken() - { - var signingKey = new byte[32]; // 32 zeros = dev mode key - var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"HS256","typ":"JWT"}""")); - var expiration = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); - var payload = Base64UrlEncode( - Encoding.UTF8.GetBytes( - JsonSerializer.Serialize( - new - { - sub = "cors-test-user", - name = "CORS Test User", - email = "corstest@example.com", - jti = Guid.NewGuid().ToString(), - exp = expiration, - roles = TestRoles, - } - ) - ) - ); - var signature = ComputeHmacSignature(header, payload, signingKey); - return $"{header}.{payload}.{signature}"; - } - - private static string Base64UrlEncode(byte[] input) => - Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - private static string ComputeHmacSignature(string header, string payload, byte[] key) - { - var data = Encoding.UTF8.GetBytes($"{header}.{payload}"); - using var hmac = new HMACSHA256(key); - var hash = hmac.ComputeHash(data); - return Base64UrlEncode(hash); - } - - public async Task DisposeAsync() - { - _clinicalClient.Dispose(); - _schedulingClient.Dispose(); - await _clinicalFactory.DisposeAsync(); - await _schedulingFactory.DisposeAsync(); - } - - #region Clinical API CORS Tests - - /// - /// CRITICAL: Dashboard at localhost:5173 must be able to fetch patients from Clinical API. - /// This test verifies CORS is configured to allow the Dashboard origin. - /// - [Fact] - public async Task ClinicalApi_PatientsEndpoint_AllowsCorsFromDashboard() - { - // Arrange - simulate browser preflight request - var request = new HttpRequestMessage(HttpMethod.Options, "/fhir/Patient"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Access-Control-Request-Method", "GET"); - request.Headers.Add("Access-Control-Request-Headers", "Accept"); - - // Act - var response = await _clinicalClient.SendAsync(request); - - // Assert - CORS headers must be present - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Clinical API must return Access-Control-Allow-Origin header for Dashboard origin" - ); - - var allowedOrigin = response - .Headers.GetValues("Access-Control-Allow-Origin") - .FirstOrDefault(); - Assert.True( - allowedOrigin == DashboardOrigin || allowedOrigin == "*", - $"Clinical API must allow Dashboard origin. Got: {allowedOrigin}" - ); - } - - /// - /// CRITICAL: Dashboard must be able to GET /fhir/Patient/ with CORS headers. - /// Note: Trailing slash is required for the Patient list endpoint. - /// - [Fact] - public async Task ClinicalApi_GetPatients_ReturnsDataWithCorsHeaders() - { - // Arrange - simulate browser request with Origin header - // Note: Clinical API uses /fhir/Patient/ (with trailing slash) for list - var request = new HttpRequestMessage(HttpMethod.Get, "/fhir/Patient/"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Accept", "application/json"); - - // Act - var response = await _clinicalClient.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - // Assert - must succeed AND have CORS header - Assert.True( - response.IsSuccessStatusCode, - $"Clinical API GET /fhir/Patient/ failed with {response.StatusCode}. Body: {body}" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Clinical API response must include Access-Control-Allow-Origin header" - ); - } - - /// - /// Dashboard fetches encounters for a patient - must work with CORS. - /// Note: Encounters are nested under Patient: /fhir/Patient/{patientId}/Encounter - /// - [Fact] - public async Task ClinicalApi_GetEncounters_ReturnsDataWithCorsHeaders() - { - // Arrange - First create a patient to get encounters for - var createRequest = new HttpRequestMessage(HttpMethod.Post, "/fhir/Patient/"); - createRequest.Headers.Add("Origin", DashboardOrigin); - createRequest.Content = new StringContent( - """{"Active": true, "GivenName": "Test", "FamilyName": "Patient", "Gender": "other"}""", - System.Text.Encoding.UTF8, - "application/json" - ); - var createResponse = await _clinicalClient.SendAsync(createRequest); - var patientJson = await createResponse.Content.ReadAsStringAsync(); - var patientId = System - .Text.Json.JsonDocument.Parse(patientJson) - .RootElement.GetProperty("Id") - .GetString(); - - // Now test the encounters endpoint with CORS - var request = new HttpRequestMessage( - HttpMethod.Get, - $"/fhir/Patient/{patientId}/Encounter" - ); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Accept", "application/json"); - - // Act - var response = await _clinicalClient.SendAsync(request); - - // Assert - Assert.True( - response.IsSuccessStatusCode, - $"Clinical API GET /fhir/Patient/{{patientId}}/Encounter failed with {response.StatusCode}" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Clinical API response must include Access-Control-Allow-Origin header" - ); - } - - #endregion - - #region Scheduling API CORS Tests - - /// - /// CRITICAL: Dashboard must be able to fetch appointments from Scheduling API. - /// Note: Scheduling API uses /Appointment (no /fhir/ prefix). - /// - [Fact] - public async Task SchedulingApi_AppointmentsEndpoint_AllowsCorsFromDashboard() - { - // Arrange - simulate browser preflight request - // Note: Scheduling API doesn't use /fhir/ prefix - var request = new HttpRequestMessage(HttpMethod.Options, "/Appointment"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Access-Control-Request-Method", "GET"); - request.Headers.Add("Access-Control-Request-Headers", "Accept"); - - // Act - var response = await _schedulingClient.SendAsync(request); - - // Assert - CORS headers must be present - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Scheduling API must return Access-Control-Allow-Origin header for Dashboard origin" - ); - - var allowedOrigin = response - .Headers.GetValues("Access-Control-Allow-Origin") - .FirstOrDefault(); - Assert.True( - allowedOrigin == DashboardOrigin || allowedOrigin == "*", - $"Scheduling API must allow Dashboard origin. Got: {allowedOrigin}" - ); - } - - /// - /// CRITICAL: Dashboard must be able to GET /Appointment with CORS headers. - /// Note: Scheduling API uses /Appointment (no /fhir/ prefix). - /// - [Fact] - public async Task SchedulingApi_GetAppointments_ReturnsDataWithCorsHeaders() - { - // Arrange - Scheduling API doesn't use /fhir/ prefix - var request = new HttpRequestMessage(HttpMethod.Get, "/Appointment"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Accept", "application/json"); - - // Act - var response = await _schedulingClient.SendAsync(request); - - // Assert - Assert.True( - response.IsSuccessStatusCode, - $"Scheduling API GET /Appointment failed with {response.StatusCode}" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Scheduling API response must include Access-Control-Allow-Origin header" - ); - } - - /// - /// Dashboard fetches practitioners - must work with CORS. - /// Note: Scheduling API uses /Practitioner (no /fhir/ prefix). - /// - [Fact] - public async Task SchedulingApi_GetPractitioners_ReturnsDataWithCorsHeaders() - { - // Arrange - Scheduling API doesn't use /fhir/ prefix - var request = new HttpRequestMessage(HttpMethod.Get, "/Practitioner"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Accept", "application/json"); - - // Act - var response = await _schedulingClient.SendAsync(request); - - // Assert - Assert.True( - response.IsSuccessStatusCode, - $"Scheduling API GET /Practitioner failed with {response.StatusCode}" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Scheduling API response must include Access-Control-Allow-Origin header" - ); - } - - #endregion - - #region Patient Creation Tests - - /// - /// CRITICAL: Proves patient creation API works end-to-end. - /// This tests the actual POST endpoint that the AddPatientModal calls. - /// - [Fact] - public async Task ClinicalApi_CreatePatient_WorksEndToEnd() - { - // Arrange - Create a patient with unique name - var uniqueName = $"IntTest{DateTime.UtcNow.Ticks % 100000}"; - var request = new HttpRequestMessage(HttpMethod.Post, "/fhir/Patient/"); - request.Headers.Add("Origin", DashboardOrigin); - request.Content = new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "IntegrationCreated", "Gender": "female"}""", - System.Text.Encoding.UTF8, - "application/json" - ); - - // Act - Create patient - var createResponse = await _clinicalClient.SendAsync(request); - createResponse.EnsureSuccessStatusCode(); - - // Verify - Fetch all patients and confirm the new one is there - var listRequest = new HttpRequestMessage(HttpMethod.Get, "/fhir/Patient/"); - listRequest.Headers.Add("Origin", DashboardOrigin); - var listResponse = await _clinicalClient.SendAsync(listRequest); - var listBody = await listResponse.Content.ReadAsStringAsync(); - - Assert.Contains(uniqueName, listBody); - Assert.Contains("IntegrationCreated", listBody); - } - - /// - /// CRITICAL: Proves practitioner creation API works end-to-end. - /// This tests the actual POST endpoint that the AddPractitionerModal would call. - /// - [Fact] - public async Task SchedulingApi_CreatePractitioner_WorksEndToEnd() - { - // Arrange - Create a practitioner with unique identifier - var uniqueId = $"DR{DateTime.UtcNow.Ticks % 100000}"; - var request = new HttpRequestMessage(HttpMethod.Post, "/Practitioner"); - request.Headers.Add("Origin", DashboardOrigin); - request.Content = new StringContent( - $$$"""{"Identifier": "{{{uniqueId}}}", "Active": true, "NameGiven": "IntDoctor", "NameFamily": "TestDoc", "Qualification": "MD", "Specialty": "Testing", "TelecomEmail": "inttest@hospital.org", "TelecomPhone": "+1-555-8888"}""", - System.Text.Encoding.UTF8, - "application/json" - ); - - // Act - Create practitioner - var createResponse = await _schedulingClient.SendAsync(request); - createResponse.EnsureSuccessStatusCode(); - - // Verify - Fetch all practitioners and confirm the new one is there - var listRequest = new HttpRequestMessage(HttpMethod.Get, "/Practitioner"); - listRequest.Headers.Add("Origin", DashboardOrigin); - var listResponse = await _schedulingClient.SendAsync(listRequest); - var listBody = await listResponse.Content.ReadAsStringAsync(); - - Assert.Contains(uniqueId, listBody); - Assert.Contains("IntDoctor", listBody); - } - - /// - /// CRITICAL: Proves appointment creation API works end-to-end. - /// This tests the actual POST endpoint that the AddAppointmentModal calls. - /// - [Fact] - public async Task SchedulingApi_CreateAppointment_WorksEndToEnd() - { - // Arrange - Create an appointment with unique service type - var uniqueService = $"Consult{DateTime.UtcNow.Ticks % 100000}"; - var request = new HttpRequestMessage(HttpMethod.Post, "/Appointment"); - request.Headers.Add("Origin", DashboardOrigin); - request.Content = new StringContent( - $$$"""{"ServiceCategory": "General", "ServiceType": "{{{uniqueService}}}", "Start": "2025-12-25T10:00:00Z", "End": "2025-12-25T11:00:00Z", "PatientReference": "Patient/test", "PractitionerReference": "Practitioner/test", "Priority": "routine"}""", - System.Text.Encoding.UTF8, - "application/json" - ); - - // Act - Create appointment - var createResponse = await _schedulingClient.SendAsync(request); - createResponse.EnsureSuccessStatusCode(); - - // Verify - Fetch all appointments and confirm the new one is there - var listRequest = new HttpRequestMessage(HttpMethod.Get, "/Appointment"); - listRequest.Headers.Add("Origin", DashboardOrigin); - var listResponse = await _schedulingClient.SendAsync(listRequest); - var listBody = await listResponse.Content.ReadAsStringAsync(); - - Assert.Contains(uniqueService, listBody); - } - - #endregion -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/DashboardE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/DashboardE2ETests.cs deleted file mode 100644 index 718437d1..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/DashboardE2ETests.cs +++ /dev/null @@ -1,2243 +0,0 @@ -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// Core Dashboard E2E tests. -/// Uses EXACTLY the same ports as the real app. -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class DashboardE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public DashboardE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Dashboard main page shows stats from both APIs. - /// - [Fact] - public async Task Dashboard_MainPage_ShowsStatsFromBothApis() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - ".metric-card", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var cards = await page.QuerySelectorAllAsync(".metric-card"); - Assert.True(cards.Count > 0, "Dashboard should display metric cards with API data"); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Add Patient button opens modal and creates patient via API. - /// Uses Playwright to load REAL Dashboard, click Add Patient, fill form, and POST to REAL API. - /// - [Fact] - public async Task AddPatientButton_OpensModal_AndCreatesPatient() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Patients page - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click Add Patient button - await page.ClickAsync("[data-testid='add-patient-btn']"); - - // Wait for modal to appear - await page.WaitForSelectorAsync( - ".modal", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Fill in patient details - var uniqueName = $"E2ECreated{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='patient-given-name']", uniqueName); - await page.FillAsync("[data-testid='patient-family-name']", "TestCreated"); - await page.SelectOptionAsync("[data-testid='patient-gender']", "male"); - - // Submit the form - await page.ClickAsync("[data-testid='submit-patient']"); - - // Wait for modal to close and patient to appear in list - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify via API that patient was actually created - using var client = E2EFixture.CreateAuthenticatedClient(); - var response = await client.GetStringAsync($"{E2EFixture.ClinicalUrl}/fhir/Patient/"); - Assert.Contains(uniqueName, response); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Add Appointment button opens modal and creates appointment via API. - /// Uses Playwright to load REAL Dashboard, click Add Appointment, fill form, and POST to REAL API. - /// - [Fact] - public async Task AddAppointmentButton_OpensModal_AndCreatesAppointment() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Appointments page - await page.ClickAsync("text=Appointments"); - await page.WaitForSelectorAsync( - "[data-testid='add-appointment-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click Add Appointment button - await page.ClickAsync("[data-testid='add-appointment-btn']"); - - // Wait for modal to appear - await page.WaitForSelectorAsync( - ".modal", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Fill in appointment details - var uniqueServiceType = $"E2EConsult{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='appointment-service-type']", uniqueServiceType); - - // Submit the form - await page.ClickAsync("[data-testid='submit-appointment']"); - - // Wait for modal to close and appointment to appear in list - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify via API that appointment was actually created - using var client = E2EFixture.CreateAuthenticatedClient(); - var response = await client.GetStringAsync($"{E2EFixture.SchedulingUrl}/Appointment"); - Assert.Contains(uniqueServiceType, response); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Patient Search button navigates to search and finds patients. - /// - [Fact] - public async Task PatientSearchButton_NavigatesToSearch_AndFindsPatients() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click the Patient Search button - await page.ClickAsync("text=Patient Search"); - - // Should navigate to patients page with search focused - await page.WaitForSelectorAsync( - "input[placeholder*='Search']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Type a search query - await page.FillAsync("input[placeholder*='Search']", "E2ETest"); - - // Wait for filtered results - await page.WaitForSelectorAsync( - "text=TestPatient", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("TestPatient", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: View Schedule button navigates to appointments view. - /// - [Fact] - public async Task ViewScheduleButton_NavigatesToAppointments() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click the View Schedule button - await page.ClickAsync("text=View Schedule"); - - // Should navigate to appointments page - await page.WaitForSelectorAsync( - "text=Appointments", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Should show the seeded appointment - await page.WaitForSelectorAsync( - "text=Checkup", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Checkup", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Proves patient creation API works end-to-end. - /// This test hits the real Clinical API directly without Playwright. - /// - [Fact] - public async Task PatientCreationApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a patient with a unique name - var uniqueName = $"ApiTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "ApiCreated", "Gender": "female"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - // Verify patient was created by fetching all patients - var listResponse = await client.GetStringAsync($"{E2EFixture.ClinicalUrl}/fhir/Patient/"); - Assert.Contains(uniqueName, listResponse); - Assert.Contains("ApiCreated", listResponse); - } - - /// - /// CRITICAL TEST: Proves practitioner creation API works end-to-end. - /// This test hits the real Scheduling API directly without Playwright. - /// - [Fact] - public async Task PractitionerCreationApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a practitioner with a unique identifier - var uniqueId = $"DR{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueId}}}", "Active": true, "NameGiven": "ApiDoctor", "NameFamily": "TestDoc", "Qualification": "MD", "Specialty": "Testing", "TelecomEmail": "test@hospital.org", "TelecomPhone": "+1-555-9999"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - // Verify practitioner was created - var listResponse = await client.GetStringAsync($"{E2EFixture.SchedulingUrl}/Practitioner"); - Assert.Contains(uniqueId, listResponse); - Assert.Contains("ApiDoctor", listResponse); - } - - /// - /// CRITICAL TEST: Edit Patient button opens edit page and updates patient via API. - /// Uses Playwright to load REAL Dashboard, click Edit, modify form, and PUT to REAL API. - /// - [Fact] - public async Task EditPatientButton_OpensEditPage_AndUpdatesPatient() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // First create a patient to edit - var uniqueName = $"EditTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "ToBeEdited", "Gender": "female"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdPatientJson = await createResponse.Content.ReadAsStringAsync(); - - // Extract patient ID from response - var patientIdMatch = System.Text.RegularExpressions.Regex.Match( - createdPatientJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - Assert.True(patientIdMatch.Success, "Should get patient ID from creation response"); - var patientId = patientIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Patients page - await page.ClickAsync("text=Patients"); - - // Wait for the page to load (add-patient-btn is a good indicator) - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Search for the patient to make sure it appears - await page.FillAsync("input[placeholder*='Search']", uniqueName); - - // Wait for the patient to appear in filtered results - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click Edit button for the created patient - await page.ClickAsync($"[data-testid='edit-patient-{patientId}']"); - - // Wait for edit page to load - await page.WaitForSelectorAsync( - "[data-testid='edit-patient-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Verify we're on the edit page with the correct patient data - await page.WaitForSelectorAsync( - "[data-testid='edit-given-name']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Modify the patient's name - var newFamilyName = $"Edited{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='edit-family-name']", newFamilyName); - - // Submit the form - await page.ClickAsync("[data-testid='save-patient']"); - - // Wait for success message - await page.WaitForSelectorAsync( - "[data-testid='edit-success']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify via API that patient was actually updated - var updatedPatientJson = await client.GetStringAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/{patientId}" - ); - Assert.Contains(newFamilyName, updatedPatientJson); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Browser back button navigates to previous view. - /// Proves history.pushState/popstate integration works correctly. - /// - [Fact] - public async Task BrowserBackButton_NavigatesToPreviousView() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Start on dashboard (default) - Assert.Contains("#dashboard", page.Url); - - // Navigate to Patients - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - // Navigate to Appointments - await page.ClickAsync("text=Appointments"); - await page.WaitForSelectorAsync( - "[data-testid='add-appointment-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#appointments", page.Url); - - // Press browser back - should go to Patients - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - // Press browser back again - should go to Dashboard - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - ".metric-card", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#dashboard", page.Url); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Deep linking works - navigating directly to a hash URL loads correct view. - /// - [Fact] - public async Task DeepLinking_LoadsCorrectView() - { - // Navigate directly to patients page via hash with auth - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#patients" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify we're on patients page - var content = await page.ContentAsync(); - Assert.Contains("Patients", content); - - // Navigate directly to appointments via hash - await page.GotoAsync($"{E2EFixture.DashboardUrl}#appointments"); - await page.WaitForSelectorAsync( - "[data-testid='add-appointment-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - content = await page.ContentAsync(); - Assert.Contains("Appointments", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Cancel button on edit page uses history.back() - same behavior as browser back. - /// - [Fact] - public async Task EditPatientCancelButton_UsesHistoryBack() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a patient to edit - var uniqueName = $"CancelTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "CancelTestPatient", "Gender": "male"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var patientIdMatch = System.Text.RegularExpressions.Regex.Match( - createdJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - var patientId = patientIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Patients - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - // Search for and click edit on the patient - await page.FillAsync("input[placeholder*='Search']", uniqueName); - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.ClickAsync($"[data-testid='edit-patient-{patientId}']"); - - // Wait for edit page - await page.WaitForSelectorAsync( - "[data-testid='edit-patient-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - Assert.Contains($"#patients/edit/{patientId}", page.Url); - - // Click Cancel button - should use history.back() and return to patients list - await page.ClickAsync("button:has-text('Cancel')"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Should be back on patients page - Assert.Contains("#patients", page.Url); - Assert.DoesNotContain("/edit/", page.Url); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Browser back button works from Edit Patient page. - /// This is THE test that proves the original bug is fixed - pressing browser back - /// from an edit page should return to patients list, NOT show a blank page. - /// - [Fact] - public async Task BrowserBackButton_FromEditPage_ReturnsToPatientsPage() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a patient to edit - var uniqueName = $"BackBtnTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "BackButtonTest", "Gender": "female"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var patientIdMatch = System.Text.RegularExpressions.Regex.Match( - createdJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - var patientId = patientIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - Assert.Contains("#dashboard", page.Url); - - // Navigate to Patients - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - // Search for the patient - await page.FillAsync("input[placeholder*='Search']", uniqueName); - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click edit to go to edit page - await page.ClickAsync($"[data-testid='edit-patient-{patientId}']"); - await page.WaitForSelectorAsync( - "[data-testid='edit-patient-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - Assert.Contains($"#patients/edit/{patientId}", page.Url); - - // THE CRITICAL TEST: Press browser back button - // Before the fix, this would show a blank "Guest browsing" page - // After the fix, it should return to the patients list - await page.GoBackAsync(); - - // Should be back on patients page with sidebar visible (NOT a blank page) - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - Assert.DoesNotContain("/edit/", page.Url); - - // Verify the page content is actually the patients page, not blank - var content = await page.ContentAsync(); - Assert.Contains("Patients", content); - Assert.Contains("Add Patient", content); - - // Press back again - should go to dashboard - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - ".metric-card", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#dashboard", page.Url); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Forward button works after going back. - /// Proves full history navigation (back AND forward) works correctly. - /// - [Fact] - public async Task BrowserForwardButton_WorksAfterGoingBack() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate: Dashboard -> Patients -> Practitioners - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - ".practitioner-card, .empty-state", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#practitioners", page.Url); - - // Go back to Patients - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - // Go forward to Practitioners - await page.GoForwardAsync(); - await page.WaitForSelectorAsync( - ".practitioner-card, .empty-state", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#practitioners", page.Url); - - // Verify page content is actually practitioners page - var content = await page.ContentAsync(); - Assert.Contains("Practitioners", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Proves patient update API works end-to-end. - /// This test hits the real Clinical API directly without Playwright. - /// - [Fact] - public async Task PatientUpdateApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a patient first - var uniqueName = $"UpdateApiTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "Original", "Gender": "male"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdPatientJson = await createResponse.Content.ReadAsStringAsync(); - - // Extract patient ID - var patientIdMatch = System.Text.RegularExpressions.Regex.Match( - createdPatientJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - Assert.True(patientIdMatch.Success, "Should get patient ID from creation response"); - var patientId = patientIdMatch.Groups[1].Value; - - // Update the patient - var updatedFamilyName = $"Updated{DateTime.UtcNow.Ticks % 100000}"; - var updateResponse = await client.PutAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/{patientId}", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "{{{updatedFamilyName}}}", "Gender": "male", "Email": "updated@test.com"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - updateResponse.EnsureSuccessStatusCode(); - - // Verify patient was updated - var getResponse = await client.GetStringAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/{patientId}" - ); - Assert.Contains(updatedFamilyName, getResponse); - Assert.Contains("updated@test.com", getResponse); - } - - /// - /// CRITICAL TEST: Add Practitioner button opens modal and creates practitioner via API. - /// Uses Playwright to load REAL Dashboard, click Add Practitioner, fill form, and POST to REAL API. - /// - [Fact] - public async Task AddPractitionerButton_OpensModal_AndCreatesPractitioner() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Practitioners page - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - "[data-testid='add-practitioner-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click Add Practitioner button - await page.ClickAsync("[data-testid='add-practitioner-btn']"); - - // Wait for modal to appear - await page.WaitForSelectorAsync( - ".modal", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Fill in practitioner details - var uniqueIdentifier = $"DR{DateTime.UtcNow.Ticks % 100000}"; - var uniqueGivenName = $"E2EDoc{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='practitioner-identifier']", uniqueIdentifier); - await page.FillAsync("[data-testid='practitioner-given-name']", uniqueGivenName); - await page.FillAsync("[data-testid='practitioner-family-name']", "TestCreated"); - await page.FillAsync("[data-testid='practitioner-specialty']", "E2E Testing"); - - // Submit the form - await page.ClickAsync("[data-testid='submit-practitioner']"); - - // Wait for modal to close and practitioner to appear in list - await page.WaitForSelectorAsync( - $"text={uniqueGivenName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify via API that practitioner was actually created - using var client = E2EFixture.CreateAuthenticatedClient(); - var response = await client.GetStringAsync($"{E2EFixture.SchedulingUrl}/Practitioner"); - Assert.Contains(uniqueIdentifier, response); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Edit Practitioner button navigates to edit page and updates practitioner. - /// Uses Playwright to load REAL Dashboard, click Edit, modify data, and PUT to REAL API. - /// - [Fact] - public async Task EditPractitionerButton_OpensEditPage_AndUpdatesPractitioner() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a practitioner to edit - var uniqueIdentifier = $"DREdit{DateTime.UtcNow.Ticks % 100000}"; - var uniqueGivenName = $"EditTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "NameFamily": "OriginalFamily", "NameGiven": "{{{uniqueGivenName}}}", "Qualification": "MD", "Specialty": "Original Specialty"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var practitionerIdMatch = System.Text.RegularExpressions.Regex.Match( - createdJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - var practitionerId = practitionerIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Practitioners page - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - "[data-testid='add-practitioner-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Wait for our practitioner to appear - await page.WaitForSelectorAsync( - $"text={uniqueGivenName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Hover over the card to show the edit button, then click it - var editButton = page.Locator($"[data-testid='edit-practitioner-{practitionerId}']"); - await editButton.HoverAsync(); - await editButton.ClickAsync(); - - // Wait for edit page - await page.WaitForSelectorAsync( - "[data-testid='edit-practitioner-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - Assert.Contains($"#practitioners/edit/{practitionerId}", page.Url); - - // Update the practitioner's specialty - var newSpecialty = $"Updated Specialty {DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='edit-practitioner-specialty']", newSpecialty); - - // Save changes - await page.ClickAsync("[data-testid='save-practitioner']"); - - // Wait for success message - await page.WaitForSelectorAsync( - "[data-testid='edit-practitioner-success']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify via API that practitioner was actually updated - var updatedPractitionerJson = await client.GetStringAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner/{practitionerId}" - ); - Assert.Contains(newSpecialty, updatedPractitionerJson); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Proves practitioner update API works end-to-end. - /// This test hits the real Scheduling API directly without Playwright. - /// - [Fact] - public async Task PractitionerUpdateApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a practitioner first - var uniqueIdentifier = $"DRApi{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "NameFamily": "ApiOriginal", "NameGiven": "TestDoc", "Qualification": "MD", "Specialty": "Original"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdPractitionerJson = await createResponse.Content.ReadAsStringAsync(); - - // Extract practitioner ID - var practitionerIdMatch = System.Text.RegularExpressions.Regex.Match( - createdPractitionerJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - Assert.True( - practitionerIdMatch.Success, - "Should get practitioner ID from creation response" - ); - var practitionerId = practitionerIdMatch.Groups[1].Value; - - // Update the practitioner - var updatedSpecialty = $"ApiUpdated{DateTime.UtcNow.Ticks % 100000}"; - var updateResponse = await client.PutAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner/{practitionerId}", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "Active": true, "NameFamily": "ApiUpdated", "NameGiven": "TestDoc", "Qualification": "DO", "Specialty": "{{{updatedSpecialty}}}", "TelecomEmail": "updated@hospital.com", "TelecomPhone": "555-1234"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - updateResponse.EnsureSuccessStatusCode(); - - // Verify practitioner was updated - var getResponse = await client.GetStringAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner/{practitionerId}" - ); - Assert.Contains(updatedSpecialty, getResponse); - Assert.Contains("ApiUpdated", getResponse); - Assert.Contains("DO", getResponse); - Assert.Contains("updated@hospital.com", getResponse); - } - - /// - /// CRITICAL TEST: Browser back button works from Edit Practitioner page. - /// Proves navigation between practitioners list and edit page works correctly. - /// - [Fact] - public async Task BrowserBackButton_FromEditPractitionerPage_ReturnsToPractitionersPage() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a practitioner to edit - var uniqueIdentifier = $"DRBack{DateTime.UtcNow.Ticks % 100000}"; - var uniqueGivenName = $"BackTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "NameFamily": "BackButtonTest", "NameGiven": "{{{uniqueGivenName}}}", "Qualification": "MD", "Specialty": "Testing"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var practitionerIdMatch = System.Text.RegularExpressions.Regex.Match( - createdJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - var practitionerId = practitionerIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - Assert.Contains("#dashboard", page.Url); - - // Navigate to Practitioners - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - "[data-testid='add-practitioner-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#practitioners", page.Url); - - // Wait for our practitioner to appear - await page.WaitForSelectorAsync( - $"text={uniqueGivenName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click edit to go to edit page - var editButton = page.Locator($"[data-testid='edit-practitioner-{practitionerId}']"); - await editButton.HoverAsync(); - await editButton.ClickAsync(); - await page.WaitForSelectorAsync( - "[data-testid='edit-practitioner-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - Assert.Contains($"#practitioners/edit/{practitionerId}", page.Url); - - // Press browser back button - await page.GoBackAsync(); - - // Should be back on practitioners page with sidebar visible - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='add-practitioner-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#practitioners", page.Url); - Assert.DoesNotContain("/edit/", page.Url); - - // Verify the page content is actually the practitioners page - var content = await page.ContentAsync(); - Assert.Contains("Practitioners", content); - Assert.Contains("Add Practitioner", content); - - // Press back again - should go to dashboard - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - ".metric-card", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#dashboard", page.Url); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Sync Dashboard menu item navigates to sync page and displays sync status. - /// Proves the sync dashboard UI is accessible from the sidebar navigation. - /// - [Fact] - public async Task SyncDashboard_NavigatesToSyncPage_AndDisplaysStatus() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click Sync Dashboard in sidebar - await page.ClickAsync("text=Sync Dashboard"); - - // Wait for sync page to load - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify URL - Assert.Contains("#sync", page.Url); - - // Verify service status cards are displayed - await page.WaitForSelectorAsync( - "[data-testid='service-status-clinical']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-scheduling']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Verify sync records table is displayed - await page.WaitForSelectorAsync( - "[data-testid='sync-records-table']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Verify filter controls exist (service-filter and action-filter) - await page.WaitForSelectorAsync( - "[data-testid='service-filter']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='action-filter']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Verify page content - var content = await page.ContentAsync(); - Assert.Contains("Sync Dashboard", content); - Assert.Contains("Clinical.Api", content); - Assert.Contains("Scheduling.Api", content); - Assert.Contains("Sync Records", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Sync Dashboard filters work correctly. - /// Tests service and action filtering functionality. - /// - [Fact] - public async Task SyncDashboard_FiltersWorkCorrectly() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Wait for sync records table to be loaded - await page.WaitForSelectorAsync( - "[data-testid='sync-records-table']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Get initial record count (may be 0 initially) - var initialRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - var initialCount = initialRows.Count; - - // Filter by service - select 'clinical' - await page.SelectOptionAsync("[data-testid='service-filter']", "clinical"); - - // Wait for filter to apply - await Task.Delay(500); - var filteredRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Assert.True( - filteredRows.Count <= initialCount, - "Filtered results should be <= initial count" - ); - - // Reset filter - await page.SelectOptionAsync("[data-testid='service-filter']", "all"); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Deep linking to sync page works. - /// Navigating directly to #sync loads the sync dashboard. - /// - [Fact] - public async Task SyncDashboard_DeepLinkingWorks() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - - // Wait for sync page to load - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Verify we're on the sync page - var content = await page.ContentAsync(); - Assert.Contains("Sync Dashboard", content); - Assert.Contains("Monitor and manage sync operations", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Edit Appointment button opens edit page and updates appointment via API. - /// Uses Playwright to load REAL Dashboard, click Edit, modify form, and PUT to REAL API. - /// - [Fact] - public async Task EditAppointmentButton_OpensEditPage_AndUpdatesAppointment() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // First create an appointment to edit - var uniqueServiceType = $"EditApptTest{DateTime.UtcNow.Ticks % 100000}"; - var startTime = DateTime.UtcNow.AddDays(7).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var endTime = DateTime - .UtcNow.AddDays(7) - .AddMinutes(30) - .ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Appointment", - new StringContent( - $$$"""{"ServiceCategory": "General", "ServiceType": "{{{uniqueServiceType}}}", "Priority": "routine", "Start": "{{{startTime}}}", "End": "{{{endTime}}}", "PatientReference": "Patient/1", "PractitionerReference": "Practitioner/1"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdAppointmentJson = await createResponse.Content.ReadAsStringAsync(); - - // Extract appointment ID from response - var appointmentIdMatch = System.Text.RegularExpressions.Regex.Match( - createdAppointmentJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - Assert.True(appointmentIdMatch.Success, "Should get appointment ID from creation response"); - var appointmentId = appointmentIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Appointments page - await page.ClickAsync("text=Appointments"); - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click Edit button for the created appointment (in the same table row) - var editButton = await page.QuerySelectorAsync( - $"tr:has-text('{uniqueServiceType}') .btn-secondary" - ); - Assert.NotNull(editButton); - await editButton.ClickAsync(); - - // Wait for edit page to load - await page.WaitForSelectorAsync( - "text=Edit Appointment", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Modify the appointment's service type - var newServiceType = $"Edited{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("#appointment-service-type", newServiceType); - - // Submit the form - await page.ClickAsync("button:has-text('Save Changes')"); - - // Wait for success message - await page.WaitForSelectorAsync( - "text=Appointment updated successfully", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify via API that appointment was actually updated - var updatedAppointmentJson = await client.GetStringAsync( - $"{E2EFixture.SchedulingUrl}/Appointment/{appointmentId}" - ); - Assert.Contains(newServiceType, updatedAppointmentJson); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Calendar page displays appointments in calendar grid. - /// Uses Playwright to navigate to calendar and verify appointments are shown. - /// - [Fact] - public async Task CalendarPage_DisplaysAppointmentsInCalendarGrid() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#calendar" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Wait for calendar grid container to appear - await page.WaitForSelectorAsync( - ".calendar-grid-container", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify calendar grid is displayed - var content = await page.ContentAsync(); - Assert.Contains("calendar-grid", content); - - // Verify day names are displayed - Assert.Contains("Sun", content); - Assert.Contains("Mon", content); - Assert.Contains("Tue", content); - Assert.Contains("Wed", content); - Assert.Contains("Thu", content); - Assert.Contains("Fri", content); - Assert.Contains("Sat", content); - - // Verify navigation controls exist - Assert.Contains("Today", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Calendar page allows clicking on a day to view appointments. - /// Uses Playwright to click on a day and verify the details panel shows. - /// - [Fact] - public async Task CalendarPage_ClickOnDay_ShowsAppointmentDetails() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create an appointment for today - use LOCAL time since browser calendar uses local timezone - var today = DateTime.Now; - var startTime = new DateTime( - today.Year, - today.Month, - today.Day, - 14, - 0, - 0, - DateTimeKind.Local - ).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var endTime = new DateTime( - today.Year, - today.Month, - today.Day, - 14, - 30, - 0, - DateTimeKind.Local - ).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var uniqueServiceType = $"CalTest{DateTime.Now.Ticks % 100000}"; - - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Appointment", - new StringContent( - $$$"""{"ServiceCategory": "General", "ServiceType": "{{{uniqueServiceType}}}", "Priority": "routine", "Start": "{{{startTime}}}", "End": "{{{endTime}}}", "PatientReference": "Patient/1", "PractitionerReference": "Practitioner/1"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - Console.WriteLine( - $"[TEST] Created appointment with ServiceType: {uniqueServiceType}, Start: {startTime}" - ); - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Calendar page - await page.ClickAsync("text=Schedule"); - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Wait for appointments to load - today's cell should have has-appointments class - await page.WaitForSelectorAsync( - ".calendar-cell.today.has-appointments", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click on today's cell (it has the "today" class and now has appointments) - var todayCell = page.Locator(".calendar-cell.today").First; - await todayCell.ClickAsync(); - - // Wait for the details panel to update (look for date header) - await page.WaitForSelectorAsync( - ".calendar-details-panel h4", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Debug: output the details panel content - var detailsContent = await page.Locator(".calendar-details-panel").InnerTextAsync(); - Console.WriteLine($"[TEST] Details panel content: {detailsContent}"); - - // Wait for the appointment content to appear in the details panel - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify the appointment is displayed in the details panel - var content = await page.ContentAsync(); - Assert.Contains(uniqueServiceType, content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Calendar page Edit button opens edit appointment page. - /// Uses Playwright to click Edit from calendar day details and verify navigation. - /// - [Fact] - public async Task CalendarPage_EditButton_OpensEditAppointmentPage() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create an appointment for today using LOCAL time (calendar uses DateTime.Now) - var today = DateTime.Now; - var startTime = new DateTime( - today.Year, - today.Month, - today.Day, - 15, - 0, - 0, - DateTimeKind.Local - ) - .ToUniversalTime() - .ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var endTime = new DateTime( - today.Year, - today.Month, - today.Day, - 15, - 30, - 0, - DateTimeKind.Local - ) - .ToUniversalTime() - .ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var uniqueServiceType = $"CalEdit{DateTime.UtcNow.Ticks % 100000}"; - - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Appointment", - new StringContent( - $$$"""{"ServiceCategory": "General", "ServiceType": "{{{uniqueServiceType}}}", "Priority": "routine", "Start": "{{{startTime}}}", "End": "{{{endTime}}}", "PatientReference": "Patient/1", "PractitionerReference": "Practitioner/1"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Calendar page - await page.ClickAsync("text=Schedule"); - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Click on today's cell - await page.ClickAsync(".calendar-cell.today"); - - // Wait for the details panel - await page.WaitForSelectorAsync( - ".calendar-details-panel", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Wait for the specific appointment to appear - await page.WaitForSelectorAsync( - $"text={uniqueServiceType}", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Click the Edit button in the calendar appointment item - var editButton = await page.QuerySelectorAsync( - $".calendar-appointment-item:has-text('{uniqueServiceType}') button:has-text('Edit')" - ); - Assert.NotNull(editButton); - await editButton.ClickAsync(); - - // Wait for edit page to load - await page.WaitForSelectorAsync( - "text=Edit Appointment", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Verify we're on the edit page with the correct data - var content = await page.ContentAsync(); - Assert.Contains("Edit Appointment", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Calendar navigation (previous/next month) works. - /// Uses Playwright to click navigation buttons and verify month changes. - /// - [Fact] - public async Task CalendarPage_NavigationButtons_ChangeMonth() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Calendar page - await page.ClickAsync("text=Schedule"); - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Get the current month displayed - var currentMonthYear = await page.TextContentAsync(".text-lg.font-semibold"); - Assert.NotNull(currentMonthYear); - - // Click next month button - use locator within the header flex container - var headerControls = page.Locator(".page-header .flex.items-center.gap-4"); - var nextButton = headerControls.Locator("button.btn-secondary").Nth(1); - await nextButton.ClickAsync(); - await Task.Delay(300); - - // Verify month changed - var newMonthYear = await page.TextContentAsync(".text-lg.font-semibold"); - Assert.NotEqual(currentMonthYear, newMonthYear); - - // Click previous month button twice to go back - var prevButton = headerControls.Locator("button.btn-secondary").First; - await prevButton.ClickAsync(); - await Task.Delay(300); - await prevButton.ClickAsync(); - await Task.Delay(300); - - // Verify month changed again - var finalMonthYear = await page.TextContentAsync(".text-lg.font-semibold"); - Assert.NotEqual(newMonthYear, finalMonthYear); - - // Click "Today" button - await page.ClickAsync("button:has-text('Today')"); - await Task.Delay(500); - - // Should be back to current month - var todayContent = await page.ContentAsync(); - Assert.Contains("today", todayContent); // Calendar cell should have "today" class - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Deep linking to calendar page works. - /// Navigating directly to #calendar loads the calendar view. - /// - [Fact] - public async Task CalendarPage_DeepLinkingWorks() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#calendar" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".calendar-grid", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Verify we're on the calendar page - var content = await page.ContentAsync(); - Assert.Contains("Schedule", content); - Assert.Contains("View and manage appointments on the calendar", content); - Assert.Contains("calendar-grid", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Login page uses discoverable credentials (no email required). - /// The login page should NOT show an email field for sign-in mode. - /// - [Fact] - public async Task LoginPage_DoesNotRequireEmailForSignIn() - { - var page = await _fixture.Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - // Navigate to Dashboard without auth - should show login page - await page.GotoAsync(E2EFixture.DashboardUrl); - - // Wait for login page to appear - await page.WaitForSelectorAsync( - ".login-card", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Verify login page is shown - var pageContent = await page.ContentAsync(); - Assert.Contains("Healthcare Dashboard", pageContent); - Assert.Contains("Sign in with your passkey", pageContent); - - // CRITICAL: Login mode should NOT have email input field - // Email is only needed for registration, not for discoverable credential login - var emailInputVisible = await page.IsVisibleAsync("input[type='email']"); - Assert.False( - emailInputVisible, - "Login mode should NOT show email field - discoverable credentials don't need email!" - ); - - // Should have a sign-in button - var signInButton = page.Locator("button:has-text('Sign in with Passkey')"); - await signInButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 }); - Assert.True(await signInButton.IsVisibleAsync()); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Registration page requires email and display name. - /// - [Fact] - public async Task LoginPage_RegistrationRequiresEmailAndDisplayName() - { - var page = await _fixture.Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - // Navigate to Dashboard without auth - await page.GotoAsync(E2EFixture.DashboardUrl); - - // Wait for login page - await page.WaitForSelectorAsync( - ".login-card", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click "Register" to switch to registration mode - await page.ClickAsync("button:has-text('Register')"); - await Task.Delay(500); - - // Verify we're in registration mode - var pageContent = await page.ContentAsync(); - Assert.Contains("Create your account", pageContent); - - // Registration mode SHOULD have email and display name fields - var emailInput = page.Locator("input[type='email']"); - var displayNameInput = page.Locator("input#displayName"); - - Assert.True(await emailInput.IsVisibleAsync(), "Registration mode should have email input"); - Assert.True( - await displayNameInput.IsVisibleAsync(), - "Registration mode should have display name input" - ); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Gatekeeper API /auth/login/begin returns valid response for discoverable credentials. - /// This verifies the API contract: empty body should return { ChallengeId, OptionsJson }. - /// - [Fact] - public async Task GatekeeperApi_LoginBegin_ReturnsValidDiscoverableCredentialOptions() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Call /auth/login/begin with empty body (discoverable credentials flow) - var response = await client.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/login/begin", - new StringContent("{}", System.Text.Encoding.UTF8, "application/json") - ); - - // Should return 200 OK - Assert.True( - response.IsSuccessStatusCode, - $"Expected 200 OK but got {response.StatusCode}: {await response.Content.ReadAsStringAsync()}" - ); - - var json = await response.Content.ReadAsStringAsync(); - Console.WriteLine($"[API TEST] Response: {json}"); - - // Parse and verify response structure - using var doc = System.Text.Json.JsonDocument.Parse(json); - var root = doc.RootElement; - - // Must have ChallengeId - Assert.True( - root.TryGetProperty("ChallengeId", out var challengeId), - "Response must have ChallengeId property" - ); - Assert.False( - string.IsNullOrEmpty(challengeId.GetString()), - "ChallengeId must not be empty" - ); - - // Must have OptionsJson (string containing JSON) - Assert.True( - root.TryGetProperty("OptionsJson", out var optionsJson), - "Response must have OptionsJson property" - ); - var optionsJsonStr = optionsJson.GetString(); - Assert.False(string.IsNullOrEmpty(optionsJsonStr), "OptionsJson must not be empty"); - - // OptionsJson should be valid JSON that can be parsed - using var optionsDoc = System.Text.Json.JsonDocument.Parse(optionsJsonStr!); - var options = optionsDoc.RootElement; - - // Verify critical WebAuthn fields - Assert.True(options.TryGetProperty("challenge", out _), "Options must have challenge"); - Assert.True(options.TryGetProperty("rpId", out _), "Options must have rpId"); - - // For discoverable credentials, allowCredentials should be empty array - if (options.TryGetProperty("allowCredentials", out var allowCreds)) - { - Assert.Equal(System.Text.Json.JsonValueKind.Array, allowCreds.ValueKind); - Assert.Equal(0, allowCreds.GetArrayLength()); - Console.WriteLine( - "[API TEST] allowCredentials is empty array - correct for discoverable credentials!" - ); - } - } - - /// - /// CRITICAL TEST: Gatekeeper API /auth/register/begin returns valid response. - /// - [Fact] - public async Task GatekeeperApi_RegisterBegin_ReturnsValidOptions() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Call /auth/register/begin with email and display name - var response = await client.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/register/begin", - new StringContent( - """{"Email": "test-e2e@example.com", "DisplayName": "E2E Test User"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - - // Should return 200 OK - Assert.True( - response.IsSuccessStatusCode, - $"Expected 200 OK but got {response.StatusCode}: {await response.Content.ReadAsStringAsync()}" - ); - - var json = await response.Content.ReadAsStringAsync(); - Console.WriteLine($"[API TEST] Response: {json}"); - - // Parse and verify response structure - using var doc = System.Text.Json.JsonDocument.Parse(json); - var root = doc.RootElement; - - // Must have ChallengeId - Assert.True( - root.TryGetProperty("ChallengeId", out var challengeId), - "Response must have ChallengeId property" - ); - Assert.False( - string.IsNullOrEmpty(challengeId.GetString()), - "ChallengeId must not be empty" - ); - - // Must have OptionsJson - Assert.True( - root.TryGetProperty("OptionsJson", out var optionsJson), - "Response must have OptionsJson property" - ); - var optionsJsonStr = optionsJson.GetString(); - Assert.False(string.IsNullOrEmpty(optionsJsonStr), "OptionsJson must not be empty"); - - // OptionsJson should be valid JSON - using var optionsDoc = System.Text.Json.JsonDocument.Parse(optionsJsonStr!); - var options = optionsDoc.RootElement; - - // Verify critical WebAuthn registration fields - Assert.True(options.TryGetProperty("challenge", out _), "Options must have challenge"); - Assert.True(options.TryGetProperty("rp", out _), "Options must have rp (relying party)"); - Assert.True(options.TryGetProperty("user", out _), "Options must have user"); - Assert.True( - options.TryGetProperty("pubKeyCredParams", out _), - "Options must have pubKeyCredParams" - ); - - // Verify resident key is required for discoverable credentials - if (options.TryGetProperty("authenticatorSelection", out var authSelection)) - { - if (authSelection.TryGetProperty("residentKey", out var residentKey)) - { - Assert.Equal("required", residentKey.GetString()); - Console.WriteLine( - "[API TEST] residentKey is 'required' - correct for discoverable credentials!" - ); - } - } - } - - /// - /// CRITICAL TEST: Dashboard sign-in flow calls API and handles response correctly. - /// Tests the full flow: button click -> API call -> no JSON parse errors. - /// - [Fact] - public async Task LoginPage_SignInButton_CallsApiWithoutJsonErrors() - { - var page = await _fixture.Browser!.NewPageAsync(); - var consoleErrors = new List(); - var networkRequests = new List(); - - page.Console += (_, msg) => - { - Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - if (msg.Type == "error") - consoleErrors.Add(msg.Text); - }; - - page.Request += (_, request) => - { - if (request.Url.Contains("/auth/")) - { - networkRequests.Add($"{request.Method} {request.Url}"); - Console.WriteLine($"[NETWORK] {request.Method} {request.Url}"); - } - }; - - // Navigate to Dashboard without auth - await page.GotoAsync(E2EFixture.DashboardUrl); - - // Wait for login page - await page.WaitForSelectorAsync( - ".login-card", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click Sign in with Passkey button - await page.ClickAsync("button:has-text('Sign in with Passkey')"); - - // Wait for API call and potential error handling - await Task.Delay(3000); - - // Verify the API was called - Assert.Contains(networkRequests, r => r.Contains("/auth/login/begin")); - - // Check for JSON parse errors in console - var hasJsonParseError = consoleErrors.Any(e => - e.Contains("undefined") || e.Contains("is not valid JSON") || e.Contains("SyntaxError") - ); - - // Check for JSON parse errors in UI - var errorVisible = await page.IsVisibleAsync(".login-error"); - var errorText = errorVisible ? await page.TextContentAsync(".login-error") : null; - - var hasUiJsonError = - errorText?.Contains("undefined") == true - || errorText?.Contains("is not valid JSON") == true - || errorText?.Contains("SyntaxError") == true; - - Assert.False( - hasJsonParseError || hasUiJsonError, - $"Sign-in flow had JSON parse errors! Console: [{string.Join(", ", consoleErrors)}], UI: [{errorText}]" - ); - - // The WebAuthn prompt will fail in headless mode (no authenticator), but that's expected - // The important thing is no JSON parsing errors - Console.WriteLine($"[TEST] API called, no JSON errors. UI error (expected): {errorText}"); - - await page.CloseAsync(); - } - - [Fact] - public async Task UserMenu_ClickShowsDropdownWithSignOut() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // User menu button should be visible in header - var userMenuButton = await page.QuerySelectorAsync("[data-testid='user-menu-button']"); - Assert.NotNull(userMenuButton); - - // Click the user menu button to open dropdown - await userMenuButton.ClickAsync(); - - // Wait for dropdown to appear - await page.WaitForSelectorAsync( - "[data-testid='user-dropdown']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Sign out button should be visible in the dropdown - var signOutButton = await page.QuerySelectorAsync("[data-testid='logout-button']"); - Assert.NotNull(signOutButton); - - var isVisible = await signOutButton.IsVisibleAsync(); - Assert.True(isVisible, "Sign out button should be visible in dropdown menu"); - - await page.CloseAsync(); - } - - [Fact] - public async Task SignOutButton_ClickShowsLoginPage() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - // Wait for the sidebar to appear (authenticated state) - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click user menu button in header to open dropdown - await page.ClickAsync("[data-testid='user-menu-button']"); - - // Wait for dropdown to appear - await page.WaitForSelectorAsync( - "[data-testid='user-dropdown']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Click sign out button in dropdown - await page.ClickAsync("[data-testid='logout-button']"); - - // Should show login page after sign out - await page.WaitForSelectorAsync( - "[data-testid='login-page']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify token was cleared from localStorage - var tokenAfterLogout = await page.EvaluateAsync( - "() => localStorage.getItem('gatekeeper_token')" - ); - Assert.Null(tokenAfterLogout); - - var userAfterLogout = await page.EvaluateAsync( - "() => localStorage.getItem('gatekeeper_user')" - ); - Assert.Null(userAfterLogout); - - await page.CloseAsync(); - } - - [Fact] - public async Task GatekeeperApi_Logout_RevokesToken() - { - // Test 1: Without a Bearer token, should return 401 Unauthorized - using var unauthClient = new HttpClient(); - var unauthResponse = await unauthClient.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/logout", - new StringContent("{}", System.Text.Encoding.UTF8, "application/json") - ); - Assert.Equal(HttpStatusCode.Unauthorized, unauthResponse.StatusCode); - - // Test 2: With a valid Bearer token, should return 204 NoContent (logout succeeds) - using var authClient = E2EFixture.CreateAuthenticatedClient(); - var authResponse = await authClient.PostAsync( - $"{E2EFixture.GatekeeperUrl}/auth/logout", - new StringContent("{}", System.Text.Encoding.UTF8, "application/json") - ); - Assert.Equal(HttpStatusCode.NoContent, authResponse.StatusCode); - } - - [Fact] - public async Task UserMenu_DisplaysUserInitialsAndNameInDropdown() - { - // Create page with specific user details - var page = await _fixture.CreateAuthenticatedPageAsync( - userId: "test-user", - displayName: "Alice Smith", - email: "alice@example.com" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Verify initials in header avatar button (should be "AS" for Alice Smith) - var avatarText = await page.TextContentAsync("[data-testid='user-menu-button']"); - Assert.Equal("AS", avatarText?.Trim()); - - // Click the user menu button to open dropdown - await page.ClickAsync("[data-testid='user-menu-button']"); - - // Wait for dropdown to appear - await page.WaitForSelectorAsync( - "[data-testid='user-dropdown']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - // Verify the user's name is displayed in dropdown header - var userNameText = await page.TextContentAsync(".user-dropdown-name"); - Assert.Contains("Alice Smith", userNameText); - - // Verify email is displayed - var emailText = await page.TextContentAsync(".user-dropdown-email"); - Assert.Contains("alice@example.com", emailText); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: First-time sign-in must work WITHOUT browser refresh. - /// This test simulates a successful WebAuthn login by injecting the token and calling - /// the onLogin callback, then verifies the app transitions to dashboard immediately. - /// BUG: Previously, first-time sign-in required a page refresh to work. - /// - [Fact] - public async Task FirstTimeSignIn_TransitionsToDashboard_WithoutRefresh() - { - var page = await _fixture.Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - // Navigate to Dashboard without auth - should show login page - await page.GotoAsync(E2EFixture.DashboardUrl); - - // Wait for login page to appear - await page.WaitForSelectorAsync( - "[data-testid='login-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Verify we're on the login page - var loginPageVisible = await page.IsVisibleAsync("[data-testid='login-page']"); - Assert.True(loginPageVisible, "Should start on login page"); - - // Wait for React to mount and set the __triggerLogin hook - await page.WaitForFunctionAsync( - "() => typeof window.__triggerLogin === 'function'", - new PageWaitForFunctionOptions { Timeout = 10000 } - ); - - // Simulate what happens after successful WebAuthn authentication: - // 1. Token is stored in localStorage - // 2. onLogin callback is called which sets isAuthenticated=true - // This is what the LoginPage component does after successful auth - var testToken = E2EFixture.GenerateTestToken( - userId: "test-user-123", - displayName: "Test User", - email: "test@example.com" - ); - await page.EvaluateAsync( - $@"() => {{ - console.log('[TEST] Setting token and triggering login'); - // Store a properly-signed token and user (what setAuthToken and setAuthUser do) - localStorage.setItem('gatekeeper_token', '{testToken}'); - localStorage.setItem('gatekeeper_user', JSON.stringify({{ - userId: 'test-user-123', - displayName: 'Test User', - email: 'test@example.com' - }})); - - // Trigger the React state update by calling the exposed login handler - // This simulates what happens when LoginPage calls onLogin after successful auth - window.__triggerLogin({{ - userId: 'test-user-123', - displayName: 'Test User', - email: 'test@example.com' - }}); - console.log('[TEST] Login triggered, waiting for React state update'); - }}" - ); - - // Wait for React state update and re-render - await Task.Delay(2000); - - // Check if sidebar is now visible (indicates successful transition to dashboard) - // If this times out, the bug exists - app didn't transition without refresh - try - { - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify login page is gone - var loginPageStillVisible = await page.IsVisibleAsync("[data-testid='login-page']"); - Assert.False( - loginPageStillVisible, - "Login page should be hidden after successful login" - ); - - // Verify sidebar is visible (dashboard state) - var sidebarVisible = await page.IsVisibleAsync(".sidebar"); - Assert.True(sidebarVisible, "Sidebar should be visible after login without refresh"); - } - catch (TimeoutException) - { - // If we get here, the bug exists - first-time sign-in doesn't work without refresh - Assert.Fail( - "FIRST-TIME SIGN-IN BUG: App did not transition to dashboard after login. " - + "User must refresh the browser for login to take effect. " - + "Fix: Expose window.__triggerLogin in App component for testing, " - + "or verify onLogin callback properly triggers React state update." - ); - } - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Clinical Coding page navigates and displays correctly. - /// - [Fact] - public async Task ClinicalCoding_NavigatesToPage_AndDisplaysSearchOptions() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Navigate to Clinical Coding page - await page.ClickAsync("text=Clinical Coding"); - - // Wait for page to load - await page.WaitForSelectorAsync( - ".clinical-coding-page", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - // Verify search tabs are present - var content = await page.ContentAsync(); - Assert.Contains("Keyword Search", content); - Assert.Contains("AI Search", content); - Assert.Contains("Code Lookup", content); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Clinical Coding keyword search returns results with Chapter and Category. - /// - [Fact] - public async Task ClinicalCoding_KeywordSearch_ReturnsResultsWithChapterAndCategory() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#clinical-coding" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - await page.WaitForSelectorAsync( - ".clinical-coding-page", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Ensure Keyword Search tab is active (it's default) - await page.ClickAsync("text=Keyword Search"); - await Task.Delay(500); - - // Enter search query - await page.FillAsync("input[placeholder*='Search by code']", "diabetes"); - - // Click search button - await page.ClickAsync("button:has-text('Search')"); - - // Wait for results table - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - // Verify table has Chapter and Category columns - var content = await page.ContentAsync(); - Assert.Contains("Chapter", content); - Assert.Contains("Category", content); - - // Verify we got results (table rows) - var rows = await page.QuerySelectorAllAsync(".table tbody tr"); - Assert.True(rows.Count > 0, "Should return search results for 'diabetes'"); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Clinical Coding AI search returns results with Chapter and Category. - /// Requires ICD-10 API with embedding service running. - /// - [Fact] - public async Task ClinicalCoding_AISearch_ReturnsResultsWithChapterAndCategory() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#clinical-coding" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - await page.WaitForSelectorAsync( - ".clinical-coding-page", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click AI Search tab - await page.ClickAsync("text=AI Search"); - await Task.Delay(500); - - // Enter semantic search query - await page.FillAsync( - "input[placeholder*='Describe symptoms']", - "chest pain with shortness of breath" - ); - - // Click search button - await page.ClickAsync("button:has-text('Search')"); - - // Wait for results - may take longer due to embedding service - try - { - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 30000 } - ); - - // Verify table has Chapter and Category columns - var content = await page.ContentAsync(); - Assert.Contains("Chapter", content); - Assert.Contains("Category", content); - - // Verify we got AI-matched results - Assert.Contains("AI-matched results", content); - } - catch (TimeoutException) - { - // AI search requires embedding service - skip if not available - Console.WriteLine("[TEST] AI search timed out - embedding service may not be running"); - } - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Clinical Coding code lookup returns detailed code info. - /// - [Fact] - public async Task ClinicalCoding_CodeLookup_ReturnsDetailedCodeInfo() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#clinical-coding" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - await page.WaitForSelectorAsync( - ".clinical-coding-page", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Click Code Lookup tab - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - // Enter exact code - await page.FillAsync("input[placeholder*='Enter exact ICD-10 code']", "E11.9"); - - // Click search button - await page.ClickAsync("button:has-text('Search')"); - - // Wait for code detail view - await page.WaitForSelectorAsync( - ".card", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - // Verify code detail is displayed - var content = await page.ContentAsync(); - - // Should show the code and description - Assert.Contains("E11", content); - Assert.Contains("diabetes", content.ToLowerInvariant()); - - await page.CloseAsync(); - } - - /// - /// CRITICAL TEST: Deep linking to clinical coding page works. - /// - [Fact] - public async Task ClinicalCoding_DeepLinkingWorks() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#clinical-coding" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - // Wait for clinical coding page to load - await page.WaitForSelectorAsync( - ".clinical-coding-page", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - // Verify we're on the clinical coding page - var content = await page.ContentAsync(); - Assert.Contains("Clinical Coding", content); - Assert.Contains("ICD-10", content); - - await page.CloseAsync(); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/E2EFixture.cs b/Samples/Dashboard/Dashboard.Integration.Tests/E2EFixture.cs deleted file mode 100644 index 6159e98a..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/E2EFixture.cs +++ /dev/null @@ -1,1253 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; -using Microsoft.Playwright; -using Npgsql; -using Testcontainers.PostgreSql; - -namespace Dashboard.Integration.Tests; - -/// -/// Shared fixture that starts all services ONCE for all E2E tests. -/// Set E2E_USE_LOCAL=true to skip Testcontainers/process startup and run against -/// an already-running local dev stack (started via scripts/start-local.sh). -/// -public sealed class E2EFixture : IAsyncLifetime -{ - /// - /// When true, tests run against an already-running local dev stack - /// instead of spinning up Testcontainers and API processes. - /// - private static readonly bool UseLocalStack = - Environment.GetEnvironmentVariable("E2E_USE_LOCAL") is "true" or "1"; - - private PostgreSqlContainer? _postgresContainer; - private Process? _clinicalProcess; - private Process? _schedulingProcess; - private Process? _gatekeeperProcess; - private Process? _icd10Process; - private Process? _clinicalSyncProcess; - private Process? _schedulingSyncProcess; - private IHost? _dashboardHost; - - /// - /// Playwright instance shared by all tests. - /// - public IPlaywright? Playwright { get; private set; } - - /// - /// Browser instance shared by all tests. - /// - public IBrowser? Browser { get; private set; } - - /// - /// Clinical API URL. Override with E2E_CLINICAL_URL env var. - /// - public static string ClinicalUrl { get; } = - Environment.GetEnvironmentVariable("E2E_CLINICAL_URL") ?? "http://localhost:5080"; - - /// - /// Scheduling API URL. Override with E2E_SCHEDULING_URL env var. - /// - public static string SchedulingUrl { get; } = - Environment.GetEnvironmentVariable("E2E_SCHEDULING_URL") ?? "http://localhost:5001"; - - /// - /// Gatekeeper Auth API URL. Override with E2E_GATEKEEPER_URL env var. - /// - public static string GatekeeperUrl { get; } = - Environment.GetEnvironmentVariable("E2E_GATEKEEPER_URL") ?? "http://localhost:5002"; - - /// - /// ICD-10 API URL. Override with E2E_ICD10_URL env var. - /// - public static string Icd10Url { get; } = - Environment.GetEnvironmentVariable("E2E_ICD10_URL") ?? "http://localhost:5090"; - - /// - /// Dashboard URL - dynamically assigned in container mode, defaults to local in local mode. - /// - public static string DashboardUrl { get; private set; } = "http://localhost:5173"; - - /// - /// Start all services ONCE for all tests. - /// When E2E_USE_LOCAL=true, skips all infrastructure and connects to already-running services. - /// - public async Task InitializeAsync() - { - if (UseLocalStack) - { - Console.WriteLine("[E2E] LOCAL MODE: connecting to already-running services"); - Console.WriteLine($"[E2E] Clinical: {ClinicalUrl}"); - Console.WriteLine($"[E2E] Scheduling: {SchedulingUrl}"); - Console.WriteLine($"[E2E] Gatekeeper: {GatekeeperUrl}"); - Console.WriteLine($"[E2E] ICD-10: {Icd10Url}"); - Console.WriteLine($"[E2E] Dashboard: {DashboardUrl}"); - - await WaitForServiceReachableAsync(ClinicalUrl, "/fhir/Patient/"); - await WaitForServiceReachableAsync(SchedulingUrl, "/Practitioner"); - await WaitForServiceReachableAsync(GatekeeperUrl, "/auth/login/begin"); - - await SeedTestDataAsync(); - - Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); - Browser = await Playwright.Chromium.LaunchAsync( - new BrowserTypeLaunchOptions { Headless = true } - ); - return; - } - - await Task.WhenAll( - KillProcessOnPortAsync(5080), - KillProcessOnPortAsync(5001), - KillProcessOnPortAsync(5002), - KillProcessOnPortAsync(5090) - ); - await Task.Delay(500); - - // Start PostgreSQL container for all APIs (use pgvector for ICD-10 support) - _postgresContainer = new PostgreSqlBuilder() - .WithImage("pgvector/pgvector:pg16") - .WithDatabase("e2e_shared") - .WithUsername("test") - .WithPassword("test") - .Build(); - - await _postgresContainer.StartAsync(); - var baseConnStr = _postgresContainer.GetConnectionString(); - - // Set environment variable so other test factories can connect - // (DashboardApiCorsTests use their own WebApplicationFactory) - Environment.SetEnvironmentVariable("TEST_POSTGRES_CONNECTION", baseConnStr); - - // Create separate databases for each API - var clinicalConnStr = await CreateDatabaseAsync(baseConnStr, "clinical_e2e"); - var schedulingConnStr = await CreateDatabaseAsync(baseConnStr, "scheduling_e2e"); - var gatekeeperConnStr = await CreateDatabaseAsync(baseConnStr, "gatekeeper_e2e"); - var icd10ConnStr = await CreateDatabaseAsync(baseConnStr, "icd10_e2e"); - - Console.WriteLine("[E2E] PostgreSQL container started"); - - var testAssemblyDir = Path.GetDirectoryName(typeof(E2EFixture).Assembly.Location)!; - var samplesDir = Path.GetFullPath( - Path.Combine(testAssemblyDir, "..", "..", "..", "..", "..") - ); - var rootDir = Path.GetFullPath(Path.Combine(samplesDir, "..")); - - // Run ICD-10 migration and import official CDC data - await SetupIcd10DatabaseAsync(icd10ConnStr, samplesDir, rootDir); - - var clinicalProjectDir = Path.Combine(samplesDir, "Clinical", "Clinical.Api"); - var schedulingProjectDir = Path.Combine(samplesDir, "Scheduling", "Scheduling.Api"); - var gatekeeperProjectDir = Path.Combine(rootDir, "Gatekeeper", "Gatekeeper.Api"); - var icd10ProjectDir = Path.Combine(samplesDir, "ICD10", "ICD10.Api"); - var configuration = ResolveBuildConfiguration(testAssemblyDir); - - Console.WriteLine($"[E2E] Test assembly dir: {testAssemblyDir}"); - Console.WriteLine($"[E2E] Build configuration: {configuration}"); - Console.WriteLine($"[E2E] Samples dir: {samplesDir}"); - Console.WriteLine($"[E2E] Clinical dir: {clinicalProjectDir}"); - Console.WriteLine($"[E2E] Gatekeeper dir: {gatekeeperProjectDir}"); - Console.WriteLine($"[E2E] ICD-10 dir: {icd10ProjectDir}"); - - var clinicalDll = Path.Combine( - clinicalProjectDir, - "bin", - configuration, - "net10.0", - "Clinical.Api.dll" - ); - var clinicalEnv = new Dictionary - { - ["ConnectionStrings__Postgres"] = clinicalConnStr, - }; - _clinicalProcess = StartApiFromDll( - clinicalDll, - clinicalProjectDir, - ClinicalUrl, - clinicalEnv - ); - - var schedulingDll = Path.Combine( - schedulingProjectDir, - "bin", - configuration, - "net10.0", - "Scheduling.Api.dll" - ); - var schedulingEnv = new Dictionary - { - ["ConnectionStrings__Postgres"] = schedulingConnStr, - }; - _schedulingProcess = StartApiFromDll( - schedulingDll, - schedulingProjectDir, - SchedulingUrl, - schedulingEnv - ); - - var gatekeeperDll = Path.Combine( - gatekeeperProjectDir, - "bin", - configuration, - "net10.0", - "Gatekeeper.Api.dll" - ); - var gatekeeperEnv = new Dictionary - { - ["ConnectionStrings__Postgres"] = gatekeeperConnStr, - }; - _gatekeeperProcess = StartApiFromDll( - gatekeeperDll, - gatekeeperProjectDir, - GatekeeperUrl, - gatekeeperEnv - ); - - // Start ICD-10 API (requires PostgreSQL with pgvector) - var icd10Dll = Path.Combine( - icd10ProjectDir, - "bin", - configuration, - "net10.0", - "ICD10.Api.dll" - ); - var icd10Env = new Dictionary - { - ["ConnectionStrings__Postgres"] = icd10ConnStr, - ["ConnectionStrings__DefaultConnection"] = icd10ConnStr, - }; - if (File.Exists(icd10Dll)) - { - _icd10Process = StartApiFromDll(icd10Dll, icd10ProjectDir, Icd10Url, icd10Env); - Console.WriteLine($"[E2E] ICD-10 API starting on {Icd10Url}"); - } - else - { - Console.WriteLine($"[E2E] ICD-10 API DLL missing: {icd10Dll}"); - } - - await Task.Delay(2000); - - // Verify API processes didn't crash on startup (e.g., "address already in use") - // If crashed, re-kill port and retry once - _clinicalProcess = await EnsureProcessAliveAsync( - _clinicalProcess, - "Clinical", - clinicalDll, - clinicalProjectDir, - ClinicalUrl, - clinicalEnv - ); - _schedulingProcess = await EnsureProcessAliveAsync( - _schedulingProcess, - "Scheduling", - schedulingDll, - schedulingProjectDir, - SchedulingUrl, - schedulingEnv - ); - _gatekeeperProcess = await EnsureProcessAliveAsync( - _gatekeeperProcess, - "Gatekeeper", - gatekeeperDll, - gatekeeperProjectDir, - GatekeeperUrl, - gatekeeperEnv - ); - if (_icd10Process is not null) - { - _icd10Process = await EnsureProcessAliveAsync( - _icd10Process, - "ICD-10", - icd10Dll, - icd10ProjectDir, - Icd10Url, - icd10Env - ); - } - - await WaitForApiAsync(ClinicalUrl, "/fhir/Patient/"); - await WaitForApiAsync(SchedulingUrl, "/Practitioner"); - await WaitForGatekeeperApiAsync(); - - // ICD-10 API requires embedding service (Docker) - make it optional - if (_icd10Process is not null) - { - try - { - await WaitForApiAsync(Icd10Url, "/api/icd10/chapters"); - } - catch (Exception ex) - { - Console.WriteLine($"[E2E] WARNING: ICD-10 API failed to start: {ex.Message}"); - Console.WriteLine("[E2E] ICD-10 dependent tests will be skipped"); - // Stop the failed ICD-10 process - StopProcess(_icd10Process); - _icd10Process = null; - } - } - - var clinicalSyncDir = Path.Combine(samplesDir, "Clinical", "Clinical.Sync"); - var clinicalSyncDll = Path.Combine( - clinicalSyncDir, - "bin", - configuration, - "net10.0", - "Clinical.Sync.dll" - ); - if (File.Exists(clinicalSyncDll)) - { - var clinicalSyncEnv = new Dictionary - { - ["ConnectionStrings__Postgres"] = clinicalConnStr, - ["SCHEDULING_API_URL"] = SchedulingUrl, - ["POLL_INTERVAL_SECONDS"] = "5", - }; - _clinicalSyncProcess = StartSyncWorker( - clinicalSyncDll, - clinicalSyncDir, - clinicalSyncEnv - ); - } - else - { - Console.WriteLine($"[E2E] Clinical sync worker missing: {clinicalSyncDll}"); - } - - var schedulingSyncDir = Path.Combine(samplesDir, "Scheduling", "Scheduling.Sync"); - var schedulingSyncDll = Path.Combine( - schedulingSyncDir, - "bin", - configuration, - "net10.0", - "Scheduling.Sync.dll" - ); - if (File.Exists(schedulingSyncDll)) - { - var schedulingSyncEnv = new Dictionary - { - ["ConnectionStrings__Postgres"] = schedulingConnStr, - ["CLINICAL_API_URL"] = ClinicalUrl, - ["POLL_INTERVAL_SECONDS"] = "5", - }; - _schedulingSyncProcess = StartSyncWorker( - schedulingSyncDll, - schedulingSyncDir, - schedulingSyncEnv - ); - } - else - { - Console.WriteLine($"[E2E] Scheduling sync worker missing: {schedulingSyncDll}"); - } - - await Task.Delay(2000); - - _dashboardHost = CreateDashboardHost(); - await _dashboardHost.StartAsync(); - - var server = _dashboardHost.Services.GetRequiredService(); - var addressFeature = server.Features.Get(); - DashboardUrl = addressFeature!.Addresses.First(); - Console.WriteLine($"[E2E] Dashboard started on {DashboardUrl}"); - - await SeedTestDataAsync(); - - Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); - Browser = await Playwright.Chromium.LaunchAsync( - new BrowserTypeLaunchOptions { Headless = true } - ); - } - - /// - /// Stop all services ONCE after all tests. - /// Order matters: stop sync workers FIRST to prevent connection errors. - /// In local mode, only Playwright is cleaned up. - /// - public async Task DisposeAsync() - { - try - { - if (Browser is not null) - await Browser.CloseAsync(); - } - catch { } - Playwright?.Dispose(); - - if (UseLocalStack) - return; - - StopProcess(_clinicalSyncProcess); - StopProcess(_schedulingSyncProcess); - await Task.Delay(1000); - - try - { - if (_dashboardHost is not null) - await _dashboardHost.StopAsync(TimeSpan.FromSeconds(5)); - } - catch { } - _dashboardHost?.Dispose(); - - StopProcess(_clinicalProcess); - StopProcess(_schedulingProcess); - StopProcess(_gatekeeperProcess); - - StopProcess(_icd10Process); - - await KillProcessOnPortAsync(5080); - await KillProcessOnPortAsync(5001); - await KillProcessOnPortAsync(5002); - await KillProcessOnPortAsync(5090); - - if (_postgresContainer is not null) - await _postgresContainer.DisposeAsync(); - } - - private static Process StartApiFromDll( - string dllPath, - string contentRoot, - string url, - Dictionary? envVars = null - ) - { - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"\"{dllPath}\" --urls \"{url}\" --contentRoot \"{contentRoot}\"", - WorkingDirectory = contentRoot, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - // Clear ASPNETCORE_URLS inherited from test process - // (Microsoft.AspNetCore.Mvc.Testing sets it to http://127.0.0.1:0) - startInfo.EnvironmentVariables.Remove("ASPNETCORE_URLS"); - - if (envVars is not null) - { - foreach (var kvp in envVars) - startInfo.EnvironmentVariables[kvp.Key] = kvp.Value; - } - - var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; - - process.OutputDataReceived += (_, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - Console.WriteLine($"[API {url}] {e.Data}"); - }; - process.ErrorDataReceived += (_, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - Console.WriteLine($"[API {url} ERR] {e.Data}"); - }; - process.Exited += (_, _) => - Console.WriteLine( - $"[API {url}] PROCESS EXITED with code {(process.HasExited ? process.ExitCode : -1)}" - ); - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - return process; - } - - private static Process StartSyncWorker( - string dllPath, - string workingDir, - Dictionary? envVars = null - ) - { - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"\"{dllPath}\"", - WorkingDirectory = workingDir, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - startInfo.EnvironmentVariables.Remove("ASPNETCORE_URLS"); - - if (envVars is not null) - { - foreach (var kvp in envVars) - startInfo.EnvironmentVariables[kvp.Key] = kvp.Value; - } - - var process = new Process { StartInfo = startInfo }; - process.OutputDataReceived += (_, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - Console.WriteLine($"[SYNC] {e.Data}"); - }; - process.ErrorDataReceived += (_, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - Console.WriteLine($"[SYNC ERR] {e.Data}"); - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - return process; - } - - private static void StopProcess(Process? process) - { - if (process is null || process.HasExited) - return; - try - { - process.Kill(entireProcessTree: true); - process.WaitForExit(5000); - } - catch { } - finally - { - process.Dispose(); - } - } - - private static async Task KillProcessOnPortAsync(int port) - { - // Try multiple times to ensure port is released - for (var attempt = 0; attempt < 5; attempt++) - { - try - { - // Use lsof to find ALL pids on this port and kill them - var findPsi = new ProcessStartInfo - { - FileName = "/bin/sh", - Arguments = $"-c \"lsof -ti :{port}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true, - }; - using var findProc = Process.Start(findPsi); - if (findProc is not null) - { - var pids = await findProc.StandardOutput.ReadToEndAsync(); - await findProc.WaitForExitAsync(); - if (!string.IsNullOrWhiteSpace(pids)) - { - Console.WriteLine( - $"[E2E] Port {port} held by PIDs: {pids.Trim().Replace("\n", ", ")}" - ); - // Kill each PID individually - foreach ( - var pid in pids.Trim() - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - ) - { - try - { - var killPsi = new ProcessStartInfo - { - FileName = "/bin/kill", - Arguments = $"-9 {pid.Trim()}", - UseShellExecute = false, - CreateNoWindow = true, - }; - using var killProc = Process.Start(killPsi); - if (killProc is not null) - await killProc.WaitForExitAsync(); - } - catch { } - } - } - } - } - catch { } - await Task.Delay(500); - - // Verify port is free - if (await IsPortAvailableAsync(port)) - { - Console.WriteLine($"[E2E] Port {port} is now free (attempt {attempt + 1})"); - return; - } - - Console.WriteLine( - $"[E2E] Port {port} still in use after attempt {attempt + 1}, retrying..." - ); - await Task.Delay(1000); - } - - Console.WriteLine($"[E2E] WARNING: Port {port} could not be freed after 5 attempts"); - } - - /// - /// Verifies an API process is still alive after startup. If it crashed (e.g., port already in use), - /// re-kills the port and restarts the process. - /// - private static async Task EnsureProcessAliveAsync( - Process process, - string name, - string dllPath, - string contentRoot, - string url, - Dictionary envVars - ) - { - if (!process.HasExited) - { - Console.WriteLine($"[E2E] {name} API process is alive (PID {process.Id})"); - return process; - } - - Console.WriteLine( - $"[E2E] WARNING: {name} API process crashed with exit code {process.ExitCode}" - ); - process.Dispose(); - - // Extract port from URL and re-kill it - var uri = new Uri(url); - var port = uri.Port; - Console.WriteLine($"[E2E] Re-killing port {port} and restarting {name} API..."); - await KillProcessOnPortAsync(port); - await Task.Delay(1000); - - if (!await IsPortAvailableAsync(port)) - { - throw new InvalidOperationException( - $"{name} API process crashed and port {port} is still in use after cleanup." - ); - } - - // Restart the process - var newProcess = StartApiFromDll(dllPath, contentRoot, url, envVars); - Console.WriteLine($"[E2E] {name} API restarted (PID {newProcess.Id})"); - - // Wait and verify the restart succeeded - await Task.Delay(2000); - if (newProcess.HasExited) - { - throw new InvalidOperationException( - $"{name} API failed to start on retry (exit code {newProcess.ExitCode})." - ); - } - - return newProcess; - } - - private static Task IsPortAvailableAsync(int port) - { - try - { - using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, port); - listener.Start(); - listener.Stop(); - return Task.FromResult(true); - } - catch - { - return Task.FromResult(false); - } - } - - private static async Task CreateDatabaseAsync( - string baseConnectionString, - string dbName - ) - { - await using var conn = new NpgsqlConnection(baseConnectionString); - await conn.OpenAsync(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = $"CREATE DATABASE \"{dbName}\""; - await cmd.ExecuteNonQueryAsync(); - - var builder = new NpgsqlConnectionStringBuilder(baseConnectionString) { Database = dbName }; - return builder.ConnectionString; - } - - private static string ResolveBuildConfiguration(string testAssemblyDir) - { - var net9Dir = new DirectoryInfo(testAssemblyDir); - var configuration = net9Dir.Parent?.Name; - return string.IsNullOrWhiteSpace(configuration) ? "Debug" : configuration; - } - - private static async Task WaitForApiAsync(string baseUrl, string healthEndpoint) - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var maxRetries = 30; // Reduced from 120 to 30 (15 seconds max instead of 60) - var lastException = (Exception?)null; - - for (var i = 0; i < maxRetries; i++) - { - try - { - var response = await client.GetAsync($"{baseUrl}{healthEndpoint}"); - if ( - response.IsSuccessStatusCode - || response.StatusCode == HttpStatusCode.NotFound - || response.StatusCode == HttpStatusCode.Unauthorized - || response.StatusCode == HttpStatusCode.Forbidden - ) - { - Console.WriteLine( - $"[E2E] API at {baseUrl} started successfully after {i} attempts" - ); - return; - } - - // If we get a non-success status code, log it but continue retrying - Console.WriteLine( - $"[E2E] API at {baseUrl} returned {response.StatusCode} on attempt {i + 1}" - ); - } - catch (Exception ex) - { - lastException = ex; - Console.WriteLine( - $"[E2E] API at {baseUrl} connection failed on attempt {i + 1}: {ex.Message}" - ); - - // If it's a connection refused error early on, fail faster - if (ex.Message.Contains("Connection refused") && i >= 5) - { - throw new TimeoutException( - $"API at {baseUrl} failed to start after {i + 1} attempts: {ex.Message}", - ex - ); - } - } - - if (i < maxRetries - 1) - { - await Task.Delay(500); - } - } - - throw new TimeoutException( - $"API at {baseUrl} did not start after {maxRetries} attempts. Last error: {lastException?.Message}" - ); - } - - private static async Task WaitForGatekeeperApiAsync() - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var maxRetries = 30; // Reduced from 120 to 30 (15 seconds max instead of 60) - var lastException = (Exception?)null; - - for (var i = 0; i < maxRetries; i++) - { - try - { - var response = await client.PostAsync( - $"{GatekeeperUrl}/auth/login/begin", - new StringContent("{}", Encoding.UTF8, "application/json") - ); - if (response.IsSuccessStatusCode) - { - Console.WriteLine( - $"[E2E] Gatekeeper API started successfully after {i} attempts" - ); - return; - } - - Console.WriteLine( - $"[E2E] Gatekeeper API returned {response.StatusCode} on attempt {i + 1}" - ); - } - catch (Exception ex) - { - lastException = ex; - Console.WriteLine( - $"[E2E] Gatekeeper API connection failed on attempt {i + 1}: {ex.Message}" - ); - - // If it's a connection refused error early on, fail faster - if (ex.Message.Contains("Connection refused") && i >= 5) - { - throw new TimeoutException( - $"Gatekeeper API failed to start after {i + 1} attempts: {ex.Message}", - ex - ); - } - } - - if (i < maxRetries - 1) - { - await Task.Delay(500); - } - } - - throw new TimeoutException( - $"Gatekeeper API did not start after {maxRetries} attempts. Last error: {lastException?.Message}" - ); - } - - /// - /// Waits for a service to be reachable (any HTTP response). - /// Used in local mode where services may be running but have DB issues. - /// - private static async Task WaitForServiceReachableAsync(string baseUrl, string endpoint) - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var maxRetries = 30; // Reduced from 60 to 30 (15 seconds max instead of 30) - var lastException = (Exception?)null; - - for (var i = 0; i < maxRetries; i++) - { - try - { - _ = await client.GetAsync($"{baseUrl}{endpoint}"); - Console.WriteLine($"[E2E] Service reachable: {baseUrl} after {i} attempts"); - return; - } - catch (Exception ex) - { - lastException = ex; - Console.WriteLine( - $"[E2E] Service at {baseUrl} connection failed on attempt {i + 1}: {ex.Message}" - ); - - // If it's a connection refused error early on, fail faster - if (ex.Message.Contains("Connection refused") && i >= 5) - { - throw new TimeoutException( - $"Service at {baseUrl} failed to respond after {i + 1} attempts: {ex.Message}", - ex - ); - } - } - - if (i < maxRetries - 1) - { - await Task.Delay(500); - } - } - - throw new TimeoutException( - $"Service at {baseUrl} is not reachable after {maxRetries} attempts. Last error: {lastException?.Message}" - ); - } - - /// - /// Creates an authenticated HTTP client with test JWT token. - /// - public static HttpClient CreateAuthenticatedClient() - { - var client = new HttpClient(); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", GenerateTestToken()); - return client; - } - - /// - /// Creates a new browser page with authentication already set up via localStorage. - /// This is the proper E2E approach - no testMode backdoor in the frontend. - /// - /// Optional URL to navigate to after auth setup. Defaults to DashboardUrl. - /// User ID for the test token. - /// Display name for the test token. - /// Email for the test token. - public async Task CreateAuthenticatedPageAsync( - string? navigateTo = null, - string userId = "e2e-test-user", - string displayName = "E2E Test User", - string email = "e2etest@example.com" - ) - { - var page = await Browser!.NewPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER {msg.Type}] {msg.Text}"); - page.PageError += (_, err) => Console.WriteLine($"[PAGE ERROR] {err}"); - - var token = GenerateTestToken(userId, displayName, email); - var userJson = JsonSerializer.Serialize( - new - { - userId, - displayName, - email, - } - ); - - // Inject API URL config BEFORE any page script runs - await page.AddInitScriptAsync( - $@"window.dashboardConfig = window.dashboardConfig || {{}}; - window.dashboardConfig.ICD10_API_URL = '{Icd10Url}';" - ); - - // Navigate first to establish the origin for localStorage - await page.GotoAsync(DashboardUrl); - - // Set auth state in localStorage - var escapedUserJson = userJson.Replace("'", "\\'"); - await page.EvaluateAsync( - $@"() => {{ - localStorage.setItem('gatekeeper_token', '{token}'); - localStorage.setItem('gatekeeper_user', '{escapedUserJson}'); - }}" - ); - - // Navigate to target URL (or reload if staying on same page) - var targetUrl = navigateTo ?? DashboardUrl; - - // Always reload first to ensure static files are fully loaded and auth state is picked up - await page.ReloadAsync(); - - // If target URL has a hash fragment, navigate to it after reload - if (targetUrl != DashboardUrl && targetUrl.Contains('#')) - { - var hash = targetUrl.Substring(targetUrl.IndexOf('#')); - await page.EvaluateAsync($"() => window.location.hash = '{hash}'"); - // Give React time to process hash change - await Task.Delay(500); - } - - return page; - } - - /// - /// Generates a test JWT token with the specified user details. - /// Uses the same all-zeros signing key that the APIs use in dev mode. - /// - public static string GenerateTestToken( - string userId = "e2e-test-user", - string displayName = "E2E Test User", - string email = "e2etest@example.com" - ) - { - var signingKey = new byte[32]; - var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"HS256","typ":"JWT"}""")); - var expiration = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); - var payload = Base64UrlEncode( - Encoding.UTF8.GetBytes( - JsonSerializer.Serialize( - new - { - sub = userId, - name = displayName, - email, - jti = Guid.NewGuid().ToString(), - exp = expiration, - roles = new[] { "admin", "user" }, - } - ) - ) - ); - var signature = ComputeHmacSignature(header, payload, signingKey); - return $"{header}.{payload}.{signature}"; - } - - private static string Base64UrlEncode(byte[] input) => - Convert.ToBase64String(input).Replace("+", "-").Replace("/", "_").TrimEnd('='); - - private static string ComputeHmacSignature(string header, string payload, byte[] key) - { - var data = Encoding.UTF8.GetBytes($"{header}.{payload}"); - using var hmac = new HMACSHA256(key); - return Base64UrlEncode(hmac.ComputeHash(data)); - } - - private static IHost CreateDashboardHost() - { - // Microsoft.AspNetCore.Mvc.Testing sets ASPNETCORE_URLS globally to - // http://127.0.0.1:0 which overrides UseUrls(). Clear it so the - // Dashboard host binds to the expected port. - Environment.SetEnvironmentVariable("ASPNETCORE_URLS", null); - - var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); - var fileProvider = new PhysicalFileProvider(wwwrootPath); - return Host.CreateDefaultBuilder() - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseUrls("http://127.0.0.1:0"); - webBuilder.Configure(app => - { - // Both middleware must share the same FileProvider so - // UseDefaultFiles can find index.html and rewrite / → /index.html - app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = fileProvider }); - app.UseStaticFiles(new StaticFileOptions { FileProvider = fileProvider }); - }); - }) - .Build(); - } - - private static async Task SeedTestDataAsync() - { - using var client = CreateAuthenticatedClient(); - - await SeedAsync( - client, - $"{ClinicalUrl}/fhir/Patient/", - """{"Active": true, "GivenName": "E2ETest", "FamilyName": "TestPatient", "Gender": "other"}""" - ); - - await SeedAsync( - client, - $"{SchedulingUrl}/Practitioner", - """{"Identifier": "DR001", "Active": true, "NameGiven": "E2EPractitioner", "NameFamily": "DrTest", "Qualification": "MD", "Specialty": "General Practice", "TelecomEmail": "drtest@hospital.org", "TelecomPhone": "+1-555-0123"}""" - ); - - await SeedAsync( - client, - $"{SchedulingUrl}/Practitioner", - """{"Identifier": "DR002", "Active": true, "NameGiven": "Sarah", "NameFamily": "Johnson", "Qualification": "DO", "Specialty": "Cardiology", "TelecomEmail": "sjohnson@hospital.org", "TelecomPhone": "+1-555-0124"}""" - ); - - await SeedAsync( - client, - $"{SchedulingUrl}/Practitioner", - """{"Identifier": "DR003", "Active": true, "NameGiven": "Michael", "NameFamily": "Chen", "Qualification": "MD", "Specialty": "Neurology", "TelecomEmail": "mchen@hospital.org", "TelecomPhone": "+1-555-0125"}""" - ); - - await SeedAsync( - client, - $"{SchedulingUrl}/Appointment", - """{"ServiceCategory": "General", "ServiceType": "Checkup", "Start": "2025-12-20T10:00:00Z", "End": "2025-12-20T11:00:00Z", "PatientReference": "Patient/1", "PractitionerReference": "Practitioner/1", "Priority": "routine"}""" - ); - } - - private static async Task SeedAsync(HttpClient client, string url, string json) - { - try - { - var response = await client.PostAsync( - url, - new StringContent(json, Encoding.UTF8, "application/json") - ); - Console.WriteLine( - $"[E2E] Seed {url}: {(int)response.StatusCode} {response.ReasonPhrase}" - ); - } - catch (Exception ex) - { - Console.WriteLine($"[E2E] Seed {url} failed: {ex.Message}"); - } - } - - /// - /// Sets up the ICD-10 database by running migration and importing official CDC data. - /// Skips import if data already exists in the database. - /// - private static async Task SetupIcd10DatabaseAsync( - string connectionString, - string samplesDir, - string rootDir - ) - { - Console.WriteLine("[E2E] Setting up ICD-10 database..."); - - var icd10ProjectDir = Path.Combine(samplesDir, "ICD10", "ICD10.Api"); - var schemaPath = Path.Combine(icd10ProjectDir, "icd10-schema.yaml"); - var migrationCliDir = Path.Combine(rootDir, "Migration", "Migration.Cli"); - var scriptsDir = Path.Combine(samplesDir, "ICD10", "scripts", "CreateDb"); - - // Check if schema already exists and has data - if (await Icd10DatabaseHasDataAsync(connectionString)) - { - Console.WriteLine( - "[E2E] ICD-10 database already has data - skipping migration and import" - ); - return; - } - - // Step 1: Run migration to create schema - Console.WriteLine("[E2E] Running ICD-10 schema migration..."); - var configuration = ResolveBuildConfiguration( - Path.GetDirectoryName(typeof(E2EFixture).Assembly.Location)! - ); - var migrationDll = Path.Combine( - migrationCliDir, - "bin", - configuration, - "net10.0", - "Migration.Cli.dll" - ); - - int migrationResult; - if (File.Exists(migrationDll)) - { - Console.WriteLine($"[E2E] Using pre-built Migration.Cli: {migrationDll}"); - migrationResult = await RunProcessAsync( - "dotnet", - $"exec \"{migrationDll}\" --schema \"{schemaPath}\" --output \"{connectionString}\" --provider postgres", - rootDir, - timeoutMs: 600_000 - ); - } - else - { - Console.WriteLine( - $"[E2E] Migration.Cli DLL not found at {migrationDll}, falling back to dotnet run" - ); - migrationResult = await RunProcessAsync( - "dotnet", - $"run --project \"{migrationCliDir}\" -- --schema \"{schemaPath}\" --output \"{connectionString}\" --provider postgres", - rootDir, - timeoutMs: 600_000 - ); - } - - if (migrationResult != 0) - { - throw new Exception($"ICD-10 migration failed with exit code {migrationResult}"); - } - - Console.WriteLine("[E2E] ICD-10 schema created successfully"); - - // Step 2: Set up Python virtual environment - var venvDir = Path.Combine(samplesDir, "ICD10", ".venv"); - var pythonScript = Path.Combine(scriptsDir, "import_postgres.py"); - - if (!File.Exists(pythonScript)) - { - throw new FileNotFoundException($"ICD-10 import script not found: {pythonScript}"); - } - - Console.WriteLine("[E2E] Setting up Python environment..."); - if (!Directory.Exists(venvDir)) - { - var venvResult = await RunProcessAsync("python3", $"-m venv \"{venvDir}\"", scriptsDir); - if (venvResult != 0) - { - throw new Exception($"Failed to create Python virtual environment"); - } - } - - // Install requirements - var requirementsPath = Path.Combine(scriptsDir, "requirements.txt"); - var pipResult = await RunProcessAsync( - $"{venvDir}/bin/pip", - $"install -r \"{requirementsPath}\"", - scriptsDir - ); - if (pipResult != 0) - { - throw new Exception($"Failed to install Python dependencies"); - } - - // Step 3: Import official CDC ICD-10 data - Console.WriteLine("[E2E] Importing official CDC ICD-10 data..."); - var importResult = await RunProcessAsync( - $"{venvDir}/bin/python", - $"\"{pythonScript}\" --connection-string \"{connectionString}\"", - scriptsDir, - timeoutMs: 600_000 - ); - - if (importResult != 0) - { - throw new Exception($"ICD-10 data import failed with exit code {importResult}"); - } - - Console.WriteLine("[E2E] ICD-10 database setup complete"); - } - - /// - /// Checks if the ICD-10 database already has the schema and data loaded. - /// - private static async Task Icd10DatabaseHasDataAsync(string connectionString) - { - try - { - await using var conn = new NpgsqlConnection(connectionString); - await conn.OpenAsync(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM icd10_code"; - var count = Convert.ToInt64( - await cmd.ExecuteScalarAsync(), - System.Globalization.CultureInfo.InvariantCulture - ); - Console.WriteLine($"[E2E] ICD-10 database has {count} codes"); - return count > 0; - } - catch (Exception ex) - { - Console.WriteLine( - $"[E2E] ICD-10 database check failed ({ex.Message}) - will create from scratch" - ); - return false; - } - } - - /// - /// Runs a process and waits for it to complete, streaming output to console. - /// Times out after 5 minutes by default. - /// - private static async Task RunProcessAsync( - string fileName, - string arguments, - string workingDir, - int timeoutMs = 300_000 - ) - { - var startInfo = new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - WorkingDirectory = workingDir, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - var process = new Process { StartInfo = startInfo }; - var output = new StringBuilder(); - var errors = new StringBuilder(); - - process.OutputDataReceived += (_, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - { - Console.WriteLine($"[E2E] {e.Data}"); - output.AppendLine(e.Data); - } - }; - process.ErrorDataReceived += (_, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - { - Console.WriteLine($"[E2E] ERR: {e.Data}"); - errors.AppendLine(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using var cts = new CancellationTokenSource(timeoutMs); - try - { - await process.WaitForExitAsync(cts.Token); - } - catch (OperationCanceledException) - { - Console.WriteLine($"[E2E] Process timed out after {timeoutMs / 1000}s: {fileName}"); - process.Kill(entireProcessTree: true); - return -1; - } - - return process.ExitCode; - } -} - -/// -/// Single collection definition for ALL E2E tests. -/// All tests share ONE E2EFixture instance to prevent port conflicts. -/// Tests within this collection run sequentially by default. -/// -[CollectionDefinition("E2E Tests")] -public sealed class E2ECollection : ICollectionFixture; diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/GlobalUsings.cs b/Samples/Dashboard/Dashboard.Integration.Tests/GlobalUsings.cs deleted file mode 100644 index bb30e84a..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/GlobalUsings.cs +++ /dev/null @@ -1,3 +0,0 @@ -global using System.Net; -global using Microsoft.AspNetCore.Mvc.Testing; -global using Xunit; diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/Icd10E2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/Icd10E2ETests.cs deleted file mode 100644 index 7cc76352..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/Icd10E2ETests.cs +++ /dev/null @@ -1,588 +0,0 @@ -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for ICD-10 Clinical Coding in the Dashboard. -/// Tests keyword search, RAG/AI search, code lookup, and drill-down to code details. -/// Requires ICD-10 API running on port 5090. -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class Icd10E2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared E2E fixture. - /// - public Icd10E2ETests(E2EFixture fixture) => _fixture = fixture; - - // ========================================================================= - // KEYWORD SEARCH - // ========================================================================= - - /// - /// Keyword search for "diabetes" returns results table with Chapter and Category. - /// - [Fact] - public async Task KeywordSearch_Diabetes_ReturnsResultsWithChapterAndCategory() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Keyword Search"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Search by code']", "diabetes"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Chapter", content); - Assert.Contains("Category", content); - Assert.Contains("E11", content); - - var rows = await page.QuerySelectorAllAsync(".table tbody tr"); - Assert.True(rows.Count > 0, "Keyword search for 'diabetes' should return results"); - - await page.CloseAsync(); - } - - /// - /// Keyword search for "pneumonia" returns results with billable status column. - /// - [Fact] - public async Task KeywordSearch_Pneumonia_ShowsBillableStatus() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Keyword Search"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Search by code']", "pneumonia"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Status", content); - - var rows = await page.QuerySelectorAllAsync(".table tbody tr"); - Assert.True(rows.Count > 0, "Keyword search for 'pneumonia' should return results"); - - await page.CloseAsync(); - } - - /// - /// Keyword search shows result count text. - /// - [Fact] - public async Task KeywordSearch_ShowsResultCount() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Keyword Search"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Search by code']", "fracture"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("results found", content); - - await page.CloseAsync(); - } - - // ========================================================================= - // RAG / AI SEARCH - // ========================================================================= - - /// - /// AI search for "chest pain with shortness of breath" returns results - /// with confidence scores and AI-matched label. - /// - [Fact] - public async Task AISearch_ChestPain_ReturnsResultsWithConfidence() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=AI Search"); - await Task.Delay(500); - - await page.FillAsync( - "input[placeholder*='Describe symptoms']", - "chest pain with shortness of breath" - ); - await page.ClickAsync("button:has-text('Search')"); - - try - { - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 30000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("AI-matched results", content); - Assert.Contains("Confidence", content); - Assert.Contains("Chapter", content); - Assert.Contains("Category", content); - - var rows = await page.QuerySelectorAllAsync(".table tbody tr"); - Assert.True(rows.Count > 0, "AI search should return results"); - } - catch (TimeoutException) - { - Console.WriteLine( - "[TEST] AI search timed out - embedding service may not be running on port 8000" - ); - } - - await page.CloseAsync(); - } - - /// - /// AI search for "heart attack" returns cardiac-related codes. - /// - [Fact] - public async Task AISearch_HeartAttack_ReturnsCardiacCodes() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=AI Search"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Describe symptoms']", "heart attack"); - await page.ClickAsync("button:has-text('Search')"); - - try - { - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 30000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("AI-matched results", content); - - var rows = await page.QuerySelectorAllAsync(".table tbody tr"); - Assert.True(rows.Count > 0, "AI search for 'heart attack' should return results"); - } - catch (TimeoutException) - { - Console.WriteLine( - "[TEST] AI search timed out - embedding service may not be running on port 8000" - ); - } - - await page.CloseAsync(); - } - - /// - /// AI search shows the "Include ACHI procedure codes" checkbox. - /// - [Fact] - public async Task AISearch_ShowsAchiCheckbox() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=AI Search"); - await Task.Delay(500); - - var content = await page.ContentAsync(); - Assert.Contains("Include ACHI procedure codes", content); - Assert.Contains("medical AI embeddings", content); - - await page.CloseAsync(); - } - - // ========================================================================= - // CODE LOOKUP - // ========================================================================= - - /// - /// Code lookup for "E11.9" shows detailed code info with Chapter, Block, Category. - /// - [Fact] - public async Task CodeLookup_E119_ShowsFullCodeDetail() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Enter exact ICD-10 code']", "E11.9"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - "text=Back to results", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - - Assert.Contains("E11.9", content); - Assert.Contains("diabetes", content.ToLowerInvariant()); - Assert.Contains("Chapter", content); - Assert.Contains("Block", content); - Assert.Contains("Category", content); - - await page.CloseAsync(); - } - - /// - /// Code lookup for "I10" shows hypertension detail with chapter info. - /// - [Fact] - public async Task CodeLookup_I10_ShowsHypertensionDetail() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Enter exact ICD-10 code']", "I10"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - "text=Back to results", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - - Assert.Contains("I10", content); - Assert.Contains("hypertension", content.ToLowerInvariant()); - Assert.Contains("Chapter", content); - Assert.Contains("circulatory", content.ToLowerInvariant()); - - await page.CloseAsync(); - } - - /// - /// Code lookup for "R07.9" shows chest pain detail with billable status. - /// - [Fact] - public async Task CodeLookup_R079_ShowsChestPainWithBillable() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Enter exact ICD-10 code']", "R07.9"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - "text=Back to results", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - - Assert.Contains("R07.9", content); - Assert.Contains("chest pain", content.ToLowerInvariant()); - Assert.Contains("Billable", content); - Assert.Contains("Block", content); - Assert.Contains("Category", content); - - await page.CloseAsync(); - } - - /// - /// Code lookup with prefix "E11" shows multiple matching codes as a list. - /// - [Fact] - public async Task CodeLookup_E11Prefix_ShowsMultipleResults() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Enter exact ICD-10 code']", "E11"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var rows = await page.QuerySelectorAllAsync(".table tbody tr"); - Assert.True(rows.Count > 1, "Prefix search for 'E11' should return multiple codes"); - - var content = await page.ContentAsync(); - Assert.Contains("E11", content); - - await page.CloseAsync(); - } - - // ========================================================================= - // DRILL-DOWN: KEYWORD SEARCH -> CODE DETAIL - // ========================================================================= - - /// - /// Keyword search then clicking a result row drills down to code detail view - /// showing Chapter, Block, Category, and description. - /// - [Fact] - public async Task DrillDown_KeywordSearch_ClickResult_ShowsCodeDetail() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Keyword Search"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Search by code']", "hypertension"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - ".table tbody tr", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - // Click the first result row to drill down - await page.ClickAsync(".search-result-row >> nth=0"); - - // Wait for detail view to load (shows "Back to results" button) - await page.WaitForSelectorAsync( - "text=Back to results", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - - // Detail view must show hierarchy - Assert.Contains("Chapter", content); - Assert.Contains("Block", content); - Assert.Contains("Category", content); - - // Must show billable status - Assert.True( - content.Contains("Billable") || content.Contains("Non-billable"), - "Detail view should show billable status" - ); - - // Must show the code badge - Assert.Contains("Copy Code", content); - - await page.CloseAsync(); - } - - /// - /// Drill down to code detail then navigate back to results list. - /// - [Fact] - public async Task DrillDown_BackToResults_RestoresResultsList() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Keyword Search"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Search by code']", "diabetes"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - ".table tbody tr", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - // Click first result to drill down - await page.ClickAsync(".search-result-row >> nth=0"); - - await page.WaitForSelectorAsync( - "text=Back to results", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - // Click back button - await page.ClickAsync("text=Back to results"); - - // Results table should reappear - await page.WaitForSelectorAsync( - ".table tbody tr", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var rows = await page.QuerySelectorAllAsync(".table tbody tr"); - Assert.True(rows.Count > 0, "Results list should be restored after clicking back"); - - await page.CloseAsync(); - } - - /// - /// Drill down from keyword search shows Full Description section when available. - /// - [Fact] - public async Task DrillDown_ShowsFullDescription() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - // G43.909 has a long description - await page.FillAsync("input[placeholder*='Enter exact ICD-10 code']", "G43.909"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - "text=Back to results", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - - Assert.Contains("G43.909", content); - Assert.Contains("migraine", content.ToLowerInvariant()); - Assert.Contains("Full Description", content); - - await page.CloseAsync(); - } - - // ========================================================================= - // DRILL-DOWN: AI SEARCH -> CODE DETAIL - // ========================================================================= - - /// - /// AI search then clicking a result drills down to the code detail view. - /// - [Fact] - public async Task DrillDown_AISearch_ClickResult_ShowsCodeDetail() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=AI Search"); - await Task.Delay(500); - - await page.FillAsync( - "input[placeholder*='Describe symptoms']", - "type 2 diabetes with kidney complications" - ); - await page.ClickAsync("button:has-text('Search')"); - - try - { - await page.WaitForSelectorAsync( - ".table tbody tr", - new PageWaitForSelectorOptions { Timeout = 30000 } - ); - - // Click first AI search result to drill down - await page.ClickAsync(".search-result-row >> nth=0"); - - // Wait for detail view - await page.WaitForSelectorAsync( - "text=Back to results", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - - // Detail view must show full hierarchy - Assert.Contains("Chapter", content); - Assert.Contains("Block", content); - Assert.Contains("Category", content); - Assert.Contains("Copy Code", content); - } - catch (TimeoutException) - { - Console.WriteLine( - "[TEST] AI search timed out - embedding service may not be running on port 8000" - ); - } - - await page.CloseAsync(); - } - - // ========================================================================= - // EDGE CASES - // ========================================================================= - - /// - /// Code lookup for nonexistent code shows "No codes found" message. - /// - [Fact] - public async Task CodeLookup_NonexistentCode_ShowsNoCodesFound() - { - var page = await NavigateToClinicalCodingAsync(); - - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Enter exact ICD-10 code']", "ZZZ99.99"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - "text=No codes found", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("No codes found", content); - - await page.CloseAsync(); - } - - /// - /// Switching between search tabs clears previous results. - /// - [Fact] - public async Task SwitchingTabs_ClearsPreviousResults() - { - var page = await NavigateToClinicalCodingAsync(); - - // Do a keyword search first - await page.ClickAsync("text=Keyword Search"); - await Task.Delay(500); - - await page.FillAsync("input[placeholder*='Search by code']", "fracture"); - await page.ClickAsync("button:has-text('Search')"); - - await page.WaitForSelectorAsync( - ".table", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - // Switch to Code Lookup tab - await page.ClickAsync("text=Code Lookup"); - await Task.Delay(500); - - // Results table should be gone - empty state should show - var content = await page.ContentAsync(); - Assert.Contains("Direct Code Lookup", content); - - await page.CloseAsync(); - } - - // ========================================================================= - // HELPER - // ========================================================================= - - private async Task NavigateToClinicalCodingAsync() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#clinical-coding" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - - await page.WaitForSelectorAsync( - ".clinical-coding-page", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - return page; - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/NavigationE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/NavigationE2ETests.cs deleted file mode 100644 index 0b3c7fe0..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/NavigationE2ETests.cs +++ /dev/null @@ -1,272 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for browser navigation (back/forward, deep linking). -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class NavigationE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public NavigationE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Browser back button navigates to previous view. - /// - [Fact] - public async Task BrowserBackButton_NavigatesToPreviousView() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - Assert.Contains("#dashboard", page.Url); - - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - await page.ClickAsync("text=Appointments"); - await page.WaitForSelectorAsync( - "[data-testid='add-appointment-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#appointments", page.Url); - - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - ".metric-card", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#dashboard", page.Url); - - await page.CloseAsync(); - } - - /// - /// Deep linking works - navigating directly to a hash URL loads correct view. - /// - [Fact] - public async Task DeepLinking_LoadsCorrectView() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#patients" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Patients", content); - - await page.GotoAsync($"{E2EFixture.DashboardUrl}#appointments"); - await page.WaitForSelectorAsync( - "[data-testid='add-appointment-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - content = await page.ContentAsync(); - Assert.Contains("Appointments", content); - - await page.CloseAsync(); - } - - /// - /// Cancel button on edit page uses history.back(). - /// - [Fact] - public async Task EditPatientCancelButton_UsesHistoryBack() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueName = $"CancelTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "CancelTestPatient", "Gender": "male"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var patientIdMatch = Regex.Match(createdJson, "\"Id\"\\s*:\\s*\"([^\"]+)\""); - var patientId = patientIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - await page.FillAsync("input[placeholder*='Search']", uniqueName); - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.ClickAsync($"[data-testid='edit-patient-{patientId}']"); - await page.WaitForSelectorAsync( - "[data-testid='edit-patient-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - await page.ClickAsync("button:has-text('Cancel')"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - Assert.Contains("#patients", page.Url); - Assert.DoesNotContain("/edit/", page.Url); - - await page.CloseAsync(); - } - - /// - /// Browser back button from Edit Patient page returns to patients list. - /// - [Fact] - public async Task BrowserBackButton_FromEditPage_ReturnsToPatientsPage() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueName = $"BackBtnTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "BackButtonTest", "Gender": "female"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var patientIdMatch = Regex.Match(createdJson, "\"Id\"\\s*:\\s*\"([^\"]+)\""); - var patientId = patientIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - await page.FillAsync("input[placeholder*='Search']", uniqueName); - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.ClickAsync($"[data-testid='edit-patient-{patientId}']"); - await page.WaitForSelectorAsync( - "[data-testid='edit-patient-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - await page.GoBackAsync(); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - var content = await page.ContentAsync(); - Assert.Contains("Patients", content); - Assert.Contains("Add Patient", content); - - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - ".metric-card", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#dashboard", page.Url); - - await page.CloseAsync(); - } - - /// - /// Forward button works after going back. - /// - [Fact] - public async Task BrowserForwardButton_WorksAfterGoingBack() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - ".practitioner-card, .empty-state", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#practitioners", page.Url); - - await page.GoBackAsync(); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#patients", page.Url); - - await page.GoForwardAsync(); - await page.WaitForSelectorAsync( - ".practitioner-card, .empty-state", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#practitioners", page.Url); - - var content = await page.ContentAsync(); - Assert.Contains("Practitioners", content); - - await page.CloseAsync(); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/PatientE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/PatientE2ETests.cs deleted file mode 100644 index 25ad6d8a..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/PatientE2ETests.cs +++ /dev/null @@ -1,239 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for patient-related functionality. -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class PatientE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public PatientE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Dashboard loads and displays patient data from Clinical API. - /// - [Fact] - public async Task Dashboard_DisplaysPatientData_FromClinicalApi() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Type}: {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "text=TestPatient", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("TestPatient", content); - Assert.Contains("E2ETest", content); - - await page.CloseAsync(); - } - - /// - /// Add Patient button opens modal and creates patient via API. - /// - [Fact] - public async Task AddPatientButton_OpensModal_AndCreatesPatient() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.ClickAsync("[data-testid='add-patient-btn']"); - await page.WaitForSelectorAsync( - ".modal", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var uniqueName = $"E2ECreated{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='patient-given-name']", uniqueName); - await page.FillAsync("[data-testid='patient-family-name']", "TestCreated"); - await page.SelectOptionAsync("[data-testid='patient-gender']", "male"); - await page.ClickAsync("[data-testid='submit-patient']"); - - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - using var client = E2EFixture.CreateAuthenticatedClient(); - var response = await client.GetStringAsync($"{E2EFixture.ClinicalUrl}/fhir/Patient/"); - Assert.Contains(uniqueName, response); - - await page.CloseAsync(); - } - - /// - /// Patient Search button navigates to search and finds patients. - /// - [Fact] - public async Task PatientSearchButton_NavigatesToSearch_AndFindsPatients() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Patient Search"); - await page.WaitForSelectorAsync( - "input[placeholder*='Search']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.FillAsync("input[placeholder*='Search']", "E2ETest"); - await page.WaitForSelectorAsync( - "text=TestPatient", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("TestPatient", content); - - await page.CloseAsync(); - } - - /// - /// Patient creation API works end-to-end. - /// - [Fact] - public async Task PatientCreationApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueName = $"ApiTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "ApiCreated", "Gender": "female"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - var listResponse = await client.GetStringAsync($"{E2EFixture.ClinicalUrl}/fhir/Patient/"); - Assert.Contains(uniqueName, listResponse); - } - - /// - /// Edit Patient button opens edit page and updates patient via API. - /// - [Fact] - public async Task EditPatientButton_OpensEditPage_AndUpdatesPatient() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueName = $"EditTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "ToBeEdited", "Gender": "female"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdPatientJson = await createResponse.Content.ReadAsStringAsync(); - - var patientIdMatch = Regex.Match(createdPatientJson, "\"Id\"\\s*:\\s*\"([^\"]+)\""); - Assert.True(patientIdMatch.Success); - var patientId = patientIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Patients"); - await page.WaitForSelectorAsync( - "[data-testid='add-patient-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.FillAsync("input[placeholder*='Search']", uniqueName); - await page.WaitForSelectorAsync( - $"text={uniqueName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.ClickAsync($"[data-testid='edit-patient-{patientId}']"); - await page.WaitForSelectorAsync( - "[data-testid='edit-patient-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var newFamilyName = $"Edited{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='edit-family-name']", newFamilyName); - await page.ClickAsync("[data-testid='save-patient']"); - await page.WaitForSelectorAsync( - "[data-testid='edit-success']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var updatedPatientJson = await client.GetStringAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/{patientId}" - ); - Assert.Contains(newFamilyName, updatedPatientJson); - - await page.CloseAsync(); - } - - /// - /// Patient update API works end-to-end. - /// - [Fact] - public async Task PatientUpdateApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueName = $"UpdateApiTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "Original", "Gender": "male"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdPatientJson = await createResponse.Content.ReadAsStringAsync(); - - var patientIdMatch = Regex.Match(createdPatientJson, "\"Id\"\\s*:\\s*\"([^\"]+)\""); - var patientId = patientIdMatch.Groups[1].Value; - - var updatedFamilyName = $"Updated{DateTime.UtcNow.Ticks % 100000}"; - var updateResponse = await client.PutAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/{patientId}", - new StringContent( - $$$"""{"Active": true, "GivenName": "{{{uniqueName}}}", "FamilyName": "{{{updatedFamilyName}}}", "Gender": "male", "Email": "updated@test.com"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - updateResponse.EnsureSuccessStatusCode(); - - var getResponse = await client.GetStringAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/{patientId}" - ); - Assert.Contains(updatedFamilyName, getResponse); - Assert.Contains("updated@test.com", getResponse); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/PractitionerE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/PractitionerE2ETests.cs deleted file mode 100644 index e67065c0..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/PractitionerE2ETests.cs +++ /dev/null @@ -1,309 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for practitioner-related functionality. -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class PractitionerE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public PractitionerE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Dashboard loads and displays practitioner data from Scheduling API. - /// - [Fact] - public async Task Dashboard_DisplaysPractitionerData_FromSchedulingApi() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - "text=DrTest", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.WaitForSelectorAsync( - ".practitioner-card", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("DrTest", content); - Assert.Contains("E2EPractitioner", content); - Assert.Contains("Johnson", content); - Assert.Contains("MD", content); - Assert.Contains("General Practice", content); - - await page.CloseAsync(); - } - - /// - /// Practitioners page data comes from REAL Scheduling API. - /// - [Fact] - public async Task PractitionersPage_LoadsFromSchedulingApi_WithFhirCompliantData() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - var apiResponse = await client.GetStringAsync($"{E2EFixture.SchedulingUrl}/Practitioner"); - - Assert.Contains("DR001", apiResponse); - Assert.Contains("E2EPractitioner", apiResponse); - Assert.Contains("MD", apiResponse); - - var page = await _fixture.CreateAuthenticatedPageAsync(); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - ".practitioner-card", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var cards = await page.QuerySelectorAllAsync(".practitioner-card"); - Assert.True(cards.Count >= 3); - - await page.CloseAsync(); - } - - /// - /// Practitioner creation API works end-to-end. - /// - [Fact] - public async Task PractitionerCreationApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueId = $"DR{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueId}}}", "Active": true, "NameGiven": "ApiDoctor", "NameFamily": "TestDoc", "Qualification": "MD", "Specialty": "Testing", "TelecomEmail": "test@hospital.org", "TelecomPhone": "+1-555-9999"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - var listResponse = await client.GetStringAsync($"{E2EFixture.SchedulingUrl}/Practitioner"); - Assert.Contains(uniqueId, listResponse); - } - - /// - /// Add Practitioner button opens modal and creates practitioner via API. - /// - [Fact] - public async Task AddPractitionerButton_OpensModal_AndCreatesPractitioner() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - "[data-testid='add-practitioner-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.ClickAsync("[data-testid='add-practitioner-btn']"); - await page.WaitForSelectorAsync( - ".modal", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var uniqueIdentifier = $"DR{DateTime.UtcNow.Ticks % 100000}"; - var uniqueGivenName = $"E2EDoc{DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='practitioner-identifier']", uniqueIdentifier); - await page.FillAsync("[data-testid='practitioner-given-name']", uniqueGivenName); - await page.FillAsync("[data-testid='practitioner-family-name']", "TestCreated"); - await page.FillAsync("[data-testid='practitioner-specialty']", "E2E Testing"); - await page.ClickAsync("[data-testid='submit-practitioner']"); - - await page.WaitForSelectorAsync( - $"text={uniqueGivenName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - using var client = E2EFixture.CreateAuthenticatedClient(); - var response = await client.GetStringAsync($"{E2EFixture.SchedulingUrl}/Practitioner"); - Assert.Contains(uniqueIdentifier, response); - - await page.CloseAsync(); - } - - /// - /// Edit Practitioner button navigates to edit page and updates practitioner. - /// - [Fact] - public async Task EditPractitionerButton_OpensEditPage_AndUpdatesPractitioner() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueIdentifier = $"DREdit{DateTime.UtcNow.Ticks % 100000}"; - var uniqueGivenName = $"EditTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "NameFamily": "OriginalFamily", "NameGiven": "{{{uniqueGivenName}}}", "Qualification": "MD", "Specialty": "Original Specialty"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var practitionerIdMatch = Regex.Match(createdJson, "\"Id\"\\s*:\\s*\"([^\"]+)\""); - var practitionerId = practitionerIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - $"text={uniqueGivenName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var editButton = page.Locator($"[data-testid='edit-practitioner-{practitionerId}']"); - await editButton.HoverAsync(); - await editButton.ClickAsync(); - - await page.WaitForSelectorAsync( - "[data-testid='edit-practitioner-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var newSpecialty = $"Updated Specialty {DateTime.UtcNow.Ticks % 100000}"; - await page.FillAsync("[data-testid='edit-practitioner-specialty']", newSpecialty); - await page.ClickAsync("[data-testid='save-practitioner']"); - await page.WaitForSelectorAsync( - "[data-testid='edit-practitioner-success']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var updatedPractitionerJson = await client.GetStringAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner/{practitionerId}" - ); - Assert.Contains(newSpecialty, updatedPractitionerJson); - - await page.CloseAsync(); - } - - /// - /// Practitioner update API works end-to-end. - /// - [Fact] - public async Task PractitionerUpdateApi_WorksEndToEnd() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueIdentifier = $"DRApi{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "NameFamily": "ApiOriginal", "NameGiven": "TestDoc", "Qualification": "MD", "Specialty": "Original"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdPractitionerJson = await createResponse.Content.ReadAsStringAsync(); - - var practitionerIdMatch = Regex.Match( - createdPractitionerJson, - "\"Id\"\\s*:\\s*\"([^\"]+)\"" - ); - var practitionerId = practitionerIdMatch.Groups[1].Value; - - var updatedSpecialty = $"ApiUpdated{DateTime.UtcNow.Ticks % 100000}"; - var updateResponse = await client.PutAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner/{practitionerId}", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "Active": true, "NameFamily": "ApiUpdated", "NameGiven": "TestDoc", "Qualification": "DO", "Specialty": "{{{updatedSpecialty}}}", "TelecomEmail": "updated@hospital.com", "TelecomPhone": "555-1234"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - updateResponse.EnsureSuccessStatusCode(); - - var getResponse = await client.GetStringAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner/{practitionerId}" - ); - Assert.Contains(updatedSpecialty, getResponse); - Assert.Contains("ApiUpdated", getResponse); - } - - /// - /// Browser back button works from Edit Practitioner page. - /// - [Fact] - public async Task BrowserBackButton_FromEditPractitionerPage_ReturnsToPractitionersPage() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueIdentifier = $"DRBack{DateTime.UtcNow.Ticks % 100000}"; - var uniqueGivenName = $"BackTest{DateTime.UtcNow.Ticks % 100000}"; - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - $$$"""{"Identifier": "{{{uniqueIdentifier}}}", "NameFamily": "BackButtonTest", "NameGiven": "{{{uniqueGivenName}}}", "Qualification": "MD", "Specialty": "Testing"}""", - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var createdJson = await createResponse.Content.ReadAsStringAsync(); - var practitionerIdMatch = Regex.Match(createdJson, "\"Id\"\\s*:\\s*\"([^\"]+)\""); - var practitionerId = practitionerIdMatch.Groups[1].Value; - - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Practitioners"); - await page.WaitForSelectorAsync( - $"text={uniqueGivenName}", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - - var editButton = page.Locator($"[data-testid='edit-practitioner-{practitionerId}']"); - await editButton.HoverAsync(); - await editButton.ClickAsync(); - await page.WaitForSelectorAsync( - "[data-testid='edit-practitioner-page']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - await page.GoBackAsync(); - - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='add-practitioner-btn']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#practitioners", page.Url); - - await page.CloseAsync(); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/SyncE2ETests.cs b/Samples/Dashboard/Dashboard.Integration.Tests/SyncE2ETests.cs deleted file mode 100644 index a02979f7..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/SyncE2ETests.cs +++ /dev/null @@ -1,741 +0,0 @@ -using Microsoft.Playwright; - -namespace Dashboard.Integration.Tests; - -/// -/// E2E tests for bidirectional sync functionality. -/// -[Collection("E2E Tests")] -[Trait("Category", "E2E")] -public sealed class SyncE2ETests -{ - private readonly E2EFixture _fixture; - - /// - /// Constructor receives shared fixture. - /// - public SyncE2ETests(E2EFixture fixture) => _fixture = fixture; - - /// - /// Sync Dashboard menu item navigates to sync page and displays sync status. - /// - [Fact] - public async Task SyncDashboard_NavigatesToSyncPage_AndDisplaysStatus() - { - var page = await _fixture.CreateAuthenticatedPageAsync(); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - ".sidebar", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.ClickAsync("text=Sync Dashboard"); - - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 10000 } - ); - Assert.Contains("#sync", page.Url); - - await page.WaitForSelectorAsync( - "[data-testid='service-status-clinical']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-scheduling']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='sync-records-table']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='action-filter']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-filter']", - new PageWaitForSelectorOptions { Timeout = 5000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Sync Dashboard", content); - Assert.Contains("Clinical.Api", content); - Assert.Contains("Scheduling.Api", content); - Assert.Contains("Sync Records", content); - - await page.CloseAsync(); - } - - /// - /// Sync Dashboard service filter shows ONLY records from selected service. - /// This test PROVES the filter works by verifying actual row content. - /// - [Fact] - public async Task SyncDashboard_ServiceFilter_ShowsOnlySelectedService() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - - // Create data in both services to ensure we have records from both - var uniqueId = $"FilterTest{DateTime.UtcNow.Ticks % 1000000}"; - - // Create patient in Clinical.Api - var patientRequest = new - { - Active = true, - GivenName = $"FilterPatient{uniqueId}", - FamilyName = "ClinicalTest", - Gender = "other", - }; - await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(patientRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - - // Create practitioner in Scheduling.Api - var practitionerRequest = new - { - Identifier = $"FILTER-DR-{uniqueId}", - Active = true, - NameGiven = $"FilterDoc{uniqueId}", - NameFamily = "SchedulingTest", - Qualification = "MD", - Specialty = "Testing", - }; - await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(practitionerRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - - await page.GotoAsync($"{E2EFixture.DashboardUrl}#sync"); - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-clinical']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - await Task.Delay(1000); // Allow data to load - - // Get initial count with all services - var allRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - var initialCount = allRows.Count; - Console.WriteLine($"[TEST] Initial row count (all services): {initialCount}"); - - // Filter to Clinical only - await page.SelectOptionAsync("[data-testid='service-filter']", "clinical"); - await Task.Delay(500); - - var clinicalRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Console.WriteLine($"[TEST] Clinical filter row count: {clinicalRows.Count}"); - - // PROVE: Every visible row must be from Clinical service - foreach (var row in clinicalRows) - { - var serviceAttr = await row.GetAttributeAsync("data-service"); - Assert.Equal("clinical", serviceAttr); - } - - // Filter to Scheduling only - await page.SelectOptionAsync("[data-testid='service-filter']", "scheduling"); - await Task.Delay(500); - - var schedulingRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Console.WriteLine($"[TEST] Scheduling filter row count: {schedulingRows.Count}"); - - // PROVE: Every visible row must be from Scheduling service - foreach (var row in schedulingRows) - { - var serviceAttr = await row.GetAttributeAsync("data-service"); - Assert.Equal("scheduling", serviceAttr); - } - - // PROVE: Combined counts should equal total (or less if overlap) - Assert.True( - clinicalRows.Count + schedulingRows.Count <= initialCount + 1, - $"Clinical ({clinicalRows.Count}) + Scheduling ({schedulingRows.Count}) should not exceed initial ({initialCount})" - ); - - await page.CloseAsync(); - } - - /// - /// Sync Dashboard action filter shows ONLY records with selected operation. - /// This test PROVES the filter works by verifying actual row content. - /// - [Fact] - public async Task SyncDashboard_ActionFilter_ShowsOnlySelectedOperation() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - - // Create a patient (Insert operation = 0) - var uniqueId = $"ActionTest{DateTime.UtcNow.Ticks % 1000000}"; - var patientRequest = new - { - Active = true, - GivenName = $"ActionPatient{uniqueId}", - FamilyName = "InsertTest", - Gender = "female", - }; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(patientRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - await page.GotoAsync($"{E2EFixture.DashboardUrl}#sync"); - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-clinical']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - await Task.Delay(1000); // Allow data to load - - // Wait for sync records to appear in the table - await page.WaitForFunctionAsync( - @"() => { - const rows = document.querySelectorAll('[data-testid=""sync-records-table""] tbody tr'); - return rows.length > 0; - }", - new PageWaitForFunctionOptions { Timeout = 20000 } - ); - - // Log initial state before filtering - var initialRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Console.WriteLine($"[TEST] Initial row count before filter: {initialRows.Count}"); - foreach (var row in initialRows.Take(5)) - { - var op = await row.GetAttributeAsync("data-operation"); - Console.WriteLine($"[TEST] Row data-operation: {op}"); - } - - // Filter to Insert operations only (operation = 0) - await page.SelectOptionAsync("[data-testid='action-filter']", "0"); - await Task.Delay(500); // Allow React to start re-rendering - - // Wait for React to apply the filter - wait until ALL visible rows have operation=0 - // OR there are no rows (which is valid if no Insert operations exist) - await page.WaitForFunctionAsync( - @"() => { - const rows = document.querySelectorAll('[data-testid=""sync-records-table""] tbody tr'); - console.log('[Filter] Row count after filter: ' + rows.length); - if (rows.length === 0) return true; - const allMatch = Array.from(rows).every(row => { - const op = row.getAttribute('data-operation'); - console.log('[Filter] Row operation: ' + op); - return op === '0'; - }); - return allMatch; - }", - new PageWaitForFunctionOptions { Timeout = 20000 } - ); - - var insertRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Console.WriteLine($"[TEST] Insert filter row count: {insertRows.Count}"); - - // PROVE: Every visible row must have Insert operation (0) - foreach (var row in insertRows) - { - var operationAttr = await row.GetAttributeAsync("data-operation"); - Assert.Equal("0", operationAttr); - } - - // Verify filter value is selected - var selectedValue = await page.EvalOnSelectorAsync( - "[data-testid='action-filter']", - "el => el.value" - ); - Assert.Equal("0", selectedValue); - - // Reset filter - await page.SelectOptionAsync("[data-testid='action-filter']", "all"); - - // Wait for React to apply the reset filter - await page.WaitForFunctionAsync( - $"() => document.querySelector('[data-testid=\"action-filter\"]').value === 'all'", - new PageWaitForFunctionOptions { Timeout = 5000 } - ); - await Task.Delay(300); // Small buffer for React re-render - - var allRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Assert.True(allRows.Count >= insertRows.Count, "All rows should be >= Insert-only rows"); - - await page.CloseAsync(); - } - - /// - /// Sync Dashboard combined filters work correctly. - /// PROVES both service AND action filters can be used together. - /// - [Fact] - public async Task SyncDashboard_CombinedFilters_WorkTogether() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - - // Create data in Clinical.Api - var uniqueId = $"ComboTest{DateTime.UtcNow.Ticks % 1000000}"; - var patientRequest = new - { - Active = true, - GivenName = $"ComboPatient{uniqueId}", - FamilyName = "ComboTest", - Gender = "male", - }; - await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(patientRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - - await page.GotoAsync($"{E2EFixture.DashboardUrl}#sync"); - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-clinical']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - await Task.Delay(1000); - - // Apply both filters: Clinical + Insert - await page.SelectOptionAsync("[data-testid='service-filter']", "clinical"); - await page.SelectOptionAsync("[data-testid='action-filter']", "0"); - - // Wait for React to apply both filters - await page.WaitForFunctionAsync( - @"() => { - const rows = document.querySelectorAll('[data-testid=""sync-records-table""] tbody tr'); - if (rows.length === 0) return true; // No rows = filters applied (or empty) - return Array.from(rows).every(row => - row.getAttribute('data-service') === 'clinical' && - row.getAttribute('data-operation') === '0' - ); - }", - new PageWaitForFunctionOptions { Timeout = 5000 } - ); - - var filteredRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Console.WriteLine( - $"[TEST] Combined filter (Clinical + Insert) row count: {filteredRows.Count}" - ); - - // PROVE: Every row must satisfy BOTH filters - foreach (var row in filteredRows) - { - var serviceAttr = await row.GetAttributeAsync("data-service"); - var operationAttr = await row.GetAttributeAsync("data-operation"); - Assert.Equal("clinical", serviceAttr); - Assert.Equal("0", operationAttr); - } - - // Try Scheduling + Insert - await page.SelectOptionAsync("[data-testid='service-filter']", "scheduling"); - - // Wait for React to apply the service filter change - await page.WaitForFunctionAsync( - @"() => { - const rows = document.querySelectorAll('[data-testid=""sync-records-table""] tbody tr'); - if (rows.length === 0) return true; - return Array.from(rows).every(row => - row.getAttribute('data-service') === 'scheduling' && - row.getAttribute('data-operation') === '0' - ); - }", - new PageWaitForFunctionOptions { Timeout = 5000 } - ); - - var schedulingInsertRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - foreach (var row in schedulingInsertRows) - { - var serviceAttr = await row.GetAttributeAsync("data-service"); - var operationAttr = await row.GetAttributeAsync("data-operation"); - Assert.Equal("scheduling", serviceAttr); - Assert.Equal("0", operationAttr); - } - - await page.CloseAsync(); - } - - /// - /// Sync Dashboard search filter works correctly. - /// PROVES search by entity ID filters correctly. - /// - [Fact] - public async Task SyncDashboard_SearchFilter_FiltersCorrectly() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - // Create a patient BEFORE loading the sync page so data is fresh - var uniqueId = $"SearchTest{DateTime.UtcNow.Ticks % 1000000}"; - var patientRequest = new - { - Active = true, - GivenName = $"SearchPatient{uniqueId}", - FamilyName = "SearchTest", - Gender = "male", - }; - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(patientRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var patientJson = await createResponse.Content.ReadAsStringAsync(); - var patientDoc = System.Text.Json.JsonDocument.Parse(patientJson); - var patientId = patientDoc.RootElement.GetProperty("Id").GetString(); - - // Navigate to sync page AFTER patient exists in sync log - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - - await page.GotoAsync($"{E2EFixture.DashboardUrl}#sync"); - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-clinical']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - await Task.Delay(1000); - - // Get initial count - var initialRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - var initialCount = initialRows.Count; - - // Search for the patient ID - await page.FillAsync("[data-testid='sync-search']", patientId!); - await Task.Delay(500); - - var searchRows = await page.QuerySelectorAllAsync( - "[data-testid='sync-records-table'] tbody tr" - ); - Console.WriteLine($"[TEST] Search for '{patientId}' found {searchRows.Count} rows"); - - // PROVE: Search should find at least one matching row - Assert.True( - searchRows.Count >= 1, - $"Search for patient ID '{patientId}' should find at least one row" - ); - Assert.True( - searchRows.Count < initialCount || initialCount <= 1, - "Search should filter down results (unless only 1 row exists)" - ); - - await page.CloseAsync(); - } - - /// - /// Deep linking to sync page works. - /// - [Fact] - public async Task SyncDashboard_DeepLinkingWorks() - { - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Sync Dashboard", content); - Assert.Contains("Monitor and manage sync operations", content); - - await page.CloseAsync(); - } - - /// - /// Data added to Clinical.Api is synced to Scheduling.Api. - /// - [Fact] - public async Task Sync_ClinicalPatient_AppearsInScheduling_AfterSync() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueId = $"SyncTest{DateTime.UtcNow.Ticks % 1000000}"; - var patientRequest = new - { - Active = true, - GivenName = $"SyncPatient{uniqueId}", - FamilyName = "ToScheduling", - Gender = "other", - Phone = "+1-555-SYNC", - Email = $"sync{uniqueId}@test.com", - }; - - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(patientRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var patientJson = await createResponse.Content.ReadAsStringAsync(); - var patientDoc = System.Text.Json.JsonDocument.Parse(patientJson); - var patientId = patientDoc.RootElement.GetProperty("Id").GetString(); - - var clinicalGetResponse = await client.GetAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/{patientId}" - ); - Assert.Equal(HttpStatusCode.OK, clinicalGetResponse.StatusCode); - - var syncedToScheduling = false; - for (var i = 0; i < 18; i++) - { - await Task.Delay(5000); - - var syncPatientsResponse = await client.GetAsync( - $"{E2EFixture.SchedulingUrl}/sync/patients" - ); - if (syncPatientsResponse.IsSuccessStatusCode) - { - var patientsJson = await syncPatientsResponse.Content.ReadAsStringAsync(); - if (patientsJson.Contains(patientId!) || patientsJson.Contains(uniqueId)) - { - syncedToScheduling = true; - break; - } - } - } - - Assert.True( - syncedToScheduling, - $"Patient '{uniqueId}' created in Clinical.Api was not synced to Scheduling.Api within 90 seconds." - ); - } - - /// - /// Data added to Scheduling.Api is synced to Clinical.Api. - /// - [Fact] - public async Task Sync_SchedulingPractitioner_AppearsInClinical_AfterSync() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var uniqueId = $"SyncTest{DateTime.UtcNow.Ticks % 1000000}"; - var practitionerRequest = new - { - Identifier = $"SYNC-DR-{uniqueId}", - Active = true, - NameGiven = $"SyncDoctor{uniqueId}", - NameFamily = "ToClinical", - Qualification = "MD", - Specialty = "Sync Testing", - TelecomEmail = $"syncdoc{uniqueId}@hospital.org", - TelecomPhone = "+1-555-SYNC", - }; - - var createResponse = await client.PostAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(practitionerRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - var practitionerJson = await createResponse.Content.ReadAsStringAsync(); - var practitionerDoc = System.Text.Json.JsonDocument.Parse(practitionerJson); - var practitionerId = practitionerDoc.RootElement.GetProperty("Id").GetString(); - - var schedulingGetResponse = await client.GetAsync( - $"{E2EFixture.SchedulingUrl}/Practitioner/{practitionerId}" - ); - Assert.Equal(HttpStatusCode.OK, schedulingGetResponse.StatusCode); - - var syncedToClinical = false; - for (var i = 0; i < 30; i++) - { - await Task.Delay(5000); - - var syncProvidersResponse = await client.GetAsync( - $"{E2EFixture.ClinicalUrl}/sync/providers" - ); - if (syncProvidersResponse.IsSuccessStatusCode) - { - var providersJson = await syncProvidersResponse.Content.ReadAsStringAsync(); - if (providersJson.Contains(practitionerId!) || providersJson.Contains(uniqueId)) - { - syncedToClinical = true; - break; - } - } - } - - Assert.True( - syncedToClinical, - $"Practitioner '{uniqueId}' created in Scheduling.Api was not synced to Clinical.Api within 150 seconds." - ); - } - - /// - /// Sync changes appear in Dashboard UI seamlessly. - /// - [Fact] - public async Task Sync_ChangesAppearInDashboardUI_Seamlessly() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - var page = await _fixture.CreateAuthenticatedPageAsync( - navigateTo: $"{E2EFixture.DashboardUrl}#sync" - ); - page.Console += (_, msg) => Console.WriteLine($"[BROWSER] {msg.Text}"); - - var uniqueId = $"DashSync{DateTime.UtcNow.Ticks % 1000000}"; - var patientRequest = new - { - Active = true, - GivenName = $"DashboardSync{uniqueId}", - FamilyName = "TestPatient", - Gender = "male", - }; - - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(patientRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - await page.GotoAsync($"{E2EFixture.DashboardUrl}#sync"); - await page.WaitForSelectorAsync( - "[data-testid='sync-page']", - new PageWaitForSelectorOptions { Timeout = 20000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-clinical']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - await page.WaitForSelectorAsync( - "[data-testid='service-status-scheduling']", - new PageWaitForSelectorOptions { Timeout = 15000 } - ); - - var content = await page.ContentAsync(); - Assert.Contains("Clinical.Api", content); - Assert.Contains("Scheduling.Api", content); - Assert.Contains("Sync Records", content); - - var clinicalCardVisible = await page.IsVisibleAsync( - "[data-testid='service-status-clinical']" - ); - var schedulingCardVisible = await page.IsVisibleAsync( - "[data-testid='service-status-scheduling']" - ); - Assert.True(clinicalCardVisible); - Assert.True(schedulingCardVisible); - - await page.CloseAsync(); - } - - /// - /// Sync log entries are created when data changes. - /// - [Fact] - public async Task Sync_CreatesLogEntries_WhenDataChanges() - { - using var client = E2EFixture.CreateAuthenticatedClient(); - - var initialClinicalResponse = await client.GetAsync( - $"{E2EFixture.ClinicalUrl}/sync/records" - ); - initialClinicalResponse.EnsureSuccessStatusCode(); - var initialClinicalJson = await initialClinicalResponse.Content.ReadAsStringAsync(); - var initialClinicalDoc = System.Text.Json.JsonDocument.Parse(initialClinicalJson); - var initialClinicalCount = initialClinicalDoc.RootElement.GetProperty("total").GetInt32(); - - var uniqueId = $"LogTest{DateTime.UtcNow.Ticks % 1000000}"; - var patientRequest = new - { - Active = true, - GivenName = $"LogPatient{uniqueId}", - FamilyName = "TestSync", - Gender = "female", - }; - - var createResponse = await client.PostAsync( - $"{E2EFixture.ClinicalUrl}/fhir/Patient/", - new StringContent( - System.Text.Json.JsonSerializer.Serialize(patientRequest), - System.Text.Encoding.UTF8, - "application/json" - ) - ); - createResponse.EnsureSuccessStatusCode(); - - var updatedClinicalResponse = await client.GetAsync( - $"{E2EFixture.ClinicalUrl}/sync/records" - ); - updatedClinicalResponse.EnsureSuccessStatusCode(); - var updatedClinicalJson = await updatedClinicalResponse.Content.ReadAsStringAsync(); - var updatedClinicalDoc = System.Text.Json.JsonDocument.Parse(updatedClinicalJson); - var updatedClinicalCount = updatedClinicalDoc.RootElement.GetProperty("total").GetInt32(); - - Assert.True( - updatedClinicalCount > initialClinicalCount, - $"Sync log count should increase after creating a patient. Initial: {initialClinicalCount}, After: {updatedClinicalCount}" - ); - } -} diff --git a/Samples/Dashboard/Dashboard.Integration.Tests/xunit.runner.json b/Samples/Dashboard/Dashboard.Integration.Tests/xunit.runner.json deleted file mode 100644 index 8d677260..00000000 --- a/Samples/Dashboard/Dashboard.Integration.Tests/xunit.runner.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false, - "maxParallelThreads": 1, - "diagnosticMessages": true, - "longRunningTestSeconds": 30, - "methodDisplay": "method" -} diff --git a/Samples/Dashboard/Dashboard.Web/.config/dotnet-tools.json b/Samples/Dashboard/Dashboard.Web/.config/dotnet-tools.json deleted file mode 100644 index c93ea067..00000000 --- a/Samples/Dashboard/Dashboard.Web/.config/dotnet-tools.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "h5-compiler": { - "version": "26.3.64893", - "commands": [ - "h5" - ], - "rollForward": false - } - } -} \ No newline at end of file diff --git a/Samples/Dashboard/Dashboard.Web/Api/ApiClient.cs b/Samples/Dashboard/Dashboard.Web/Api/ApiClient.cs deleted file mode 100644 index d232a5ff..00000000 --- a/Samples/Dashboard/Dashboard.Web/Api/ApiClient.cs +++ /dev/null @@ -1,528 +0,0 @@ -using System; -using System.Threading.Tasks; -using Dashboard.Models; -using H5; -using static H5.Core.dom; - -namespace Dashboard.Api -{ - /// - /// HTTP API client for Clinical and Scheduling microservices. - /// - public static class ApiClient - { - private static string _clinicalBaseUrl = "http://localhost:5080"; - private static string _schedulingBaseUrl = "http://localhost:5001"; - private static string _icd10BaseUrl = "http://localhost:5090"; - private static string _clinicalToken = ""; - private static string _schedulingToken = ""; - private static string _icd10Token = ""; - - /// - /// Sets the base URLs for the microservices. - /// - public static void Configure(string clinicalUrl, string schedulingUrl) - { - _clinicalBaseUrl = clinicalUrl; - _schedulingBaseUrl = schedulingUrl; - } - - /// - /// Sets the ICD-10 API base URL. - /// - public static void ConfigureIcd10(string icd10Url) - { - _icd10BaseUrl = icd10Url; - } - - /// - /// Sets the authentication tokens for the microservices. - /// - public static void SetTokens(string clinicalToken, string schedulingToken) - { - _clinicalToken = clinicalToken; - _schedulingToken = schedulingToken; - } - - /// - /// Sets the ICD-10 API authentication token. - /// - public static void SetIcd10Token(string icd10Token) - { - _icd10Token = icd10Token; - } - - // === CLINICAL API === - - /// - /// Fetches all patients from the Clinical API. - /// - public static async Task GetPatientsAsync() - { - var response = await FetchClinicalAsync(_clinicalBaseUrl + "/fhir/Patient"); - return ParseJson(response); - } - - /// - /// Fetches a patient by ID from the Clinical API. - /// - public static async Task GetPatientAsync(string id) - { - var response = await FetchClinicalAsync(_clinicalBaseUrl + "/fhir/Patient/" + id); - return ParseJson(response); - } - - /// - /// Searches patients by query string. - /// - public static async Task SearchPatientsAsync(string query) - { - var response = await FetchClinicalAsync( - _clinicalBaseUrl + "/fhir/Patient/_search?q=" + EncodeUri(query) - ); - return ParseJson(response); - } - - /// - /// Fetches encounters for a patient. - /// - public static async Task GetEncountersAsync(string patientId) - { - var response = await FetchClinicalAsync( - _clinicalBaseUrl + "/fhir/Patient/" + patientId + "/Encounter" - ); - return ParseJson(response); - } - - /// - /// Fetches conditions for a patient. - /// - public static async Task GetConditionsAsync(string patientId) - { - var response = await FetchClinicalAsync( - _clinicalBaseUrl + "/fhir/Patient/" + patientId + "/Condition" - ); - return ParseJson(response); - } - - /// - /// Fetches medications for a patient. - /// - public static async Task GetMedicationsAsync(string patientId) - { - var response = await FetchClinicalAsync( - _clinicalBaseUrl + "/fhir/Patient/" + patientId + "/MedicationRequest" - ); - return ParseJson(response); - } - - /// - /// Creates a new patient. - /// - public static async Task CreatePatientAsync(Patient patient) - { - var response = await PostClinicalAsync(_clinicalBaseUrl + "/fhir/Patient/", patient); - return ParseJson(response); - } - - /// - /// Updates an existing patient. - /// - public static async Task UpdatePatientAsync(string id, Patient patient) - { - var response = await PutClinicalAsync( - _clinicalBaseUrl + "/fhir/Patient/" + id, - patient - ); - return ParseJson(response); - } - - // === SCHEDULING API === - - /// - /// Fetches all practitioners from the Scheduling API. - /// - public static async Task GetPractitionersAsync() - { - var response = await FetchSchedulingAsync(_schedulingBaseUrl + "/Practitioner"); - return ParseJson(response); - } - - /// - /// Fetches a practitioner by ID from the Scheduling API. - /// - public static async Task GetPractitionerAsync(string id) - { - var response = await FetchSchedulingAsync(_schedulingBaseUrl + "/Practitioner/" + id); - return ParseJson(response); - } - - /// - /// Searches practitioners by specialty. - /// - public static async Task SearchPractitionersAsync(string specialty) - { - var response = await FetchSchedulingAsync( - _schedulingBaseUrl + "/Practitioner/_search?specialty=" + EncodeUri(specialty) - ); - return ParseJson(response); - } - - /// - /// Fetches all appointments from the Scheduling API. - /// - public static async Task GetAppointmentsAsync() - { - var response = await FetchSchedulingAsync(_schedulingBaseUrl + "/Appointment"); - return ParseJson(response); - } - - /// - /// Fetches an appointment by ID from the Scheduling API. - /// - public static async Task GetAppointmentAsync(string id) - { - var response = await FetchSchedulingAsync(_schedulingBaseUrl + "/Appointment/" + id); - return ParseJson(response); - } - - /// - /// Updates an existing appointment. - /// - public static async Task UpdateAppointmentAsync(string id, object appointment) - { - var response = await PutSchedulingAsync( - _schedulingBaseUrl + "/Appointment/" + id, - appointment - ); - return ParseJson(response); - } - - /// - /// Fetches appointments for a patient. - /// - public static async Task GetPatientAppointmentsAsync(string patientId) - { - var response = await FetchSchedulingAsync( - _schedulingBaseUrl + "/Patient/" + patientId + "/Appointment" - ); - return ParseJson(response); - } - - /// - /// Fetches appointments for a practitioner. - /// - public static async Task GetPractitionerAppointmentsAsync( - string practitionerId - ) - { - var response = await FetchSchedulingAsync( - _schedulingBaseUrl + "/Practitioner/" + practitionerId + "/Appointment" - ); - return ParseJson(response); - } - - // === ICD-10 API === - - /// - /// Fetches all ICD-10 chapters. - /// - public static async Task GetIcd10ChaptersAsync() - { - var response = await FetchIcd10Async(_icd10BaseUrl + "/api/icd10/chapters"); - return ParseJson(response); - } - - /// - /// Fetches blocks for a chapter. - /// - public static async Task GetIcd10BlocksAsync(string chapterId) - { - var response = await FetchIcd10Async( - _icd10BaseUrl + "/api/icd10/chapters/" + chapterId + "/blocks" - ); - return ParseJson(response); - } - - /// - /// Fetches categories for a block. - /// - public static async Task GetIcd10CategoriesAsync(string blockId) - { - var response = await FetchIcd10Async( - _icd10BaseUrl + "/api/icd10/blocks/" + blockId + "/categories" - ); - return ParseJson(response); - } - - /// - /// Fetches codes for a category. - /// - public static async Task GetIcd10CodesAsync(string categoryId) - { - var response = await FetchIcd10Async( - _icd10BaseUrl + "/api/icd10/categories/" + categoryId + "/codes" - ); - return ParseJson(response); - } - - /// - /// Looks up a specific ICD-10 code. - /// - public static async Task GetIcd10CodeAsync(string code) - { - var response = await FetchIcd10Async( - _icd10BaseUrl + "/api/icd10/codes/" + EncodeUri(code) - ); - return ParseJson(response); - } - - /// - /// Searches ICD-10 codes by keyword. - /// - public static async Task SearchIcd10CodesAsync(string query, int limit = 20) - { - var response = await FetchIcd10Async( - _icd10BaseUrl + "/api/icd10/codes?q=" + EncodeUri(query) + "&limit=" + limit - ); - return ParseJson(response); - } - - /// - /// Searches ACHI procedure codes by keyword. - /// - public static async Task SearchAchiCodesAsync(string query, int limit = 20) - { - var response = await FetchIcd10Async( - _icd10BaseUrl + "/api/achi/codes?q=" + EncodeUri(query) + "&limit=" + limit - ); - return ParseJson(response); - } - - /// - /// Performs semantic search using AI embeddings. - /// - public static async Task SemanticSearchAsync( - string query, - int limit = 10, - bool includeAchi = false - ) - { - var request = new SemanticSearchRequest - { - Query = query, - Limit = limit, - IncludeAchi = includeAchi, - }; - var response = await PostIcd10Async(_icd10BaseUrl + "/api/search", request); - var parsed = ParseJson(response); - return parsed.Results ?? new SemanticSearchResult[0]; - } - - // === HELPER METHODS === - - private static async Task FetchIcd10Async(string url) - { - var response = await Script.Call>( - "fetch", - url, - new - { - method = "GET", - headers = new - { - Accept = "application/json", - Authorization = "Bearer " + _icd10Token, - }, - } - ); - - if (!response.Ok) - { - throw new Exception("HTTP " + response.Status); - } - - return await response.Text(); - } - - private static async Task PostIcd10Async(string url, object data) - { - var response = await Script.Call>( - "fetch", - url, - new - { - method = "POST", - headers = new - { - Accept = "application/json", - ContentType = "application/json", - Authorization = "Bearer " + _icd10Token, - }, - body = Script.Call("JSON.stringify", data), - } - ); - - if (!response.Ok) - { - throw new Exception("HTTP " + response.Status); - } - - return await response.Text(); - } - - private static async Task FetchClinicalAsync(string url) - { - var response = await Script.Call>( - "fetch", - url, - new - { - method = "GET", - headers = new - { - Accept = "application/json", - Authorization = "Bearer " + _clinicalToken, - }, - } - ); - - if (!response.Ok) - { - throw new Exception("HTTP " + response.Status); - } - - return await response.Text(); - } - - private static async Task FetchSchedulingAsync(string url) - { - var response = await Script.Call>( - "fetch", - url, - new - { - method = "GET", - headers = new - { - Accept = "application/json", - Authorization = "Bearer " + _schedulingToken, - }, - } - ); - - if (!response.Ok) - { - throw new Exception("HTTP " + response.Status); - } - - return await response.Text(); - } - - private static async Task PostClinicalAsync(string url, object data) - { - var response = await Script.Call>( - "fetch", - url, - new - { - method = "POST", - headers = new - { - Accept = "application/json", - ContentType = "application/json", - Authorization = "Bearer " + _clinicalToken, - }, - body = Script.Call("JSON.stringify", data), - } - ); - - if (!response.Ok) - { - throw new Exception("HTTP " + response.Status); - } - - return await response.Text(); - } - - private static async Task PutClinicalAsync(string url, object data) - { - var response = await Script.Call>( - "fetch", - url, - new - { - method = "PUT", - headers = new - { - Accept = "application/json", - ContentType = "application/json", - Authorization = "Bearer " + _clinicalToken, - }, - body = Script.Call("JSON.stringify", data), - } - ); - - if (!response.Ok) - { - throw new Exception("HTTP " + response.Status); - } - - return await response.Text(); - } - - private static async Task PutSchedulingAsync(string url, object data) - { - var response = await Script.Call>( - "fetch", - url, - new - { - method = "PUT", - headers = new - { - Accept = "application/json", - ContentType = "application/json", - Authorization = "Bearer " + _schedulingToken, - }, - body = Script.Call("JSON.stringify", data), - } - ); - - if (!response.Ok) - { - throw new Exception("HTTP " + response.Status); - } - - return await response.Text(); - } - - private static T ParseJson(string json) => Script.Call("JSON.parse", json); - - private static string EncodeUri(string value) => - Script.Call("encodeURIComponent", value); - } - - /// - /// Fetch API Response type. - /// - [External] - [Name("Response")] - public class Response - { - /// Whether the response was successful. - public extern bool Ok { get; } - - /// HTTP status code. - public extern int Status { get; } - - /// HTTP status text. - public extern string StatusText { get; } - - /// Gets the response body as text. - public extern Task Text(); - - /// Gets the response body as JSON. - public extern Task Json(); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/App.cs b/Samples/Dashboard/Dashboard.Web/App.cs deleted file mode 100644 index 91a483d8..00000000 --- a/Samples/Dashboard/Dashboard.Web/App.cs +++ /dev/null @@ -1,305 +0,0 @@ -using Dashboard.Components; -using Dashboard.Pages; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard -{ - /// - /// Application state class. - /// - public class AppState - { - /// Active view identifier. - public string ActiveView { get; set; } - - /// Whether sidebar is collapsed. - public bool SidebarCollapsed { get; set; } - - /// Search query string. - public string SearchQuery { get; set; } - - /// Notification count. - public int NotificationCount { get; set; } - - /// Patient ID being edited (null if not editing). - public string EditingPatientId { get; set; } - - /// Appointment ID being edited (null if not editing). - public string EditingAppointmentId { get; set; } - } - - /// - /// Main application component. - /// - public static class App - { - /// - /// Renders the main application. - /// - public static ReactElement Render() - { - var stateResult = UseState( - new AppState - { - ActiveView = "dashboard", - SidebarCollapsed = false, - SearchQuery = "", - NotificationCount = 3, - EditingPatientId = null, - EditingAppointmentId = null, - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - return Div( - className: "app", - children: new[] - { - // Sidebar - Sidebar.Render( - activeView: state.ActiveView, - onNavigate: view => - { - var newState = new AppState - { - ActiveView = view, - SidebarCollapsed = state.SidebarCollapsed, - SearchQuery = state.SearchQuery, - NotificationCount = state.NotificationCount, - EditingPatientId = null, - EditingAppointmentId = null, - }; - setState(newState); - }, - collapsed: state.SidebarCollapsed, - onToggle: () => - { - var newState = new AppState - { - ActiveView = state.ActiveView, - SidebarCollapsed = !state.SidebarCollapsed, - SearchQuery = state.SearchQuery, - NotificationCount = state.NotificationCount, - EditingPatientId = state.EditingPatientId, - EditingAppointmentId = state.EditingAppointmentId, - }; - setState(newState); - } - ), - // Main content wrapper - Div( - className: "main-wrapper", - children: new[] - { - // Header - Components.Header.Render( - title: GetPageTitle(state.ActiveView), - searchQuery: state.SearchQuery, - onSearchChange: query => - { - var newState = new AppState - { - ActiveView = state.ActiveView, - SidebarCollapsed = state.SidebarCollapsed, - SearchQuery = query, - NotificationCount = state.NotificationCount, - EditingPatientId = state.EditingPatientId, - EditingAppointmentId = state.EditingAppointmentId, - }; - setState(newState); - }, - notificationCount: state.NotificationCount - ), - // Main content area - Main( - className: "main-content", - children: new[] { RenderPage(state, setState) } - ), - } - ), - } - ); - } - - private static string GetPageTitle(string view) - { - if (view == "dashboard") - return "Dashboard"; - if (view == "patients") - return "Patients"; - if (view == "clinical-coding") - return "Clinical Coding"; - if (view == "encounters") - return "Encounters"; - if (view == "conditions") - return "Conditions"; - if (view == "medications") - return "Medications"; - if (view == "practitioners") - return "Practitioners"; - if (view == "appointments") - return "Appointments"; - if (view == "calendar") - return "Schedule"; - if (view == "settings") - return "Settings"; - return "Healthcare"; - } - - private static ReactElement RenderPage(AppState state, System.Action setState) - { - var view = state.ActiveView; - - // Handle editing patient - if (view == "patients" && state.EditingPatientId != null) - { - return EditPatientPage.Render( - state.EditingPatientId, - () => - { - var newState = new AppState - { - ActiveView = "patients", - SidebarCollapsed = state.SidebarCollapsed, - SearchQuery = state.SearchQuery, - NotificationCount = state.NotificationCount, - EditingPatientId = null, - EditingAppointmentId = null, - }; - setState(newState); - } - ); - } - - // Handle editing appointment - if ( - (view == "appointments" || view == "calendar") - && state.EditingAppointmentId != null - ) - { - return EditAppointmentPage.Render( - state.EditingAppointmentId, - () => - { - var newState = new AppState - { - ActiveView = view, - SidebarCollapsed = state.SidebarCollapsed, - SearchQuery = state.SearchQuery, - NotificationCount = state.NotificationCount, - EditingPatientId = null, - EditingAppointmentId = null, - }; - setState(newState); - } - ); - } - - if (view == "dashboard") - return DashboardPage.Render(); - if (view == "clinical-coding") - return ClinicalCodingPage.Render(); - if (view == "patients") - { - return PatientsPage.Render(patientId => - { - var newState = new AppState - { - ActiveView = "patients", - SidebarCollapsed = state.SidebarCollapsed, - SearchQuery = state.SearchQuery, - NotificationCount = state.NotificationCount, - EditingPatientId = patientId, - EditingAppointmentId = null, - }; - setState(newState); - }); - } - if (view == "practitioners") - return PractitionersPage.Render(); - if (view == "appointments") - { - return AppointmentsPage.Render(appointmentId => - { - var newState = new AppState - { - ActiveView = "appointments", - SidebarCollapsed = state.SidebarCollapsed, - SearchQuery = state.SearchQuery, - NotificationCount = state.NotificationCount, - EditingPatientId = null, - EditingAppointmentId = appointmentId, - }; - setState(newState); - }); - } - if (view == "calendar") - { - return CalendarPage.Render(appointmentId => - { - var newState = new AppState - { - ActiveView = "calendar", - SidebarCollapsed = state.SidebarCollapsed, - SearchQuery = state.SearchQuery, - NotificationCount = state.NotificationCount, - EditingPatientId = null, - EditingAppointmentId = appointmentId, - }; - setState(newState); - }); - } - if (view == "encounters") - return RenderPlaceholderPage("Encounters", "Manage patient encounters and visits"); - if (view == "conditions") - return RenderPlaceholderPage("Conditions", "View and manage patient conditions"); - if (view == "medications") - return RenderPlaceholderPage("Medications", "Manage medication requests"); - if (view == "settings") - return RenderPlaceholderPage("Settings", "Configure application settings"); - return RenderPlaceholderPage("Page Not Found", "The requested page does not exist"); - } - - private static ReactElement RenderPlaceholderPage(string title, string description) => - Div( - className: "page", - children: new[] - { - Div( - className: "page-header", - children: new[] - { - H(2, className: "page-title", children: new[] { Text(title) }), - P(className: "page-description", children: new[] { Text(description) }), - } - ), - Div( - className: "card", - children: new[] - { - Div( - className: "empty-state", - children: new[] - { - Icons.Clipboard(), - H( - 4, - className: "empty-state-title", - children: new[] { Text("Coming Soon") } - ), - P( - className: "empty-state-description", - children: new[] { Text("This page is under development.") } - ), - } - ), - } - ), - } - ); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Components/DataTable.cs b/Samples/Dashboard/Dashboard.Web/Components/DataTable.cs deleted file mode 100644 index c6cf1a57..00000000 --- a/Samples/Dashboard/Dashboard.Web/Components/DataTable.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System; -using System.Linq; -using Dashboard.React; -using static Dashboard.React.Elements; - -namespace Dashboard.Components -{ - /// - /// Column definition class. - /// - public class Column - { - /// Column key. - public string Key { get; set; } - - /// Column header text. - public string Header { get; set; } - - /// Optional CSS class name. - public string ClassName { get; set; } - } - - /// - /// Data table component. - /// - public static class DataTable - { - /// - /// Renders a data table. - /// - public static ReactElement Render( - Column[] columns, - T[] data, - Func getKey, - Func renderCell, - Action onRowClick = null - ) => - Div( - className: "table-container", - children: new[] - { - Table( - className: "table", - children: new[] - { - THead( - children: new[] - { - Tr( - children: columns - .Select(col => - Th( - className: col.ClassName, - children: new[] { Text(col.Header) } - ) - ) - .ToArray() - ), - } - ), - TBody( - children: data.Select(row => - Tr( - className: onRowClick != null ? "cursor-pointer" : null, - onClick: onRowClick != null - ? (Action)(() => onRowClick(row)) - : null, - children: columns - .Select(col => - Td( - className: col.ClassName, - children: new[] { renderCell(row, col.Key) } - ) - ) - .ToArray() - ) - ) - .ToArray() - ), - } - ), - } - ); - - /// - /// Renders an empty state for the table. - /// - public static ReactElement RenderEmpty(string message = "No data available") => - Div( - className: "empty-state", - children: new[] - { - Div(className: "empty-state-icon", children: new[] { Icons.Clipboard() }), - H(4, className: "empty-state-title", children: new[] { Text("No Results") }), - P(className: "empty-state-description", children: new[] { Text(message) }), - } - ); - - /// - /// Renders a loading skeleton. - /// - public static ReactElement RenderLoading(int rows = 5, int columns = 4) => - Div( - className: "table-container", - children: new[] - { - Table( - className: "table", - children: new[] - { - THead( - children: new[] - { - Tr( - children: Enumerable - .Range(0, columns) - .Select(i => - Th( - children: new[] - { - Div( - className: "skeleton", - style: new - { - width = "80px", - height = "16px", - } - ), - } - ) - ) - .ToArray() - ), - } - ), - TBody( - children: Enumerable - .Range(0, rows) - .Select(i => - Tr( - children: Enumerable - .Range(0, columns) - .Select(j => - Td( - children: new[] - { - Div( - className: "skeleton", - style: new - { - width = "100%", - height = "16px", - } - ), - } - ) - ) - .ToArray() - ) - ) - .ToArray() - ), - } - ), - } - ); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Components/Header.cs b/Samples/Dashboard/Dashboard.Web/Components/Header.cs deleted file mode 100644 index 881d8d36..00000000 --- a/Samples/Dashboard/Dashboard.Web/Components/Header.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using Dashboard.React; -using static Dashboard.React.Elements; - -namespace Dashboard.Components -{ - /// - /// Header component with search and actions. - /// - public static class Header - { - /// - /// Renders the header component. - /// - public static ReactElement Render( - string title, - string searchQuery = null, - Action onSearchChange = null, - int notificationCount = 0 - ) => - React.Elements.Header( - className: "header", - children: new[] - { - // Left section - Div( - className: "header-left", - children: new[] - { - H(1, className: "header-title", children: new[] { Text(title) }), - } - ), - // Right section - Div( - className: "header-right", - children: new[] - { - // Search - Div( - className: "header-search", - children: new[] - { - Span( - className: "header-search-icon", - children: new[] { Icons.Search() } - ), - Input( - className: "input", - type: "text", - placeholder: "Search patients, appointments...", - value: searchQuery, - onChange: onSearchChange - ), - } - ), - // Actions - Div( - className: "header-actions", - children: new[] - { - RenderNotificationButton(notificationCount), - RenderUserAvatar(), - } - ), - } - ), - } - ); - - private static ReactElement RenderNotificationButton(int count) - { - ReactElement[] children; - if (count > 0) - { - children = new[] { Icons.Bell(), Span(className: "header-action-badge") }; - } - else - { - children = new[] { Icons.Bell() }; - } - return Button( - className: "header-action-btn", - onClick: () => { /* TODO: Open notifications */ - }, - children: children - ); - } - - private static ReactElement RenderUserAvatar() => - Div(className: "avatar avatar-md", children: new[] { Text("JD") }); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Components/Icons.cs b/Samples/Dashboard/Dashboard.Web/Components/Icons.cs deleted file mode 100644 index b7d3f81c..00000000 --- a/Samples/Dashboard/Dashboard.Web/Components/Icons.cs +++ /dev/null @@ -1,438 +0,0 @@ -using Dashboard.React; -using static Dashboard.React.Elements; - -namespace Dashboard.Components -{ - /// - /// SVG icon components. - /// - public static class Icons - { - /// - /// Home icon. - /// - public static ReactElement Home() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M9 22V12h6v10", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Users icon. - /// - public static ReactElement Users() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// User/Doctor icon. - /// - public static ReactElement UserDoctor() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M12 14v7 M9 18h6", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Calendar icon. - /// - public static ReactElement Calendar() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M19 4H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z M16 2v4 M8 2v4 M3 10h18", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Clipboard/Encounter icon. - /// - public static ReactElement Clipboard() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2 M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Heart/Condition icon. - /// - public static ReactElement Heart() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Pill/Medication icon. - /// - public static ReactElement Pill() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M10.5 20.5L3.5 13.5a4.95 4.95 0 1 1 7-7l7 7a4.95 4.95 0 1 1-7 7z M8.5 8.5l7 7", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Activity/Heartbeat icon. - /// - public static ReactElement Activity() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M22 12h-4l-3 9L9 3l-3 9H2", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Search icon. - /// - public static ReactElement Search() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z M21 21l-4.35-4.35", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Bell/Notification icon. - /// - public static ReactElement Bell() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9 M13.73 21a2 2 0 0 1-3.46 0", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Settings icon. - /// - public static ReactElement Settings() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// ChevronLeft icon. - /// - public static ReactElement ChevronLeft() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M15 18l-6-6 6-6", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// ChevronRight icon. - /// - public static ReactElement ChevronRight() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M9 18l6-6-6-6", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// Plus icon. - /// - public static ReactElement Plus() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M12 5v14 M5 12h14", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// Edit/Pencil icon. - /// - public static ReactElement Edit() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7 M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Trash icon. - /// - public static ReactElement Trash() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Eye icon. - /// - public static ReactElement Eye() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Refresh icon. - /// - public static ReactElement Refresh() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// TrendUp icon. - /// - public static ReactElement TrendUp() => - Svg( - className: "icon", - width: 16, - height: 16, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M23 6l-9.5 9.5-5-5L1 18", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// TrendDown icon. - /// - public static ReactElement TrendDown() => - Svg( - className: "icon", - width: 16, - height: 16, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M23 18l-9.5-9.5-5 5L1 6", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// Menu icon (hamburger). - /// - public static ReactElement Menu() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M3 12h18M3 6h18M3 18h18", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// X/Close icon. - /// - public static ReactElement X() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M18 6L6 18M6 6l12 12", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// Code/Book icon for clinical coding. - /// - public static ReactElement Code() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15z M8 7h8 M8 11h8 M8 15h5", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// FileText icon for documents. - /// - public static ReactElement FileText() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M16 13H8 M16 17H8 M10 9H8", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Sparkles icon for AI/semantic search. - /// - public static ReactElement Sparkles() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z M19 13l1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3z M5 17l.5 1.5L7 19l-1.5.5L5 21l-.5-1.5L3 19l1.5-.5L5 17z", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - - /// - /// Check icon for confirmation. - /// - public static ReactElement Check() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path(d: "M20 6L9 17l-5-5", stroke: "currentColor", strokeWidth: 2) - ); - - /// - /// Copy icon for clipboard copy. - /// - public static ReactElement Copy() => - Svg( - className: "icon", - width: 20, - height: 20, - viewBox: "0 0 24 24", - fill: "none", - children: Path( - d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2 M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z", - stroke: "currentColor", - strokeWidth: 2 - ) - ); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Components/MetricCard.cs b/Samples/Dashboard/Dashboard.Web/Components/MetricCard.cs deleted file mode 100644 index fdb3f8dc..00000000 --- a/Samples/Dashboard/Dashboard.Web/Components/MetricCard.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using Dashboard.React; -using static Dashboard.React.Elements; - -namespace Dashboard.Components -{ - /// - /// Trend direction enum. - /// - public enum TrendDirection - { - /// Upward trend. - Up, - - /// Downward trend. - Down, - - /// No change. - Neutral, - } - - /// - /// Metric card props class. - /// - public class MetricCardProps - { - /// Label text. - public string Label { get; set; } - - /// Value text. - public string Value { get; set; } - - /// Icon factory. - public Func Icon { get; set; } - - /// Icon color class. - public string IconColor { get; set; } - - /// Trend value text. - public string TrendValue { get; set; } - - /// Trend direction. - public TrendDirection Trend { get; set; } - } - - /// - /// Metric card component for displaying KPIs. - /// - public static class MetricCard - { - /// - /// Renders a metric card. - /// - public static ReactElement Render(MetricCardProps props) - { - ReactElement trendElement; - if (props.TrendValue != null) - { - trendElement = Div( - className: "metric-trend " + TrendClass(props.Trend), - children: new[] { TrendIcon(props.Trend), Text(props.TrendValue) } - ); - } - else - { - trendElement = Text(""); - } - - return Div( - className: "metric-card", - children: new[] - { - // Icon - Div( - className: "metric-icon " + (props.IconColor ?? "blue"), - children: new[] { props.Icon() } - ), - // Value - Div(className: "metric-value", children: new[] { Text(props.Value) }), - // Label - Div(className: "metric-label", children: new[] { Text(props.Label) }), - // Trend (if provided) - trendElement, - } - ); - } - - private static string TrendClass(TrendDirection trend) - { - if (trend == TrendDirection.Up) - { - return "up"; - } - else if (trend == TrendDirection.Down) - { - return "down"; - } - else - { - return "neutral"; - } - } - - private static ReactElement TrendIcon(TrendDirection trend) - { - if (trend == TrendDirection.Up) - { - return Icons.TrendUp(); - } - else if (trend == TrendDirection.Down) - { - return Icons.TrendDown(); - } - else - { - return Text(""); - } - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Components/Sidebar.cs b/Samples/Dashboard/Dashboard.Web/Components/Sidebar.cs deleted file mode 100644 index 3a444933..00000000 --- a/Samples/Dashboard/Dashboard.Web/Components/Sidebar.cs +++ /dev/null @@ -1,285 +0,0 @@ -using System; -using System.Linq; -using Dashboard.React; -using static Dashboard.React.Elements; - -namespace Dashboard.Components -{ - /// - /// Navigation item class. - /// - public class NavItem - { - /// Item identifier. - public string Id { get; set; } - - /// Display label. - public string Label { get; set; } - - /// Icon factory. - public Func Icon { get; set; } - - /// Optional badge count. - public int? Badge { get; set; } - } - - /// - /// Navigation section class. - /// - public class NavSection - { - /// Section title. - public string Title { get; set; } - - /// Navigation items. - public NavItem[] Items { get; set; } - } - - /// - /// Sidebar navigation component. - /// - public static class Sidebar - { - /// - /// Renders the sidebar component. - /// - public static ReactElement Render( - string activeView, - Action onNavigate, - bool collapsed, - Action onToggle - ) - { - var sections = GetNavSections(); - - return Aside( - className: "sidebar " + (collapsed ? "collapsed" : ""), - children: new[] - { - // Header with logo - RenderHeader(collapsed), - // Navigation sections - Nav( - className: "sidebar-nav", - children: sections - .Select(section => RenderSection(section, activeView, onNavigate)) - .ToArray() - ), - // Toggle button - Button( - className: "sidebar-toggle", - onClick: onToggle, - children: new[] { collapsed ? Icons.ChevronRight() : Icons.ChevronLeft() } - ), - // Footer with user - RenderFooter(collapsed), - } - ); - } - - private static NavSection[] GetNavSections() => - new[] - { - new NavSection - { - Title = "Overview", - Items = new[] - { - new NavItem - { - Id = "dashboard", - Label = "Dashboard", - Icon = Icons.Home, - }, - }, - }, - new NavSection - { - Title = "Clinical", - Items = new[] - { - new NavItem - { - Id = "patients", - Label = "Patients", - Icon = Icons.Users, - }, - new NavItem - { - Id = "clinical-coding", - Label = "Clinical Coding", - Icon = Icons.Code, - }, - new NavItem - { - Id = "encounters", - Label = "Encounters", - Icon = Icons.Clipboard, - }, - new NavItem - { - Id = "conditions", - Label = "Conditions", - Icon = Icons.Heart, - }, - new NavItem - { - Id = "medications", - Label = "Medications", - Icon = Icons.Pill, - }, - }, - }, - new NavSection - { - Title = "Scheduling", - Items = new[] - { - new NavItem - { - Id = "practitioners", - Label = "Practitioners", - Icon = Icons.UserDoctor, - }, - new NavItem - { - Id = "appointments", - Label = "Appointments", - Icon = Icons.Clipboard, - Badge = 3, - }, - new NavItem - { - Id = "calendar", - Label = "Schedule", - Icon = Icons.Calendar, - }, - }, - }, - new NavSection - { - Title = "System", - Items = new[] - { - new NavItem - { - Id = "settings", - Label = "Settings", - Icon = Icons.Settings, - }, - }, - }, - }; - - private static ReactElement RenderHeader(bool collapsed) => - Div( - className: "sidebar-header", - children: new[] - { - A( - href: "#", - className: "sidebar-logo", - children: new[] - { - Div( - className: "sidebar-logo-icon", - children: new[] { Icons.Activity() } - ), - Span( - className: "sidebar-logo-text", - children: new[] { Text("HealthCare") } - ), - } - ), - } - ); - - private static ReactElement RenderSection( - NavSection section, - string activeView, - Action onNavigate - ) - { - var items = section - .Items.Select(item => RenderNavItem(item, activeView, onNavigate)) - .ToArray(); - var allChildren = new ReactElement[items.Length + 1]; - allChildren[0] = Div( - className: "nav-section-title", - children: new[] { Text(section.Title) } - ); - for (int i = 0; i < items.Length; i++) - { - allChildren[i + 1] = items[i]; - } - return Div(className: "nav-section", children: allChildren); - } - - private static ReactElement RenderNavItem( - NavItem item, - string activeView, - Action onNavigate - ) - { - var isActive = activeView == item.Id; - ReactElement[] children; - - if (item.Badge.HasValue) - { - children = new ReactElement[] - { - Span(className: "nav-item-icon", children: new[] { item.Icon() }), - Span(className: "nav-item-text", children: new[] { Text(item.Label) }), - Span( - className: "nav-item-badge", - children: new[] { Text(item.Badge.Value.ToString()) } - ), - }; - } - else - { - children = new ReactElement[] - { - Span(className: "nav-item-icon", children: new[] { item.Icon() }), - Span(className: "nav-item-text", children: new[] { Text(item.Label) }), - }; - } - - return A( - href: "#", - className: "nav-item " + (isActive ? "active" : ""), - onClick: () => onNavigate(item.Id), - children: children - ); - } - - private static ReactElement RenderFooter(bool collapsed) => - Div( - className: "sidebar-footer", - children: new[] - { - Div( - className: "sidebar-user", - children: new[] - { - Div(className: "avatar avatar-md", children: new[] { Text("JD") }), - Div( - className: "sidebar-user-info", - children: new[] - { - Div( - className: "sidebar-user-name", - children: new[] { Text("John Doe") } - ), - Div( - className: "sidebar-user-role", - children: new[] { Text("Administrator") } - ), - } - ), - } - ), - } - ); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Dashboard.Web.csproj b/Samples/Dashboard/Dashboard.Web/Dashboard.Web.csproj deleted file mode 100644 index 2b019bd9..00000000 --- a/Samples/Dashboard/Dashboard.Web/Dashboard.Web.csproj +++ /dev/null @@ -1,53 +0,0 @@ - - - Library - netstandard2.1 - 9.0 - enable - Dashboard - false - CS0626;CS1591;CA1812;CA2100;CS8632 - - H5 - true - false - - - - - false - false - false - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/Dashboard/Dashboard.Web/Models/ClinicalModels.cs b/Samples/Dashboard/Dashboard.Web/Models/ClinicalModels.cs deleted file mode 100644 index 951436a8..00000000 --- a/Samples/Dashboard/Dashboard.Web/Models/ClinicalModels.cs +++ /dev/null @@ -1,186 +0,0 @@ -using H5; - -namespace Dashboard.Models -{ - /// - /// FHIR Patient resource model. - /// - [External] - [Name("Object")] - public class Patient - { - /// Patient unique identifier. - public extern string Id { get; set; } - - /// Whether patient record is active. - public extern bool Active { get; set; } - - /// Patient's given name. - public extern string GivenName { get; set; } - - /// Patient's family name. - public extern string FamilyName { get; set; } - - /// Patient's birth date. - public extern string BirthDate { get; set; } - - /// Patient's gender. - public extern string Gender { get; set; } - - /// Patient's phone number. - public extern string Phone { get; set; } - - /// Patient's email address. - public extern string Email { get; set; } - - /// Patient's address line. - public extern string AddressLine { get; set; } - - /// Patient's city. - public extern string City { get; set; } - - /// Patient's state. - public extern string State { get; set; } - - /// Patient's postal code. - public extern string PostalCode { get; set; } - - /// Patient's country. - public extern string Country { get; set; } - - /// Last updated timestamp. - public extern string LastUpdated { get; set; } - - /// Version identifier. - public extern long VersionId { get; set; } - } - - /// - /// FHIR Encounter resource model. - /// - [External] - [Name("Object")] - public class Encounter - { - /// Encounter unique identifier. - public extern string Id { get; set; } - - /// Encounter status. - public extern string Status { get; set; } - - /// Encounter class. - public extern string Class { get; set; } - - /// Patient reference. - public extern string PatientId { get; set; } - - /// Practitioner reference. - public extern string PractitionerId { get; set; } - - /// Service type. - public extern string ServiceType { get; set; } - - /// Reason code. - public extern string ReasonCode { get; set; } - - /// Period start. - public extern string PeriodStart { get; set; } - - /// Period end. - public extern string PeriodEnd { get; set; } - - /// Notes. - public extern string Notes { get; set; } - - /// Last updated timestamp. - public extern string LastUpdated { get; set; } - - /// Version identifier. - public extern long VersionId { get; set; } - } - - /// - /// FHIR Condition resource model. - /// - [External] - [Name("Object")] - public class Condition - { - /// Condition unique identifier. - public extern string Id { get; set; } - - /// Clinical status. - public extern string ClinicalStatus { get; set; } - - /// Verification status. - public extern string VerificationStatus { get; set; } - - /// Condition category. - public extern string Category { get; set; } - - /// Severity. - public extern string Severity { get; set; } - - /// ICD-10 code value. - public extern string CodeValue { get; set; } - - /// Code display name. - public extern string CodeDisplay { get; set; } - - /// Subject reference (patient). - public extern string SubjectReference { get; set; } - - /// Onset date/time. - public extern string OnsetDateTime { get; set; } - - /// Recorded date. - public extern string RecordedDate { get; set; } - - /// Note text. - public extern string NoteText { get; set; } - } - - /// - /// FHIR MedicationRequest resource model. - /// - [External] - [Name("Object")] - public class MedicationRequest - { - /// MedicationRequest unique identifier. - public extern string Id { get; set; } - - /// Status. - public extern string Status { get; set; } - - /// Intent. - public extern string Intent { get; set; } - - /// Patient reference. - public extern string PatientId { get; set; } - - /// Practitioner reference. - public extern string PractitionerId { get; set; } - - /// Medication code (RxNorm). - public extern string MedicationCode { get; set; } - - /// Medication display name. - public extern string MedicationDisplay { get; set; } - - /// Dosage instruction. - public extern string DosageInstruction { get; set; } - - /// Quantity. - public extern double Quantity { get; set; } - - /// Unit. - public extern string Unit { get; set; } - - /// Number of refills. - public extern int Refills { get; set; } - - /// Authored on date. - public extern string AuthoredOn { get; set; } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Models/Icd10Models.cs b/Samples/Dashboard/Dashboard.Web/Models/Icd10Models.cs deleted file mode 100644 index c6d76936..00000000 --- a/Samples/Dashboard/Dashboard.Web/Models/Icd10Models.cs +++ /dev/null @@ -1,299 +0,0 @@ -using H5; - -namespace Dashboard.Models -{ - /// - /// ICD-10 chapter model. - /// - [External] - [Name("Object")] - public class Icd10Chapter - { - /// Chapter unique identifier. - [Name("Id")] - public extern string Id { get; set; } - - /// Chapter number (1-22). - [Name("ChapterNumber")] - public extern string ChapterNumber { get; set; } - - /// Chapter title. - [Name("Title")] - public extern string Title { get; set; } - - /// Code range start. - [Name("CodeRangeStart")] - public extern string CodeRangeStart { get; set; } - - /// Code range end. - [Name("CodeRangeEnd")] - public extern string CodeRangeEnd { get; set; } - } - - /// - /// ICD-10 block model. - /// - [External] - [Name("Object")] - public class Icd10Block - { - /// Block unique identifier. - [Name("Id")] - public extern string Id { get; set; } - - /// Chapter identifier. - [Name("ChapterId")] - public extern string ChapterId { get; set; } - - /// Block code. - [Name("BlockCode")] - public extern string BlockCode { get; set; } - - /// Block title. - [Name("Title")] - public extern string Title { get; set; } - - /// Code range start. - [Name("CodeRangeStart")] - public extern string CodeRangeStart { get; set; } - - /// Code range end. - [Name("CodeRangeEnd")] - public extern string CodeRangeEnd { get; set; } - } - - /// - /// ICD-10 category model. - /// - [External] - [Name("Object")] - public class Icd10Category - { - /// Category unique identifier. - [Name("Id")] - public extern string Id { get; set; } - - /// Block identifier. - [Name("BlockId")] - public extern string BlockId { get; set; } - - /// Category code. - [Name("CategoryCode")] - public extern string CategoryCode { get; set; } - - /// Category title. - [Name("Title")] - public extern string Title { get; set; } - } - - /// - /// ICD-10 code model. - /// - [External] - [Name("Object")] - public class Icd10Code - { - /// Code unique identifier. - [Name("Id")] - public extern string Id { get; set; } - - /// Category identifier. - [Name("CategoryId")] - public extern string CategoryId { get; set; } - - /// ICD-10 code value. - [Name("Code")] - public extern string Code { get; set; } - - /// Short description. - [Name("ShortDescription")] - public extern string ShortDescription { get; set; } - - /// Long description. - [Name("LongDescription")] - public extern string LongDescription { get; set; } - - /// Inclusion terms. - [Name("InclusionTerms")] - public extern string InclusionTerms { get; set; } - - /// Exclusion terms. - [Name("ExclusionTerms")] - public extern string ExclusionTerms { get; set; } - - /// Code also reference. - [Name("CodeAlso")] - public extern string CodeAlso { get; set; } - - /// Code first reference. - [Name("CodeFirst")] - public extern string CodeFirst { get; set; } - - /// Synonyms for the code. - [Name("Synonyms")] - public extern string Synonyms { get; set; } - - /// Whether code is billable. - [Name("Billable")] - public extern bool Billable { get; set; } - - /// Edition of the code. - [Name("Edition")] - public extern string Edition { get; set; } - - /// Category code. - [Name("CategoryCode")] - public extern string CategoryCode { get; set; } - - /// Category title. - [Name("CategoryTitle")] - public extern string CategoryTitle { get; set; } - - /// Block code. - [Name("BlockCode")] - public extern string BlockCode { get; set; } - - /// Block title. - [Name("BlockTitle")] - public extern string BlockTitle { get; set; } - - /// Chapter number. - [Name("ChapterNumber")] - public extern string ChapterNumber { get; set; } - - /// Chapter title. - [Name("ChapterTitle")] - public extern string ChapterTitle { get; set; } - } - - /// - /// ACHI procedure code model. - /// - [External] - [Name("Object")] - public class AchiCode - { - /// Code unique identifier. - [Name("Id")] - public extern string Id { get; set; } - - /// Block identifier. - [Name("BlockId")] - public extern string BlockId { get; set; } - - /// ACHI code value. - [Name("Code")] - public extern string Code { get; set; } - - /// Short description. - [Name("ShortDescription")] - public extern string ShortDescription { get; set; } - - /// Long description. - [Name("LongDescription")] - public extern string LongDescription { get; set; } - - /// Whether code is billable. - [Name("Billable")] - public extern bool Billable { get; set; } - - /// Block number. - [Name("BlockNumber")] - public extern string BlockNumber { get; set; } - - /// Block title. - [Name("BlockTitle")] - public extern string BlockTitle { get; set; } - } - - /// - /// Semantic search result model. - /// - [External] - [Name("Object")] - public class SemanticSearchResult - { - /// ICD-10 code. - [Name("Code")] - public extern string Code { get; set; } - - /// Code description. - [Name("Description")] - public extern string Description { get; set; } - - /// Long description with clinical details. - [Name("LongDescription")] - public extern string LongDescription { get; set; } - - /// Confidence score (0-1). - [Name("Confidence")] - public extern double Confidence { get; set; } - - /// Code type (ICD10CM or ACHI). - [Name("CodeType")] - public extern string CodeType { get; set; } - - /// Chapter number (e.g., "19"). - [Name("Chapter")] - public extern string Chapter { get; set; } - - /// Chapter title (e.g., "Injury, poisoning and external causes"). - [Name("ChapterTitle")] - public extern string ChapterTitle { get; set; } - - /// Category code (first 3 characters, e.g., "S70"). - [Name("Category")] - public extern string Category { get; set; } - - /// Inclusion terms for the code. - [Name("InclusionTerms")] - public extern string InclusionTerms { get; set; } - - /// Exclusion terms for the code. - [Name("ExclusionTerms")] - public extern string ExclusionTerms { get; set; } - - /// Code also references. - [Name("CodeAlso")] - public extern string CodeAlso { get; set; } - - /// Code first references. - [Name("CodeFirst")] - public extern string CodeFirst { get; set; } - } - - /// - /// Search request model. - /// - public class SemanticSearchRequest - { - /// Search query text. - public string Query { get; set; } - - /// Maximum results to return. - public int Limit { get; set; } - - /// Whether to include ACHI codes. - public bool IncludeAchi { get; set; } - } - - /// - /// Semantic search API response model. - /// - [External] - [Name("Object")] - public class SemanticSearchResponse - { - /// Search results. - [Name("Results")] - public extern SemanticSearchResult[] Results { get; set; } - - /// Original query. - [Name("Query")] - public extern string Query { get; set; } - - /// Model used for embeddings. - [Name("Model")] - public extern string Model { get; set; } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Models/SchedulingModels.cs b/Samples/Dashboard/Dashboard.Web/Models/SchedulingModels.cs deleted file mode 100644 index ea7187d6..00000000 --- a/Samples/Dashboard/Dashboard.Web/Models/SchedulingModels.cs +++ /dev/null @@ -1,141 +0,0 @@ -using H5; - -namespace Dashboard.Models -{ - /// - /// FHIR Practitioner resource model. - /// - [External] - [Name("Object")] - public class Practitioner - { - /// Practitioner unique identifier. - public extern string Id { get; set; } - - /// Practitioner identifier (NPI). - public extern string Identifier { get; set; } - - /// Whether practitioner is active. - public extern bool Active { get; set; } - - /// Family name. - public extern string NameFamily { get; set; } - - /// Given name. - public extern string NameGiven { get; set; } - - /// Qualification. - public extern string Qualification { get; set; } - - /// Specialty. - public extern string Specialty { get; set; } - - /// Email. - public extern string TelecomEmail { get; set; } - - /// Phone. - public extern string TelecomPhone { get; set; } - } - - /// - /// FHIR Appointment resource model. - /// - [External] - [Name("Object")] - public class Appointment - { - /// Appointment unique identifier. - public extern string Id { get; set; } - - /// Appointment status. - public extern string Status { get; set; } - - /// Service category. - public extern string ServiceCategory { get; set; } - - /// Service type. - public extern string ServiceType { get; set; } - - /// Reason code. - public extern string ReasonCode { get; set; } - - /// Priority. - public extern string Priority { get; set; } - - /// Description. - public extern string Description { get; set; } - - /// Start time. - public extern string StartTime { get; set; } - - /// End time. - public extern string EndTime { get; set; } - - /// Duration in minutes. - public extern int MinutesDuration { get; set; } - - /// Patient reference. - public extern string PatientReference { get; set; } - - /// Practitioner reference. - public extern string PractitionerReference { get; set; } - - /// Created timestamp. - public extern string Created { get; set; } - - /// Comment. - public extern string Comment { get; set; } - } - - /// - /// FHIR Schedule resource model. - /// - [External] - [Name("Object")] - public class Schedule - { - /// Schedule unique identifier. - public extern string Id { get; set; } - - /// Whether schedule is active. - public extern bool Active { get; set; } - - /// Practitioner reference. - public extern string PractitionerReference { get; set; } - - /// Planning horizon in days. - public extern int PlanningHorizon { get; set; } - - /// Comment. - public extern string Comment { get; set; } - } - - /// - /// FHIR Slot resource model. - /// - [External] - [Name("Object")] - public class Slot - { - /// Slot unique identifier. - public extern string Id { get; set; } - - /// Schedule reference. - public extern string ScheduleReference { get; set; } - - /// Slot status. - public extern string Status { get; set; } - - /// Start time. - public extern string StartTime { get; set; } - - /// End time. - public extern string EndTime { get; set; } - - /// Whether overbooked. - public extern bool Overbooked { get; set; } - - /// Comment. - public extern string Comment { get; set; } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/AppointmentsPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/AppointmentsPage.cs deleted file mode 100644 index 7a02f022..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/AppointmentsPage.cs +++ /dev/null @@ -1,533 +0,0 @@ -using System; -using System.Linq; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.Models; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Appointments page state class. - /// - public class AppointmentsState - { - /// List of appointments. - public Appointment[] Appointments { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Error message if any. - public string Error { get; set; } - - /// Current status filter. - public string StatusFilter { get; set; } - } - - /// - /// Appointments management page. - /// - public static class AppointmentsPage - { - /// - /// Renders the appointments page. - /// - public static ReactElement Render(Action onEditAppointment) => - RenderInternal(onEditAppointment); - - private static ReactElement RenderInternal(Action onEditAppointment) - { - var stateResult = UseState( - new AppointmentsState - { - Appointments = new Appointment[0], - Loading = true, - Error = null, - StatusFilter = null, - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - UseEffect( - () => - { - LoadAppointments(setState); - }, - new object[0] - ); - - ReactElement content; - if (state.Loading) - { - content = RenderLoadingList(); - } - else if (state.Error != null) - { - content = RenderError(state.Error); - } - else if (state.Appointments.Length == 0) - { - content = RenderEmpty(); - } - else - { - content = RenderAppointmentList( - state.Appointments, - state.StatusFilter, - onEditAppointment - ); - } - - return Div( - className: "page", - children: new[] - { - // Page header - Div( - className: "page-header flex justify-between items-center", - children: new[] - { - Div( - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Appointments") } - ), - P( - className: "page-description", - children: new[] - { - Text( - "Manage scheduled appointments from the Scheduling domain" - ), - } - ), - } - ), - Button( - className: "btn btn-primary", - children: new[] { Icons.Plus(), Text("New Appointment") } - ), - } - ), - // Filters - Div( - className: "card mb-6", - children: new[] - { - Div( - className: "tabs", - children: new[] - { - RenderTab( - "All", - null, - state.StatusFilter, - s => FilterByStatus(s, state, setState) - ), - RenderTab( - "Booked", - "booked", - state.StatusFilter, - s => FilterByStatus(s, state, setState) - ), - RenderTab( - "Arrived", - "arrived", - state.StatusFilter, - s => FilterByStatus(s, state, setState) - ), - RenderTab( - "Fulfilled", - "fulfilled", - state.StatusFilter, - s => FilterByStatus(s, state, setState) - ), - RenderTab( - "Cancelled", - "cancelled", - state.StatusFilter, - s => FilterByStatus(s, state, setState) - ), - } - ), - } - ), - // Content - content, - } - ); - } - - private static async void LoadAppointments(Action setState) - { - try - { - var appointments = await ApiClient.GetAppointmentsAsync(); - setState( - new AppointmentsState - { - Appointments = appointments, - Loading = false, - Error = null, - StatusFilter = null, - } - ); - } - catch (Exception ex) - { - setState( - new AppointmentsState - { - Appointments = new Appointment[0], - Loading = false, - Error = ex.Message, - StatusFilter = null, - } - ); - } - } - - private static void FilterByStatus( - string status, - AppointmentsState currentState, - Action setState - ) => - setState( - new AppointmentsState - { - Appointments = currentState.Appointments, - Loading = currentState.Loading, - Error = currentState.Error, - StatusFilter = status, - } - ); - - private static ReactElement RenderTab( - string label, - string status, - string currentFilter, - Action onSelect - ) - { - var isActive = status == currentFilter; - return Button( - className: "tab " + (isActive ? "active" : ""), - onClick: () => onSelect(status), - children: new[] { Text(label) } - ); - } - - private static ReactElement RenderError(string message) => - Div( - className: "card", - style: new { borderLeft = "4px solid var(--error)" }, - children: new[] - { - Div( - className: "flex items-center gap-3 p-4", - children: new[] - { - Icons.X(), - Text("Error loading appointments: " + message), - } - ), - } - ); - - private static ReactElement RenderEmpty() => - Div( - className: "card", - children: new[] - { - Div( - className: "empty-state", - children: new[] - { - Icons.Calendar(), - H( - 4, - className: "empty-state-title", - children: new[] { Text("No Appointments") } - ), - P( - className: "empty-state-description", - children: new[] - { - Text( - "No appointments scheduled. Create a new appointment to get started." - ), - } - ), - Button( - className: "btn btn-primary mt-4", - children: new[] { Icons.Plus(), Text("New Appointment") } - ), - } - ), - } - ); - - private static ReactElement RenderLoadingList() => - Div( - className: "data-list", - children: Enumerable - .Range(0, 5) - .Select(i => - Div( - className: "card", - children: new[] - { - Div( - className: "flex items-center gap-4", - children: new[] - { - Div( - className: "skeleton", - style: new - { - width = "60px", - height = "60px", - borderRadius = "var(--radius-lg)", - } - ), - Div( - className: "flex-1", - children: new[] - { - Div( - className: "skeleton", - style: new { width = "200px", height = "20px" } - ), - Div( - className: "skeleton mt-2", - style: new { width = "150px", height = "16px" } - ), - } - ), - Div( - className: "skeleton", - style: new { width = "100px", height = "32px" } - ), - } - ), - } - ) - ) - .ToArray() - ); - - private static ReactElement RenderAppointmentList( - Appointment[] appointments, - string statusFilter, - Action onEditAppointment - ) - { - var filtered = - statusFilter == null - ? appointments - : appointments.Where(a => a.Status == statusFilter).ToArray(); - - return Div( - className: "data-list", - children: filtered - .Select(a => RenderAppointmentCard(a, onEditAppointment)) - .ToArray() - ); - } - - private static ReactElement RenderAppointmentCard( - Appointment appointment, - Action onEditAppointment - ) - { - ReactElement descElement; - if (appointment.Description != null) - { - descElement = P( - className: "text-sm mt-2", - children: new[] { Text(appointment.Description) } - ); - } - else - { - descElement = Text(""); - } - - return Div( - className: "card-glass mb-4", - children: new[] - { - Div( - className: "flex items-start gap-4", - children: new[] - { - // Time block - Div( - className: "metric-icon blue", - style: new { width = "60px", height = "60px" }, - children: new[] - { - Div( - className: "text-center", - children: new[] - { - Div( - className: "text-lg font-bold", - children: new[] - { - Text(FormatTime(appointment.StartTime)), - } - ), - Div( - className: "text-xs", - children: new[] - { - Text(appointment.MinutesDuration + "min"), - } - ), - } - ), - } - ), - // Details - Div( - className: "flex-1", - children: new[] - { - Div( - className: "flex items-center gap-2", - children: new[] - { - H( - 4, - className: "font-semibold", - children: new[] - { - Text(appointment.ServiceType ?? "Appointment"), - } - ), - RenderStatusBadge(appointment.Status), - RenderPriorityBadge(appointment.Priority), - } - ), - Div( - className: "text-sm text-gray-600 mt-1", - children: new[] - { - Icons.Users(), - Text( - " Patient: " - + FormatReference(appointment.PatientReference) - ), - } - ), - Div( - className: "text-sm text-gray-600", - children: new[] - { - Icons.UserDoctor(), - Text( - " Provider: " - + FormatReference( - appointment.PractitionerReference - ) - ), - } - ), - descElement, - } - ), - // Actions - Div( - className: "flex flex-col gap-2", - children: new[] - { - Button( - className: "btn btn-primary btn-sm", - children: new[] { Text("Check In") } - ), - Button( - className: "btn btn-secondary btn-sm", - onClick: () => onEditAppointment(appointment.Id), - children: new[] { Icons.Edit() } - ), - } - ), - } - ), - } - ); - } - - private static ReactElement RenderStatusBadge(string status) - { - string badgeClass; - if (status == "booked") - badgeClass = "badge-primary"; - else if (status == "arrived") - badgeClass = "badge-teal"; - else if (status == "fulfilled") - badgeClass = "badge-success"; - else if (status == "cancelled") - badgeClass = "badge-error"; - else if (status == "noshow") - badgeClass = "badge-warning"; - else - badgeClass = "badge-gray"; - - return Span(className: "badge " + badgeClass, children: new[] { Text(status) }); - } - - private static ReactElement RenderPriorityBadge(string priority) - { - if (priority == "routine") - return Text(""); - - string badgeClass; - if (priority == "urgent") - badgeClass = "badge-warning"; - else if (priority == "asap") - badgeClass = "badge-error"; - else if (priority == "stat") - badgeClass = "badge-error"; - else - badgeClass = "badge-gray"; - - return Span( - className: "badge " + badgeClass, - children: new[] { Text(priority.ToUpper()) } - ); - } - - private static string FormatTime(string dateTime) - { - if (string.IsNullOrEmpty(dateTime)) - return "N/A"; - // Simple time extraction - in real app use proper date parsing - if (dateTime.Length > 16) - return dateTime.Substring(11, 5); - return dateTime; - } - - private static string FormatReference(string reference) - { - // Extract ID from reference like "Patient/abc-123" -> "abc-123" - var parts = reference.Split('/'); - if (parts.Length > 1) - { - var id = parts[1]; - var length = Math.Min(8, id.Length); - return id.Substring(0, length) + "..."; - } - return reference; - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/CalendarPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/CalendarPage.cs deleted file mode 100644 index a3862947..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/CalendarPage.cs +++ /dev/null @@ -1,630 +0,0 @@ -using System; -using System.Linq; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.Models; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Calendar page state class. - /// - public class CalendarState - { - /// List of appointments. - public Appointment[] Appointments { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Error message if any. - public string Error { get; set; } - - /// Current view year. - public int Year { get; set; } - - /// Current view month (1-12). - public int Month { get; set; } - - /// Selected day for details view. - public int SelectedDay { get; set; } - } - - /// - /// Calendar-based schedule view page. - /// - public static class CalendarPage - { - private static readonly string[] MonthNames = new[] - { - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - }; - - private static readonly string[] DayNames = new[] - { - "Sun", - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - }; - - /// - /// Renders the calendar page. - /// - public static ReactElement Render(Action onEditAppointment) - { - var now = DateTime.Now; - var stateResult = UseState( - new CalendarState - { - Appointments = new Appointment[0], - Loading = true, - Error = null, - Year = now.Year, - Month = now.Month, - SelectedDay = 0, - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - UseEffect( - () => - { - LoadAppointments(setState, state); - }, - new object[0] - ); - - return Div( - className: "page", - children: new[] - { - RenderHeader(state, setState), - state.Loading ? RenderLoadingState() - : state.Error != null ? RenderError(state.Error) - : RenderCalendarContent(state, setState, onEditAppointment), - } - ); - } - - private static async void LoadAppointments( - Action setState, - CalendarState currentState - ) - { - try - { - var appointments = await ApiClient.GetAppointmentsAsync(); - setState( - new CalendarState - { - Appointments = appointments, - Loading = false, - Error = null, - Year = currentState.Year, - Month = currentState.Month, - SelectedDay = currentState.SelectedDay, - } - ); - } - catch (Exception ex) - { - setState( - new CalendarState - { - Appointments = new Appointment[0], - Loading = false, - Error = ex.Message, - Year = currentState.Year, - Month = currentState.Month, - SelectedDay = currentState.SelectedDay, - } - ); - } - } - - private static ReactElement RenderHeader( - CalendarState state, - Action setState - ) - { - var monthName = MonthNames[state.Month - 1]; - return Div( - className: "page-header flex justify-between items-center mb-6", - children: new[] - { - Div( - children: new[] - { - H(2, className: "page-title", children: new[] { Text("Schedule") }), - P( - className: "page-description", - children: new[] - { - Text("View and manage appointments on the calendar"), - } - ), - } - ), - Div( - className: "flex items-center gap-4", - children: new[] - { - Button( - className: "btn btn-secondary btn-sm", - onClick: () => NavigateMonth(state, setState, -1), - children: new[] { Icons.ChevronLeft() } - ), - Span( - className: "text-lg font-semibold", - children: new[] { Text(monthName + " " + state.Year) } - ), - Button( - className: "btn btn-secondary btn-sm", - onClick: () => NavigateMonth(state, setState, 1), - children: new[] { Icons.ChevronRight() } - ), - Button( - className: "btn btn-primary btn-sm ml-4", - onClick: () => GoToToday(state, setState), - children: new[] { Text("Today") } - ), - } - ), - } - ); - } - - private static void NavigateMonth( - CalendarState state, - Action setState, - int delta - ) - { - var newMonth = state.Month + delta; - var newYear = state.Year; - - if (newMonth < 1) - { - newMonth = 12; - newYear--; - } - else if (newMonth > 12) - { - newMonth = 1; - newYear++; - } - - setState( - new CalendarState - { - Appointments = state.Appointments, - Loading = state.Loading, - Error = state.Error, - Year = newYear, - Month = newMonth, - SelectedDay = 0, - } - ); - } - - private static void GoToToday(CalendarState state, Action setState) - { - var now = DateTime.Now; - setState( - new CalendarState - { - Appointments = state.Appointments, - Loading = state.Loading, - Error = state.Error, - Year = now.Year, - Month = now.Month, - SelectedDay = now.Day, - } - ); - } - - private static ReactElement RenderLoadingState() => - Div( - className: "card", - children: new[] - { - Div( - className: "flex items-center justify-center p-8", - children: new[] { Text("Loading calendar...") } - ), - } - ); - - private static ReactElement RenderError(string message) => - Div( - className: "card", - style: new { borderLeft = "4px solid var(--error)" }, - children: new[] - { - Div( - className: "flex items-center gap-3 p-4", - children: new[] - { - Icons.X(), - Text("Error loading appointments: " + message), - } - ), - } - ); - - private static ReactElement RenderCalendarContent( - CalendarState state, - Action setState, - Action onEditAppointment - ) => - Div( - className: "flex gap-6", - children: new[] - { - Div( - className: "flex-1", - children: new[] { RenderCalendarGrid(state, setState) } - ), - state.SelectedDay > 0 - ? RenderDayDetails(state, setState, onEditAppointment) - : RenderNoSelection(), - } - ); - - private static ReactElement RenderCalendarGrid( - CalendarState state, - Action setState - ) - { - var daysInMonth = DateTime.DaysInMonth(state.Year, state.Month); - var firstDay = new DateTime(state.Year, state.Month, 1); - var startDayOfWeek = (int)firstDay.DayOfWeek; - - var headerCells = DayNames - .Select(day => - Div( - className: "calendar-header-cell text-center font-semibold p-2", - children: new[] { Text(day) } - ) - ) - .ToArray(); - - var dayCells = new ReactElement[42]; // 6 rows * 7 days - var today = DateTime.Now; - - for (var i = 0; i < 42; i++) - { - var dayNum = i - startDayOfWeek + 1; - if (dayNum < 1 || dayNum > daysInMonth) - { - dayCells[i] = Div( - className: "calendar-cell empty", - children: new ReactElement[0] - ); - } - else - { - var appointments = GetAppointmentsForDay( - state.Appointments, - state.Year, - state.Month, - dayNum - ); - var isToday = - state.Year == today.Year - && state.Month == today.Month - && dayNum == today.Day; - var isSelected = dayNum == state.SelectedDay; - var dayNumCaptured = dayNum; - - var cellClasses = "calendar-cell"; - if (isToday) - cellClasses += " today"; - if (isSelected) - cellClasses += " selected"; - if (appointments.Length > 0) - cellClasses += " has-appointments"; - - dayCells[i] = Div( - className: cellClasses, - onClick: () => SelectDay(state, setState, dayNumCaptured), - children: new[] - { - Div( - className: "calendar-day-number", - children: new[] { Text(dayNum.ToString()) } - ), - appointments.Length > 0 - ? Div( - className: "calendar-appointments-preview", - children: appointments - .Take(3) - .Select(a => RenderAppointmentDot(a)) - .ToArray() - ) - : null, - appointments.Length > 3 - ? Span( - className: "calendar-more-indicator", - children: new[] { Text("+" + (appointments.Length - 3)) } - ) - : null, - } - ); - } - } - - return Div( - className: "card calendar-grid-container", - children: new[] - { - Div(className: "calendar-grid-header grid grid-cols-7", children: headerCells), - Div(className: "calendar-grid grid grid-cols-7", children: dayCells), - } - ); - } - - private static ReactElement RenderAppointmentDot(Appointment appointment) - { - var dotClass = "calendar-dot"; - if (appointment.Status == "booked") - dotClass += " blue"; - else if (appointment.Status == "arrived") - dotClass += " teal"; - else if (appointment.Status == "fulfilled") - dotClass += " green"; - else if (appointment.Status == "cancelled") - dotClass += " red"; - else - dotClass += " gray"; - - return Span(className: dotClass); - } - - private static void SelectDay( - CalendarState state, - Action setState, - int day - ) => - setState( - new CalendarState - { - Appointments = state.Appointments, - Loading = state.Loading, - Error = state.Error, - Year = state.Year, - Month = state.Month, - SelectedDay = day, - } - ); - - private static Appointment[] GetAppointmentsForDay( - Appointment[] appointments, - int year, - int month, - int day - ) - { - var targetDate = new DateTime(year, month, day).ToString("yyyy-MM-dd"); - return appointments - .Where(a => a.StartTime != null && a.StartTime.StartsWith(targetDate)) - .OrderBy(a => a.StartTime) - .ToArray(); - } - - private static ReactElement RenderNoSelection() => - Div( - className: "card calendar-details-panel", - style: new { width = "320px", minHeight = "400px" }, - children: new[] - { - Div( - className: "empty-state p-6", - children: new[] - { - Icons.Calendar(), - H( - 4, - className: "empty-state-title mt-4", - children: new[] { Text("Select a Day") } - ), - P( - className: "empty-state-description", - children: new[] { Text("Click on a day to view appointments") } - ), - } - ), - } - ); - - private static ReactElement RenderDayDetails( - CalendarState state, - Action setState, - Action onEditAppointment - ) - { - var appointments = GetAppointmentsForDay( - state.Appointments, - state.Year, - state.Month, - state.SelectedDay - ); - - var monthName = MonthNames[state.Month - 1]; - var dateStr = monthName + " " + state.SelectedDay + ", " + state.Year; - - return Div( - className: "card calendar-details-panel", - style: new { width = "320px", minHeight = "400px" }, - children: new[] - { - Div( - className: "flex justify-between items-center mb-4 p-4 border-b", - children: new[] - { - H(4, className: "font-semibold", children: new[] { Text(dateStr) }), - Button( - className: "btn btn-secondary btn-sm", - onClick: () => SelectDay(state, setState, 0), - children: new[] { Icons.X() } - ), - } - ), - appointments.Length == 0 - ? Div( - className: "empty-state p-6", - children: new[] - { - Icons.Calendar(), - P( - className: "empty-state-description mt-4", - children: new[] { Text("No appointments scheduled") } - ), - } - ) - : Div( - className: "p-4 space-y-3", - children: appointments - .Select(a => RenderDayAppointment(a, onEditAppointment)) - .ToArray() - ), - } - ); - } - - private static ReactElement RenderDayAppointment( - Appointment appointment, - Action onEditAppointment - ) - { - var time = FormatTime(appointment.StartTime); - var endTime = FormatTime(appointment.EndTime); - var statusClass = GetStatusClass(appointment.Status); - - return Div( - className: "calendar-appointment-item p-3 rounded-lg border", - children: new[] - { - Div( - className: "flex justify-between items-start mb-2", - children: new[] - { - Div( - children: new[] - { - Div( - className: "font-semibold", - children: new[] - { - Text(appointment.ServiceType ?? "Appointment"), - } - ), - Div( - className: "text-sm text-gray-500", - children: new[] { Text(time + " - " + endTime) } - ), - } - ), - Span( - className: "badge " + statusClass, - children: new[] { Text(appointment.Status ?? "unknown") } - ), - } - ), - Div( - className: "text-sm text-gray-600 mb-2", - children: new[] - { - Div( - children: new[] - { - Text( - "Patient: " + FormatReference(appointment.PatientReference) - ), - } - ), - Div( - children: new[] - { - Text( - "Provider: " - + FormatReference(appointment.PractitionerReference) - ), - } - ), - } - ), - Div( - className: "flex gap-2", - children: new[] - { - Button( - className: "btn btn-primary btn-sm flex-1", - onClick: () => onEditAppointment(appointment.Id), - children: new[] { Icons.Edit(), Text("Edit") } - ), - } - ), - } - ); - } - - private static string FormatTime(string dateTime) - { - if (string.IsNullOrEmpty(dateTime)) - return "N/A"; - if (dateTime.Length > 16) - return dateTime.Substring(11, 5); - return dateTime; - } - - private static string FormatReference(string reference) - { - if (string.IsNullOrEmpty(reference)) - return "N/A"; - var parts = reference.Split('/'); - if (parts.Length > 1) - { - var id = parts[1]; - var length = Math.Min(8, id.Length); - return id.Substring(0, length) + "..."; - } - return reference; - } - - private static string GetStatusClass(string status) - { - if (status == "booked") - return "badge-primary"; - if (status == "arrived") - return "badge-teal"; - if (status == "fulfilled") - return "badge-success"; - if (status == "cancelled") - return "badge-error"; - if (status == "noshow") - return "badge-warning"; - return "badge-gray"; - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/ClinicalCodingPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/ClinicalCodingPage.cs deleted file mode 100644 index 08eb232d..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/ClinicalCodingPage.cs +++ /dev/null @@ -1,1568 +0,0 @@ -using System; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.Models; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Clinical coding page state. - /// - public class ClinicalCodingState - { - /// Current search query. - public string SearchQuery { get; set; } - - /// Active search mode (keyword, semantic, lookup). - public string SearchMode { get; set; } - - /// ICD-10 search results. - public Icd10Code[] Icd10Results { get; set; } - - /// ACHI search results. - public AchiCode[] AchiResults { get; set; } - - /// Semantic search results. - public SemanticSearchResult[] SemanticResults { get; set; } - - /// Selected code for detail view. - public Icd10Code SelectedCode { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Error message if any. - public string Error { get; set; } - - /// Whether to include ACHI in semantic search. - public bool IncludeAchi { get; set; } - - /// Copied code for feedback. - public string CopiedCode { get; set; } - } - - /// - /// Clinical coding page for ICD-10 code lookup and search. - /// - public static class ClinicalCodingPage - { - /// - /// Renders the clinical coding page. - /// - public static ReactElement Render() - { - var stateResult = UseState( - new ClinicalCodingState - { - SearchQuery = "", - SearchMode = "keyword", - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = false, - Error = null, - IncludeAchi = false, - CopiedCode = null, - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - return Div( - className: "page clinical-coding-page", - children: new[] - { - RenderHeader(), - RenderSearchSection(state, setState), - RenderContent(state, setState), - } - ); - } - - private static ReactElement RenderHeader() => - Div( - className: "page-header", - children: new[] - { - Div( - className: "flex items-center gap-3", - children: new[] - { - Div( - className: "page-header-icon", - style: new - { - background = "linear-gradient(135deg, #3b82f6, #8b5cf6)", - borderRadius = "12px", - padding = "12px", - display = "flex", - alignItems = "center", - justifyContent = "center", - }, - children: new[] { Icons.Code() } - ), - Div( - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Clinical Coding") } - ), - P( - className: "page-description", - children: new[] - { - Text( - "Search ICD-10-AM diagnosis codes and ACHI procedure codes" - ), - } - ), - } - ), - } - ), - } - ); - - private static ReactElement RenderSearchSection( - ClinicalCodingState state, - Action setState - ) => - Div( - className: "card mb-6", - style: new { padding = "24px" }, - children: new[] - { - RenderSearchTabs(state, setState), - RenderSearchInput(state, setState), - RenderSearchOptions(state, setState), - } - ); - - private static ReactElement RenderSearchTabs( - ClinicalCodingState state, - Action setState - ) => - Div( - className: "flex gap-2 mb-4", - children: new[] - { - RenderTab( - label: "Keyword Search", - icon: Icons.Search, - isActive: state.SearchMode == "keyword", - onClick: () => SetSearchMode(state, setState, mode: "keyword") - ), - RenderTab( - label: "AI Search", - icon: Icons.Sparkles, - isActive: state.SearchMode == "semantic", - onClick: () => SetSearchMode(state, setState, mode: "semantic") - ), - RenderTab( - label: "Code Lookup", - icon: Icons.FileText, - isActive: state.SearchMode == "lookup", - onClick: () => SetSearchMode(state, setState, mode: "lookup") - ), - } - ); - - private static ReactElement RenderTab( - string label, - Func icon, - bool isActive, - Action onClick - ) => - Button( - className: "btn " + (isActive ? "btn-primary" : "btn-secondary"), - onClick: onClick, - children: new[] { icon(), Text(label) } - ); - - private static void SetSearchMode( - ClinicalCodingState state, - Action setState, - string mode - ) - { - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = mode, - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - - private static ReactElement RenderSearchInput( - ClinicalCodingState state, - Action setState - ) - { - var placeholder = GetPlaceholder(state.SearchMode); - - return Div( - className: "flex gap-4", - children: new[] - { - Div( - className: "flex-1 search-input search-input-lg", - children: new[] - { - Span(className: "search-icon", children: new[] { Icons.Search() }), - Input( - className: "input input-lg", - type: "text", - placeholder: placeholder, - value: state.SearchQuery, - onChange: query => UpdateQuery(state, setState, query: query), - onKeyDown: key => - { - if (key == "Enter") - ExecuteSearch(state, setState); - } - ), - } - ), - Button( - className: "btn btn-primary btn-lg", - onClick: () => ExecuteSearch(state, setState), - children: new[] - { - state.Loading ? Icons.Refresh() : Icons.Search(), - Text(state.Loading ? "Searching..." : "Search"), - } - ), - } - ); - } - - private static string GetPlaceholder(string mode) - { - if (mode == "keyword") - return "Search by code, description, or keywords (e.g., 'diabetes', 'fracture')"; - if (mode == "semantic") - return "Describe symptoms or conditions in natural language..."; - return "Enter ICD-10 code or prefix (e.g., 'O9A.', 'E11', 'J18.9')"; - } - - private static void UpdateQuery( - ClinicalCodingState state, - Action setState, - string query - ) - { - setState( - new ClinicalCodingState - { - SearchQuery = query, - SearchMode = state.SearchMode, - Icd10Results = state.Icd10Results, - AchiResults = state.AchiResults, - SemanticResults = state.SemanticResults, - SelectedCode = state.SelectedCode, - Loading = state.Loading, - Error = state.Error, - IncludeAchi = state.IncludeAchi, - CopiedCode = state.CopiedCode, - } - ); - } - - private static ReactElement RenderSearchOptions( - ClinicalCodingState state, - Action setState - ) - { - if (state.SearchMode != "semantic") - return Text(""); - - return Div( - className: "flex items-center gap-4 mt-4", - children: new[] - { - Label( - className: "flex items-center gap-2 cursor-pointer", - children: new[] - { - Input( - className: "checkbox", - type: "checkbox", - value: state.IncludeAchi ? "true" : "", - onChange: _ => ToggleAchi(state, setState) - ), - Span(children: new[] { Text("Include ACHI procedure codes") }), - } - ), - Span( - className: "text-sm text-gray-500", - children: new[] - { - Icons.Sparkles(), - Text(" Powered by medical AI embeddings"), - } - ), - } - ); - } - - private static void ToggleAchi( - ClinicalCodingState state, - Action setState - ) - { - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = state.Icd10Results, - AchiResults = state.AchiResults, - SemanticResults = state.SemanticResults, - SelectedCode = state.SelectedCode, - Loading = state.Loading, - Error = state.Error, - IncludeAchi = !state.IncludeAchi, - CopiedCode = state.CopiedCode, - } - ); - } - - private static async void ExecuteSearch( - ClinicalCodingState state, - Action setState - ) - { - if (string.IsNullOrWhiteSpace(state.SearchQuery)) - return; - - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = true, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - - try - { - if (state.SearchMode == "keyword") - { - var results = await ApiClient.SearchIcd10CodesAsync( - query: state.SearchQuery, - limit: 50 - ); - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = results, - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - else if (state.SearchMode == "semantic") - { - var results = await ApiClient.SemanticSearchAsync( - query: state.SearchQuery, - limit: 20, - includeAchi: state.IncludeAchi - ); - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = results, - SelectedCode = null, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - else - { - // Code Lookup: Use keyword search with prefix filter - var allResults = await ApiClient.SearchIcd10CodesAsync( - query: state.SearchQuery, - limit: 100 - ); - - // Filter to codes that start with the search query (case-insensitive prefix match) - var query = state.SearchQuery.ToUpper(); - var matchingCodes = new System.Collections.Generic.List(); - foreach (var c in allResults) - { - if (c.Code != null && c.Code.ToUpper().StartsWith(query)) - { - matchingCodes.Add(c); - } - } - - // If exactly one result, show detail view; otherwise show list - if (matchingCodes.Count == 1) - { - var fullCode = await ApiClient.GetIcd10CodeAsync( - code: matchingCodes[0].Code - ); - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = fullCode, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - else - { - // Multiple matches or no matches - show as list - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = matchingCodes.ToArray(), - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - } - } - catch (Exception ex) - { - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = false, - Error = ex.Message, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - } - - private static ReactElement RenderContent( - ClinicalCodingState state, - Action setState - ) - { - if (state.Loading) - return RenderLoading(); - - if (state.Error != null) - return RenderError(state.Error); - - if (state.SelectedCode != null) - return RenderCodeDetail(state, setState); - - if (state.SemanticResults.Length > 0) - return RenderSemanticResults(state, setState); - - if (state.Icd10Results.Length > 0) - return RenderKeywordResults(state, setState); - - // Show "no results" for Code Lookup when search was performed - if ( - state.SearchMode == "lookup" - && !string.IsNullOrWhiteSpace(state.SearchQuery) - && !state.Loading - ) - return RenderNoResults(state.SearchQuery); - - return RenderEmptyState(state); - } - - private static ReactElement RenderNoResults(string query) => - Div( - className: "card", - children: new[] - { - Div( - className: "empty-state", - children: new[] - { - Div( - style: new - { - background = "linear-gradient(135deg, #6b7280, #9ca3af)", - borderRadius = "16px", - padding = "20px", - marginBottom = "16px", - }, - children: new[] { Icons.Search() } - ), - H( - 4, - className: "empty-state-title", - children: new[] { Text("No codes found") } - ), - P( - className: "empty-state-description", - children: new[] - { - Text( - "No ICD-10 codes match '" - + query - + "'. Try a different code or use keyword search." - ), - } - ), - } - ), - } - ); - - private static ReactElement RenderLoading() => - Div( - className: "card", - children: new[] - { - Div( - className: "flex items-center justify-center p-12", - children: new[] - { - Div( - className: "loading-spinner", - style: new - { - width = "48px", - height = "48px", - border = "4px solid #e5e7eb", - borderTop = "4px solid #3b82f6", - borderRadius = "50%", - animation = "spin 1s linear infinite", - } - ), - } - ), - } - ); - - private static ReactElement RenderError(string error) => - Div( - className: "card", - style: new { borderLeft = "4px solid var(--error)" }, - children: new[] - { - Div( - className: "flex items-center gap-3 p-4", - children: new[] - { - Icons.X(), - Div( - children: new[] - { - H( - 4, - className: "font-semibold", - children: new[] { Text("Search Error") } - ), - P( - className: "text-sm text-gray-600", - children: new[] { Text(error) } - ), - P( - className: "text-sm text-gray-500 mt-2", - children: new[] - { - Text( - "Make sure the ICD-10 API (port 5090) is running." - ), - } - ), - } - ), - } - ), - } - ); - - private static ReactElement RenderEmptyState(ClinicalCodingState state) - { - var title = GetEmptyTitle(state.SearchMode); - var description = GetEmptyDescription(state.SearchMode); - - return Div( - className: "card", - children: new[] - { - Div( - className: "empty-state", - children: new[] - { - Div( - style: new - { - background = "linear-gradient(135deg, #3b82f6, #8b5cf6)", - borderRadius = "16px", - padding = "20px", - marginBottom = "16px", - }, - children: new[] { Icons.Code() } - ), - H(4, className: "empty-state-title", children: new[] { Text(title) }), - P( - className: "empty-state-description", - children: new[] { Text(description) } - ), - RenderQuickSearches(state), - } - ), - } - ); - } - - private static string GetEmptyTitle(string mode) - { - if (mode == "semantic") - return "AI-Powered Code Search"; - if (mode == "lookup") - return "Direct Code Lookup"; - return "ICD-10-AM Code Search"; - } - - private static string GetEmptyDescription(string mode) - { - if (mode == "semantic") - return "Describe symptoms in natural language and let AI find the right codes."; - if (mode == "lookup") - return "Enter an ICD-10 code or prefix to find matching codes (e.g., 'O9A.' lists all O9A codes)."; - return "Search diagnosis codes by keyword, description, or code fragment."; - } - - private static ReactElement RenderQuickSearches(ClinicalCodingState state) - { - if (state.SearchMode == "lookup") - return Text(""); - - string[] examples; - if (state.SearchMode == "semantic") - { - examples = new[] - { - "Patient with chest pain and shortness of breath", - "Type 2 diabetes with kidney complications", - "Broken arm from fall", - "Chronic lower back pain", - }; - } - else - { - examples = new[] { "diabetes", "pneumonia", "fracture", "hypertension" }; - } - - var buttons = new ReactElement[examples.Length]; - for (int i = 0; i < examples.Length; i++) - { - var example = examples[i]; - buttons[i] = Button( - className: "btn btn-ghost btn-sm", - onClick: () => { }, - children: new[] { Text(example) } - ); - } - - return Div( - className: "flex flex-wrap gap-2 mt-4", - children: new ReactElement[] - { - Span(className: "text-sm text-gray-500", children: new[] { Text("Try: ") }), - }.Concat(buttons) - ); - } - - private static ReactElement[] Concat(this ReactElement[] arr1, ReactElement[] arr2) - { - var result = new ReactElement[arr1.Length + arr2.Length]; - for (int i = 0; i < arr1.Length; i++) - result[i] = arr1[i]; - for (int i = 0; i < arr2.Length; i++) - result[arr1.Length + i] = arr2[i]; - return result; - } - - private static ReactElement RenderKeywordResults( - ClinicalCodingState state, - Action setState - ) - { - var resultRows = new ReactElement[state.Icd10Results.Length]; - for (int i = 0; i < state.Icd10Results.Length; i++) - { - var code = state.Icd10Results[i]; - resultRows[i] = RenderCodeRow(code, state, setState); - } - - return Div( - children: new[] - { - Div( - className: "flex items-center justify-between mb-4", - children: new[] - { - Span( - className: "text-sm text-gray-600", - children: new[] - { - Text(state.Icd10Results.Length + " results found"), - } - ), - } - ), - Div( - className: "table-container", - children: new[] - { - Table( - className: "table", - children: new[] - { - THead( - Tr( - children: new[] - { - Th(children: new[] { Text("Code") }), - Th(children: new[] { Text("Description") }), - Th(children: new[] { Text("Chapter") }), - Th(children: new[] { Text("Category") }), - Th(children: new[] { Text("Status") }), - Th(children: new[] { Text("") }), - } - ) - ), - TBody(resultRows), - } - ), - } - ), - } - ); - } - - private static ReactElement RenderCodeRow( - Icd10Code code, - ClinicalCodingState state, - Action setState - ) => - Tr( - className: "search-result-row", - onClick: () => SelectCode(code, state, setState), - children: new[] - { - Td( - children: new[] - { - Span( - className: "badge badge-primary", - style: new - { - background = "linear-gradient(135deg, #3b82f6, #8b5cf6)", - color = "white", - fontWeight = "600", - }, - children: new[] { Text(code.Code) } - ), - } - ), - Td( - className: "result-description-cell", - children: new[] - { - Span(children: new[] { Text(code.ShortDescription ?? "") }), - Div( - className: "result-tooltip", - children: new[] - { - H( - 4, - className: "font-semibold mb-2", - children: new[] { Text(code.ShortDescription ?? "") } - ), - P( - className: "text-sm text-gray-600 mb-3", - children: new[] - { - Text( - code.LongDescription ?? code.ShortDescription ?? "" - ), - } - ), - !string.IsNullOrEmpty(code.InclusionTerms) - ? Div( - className: "text-xs text-gray-500 mb-2", - children: new[] - { - Span( - className: "font-semibold", - children: new[] { Text("Includes: ") } - ), - Text(code.InclusionTerms), - } - ) - : Text(""), - !string.IsNullOrEmpty(code.ExclusionTerms) - ? Div( - className: "text-xs text-gray-500", - children: new[] - { - Span( - className: "font-semibold", - children: new[] { Text("Excludes: ") } - ), - Text(code.ExclusionTerms), - } - ) - : Text(""), - } - ), - } - ), - Td( - className: "text-sm text-gray-600", - children: new[] { Text("Ch. " + code.ChapterNumber) } - ), - Td( - className: "text-sm text-gray-600", - children: new[] { Text(code.CategoryCode ?? "") } - ), - Td( - children: new[] - { - code.Billable - ? Span( - className: "badge badge-success", - children: new[] { Text("Billable") } - ) - : Span( - className: "badge badge-gray", - children: new[] { Text("Non-billable") } - ), - } - ), - Td( - children: new[] - { - Button( - className: "btn btn-ghost btn-sm", - onClick: () => CopyCode(code.Code, state, setState), - children: new[] - { - state.CopiedCode == code.Code ? Icons.Check() : Icons.Copy(), - } - ), - } - ), - } - ); - - private static async void SelectCode( - Icd10Code code, - ClinicalCodingState state, - Action setState - ) - { - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = state.Icd10Results, - AchiResults = state.AchiResults, - SemanticResults = state.SemanticResults, - SelectedCode = null, - Loading = true, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = state.CopiedCode, - } - ); - - try - { - var fullCode = await ApiClient.GetIcd10CodeAsync(code: code.Code); - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = state.Icd10Results, - AchiResults = state.AchiResults, - SemanticResults = state.SemanticResults, - SelectedCode = fullCode, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = state.CopiedCode, - } - ); - } - catch (Exception ex) - { - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = state.Icd10Results, - AchiResults = state.AchiResults, - SemanticResults = state.SemanticResults, - SelectedCode = null, - Loading = false, - Error = "Failed to load code details: " + ex.Message, - IncludeAchi = state.IncludeAchi, - CopiedCode = state.CopiedCode, - } - ); - } - } - - private static void CopyCode( - string code, - ClinicalCodingState state, - Action setState - ) - { - H5.Script.Call("navigator.clipboard.writeText", code); - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = state.Icd10Results, - AchiResults = state.AchiResults, - SemanticResults = state.SemanticResults, - SelectedCode = state.SelectedCode, - Loading = state.Loading, - Error = state.Error, - IncludeAchi = state.IncludeAchi, - CopiedCode = code, - } - ); - } - - private static ReactElement RenderSemanticResults( - ClinicalCodingState state, - Action setState - ) - { - var resultRows = new ReactElement[state.SemanticResults.Length]; - for (int i = 0; i < state.SemanticResults.Length; i++) - { - var result = state.SemanticResults[i]; - resultRows[i] = RenderSemanticRow(result, state, setState); - } - - return Div( - children: new[] - { - Div( - className: "flex items-center justify-between mb-4", - children: new[] - { - Div( - className: "flex items-center gap-2", - children: new[] - { - Icons.Sparkles(), - Span( - className: "text-sm text-gray-600", - children: new[] - { - Text( - state.SemanticResults.Length + " AI-matched results" - ), - } - ), - } - ), - } - ), - Div( - className: "table-container", - children: new[] - { - Table( - className: "table", - children: new[] - { - THead( - Tr( - children: new[] - { - Th(children: new[] { Text("Code") }), - Th(children: new[] { Text("Type") }), - Th(children: new[] { Text("Chapter") }), - Th(children: new[] { Text("Category") }), - Th(children: new[] { Text("Description") }), - Th(children: new[] { Text("Confidence") }), - } - ) - ), - TBody(resultRows), - } - ), - } - ), - } - ); - } - - private static ReactElement RenderSemanticRow( - SemanticSearchResult result, - ClinicalCodingState state, - Action setState - ) - { - var confidencePercent = (int)(result.Confidence * 100); - var confidenceColor = - confidencePercent >= 80 ? "#22c55e" - : confidencePercent >= 60 ? "#f59e0b" - : "#ef4444"; - var badgeClass = - confidencePercent >= 80 ? "badge-success" - : confidencePercent >= 60 ? "badge-warning" - : "badge-error"; - - return Tr( - className: "search-result-row", - onClick: () => LookupSemanticCode(result.Code, state, setState), - children: new[] - { - Td( - children: new[] - { - Span( - className: "badge badge-primary", - style: new - { - background = result.CodeType == "ACHI" - ? "linear-gradient(135deg, #14b8a6, #0d9488)" - : "linear-gradient(135deg, #3b82f6, #8b5cf6)", - color = "white", - fontWeight = "600", - }, - children: new[] { Text(result.Code) } - ), - } - ), - Td( - children: new[] - { - Span( - className: result.CodeType == "ACHI" - ? "badge badge-teal" - : "badge badge-violet", - children: new[] { Text(result.CodeType ?? "ICD10CM") } - ), - } - ), - Td( - className: "text-sm text-gray-600", - children: new[] - { - Text( - !string.IsNullOrEmpty(result.Chapter) - ? "Ch. " + result.Chapter - : "-" - ), - } - ), - Td( - className: "text-sm text-gray-600", - children: new[] { Text(result.Category ?? "-") } - ), - Td( - className: "result-description-cell", - children: new[] - { - Span(children: new[] { Text(result.Description ?? "") }), - Div( - className: "result-tooltip", - children: RenderSemanticTooltipContent( - result: result, - confidenceColor: confidenceColor, - confidencePercent: confidencePercent - ) - ), - } - ), - Td( - children: new[] - { - Div( - className: "flex items-center gap-2", - children: new[] - { - Div( - style: new - { - width = "60px", - height = "8px", - background = "#e5e7eb", - borderRadius = "4px", - overflow = "hidden", - }, - children: new[] - { - Div( - style: new - { - width = confidencePercent + "%", - height = "100%", - background = confidenceColor, - } - ), - } - ), - Span( - className: "badge " + badgeClass, - children: new[] { Text(confidencePercent + "%") } - ), - } - ), - } - ), - } - ); - } - - private static ReactElement[] RenderSemanticTooltipContent( - SemanticSearchResult result, - string confidenceColor, - int confidencePercent - ) - { - var elements = new System.Collections.Generic.List - { - H( - 4, - className: "font-semibold mb-2", - children: new[] { Text(result.Code + " - " + (result.Description ?? "")) } - ), - P( - className: "text-sm text-gray-600 mb-3", - children: new[] { Text(result.LongDescription ?? result.Description ?? "") } - ), - }; - - if (!string.IsNullOrEmpty(result.InclusionTerms)) - { - elements.Add( - Div( - className: "text-xs text-green-700 mb-2", - children: new[] - { - Span( - className: "font-semibold", - children: new[] { Text("Includes: ") } - ), - Text(result.InclusionTerms), - } - ) - ); - } - - if (!string.IsNullOrEmpty(result.ExclusionTerms)) - { - elements.Add( - Div( - className: "text-xs text-red-700 mb-2", - children: new[] - { - Span( - className: "font-semibold", - children: new[] { Text("Excludes: ") } - ), - Text(result.ExclusionTerms), - } - ) - ); - } - - if (!string.IsNullOrEmpty(result.CodeAlso)) - { - elements.Add( - Div( - className: "text-xs text-blue-700 mb-2", - children: new[] - { - Span( - className: "font-semibold", - children: new[] { Text("Code also: ") } - ), - Text(result.CodeAlso), - } - ) - ); - } - - if (!string.IsNullOrEmpty(result.CodeFirst)) - { - elements.Add( - Div( - className: "text-xs text-purple-700 mb-2", - children: new[] - { - Span( - className: "font-semibold", - children: new[] { Text("Code first: ") } - ), - Text(result.CodeFirst), - } - ) - ); - } - - var footerChildren = new System.Collections.Generic.List - { - Span(className: "font-semibold", children: new[] { Text("Type: ") }), - Text(result.CodeType ?? "ICD10CM"), - }; - - if (!string.IsNullOrEmpty(result.Chapter)) - { - footerChildren.Add(Text(" | ")); - footerChildren.Add( - Span(className: "font-semibold", children: new[] { Text("Chapter: ") }) - ); - footerChildren.Add(Text(result.Chapter + " - " + (result.ChapterTitle ?? ""))); - } - - if (!string.IsNullOrEmpty(result.Category)) - { - footerChildren.Add(Text(" | ")); - footerChildren.Add( - Span(className: "font-semibold", children: new[] { Text("Category: ") }) - ); - footerChildren.Add(Text(result.Category)); - } - - footerChildren.Add(Text(" | ")); - footerChildren.Add( - Span(className: "font-semibold", children: new[] { Text("Confidence: ") }) - ); - footerChildren.Add( - Span( - style: new { color = confidenceColor }, - children: new[] { Text(confidencePercent + "%") } - ) - ); - - elements.Add( - Div( - className: "text-xs text-gray-500 mt-2 pt-2 border-t border-gray-200", - children: footerChildren.ToArray() - ) - ); - - return elements.ToArray(); - } - - private static async void LookupSemanticCode( - string code, - ClinicalCodingState state, - Action setState - ) - { - setState( - new ClinicalCodingState - { - SearchQuery = code, - SearchMode = "lookup", - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = true, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - - try - { - var result = await ApiClient.GetIcd10CodeAsync(code: code); - setState( - new ClinicalCodingState - { - SearchQuery = code, - SearchMode = "lookup", - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = result, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - catch (Exception ex) - { - setState( - new ClinicalCodingState - { - SearchQuery = code, - SearchMode = "lookup", - Icd10Results = new Icd10Code[0], - AchiResults = new AchiCode[0], - SemanticResults = new SemanticSearchResult[0], - SelectedCode = null, - Loading = false, - Error = ex.Message, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - } - - private static ReactElement RenderCodeDetail( - ClinicalCodingState state, - Action setState - ) - { - var code = state.SelectedCode; - - return Div( - children: new[] - { - Button( - className: "btn btn-ghost mb-4", - onClick: () => ClearSelection(state, setState), - children: new[] { Icons.ChevronLeft(), Text("Back to results") } - ), - Div( - className: "card", - style: new { padding = "32px" }, - children: new[] - { - Div( - className: "flex items-start justify-between mb-6", - children: new[] - { - Div( - children: new[] - { - Div( - className: "flex items-center gap-3 mb-2", - children: new[] - { - Span( - style: new - { - background = "linear-gradient(135deg, #3b82f6, #8b5cf6)", - color = "white", - padding = "8px 20px", - borderRadius = "8px", - fontWeight = "700", - fontSize = "20px", - }, - children: new[] { Text(code.Code) } - ), - code.Billable - ? Span( - className: "badge badge-success", - children: new[] - { - Icons.Check(), - Text("Billable"), - } - ) - : Span( - className: "badge badge-gray", - children: new[] { Text("Non-billable") } - ), - } - ), - H( - 2, - className: "text-xl font-semibold mt-4", - children: new[] - { - Text(code.ShortDescription ?? ""), - } - ), - } - ), - Button( - className: "btn btn-primary", - onClick: () => CopyCode(code.Code, state, setState), - children: new[] - { - state.CopiedCode == code.Code - ? Icons.Check() - : Icons.Copy(), - Text( - state.CopiedCode == code.Code - ? "Copied!" - : "Copy Code" - ), - } - ), - } - ), - Div( - className: "grid grid-cols-3 gap-4 mb-6 p-4", - style: new { background = "#f9fafb", borderRadius = "8px" }, - children: new[] - { - RenderDetailItem( - label: "Chapter", - value: code.ChapterNumber - + " - " - + (code.ChapterTitle ?? "") - ), - RenderDetailItem(label: "Block", value: code.BlockCode ?? ""), - RenderDetailItem( - label: "Category", - value: code.CategoryCode ?? "" - ), - } - ), - RenderDetailSection( - title: "Full Description", - content: code.LongDescription - ), - RenderDetailSection( - title: "Inclusion Terms", - content: code.InclusionTerms - ), - RenderDetailSection( - title: "Exclusion Terms", - content: code.ExclusionTerms - ), - RenderDetailSection(title: "Code Also", content: code.CodeAlso), - RenderDetailSection(title: "Code First", content: code.CodeFirst), - } - ), - } - ); - } - - private static ReactElement RenderDetailItem(string label, string value) => - Div( - children: new[] - { - Span( - className: "text-xs text-gray-500 uppercase tracking-wide", - children: new[] { Text(label) } - ), - P(className: "font-medium", children: new[] { Text(value) }), - } - ); - - private static ReactElement RenderDetailSection(string title, string content) - { - if (string.IsNullOrWhiteSpace(content)) - return Text(""); - - return Div( - className: "mb-4", - children: new[] - { - H( - 4, - className: "font-semibold text-gray-700 mb-2", - children: new[] { Text(title) } - ), - Div( - className: "p-4", - style: new - { - background = "#f9fafb", - borderRadius = "8px", - borderLeft = "4px solid #3b82f6", - }, - children: new[] - { - P( - className: "text-gray-700 whitespace-pre-wrap", - children: new[] { Text(content) } - ), - } - ), - } - ); - } - - private static void ClearSelection( - ClinicalCodingState state, - Action setState - ) - { - setState( - new ClinicalCodingState - { - SearchQuery = state.SearchQuery, - SearchMode = state.SearchMode, - Icd10Results = state.Icd10Results, - AchiResults = state.AchiResults, - SemanticResults = state.SemanticResults, - SelectedCode = null, - Loading = false, - Error = null, - IncludeAchi = state.IncludeAchi, - CopiedCode = null, - } - ); - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/DashboardPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/DashboardPage.cs deleted file mode 100644 index ad7a7339..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/DashboardPage.cs +++ /dev/null @@ -1,361 +0,0 @@ -using System; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Dashboard state class. - /// - public class DashboardState - { - /// Patient count. - public int PatientCount { get; set; } - - /// Practitioner count. - public int PractitionerCount { get; set; } - - /// Appointment count. - public int AppointmentCount { get; set; } - - /// Encounter count. - public int EncounterCount { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Error message if any. - public string Error { get; set; } - } - - /// - /// Main dashboard overview page. - /// - public static class DashboardPage - { - /// - /// Renders the dashboard page. - /// - public static ReactElement Render() - { - var stateResult = UseState( - new DashboardState - { - PatientCount = 0, - PractitionerCount = 0, - AppointmentCount = 0, - EncounterCount = 0, - Loading = true, - Error = null, - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - UseEffect( - () => - { - LoadData(setState); - }, - new object[0] - ); - - ReactElement errorElement; - if (state.Error != null) - { - errorElement = RenderError(state.Error); - } - else - { - errorElement = Text(""); - } - - return Div( - className: "page", - children: new[] - { - // Page header - Div( - className: "page-header", - children: new[] - { - H(2, className: "page-title", children: new[] { Text("Dashboard") }), - P( - className: "page-description", - children: new[] { Text("Overview of your healthcare system") } - ), - } - ), - // Error display - errorElement, - // Metrics grid - Div( - className: "dashboard-grid metrics mb-6", - children: new[] - { - MetricCard.Render( - new MetricCardProps - { - Label = "Total Patients", - Value = state.Loading ? "-" : state.PatientCount.ToString(), - Icon = Icons.Users, - IconColor = "blue", - TrendValue = "+12%", - Trend = TrendDirection.Up, - } - ), - MetricCard.Render( - new MetricCardProps - { - Label = "Practitioners", - Value = state.Loading - ? "-" - : state.PractitionerCount.ToString(), - Icon = Icons.UserDoctor, - IconColor = "teal", - } - ), - MetricCard.Render( - new MetricCardProps - { - Label = "Appointments", - Value = state.Loading ? "-" : state.AppointmentCount.ToString(), - Icon = Icons.Calendar, - IconColor = "success", - TrendValue = "+8%", - Trend = TrendDirection.Up, - } - ), - MetricCard.Render( - new MetricCardProps - { - Label = "Encounters", - Value = state.Loading ? "-" : state.EncounterCount.ToString(), - Icon = Icons.Clipboard, - IconColor = "warning", - TrendValue = "-3%", - Trend = TrendDirection.Down, - } - ), - } - ), - // Quick actions and activity - Div( - className: "dashboard-grid mixed", - children: new[] { RenderQuickActions(), RenderRecentActivity() } - ), - } - ); - } - - private static async void LoadData(Action setState) - { - try - { - var patients = await ApiClient.GetPatientsAsync(); - var practitioners = await ApiClient.GetPractitionersAsync(); - var appointments = await ApiClient.GetAppointmentsAsync(); - - setState( - new DashboardState - { - PatientCount = patients.Length, - PractitionerCount = practitioners.Length, - AppointmentCount = appointments.Length, - EncounterCount = 0, - Loading = false, - Error = null, - } - ); - } - catch (Exception ex) - { - setState( - new DashboardState - { - PatientCount = 0, - PractitionerCount = 0, - AppointmentCount = 0, - EncounterCount = 0, - Loading = false, - Error = ex.Message, - } - ); - } - } - - private static ReactElement RenderError(string message) => - Div( - className: "card mb-6", - style: new { borderLeft = "4px solid var(--warning)" }, - children: new[] - { - Div( - className: "flex items-center gap-3", - children: new[] - { - Icons.Bell(), - Div( - children: new[] - { - H( - 4, - className: "font-semibold", - children: new[] { Text("Connection Warning") } - ), - P( - className: "text-sm text-gray-600", - children: new[] - { - Text("Could not connect to API: " + message), - } - ), - P( - className: "text-sm text-gray-500", - children: new[] - { - Text( - "Make sure Clinical API (port 5000) and Scheduling API (port 5001) are running." - ), - } - ), - } - ), - } - ), - } - ); - - private static ReactElement RenderQuickActions() => - Div( - className: "card", - children: new[] - { - Div( - className: "card-header", - children: new[] - { - H( - 3, - className: "card-title", - children: new[] { Text("Quick Actions") } - ), - } - ), - Div( - className: "card-body", - children: new[] - { - Div( - className: "grid grid-cols-2 gap-4", - children: new[] - { - RenderActionButton("New Patient", Icons.Plus, "primary"), - RenderActionButton( - "New Appointment", - Icons.Calendar, - "secondary" - ), - RenderActionButton( - "View Schedule", - Icons.Calendar, - "secondary" - ), - RenderActionButton("Patient Search", Icons.Search, "secondary"), - } - ), - } - ), - } - ); - - private static ReactElement RenderActionButton( - string label, - Func icon, - string variant - ) => - Button( - className: "btn btn-" + variant + " w-full", - children: new[] { icon(), Text(label) } - ); - - private static ReactElement RenderRecentActivity() => - Div( - className: "card", - children: new[] - { - Div( - className: "card-header", - children: new[] - { - H( - 3, - className: "card-title", - children: new[] { Text("Recent Activity") } - ), - Button( - className: "btn btn-ghost btn-sm", - children: new[] { Text("View All") } - ), - } - ), - Div( - className: "card-body", - children: new[] - { - Div( - className: "data-list", - children: new[] - { - RenderActivityItem( - "New patient registered", - "John Smith added to system", - "2 min ago" - ), - RenderActivityItem( - "Appointment completed", - "Dr. Wilson with Jane Doe", - "15 min ago" - ), - RenderActivityItem( - "Lab results available", - "Patient ID: PAT-0042", - "1 hour ago" - ), - } - ), - } - ), - } - ); - - private static ReactElement RenderActivityItem( - string title, - string subtitle, - string time - ) => - Div( - className: "data-list-item", - children: new[] - { - Div(className: "avatar avatar-sm", children: new[] { Icons.Activity() }), - Div( - className: "data-list-item-content", - children: new[] - { - Div(className: "data-list-item-title", children: new[] { Text(title) }), - Div( - className: "data-list-item-subtitle", - children: new[] { Text(subtitle) } - ), - } - ), - Div(className: "data-list-item-meta", children: new[] { Text(time) }), - } - ); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/EditAppointmentPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/EditAppointmentPage.cs deleted file mode 100644 index 55f89494..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/EditAppointmentPage.cs +++ /dev/null @@ -1,777 +0,0 @@ -using System; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.Models; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Edit appointment page state class. - /// - public class EditAppointmentState - { - /// Appointment being edited. - public Appointment Appointment { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Whether saving. - public bool Saving { get; set; } - - /// Error message if any. - public string Error { get; set; } - - /// Success message if any. - public string Success { get; set; } - - /// Form field: Service category. - public string ServiceCategory { get; set; } - - /// Form field: Service type. - public string ServiceType { get; set; } - - /// Form field: Reason code. - public string ReasonCode { get; set; } - - /// Form field: Priority. - public string Priority { get; set; } - - /// Form field: Description. - public string Description { get; set; } - - /// Form field: Start date. - public string StartDate { get; set; } - - /// Form field: Start time. - public string StartTime { get; set; } - - /// Form field: End date. - public string EndDate { get; set; } - - /// Form field: End time. - public string EndTime { get; set; } - - /// Form field: Patient reference. - public string PatientReference { get; set; } - - /// Form field: Practitioner reference. - public string PractitionerReference { get; set; } - - /// Form field: Comment. - public string Comment { get; set; } - - /// Form field: Status. - public string Status { get; set; } - } - - /// - /// Edit appointment page component. - /// - public static class EditAppointmentPage - { - /// - /// Renders the edit appointment page. - /// - public static ReactElement Render(string appointmentId, Action onBack) - { - var stateResult = UseState( - new EditAppointmentState - { - Appointment = null, - Loading = true, - Saving = false, - Error = null, - Success = null, - ServiceCategory = "", - ServiceType = "", - ReasonCode = "", - Priority = "routine", - Description = "", - StartDate = "", - StartTime = "", - EndDate = "", - EndTime = "", - PatientReference = "", - PractitionerReference = "", - Comment = "", - Status = "booked", - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - UseEffect( - () => - { - LoadAppointment(appointmentId, setState); - }, - new object[] { appointmentId } - ); - - if (state.Loading) - { - return RenderLoadingState(); - } - - if (state.Error != null && state.Appointment == null) - { - return RenderErrorState(state.Error, onBack); - } - - return Div( - className: "page", - children: new[] - { - RenderHeader(state.Appointment, onBack), - RenderForm(state, setState, onBack), - } - ); - } - - private static async void LoadAppointment( - string appointmentId, - Action setState - ) - { - try - { - var appointment = await ApiClient.GetAppointmentAsync(appointmentId); - var startParts = ParseDateTime(appointment.StartTime); - var endParts = ParseDateTime(appointment.EndTime); - - setState( - new EditAppointmentState - { - Appointment = appointment, - Loading = false, - Saving = false, - Error = null, - Success = null, - ServiceCategory = appointment.ServiceCategory ?? "", - ServiceType = appointment.ServiceType ?? "", - ReasonCode = appointment.ReasonCode ?? "", - Priority = appointment.Priority ?? "routine", - Description = appointment.Description ?? "", - StartDate = startParts.Item1, - StartTime = startParts.Item2, - EndDate = endParts.Item1, - EndTime = endParts.Item2, - PatientReference = appointment.PatientReference ?? "", - PractitionerReference = appointment.PractitionerReference ?? "", - Comment = appointment.Comment ?? "", - Status = appointment.Status ?? "booked", - } - ); - } - catch (Exception ex) - { - setState( - new EditAppointmentState - { - Appointment = null, - Loading = false, - Saving = false, - Error = ex.Message, - Success = null, - ServiceCategory = "", - ServiceType = "", - ReasonCode = "", - Priority = "routine", - Description = "", - StartDate = "", - StartTime = "", - EndDate = "", - EndTime = "", - PatientReference = "", - PractitionerReference = "", - Comment = "", - Status = "booked", - } - ); - } - } - - private static (string, string) ParseDateTime(string isoDateTime) - { - if (string.IsNullOrEmpty(isoDateTime)) - return ("", ""); - - // Parse ISO datetime like "2025-12-20T12:02:00.000Z" - if (isoDateTime.Length >= 16) - { - var datePart = isoDateTime.Substring(0, 10); - var timePart = isoDateTime.Substring(11, 5); - return (datePart, timePart); - } - - return ("", ""); - } - - private static string CombineDateTime(string date, string time) - { - if (string.IsNullOrEmpty(date) || string.IsNullOrEmpty(time)) - return ""; - return date + "T" + time + ":00.000Z"; - } - - private static async void SaveAppointment( - EditAppointmentState state, - Action setState, - Action onBack - ) - { - setState( - new EditAppointmentState - { - Appointment = state.Appointment, - Loading = false, - Saving = true, - Error = null, - Success = null, - ServiceCategory = state.ServiceCategory, - ServiceType = state.ServiceType, - ReasonCode = state.ReasonCode, - Priority = state.Priority, - Description = state.Description, - StartDate = state.StartDate, - StartTime = state.StartTime, - EndDate = state.EndDate, - EndTime = state.EndTime, - PatientReference = state.PatientReference, - PractitionerReference = state.PractitionerReference, - Comment = state.Comment, - Status = state.Status, - } - ); - - try - { - var updateData = new - { - ServiceCategory = state.ServiceCategory, - ServiceType = state.ServiceType, - ReasonCode = string.IsNullOrWhiteSpace(state.ReasonCode) - ? null - : state.ReasonCode, - Priority = state.Priority, - Description = string.IsNullOrWhiteSpace(state.Description) - ? null - : state.Description, - Start = CombineDateTime(state.StartDate, state.StartTime), - End = CombineDateTime(state.EndDate, state.EndTime), - PatientReference = state.PatientReference, - PractitionerReference = state.PractitionerReference, - Comment = string.IsNullOrWhiteSpace(state.Comment) ? null : state.Comment, - Status = state.Status, - }; - - var updatedAppointment = await ApiClient.UpdateAppointmentAsync( - state.Appointment.Id, - updateData - ); - - var startParts = ParseDateTime(updatedAppointment.StartTime); - var endParts = ParseDateTime(updatedAppointment.EndTime); - - setState( - new EditAppointmentState - { - Appointment = updatedAppointment, - Loading = false, - Saving = false, - Error = null, - Success = "Appointment updated successfully!", - ServiceCategory = updatedAppointment.ServiceCategory ?? "", - ServiceType = updatedAppointment.ServiceType ?? "", - ReasonCode = updatedAppointment.ReasonCode ?? "", - Priority = updatedAppointment.Priority ?? "routine", - Description = updatedAppointment.Description ?? "", - StartDate = startParts.Item1, - StartTime = startParts.Item2, - EndDate = endParts.Item1, - EndTime = endParts.Item2, - PatientReference = updatedAppointment.PatientReference ?? "", - PractitionerReference = updatedAppointment.PractitionerReference ?? "", - Comment = updatedAppointment.Comment ?? "", - Status = updatedAppointment.Status ?? "booked", - } - ); - } - catch (Exception ex) - { - setState( - new EditAppointmentState - { - Appointment = state.Appointment, - Loading = false, - Saving = false, - Error = ex.Message, - Success = null, - ServiceCategory = state.ServiceCategory, - ServiceType = state.ServiceType, - ReasonCode = state.ReasonCode, - Priority = state.Priority, - Description = state.Description, - StartDate = state.StartDate, - StartTime = state.StartTime, - EndDate = state.EndDate, - EndTime = state.EndTime, - PatientReference = state.PatientReference, - PractitionerReference = state.PractitionerReference, - Comment = state.Comment, - Status = state.Status, - } - ); - } - } - - private static ReactElement RenderLoadingState() => - Div( - className: "page", - children: new[] - { - Div( - className: "page-header", - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Edit Appointment") } - ), - P( - className: "page-description", - children: new[] { Text("Loading appointment data...") } - ), - } - ), - Div( - className: "card", - children: new[] - { - Div( - className: "flex items-center justify-center p-8", - children: new[] { Text("Loading...") } - ), - } - ), - } - ); - - private static ReactElement RenderErrorState(string error, Action onBack) => - Div( - className: "page", - children: new[] - { - Div( - className: "page-header flex justify-between items-center", - children: new[] - { - Div( - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Edit Appointment") } - ), - P( - className: "page-description", - children: new[] { Text("Error loading appointment") } - ), - } - ), - Button( - className: "btn btn-secondary", - onClick: onBack, - children: new[] - { - Icons.ChevronLeft(), - Text("Back to Appointments"), - } - ), - } - ), - Div( - className: "card", - style: new { borderLeft = "4px solid var(--error)" }, - children: new[] - { - Div( - className: "flex items-center gap-3 p-4", - children: new[] - { - Icons.X(), - Text("Error loading appointment: " + error), - } - ), - } - ), - } - ); - - private static ReactElement RenderHeader(Appointment appointment, Action onBack) - { - var title = appointment.ServiceType ?? "Appointment"; - return Div( - className: "page-header flex justify-between items-center", - children: new[] - { - Div( - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Edit Appointment") } - ), - P( - className: "page-description", - children: new[] { Text("Update details for " + title) } - ), - } - ), - Button( - className: "btn btn-secondary", - onClick: onBack, - children: new[] { Icons.ChevronLeft(), Text("Back to Appointments") } - ), - } - ); - } - - private static ReactElement RenderForm( - EditAppointmentState state, - Action setState, - Action onBack - ) => - Div( - className: "card", - children: new[] - { - state.Error != null - ? Div( - className: "alert alert-error mb-4", - children: new[] { Icons.X(), Text(state.Error) } - ) - : null, - state.Success != null - ? Div( - className: "alert alert-success mb-4", - children: new[] { Text(state.Success) } - ) - : null, - Form( - className: "form", - onSubmit: () => SaveAppointment(state, setState, onBack), - children: new[] - { - RenderFormSection( - "Appointment Details", - new[] - { - RenderInputField( - "Service Category", - "appointment-service-category", - state.ServiceCategory, - "e.g., General Practice", - v => UpdateField(state, setState, "ServiceCategory", v) - ), - RenderInputField( - "Service Type", - "appointment-service-type", - state.ServiceType, - "e.g., Checkup, Follow-up", - v => UpdateField(state, setState, "ServiceType", v) - ), - RenderInputField( - "Reason", - "appointment-reason", - state.ReasonCode, - "Reason for appointment", - v => UpdateField(state, setState, "ReasonCode", v) - ), - RenderSelectField( - "Priority", - "appointment-priority", - state.Priority, - new[] - { - ("routine", "Routine"), - ("urgent", "Urgent"), - ("asap", "ASAP"), - ("stat", "STAT"), - }, - v => UpdateField(state, setState, "Priority", v) - ), - RenderSelectField( - "Status", - "appointment-status", - state.Status, - new[] - { - ("booked", "Booked"), - ("arrived", "Arrived"), - ("fulfilled", "Fulfilled"), - ("cancelled", "Cancelled"), - ("noshow", "No Show"), - }, - v => UpdateField(state, setState, "Status", v) - ), - RenderTextareaField( - "Description", - "appointment-description", - state.Description, - "Additional details", - v => UpdateField(state, setState, "Description", v) - ), - } - ), - RenderFormSection( - "Schedule", - new[] - { - RenderInputField( - "Start Date", - "appointment-start-date", - state.StartDate, - "YYYY-MM-DD", - v => UpdateField(state, setState, "StartDate", v), - "date" - ), - RenderInputField( - "Start Time", - "appointment-start-time", - state.StartTime, - "HH:MM", - v => UpdateField(state, setState, "StartTime", v), - "time" - ), - RenderInputField( - "End Date", - "appointment-end-date", - state.EndDate, - "YYYY-MM-DD", - v => UpdateField(state, setState, "EndDate", v), - "date" - ), - RenderInputField( - "End Time", - "appointment-end-time", - state.EndTime, - "HH:MM", - v => UpdateField(state, setState, "EndTime", v), - "time" - ), - } - ), - RenderFormSection( - "Participants", - new[] - { - RenderInputField( - "Patient Reference", - "appointment-patient", - state.PatientReference, - "Patient/[id]", - v => UpdateField(state, setState, "PatientReference", v) - ), - RenderInputField( - "Practitioner Reference", - "appointment-practitioner", - state.PractitionerReference, - "Practitioner/[id]", - v => - UpdateField(state, setState, "PractitionerReference", v) - ), - } - ), - RenderFormSection( - "Notes", - new[] - { - RenderTextareaField( - "Comment", - "appointment-comment", - state.Comment, - "Any additional comments", - v => UpdateField(state, setState, "Comment", v) - ), - } - ), - RenderFormActions(state, onBack), - } - ), - } - ); - - private static ReactElement RenderFormSection(string title, ReactElement[] fields) => - Div( - className: "form-section mb-6", - children: new[] - { - H(4, className: "form-section-title mb-4", children: new[] { Text(title) }), - Div(className: "grid grid-cols-2 gap-4", children: fields), - } - ); - - private static ReactElement RenderInputField( - string label, - string id, - string value, - string placeholder, - Action onChange, - string type = "text" - ) => - Div( - className: "form-group", - children: new[] - { - Label(htmlFor: id, className: "form-label", children: new[] { Text(label) }), - Input( - className: "input", - type: type, - value: value, - placeholder: placeholder, - onChange: onChange - ), - } - ); - - private static ReactElement RenderTextareaField( - string label, - string id, - string value, - string placeholder, - Action onChange - ) => - Div( - className: "form-group col-span-2", - children: new[] - { - Label(htmlFor: id, className: "form-label", children: new[] { Text(label) }), - TextArea( - className: "input", - value: value, - placeholder: placeholder, - onChange: onChange, - rows: 3 - ), - } - ); - - private static ReactElement RenderSelectField( - string label, - string id, - string value, - (string value, string label)[] options, - Action onChange - ) - { - var optionElements = new ReactElement[options.Length]; - for (var i = 0; i < options.Length; i++) - { - optionElements[i] = Option(options[i].value, options[i].label); - } - - return Div( - className: "form-group", - children: new[] - { - Label(htmlFor: id, className: "form-label", children: new[] { Text(label) }), - Select( - className: "input", - value: value, - onChange: onChange, - children: optionElements - ), - } - ); - } - - private static ReactElement RenderFormActions(EditAppointmentState state, Action onBack) => - Div( - className: "form-actions flex justify-end gap-4 mt-6", - children: new[] - { - Button( - className: "btn btn-secondary", - type: "button", - onClick: onBack, - disabled: state.Saving, - children: new[] { Text("Cancel") } - ), - Button( - className: "btn btn-primary", - type: "submit", - disabled: state.Saving, - children: new[] { Text(state.Saving ? "Saving..." : "Save Changes") } - ), - } - ); - - private static void UpdateField( - EditAppointmentState state, - Action setState, - string field, - string value - ) - { - var newState = new EditAppointmentState - { - Appointment = state.Appointment, - Loading = state.Loading, - Saving = state.Saving, - Error = null, - Success = null, - ServiceCategory = state.ServiceCategory, - ServiceType = state.ServiceType, - ReasonCode = state.ReasonCode, - Priority = state.Priority, - Description = state.Description, - StartDate = state.StartDate, - StartTime = state.StartTime, - EndDate = state.EndDate, - EndTime = state.EndTime, - PatientReference = state.PatientReference, - PractitionerReference = state.PractitionerReference, - Comment = state.Comment, - Status = state.Status, - }; - - if (field == "ServiceCategory") - newState.ServiceCategory = value; - else if (field == "ServiceType") - newState.ServiceType = value; - else if (field == "ReasonCode") - newState.ReasonCode = value; - else if (field == "Priority") - newState.Priority = value; - else if (field == "Description") - newState.Description = value; - else if (field == "StartDate") - newState.StartDate = value; - else if (field == "StartTime") - newState.StartTime = value; - else if (field == "EndDate") - newState.EndDate = value; - else if (field == "EndTime") - newState.EndTime = value; - else if (field == "PatientReference") - newState.PatientReference = value; - else if (field == "PractitionerReference") - newState.PractitionerReference = value; - else if (field == "Comment") - newState.Comment = value; - else if (field == "Status") - newState.Status = value; - - setState(newState); - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/EditPatientPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/EditPatientPage.cs deleted file mode 100644 index 7ffa90b8..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/EditPatientPage.cs +++ /dev/null @@ -1,727 +0,0 @@ -using System; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.Models; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Edit patient page state class. - /// - public class EditPatientState - { - /// Patient being edited. - public Patient Patient { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Whether saving. - public bool Saving { get; set; } - - /// Error message if any. - public string Error { get; set; } - - /// Success message if any. - public string Success { get; set; } - - /// Form field: Active status. - public bool Active { get; set; } - - /// Form field: Given name. - public string GivenName { get; set; } - - /// Form field: Family name. - public string FamilyName { get; set; } - - /// Form field: Birth date. - public string BirthDate { get; set; } - - /// Form field: Gender. - public string Gender { get; set; } - - /// Form field: Phone. - public string Phone { get; set; } - - /// Form field: Email. - public string Email { get; set; } - - /// Form field: Address line. - public string AddressLine { get; set; } - - /// Form field: City. - public string City { get; set; } - - /// Form field: State. - public string State { get; set; } - - /// Form field: Postal code. - public string PostalCode { get; set; } - - /// Form field: Country. - public string Country { get; set; } - } - - /// - /// Edit patient page component. - /// - public static class EditPatientPage - { - /// - /// Renders the edit patient page. - /// - public static ReactElement Render(string patientId, Action onBack) - { - var stateResult = UseState( - new EditPatientState - { - Patient = null, - Loading = true, - Saving = false, - Error = null, - Success = null, - Active = true, - GivenName = "", - FamilyName = "", - BirthDate = "", - Gender = "", - Phone = "", - Email = "", - AddressLine = "", - City = "", - State = "", - PostalCode = "", - Country = "", - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - UseEffect( - () => - { - LoadPatient(patientId, setState); - }, - new object[] { patientId } - ); - - if (state.Loading) - { - return RenderLoadingState(); - } - - if (state.Error != null && state.Patient == null) - { - return RenderErrorState(state.Error, onBack); - } - - return Div( - className: "page", - children: new[] - { - RenderHeader(state.Patient, onBack), - RenderForm(state, setState, onBack), - } - ); - } - - private static async void LoadPatient(string patientId, Action setState) - { - try - { - var patient = await ApiClient.GetPatientAsync(patientId); - setState( - new EditPatientState - { - Patient = patient, - Loading = false, - Saving = false, - Error = null, - Success = null, - Active = patient.Active, - GivenName = patient.GivenName ?? "", - FamilyName = patient.FamilyName ?? "", - BirthDate = patient.BirthDate ?? "", - Gender = patient.Gender ?? "", - Phone = patient.Phone ?? "", - Email = patient.Email ?? "", - AddressLine = patient.AddressLine ?? "", - City = patient.City ?? "", - State = patient.State ?? "", - PostalCode = patient.PostalCode ?? "", - Country = patient.Country ?? "", - } - ); - } - catch (Exception ex) - { - setState( - new EditPatientState - { - Patient = null, - Loading = false, - Saving = false, - Error = ex.Message, - Success = null, - Active = true, - GivenName = "", - FamilyName = "", - BirthDate = "", - Gender = "", - Phone = "", - Email = "", - AddressLine = "", - City = "", - State = "", - PostalCode = "", - Country = "", - } - ); - } - } - - private static async void SavePatient( - EditPatientState state, - Action setState, - Action onBack - ) - { - setState( - new EditPatientState - { - Patient = state.Patient, - Loading = false, - Saving = true, - Error = null, - Success = null, - Active = state.Active, - GivenName = state.GivenName, - FamilyName = state.FamilyName, - BirthDate = state.BirthDate, - Gender = state.Gender, - Phone = state.Phone, - Email = state.Email, - AddressLine = state.AddressLine, - City = state.City, - State = state.State, - PostalCode = state.PostalCode, - Country = state.Country, - } - ); - - try - { - var updateData = new Patient - { - Id = state.Patient.Id, - Active = state.Active, - GivenName = state.GivenName, - FamilyName = state.FamilyName, - BirthDate = string.IsNullOrWhiteSpace(state.BirthDate) ? null : state.BirthDate, - Gender = string.IsNullOrWhiteSpace(state.Gender) ? null : state.Gender, - Phone = string.IsNullOrWhiteSpace(state.Phone) ? null : state.Phone, - Email = string.IsNullOrWhiteSpace(state.Email) ? null : state.Email, - AddressLine = string.IsNullOrWhiteSpace(state.AddressLine) - ? null - : state.AddressLine, - City = string.IsNullOrWhiteSpace(state.City) ? null : state.City, - State = string.IsNullOrWhiteSpace(state.State) ? null : state.State, - PostalCode = string.IsNullOrWhiteSpace(state.PostalCode) - ? null - : state.PostalCode, - Country = string.IsNullOrWhiteSpace(state.Country) ? null : state.Country, - }; - - var updatedPatient = await ApiClient.UpdatePatientAsync( - state.Patient.Id, - updateData - ); - - setState( - new EditPatientState - { - Patient = updatedPatient, - Loading = false, - Saving = false, - Error = null, - Success = "Patient updated successfully!", - Active = updatedPatient.Active, - GivenName = updatedPatient.GivenName ?? "", - FamilyName = updatedPatient.FamilyName ?? "", - BirthDate = updatedPatient.BirthDate ?? "", - Gender = updatedPatient.Gender ?? "", - Phone = updatedPatient.Phone ?? "", - Email = updatedPatient.Email ?? "", - AddressLine = updatedPatient.AddressLine ?? "", - City = updatedPatient.City ?? "", - State = updatedPatient.State ?? "", - PostalCode = updatedPatient.PostalCode ?? "", - Country = updatedPatient.Country ?? "", - } - ); - } - catch (Exception ex) - { - setState( - new EditPatientState - { - Patient = state.Patient, - Loading = false, - Saving = false, - Error = ex.Message, - Success = null, - Active = state.Active, - GivenName = state.GivenName, - FamilyName = state.FamilyName, - BirthDate = state.BirthDate, - Gender = state.Gender, - Phone = state.Phone, - Email = state.Email, - AddressLine = state.AddressLine, - City = state.City, - State = state.State, - PostalCode = state.PostalCode, - Country = state.Country, - } - ); - } - } - - private static ReactElement RenderLoadingState() => - Div( - className: "page", - children: new[] - { - Div( - className: "page-header", - children: new[] - { - H(2, className: "page-title", children: new[] { Text("Edit Patient") }), - P( - className: "page-description", - children: new[] { Text("Loading patient data...") } - ), - } - ), - Div( - className: "card", - children: new[] - { - Div( - className: "flex items-center justify-center p-8", - children: new[] { Text("Loading...") } - ), - } - ), - } - ); - - private static ReactElement RenderErrorState(string error, Action onBack) => - Div( - className: "page", - children: new[] - { - Div( - className: "page-header flex justify-between items-center", - children: new[] - { - Div( - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Edit Patient") } - ), - P( - className: "page-description", - children: new[] { Text("Error loading patient") } - ), - } - ), - Button( - className: "btn btn-secondary", - onClick: onBack, - children: new[] { Icons.ChevronLeft(), Text("Back to Patients") } - ), - } - ), - Div( - className: "card", - style: new { borderLeft = "4px solid var(--error)" }, - children: new[] - { - Div( - className: "flex items-center gap-3 p-4", - children: new[] - { - Icons.X(), - Text("Error loading patient: " + error), - } - ), - } - ), - } - ); - - private static ReactElement RenderHeader(Patient patient, Action onBack) - { - var fullName = patient.GivenName + " " + patient.FamilyName; - return Div( - className: "page-header flex justify-between items-center", - children: new[] - { - Div( - children: new[] - { - H(2, className: "page-title", children: new[] { Text("Edit Patient") }), - P( - className: "page-description", - children: new[] { Text("Update information for " + fullName) } - ), - } - ), - Button( - className: "btn btn-secondary", - onClick: onBack, - children: new[] { Icons.ChevronLeft(), Text("Back to Patients") } - ), - } - ); - } - - private static ReactElement RenderForm( - EditPatientState state, - Action setState, - Action onBack - ) => - Div( - className: "card", - children: new[] - { - state.Error != null - ? Div( - className: "alert alert-error mb-4", - children: new[] { Icons.X(), Text(state.Error) } - ) - : null, - state.Success != null - ? Div( - className: "alert alert-success mb-4", - children: new[] { Text(state.Success) } - ) - : null, - Form( - className: "form", - onSubmit: () => SavePatient(state, setState, onBack), - children: new[] - { - RenderFormSection( - "Personal Information", - new[] - { - RenderInputField( - "Given Name", - "patient-edit-given-name", - state.GivenName, - "Enter first name", - v => UpdateField(state, setState, "GivenName", v) - ), - RenderInputField( - "Family Name", - "patient-edit-family-name", - state.FamilyName, - "Enter last name", - v => UpdateField(state, setState, "FamilyName", v) - ), - RenderInputField( - "Birth Date", - "patient-edit-birth-date", - state.BirthDate, - "YYYY-MM-DD", - v => UpdateField(state, setState, "BirthDate", v), - "date" - ), - RenderSelectField( - "Gender", - "patient-edit-gender", - state.Gender, - new[] - { - ("", "Select gender"), - ("male", "Male"), - ("female", "Female"), - ("other", "Other"), - ("unknown", "Unknown"), - }, - v => UpdateField(state, setState, "Gender", v) - ), - RenderCheckboxField( - "Active", - "patient-edit-active", - state.Active, - v => UpdateActive(state, setState, v) - ), - } - ), - RenderFormSection( - "Contact Information", - new[] - { - RenderInputField( - "Phone", - "patient-edit-phone", - state.Phone, - "Enter phone number", - v => UpdateField(state, setState, "Phone", v), - "tel" - ), - RenderInputField( - "Email", - "patient-edit-email", - state.Email, - "Enter email address", - v => UpdateField(state, setState, "Email", v), - "email" - ), - } - ), - RenderFormSection( - "Address", - new[] - { - RenderInputField( - "Address Line", - "patient-edit-address", - state.AddressLine, - "Enter street address", - v => UpdateField(state, setState, "AddressLine", v) - ), - RenderInputField( - "City", - "patient-edit-city", - state.City, - "Enter city", - v => UpdateField(state, setState, "City", v) - ), - RenderInputField( - "State", - "patient-edit-state", - state.State, - "Enter state/province", - v => UpdateField(state, setState, "State", v) - ), - RenderInputField( - "Postal Code", - "patient-edit-postal-code", - state.PostalCode, - "Enter postal code", - v => UpdateField(state, setState, "PostalCode", v) - ), - RenderInputField( - "Country", - "patient-edit-country", - state.Country, - "Enter country", - v => UpdateField(state, setState, "Country", v) - ), - } - ), - RenderFormActions(state, onBack), - } - ), - } - ); - - private static ReactElement RenderFormSection(string title, ReactElement[] fields) => - Div( - className: "form-section mb-6", - children: new[] - { - H(4, className: "form-section-title mb-4", children: new[] { Text(title) }), - Div(className: "grid grid-cols-2 gap-4", children: fields), - } - ); - - private static ReactElement RenderInputField( - string label, - string id, - string value, - string placeholder, - Action onChange, - string type = "text" - ) => - Div( - className: "form-group", - children: new[] - { - Label(htmlFor: id, className: "form-label", children: new[] { Text(label) }), - Input( - className: "input", - type: type, - value: value, - placeholder: placeholder, - onChange: onChange - ), - } - ); - - private static ReactElement RenderSelectField( - string label, - string id, - string value, - (string value, string label)[] options, - Action onChange - ) - { - var optionElements = new ReactElement[options.Length]; - for (var i = 0; i < options.Length; i++) - { - optionElements[i] = Option(options[i].value, options[i].label); - } - - return Div( - className: "form-group", - children: new[] - { - Label(htmlFor: id, className: "form-label", children: new[] { Text(label) }), - Select( - className: "input", - value: value, - onChange: onChange, - children: optionElements - ), - } - ); - } - - private static ReactElement RenderCheckboxField( - string label, - string id, - bool value, - Action onChange - ) => - Div( - className: "form-group flex items-center gap-2", - children: new[] - { - Div( - className: "flex items-center gap-2", - onClick: () => onChange(!value), - children: new[] - { - Span(className: "status-dot " + (value ? "active" : "inactive")), - Text(label + ": " + (value ? "Active" : "Inactive")), - } - ), - } - ); - - private static ReactElement RenderFormActions(EditPatientState state, Action onBack) => - Div( - className: "form-actions flex justify-end gap-4 mt-6", - children: new[] - { - Button( - className: "btn btn-secondary", - type: "button", - onClick: onBack, - disabled: state.Saving, - children: new[] { Text("Cancel") } - ), - Button( - className: "btn btn-primary", - type: "submit", - disabled: state.Saving, - children: new[] { Text(state.Saving ? "Saving..." : "Save Changes") } - ), - } - ); - - private static void UpdateField( - EditPatientState state, - Action setState, - string field, - string value - ) - { - var newState = new EditPatientState - { - Patient = state.Patient, - Loading = state.Loading, - Saving = state.Saving, - Error = null, - Success = null, - Active = state.Active, - GivenName = state.GivenName, - FamilyName = state.FamilyName, - BirthDate = state.BirthDate, - Gender = state.Gender, - Phone = state.Phone, - Email = state.Email, - AddressLine = state.AddressLine, - City = state.City, - State = state.State, - PostalCode = state.PostalCode, - Country = state.Country, - }; - - if (field == "GivenName") - newState.GivenName = value; - else if (field == "FamilyName") - newState.FamilyName = value; - else if (field == "BirthDate") - newState.BirthDate = value; - else if (field == "Gender") - newState.Gender = value; - else if (field == "Phone") - newState.Phone = value; - else if (field == "Email") - newState.Email = value; - else if (field == "AddressLine") - newState.AddressLine = value; - else if (field == "City") - newState.City = value; - else if (field == "State") - newState.State = value; - else if (field == "PostalCode") - newState.PostalCode = value; - else if (field == "Country") - newState.Country = value; - - setState(newState); - } - - private static void UpdateActive( - EditPatientState state, - Action setState, - bool value - ) => - setState( - new EditPatientState - { - Patient = state.Patient, - Loading = state.Loading, - Saving = state.Saving, - Error = null, - Success = null, - Active = value, - GivenName = state.GivenName, - FamilyName = state.FamilyName, - BirthDate = state.BirthDate, - Gender = state.Gender, - Phone = state.Phone, - Email = state.Email, - AddressLine = state.AddressLine, - City = state.City, - State = state.State, - PostalCode = state.PostalCode, - Country = state.Country, - } - ); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/PatientsPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/PatientsPage.cs deleted file mode 100644 index 9ac17314..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/PatientsPage.cs +++ /dev/null @@ -1,383 +0,0 @@ -using System; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.Models; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Patients page state class. - /// - public class PatientsState - { - /// List of patients. - public Patient[] Patients { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Error message if any. - public string Error { get; set; } - - /// Current search query. - public string SearchQuery { get; set; } - - /// Selected patient. - public Patient SelectedPatient { get; set; } - } - - /// - /// Patients list page. - /// - public static class PatientsPage - { - private static Action _onEditPatient; - - /// - /// Renders the patients page. - /// - public static ReactElement Render(Action onEditPatient = null) - { - _onEditPatient = onEditPatient; - var stateResult = UseState( - new PatientsState - { - Patients = new Patient[0], - Loading = true, - Error = null, - SearchQuery = "", - SelectedPatient = null, - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - UseEffect( - () => - { - LoadPatients(setState); - }, - new object[0] - ); - - ReactElement content; - if (state.Loading) - { - content = DataTable.RenderLoading(5, 5); - } - else if (state.Error != null) - { - content = RenderError(state.Error); - } - else if (state.Patients.Length == 0) - { - content = DataTable.RenderEmpty( - "No patients found. Start by adding a new patient." - ); - } - else - { - content = RenderPatientTable(state.Patients, p => SelectPatient(p, setState)); - } - - return Div( - className: "page", - children: new[] - { - // Page header - Div( - className: "page-header flex justify-between items-center", - children: new[] - { - Div( - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Patients") } - ), - P( - className: "page-description", - children: new[] - { - Text("Manage patient records from the Clinical domain"), - } - ), - } - ), - Button( - className: "btn btn-primary", - children: new[] { Icons.Plus(), Text("Add Patient") } - ), - } - ), - // Search bar - Div( - className: "card mb-6", - children: new[] - { - Div( - className: "flex gap-4", - children: new[] - { - Div( - className: "flex-1 search-input", - children: new[] - { - Span( - className: "search-icon", - children: new[] { Icons.Search() } - ), - Input( - className: "input", - type: "text", - placeholder: "Search patients by name...", - value: state.SearchQuery, - onChange: query => HandleSearch(query, setState) - ), - } - ), - Button( - className: "btn btn-secondary", - onClick: () => LoadPatients(setState), - children: new[] { Icons.Refresh(), Text("Refresh") } - ), - } - ), - } - ), - // Content - content, - } - ); - } - - private static async void LoadPatients(Action setState) - { - try - { - var patients = await ApiClient.GetPatientsAsync(); - setState( - new PatientsState - { - Patients = patients, - Loading = false, - Error = null, - SearchQuery = "", - SelectedPatient = null, - } - ); - } - catch (Exception ex) - { - setState( - new PatientsState - { - Patients = new Patient[0], - Loading = false, - Error = ex.Message, - SearchQuery = "", - SelectedPatient = null, - } - ); - } - } - - private static async void HandleSearch(string query, Action setState) - { - if (string.IsNullOrWhiteSpace(query)) - { - LoadPatients(setState); - return; - } - - try - { - var patients = await ApiClient.SearchPatientsAsync(query); - setState( - new PatientsState - { - Patients = patients, - Loading = false, - Error = null, - SearchQuery = query, - SelectedPatient = null, - } - ); - } - catch (Exception ex) - { - setState( - new PatientsState - { - Patients = new Patient[0], - Loading = false, - Error = ex.Message, - SearchQuery = query, - SelectedPatient = null, - } - ); - } - } - - private static void SelectPatient(Patient patient, Action setState) - { - // TODO: Navigate to patient detail or open modal - } - - private static ReactElement RenderError(string message) => - Div( - className: "card", - style: new { borderLeft = "4px solid var(--error)" }, - children: new[] - { - Div( - className: "flex items-center gap-3 p-4", - children: new[] { Icons.X(), Text("Error loading patients: " + message) } - ), - } - ); - - private static ReactElement RenderPatientTable(Patient[] patients, Action onSelect) - { - var columns = new[] - { - new Column { Key = "name", Header = "Name" }, - new Column { Key = "gender", Header = "Gender" }, - new Column { Key = "birthDate", Header = "Birth Date" }, - new Column { Key = "contact", Header = "Contact" }, - new Column { Key = "status", Header = "Status" }, - new Column - { - Key = "actions", - Header = "Actions", - ClassName = "text-right", - }, - }; - - return DataTable.Render( - columns: columns, - data: patients, - getKey: p => p.Id, - renderCell: (patient, key) => RenderCell(patient, key, onSelect), - onRowClick: onSelect - ); - } - - private static ReactElement RenderCell( - Patient patient, - string key, - Action onSelect - ) - { - if (key == "name") - return RenderPatientName(patient); - if (key == "gender") - return RenderGender(patient.Gender); - if (key == "birthDate") - return Text(patient.BirthDate ?? "N/A"); - if (key == "contact") - return RenderContact(patient); - if (key == "status") - return RenderStatus(patient.Active); - if (key == "actions") - return RenderActions(patient, onSelect); - return Text(""); - } - - private static ReactElement RenderPatientName(Patient patient) - { - var idPrefix = patient.Id.Length > 8 ? patient.Id.Substring(0, 8) : patient.Id; - return Div( - className: "flex items-center gap-3", - children: new[] - { - Div( - className: "avatar avatar-sm", - children: new[] { Text(GetInitials(patient)) } - ), - Div( - children: new[] - { - Div( - className: "font-medium", - children: new[] - { - Text(patient.GivenName + " " + patient.FamilyName), - } - ), - Div( - className: "text-sm text-gray-500", - children: new[] { Text("ID: " + idPrefix + "...") } - ), - } - ), - } - ); - } - - private static ReactElement RenderGender(string gender) => - Span( - className: "badge " + GenderBadgeClass(gender), - children: new[] { Text(gender ?? "Unknown") } - ); - - private static string GenderBadgeClass(string gender) - { - if (gender == "male") - return "badge-primary"; - if (gender == "female") - return "badge-teal"; - return "badge-gray"; - } - - private static ReactElement RenderContact(Patient patient) - { - var contact = patient.Email ?? patient.Phone ?? "No contact"; - return Text(contact); - } - - private static ReactElement RenderStatus(bool active) => - Div( - className: "flex items-center gap-2", - children: new[] - { - Span(className: "status-dot " + (active ? "active" : "inactive")), - Text(active ? "Active" : "Inactive"), - } - ); - - private static ReactElement RenderActions(Patient patient, Action onSelect) => - Div( - className: "table-action", - children: new[] - { - Button( - className: "btn btn-ghost btn-sm", - onClick: () => onSelect(patient), - children: new[] { Icons.Eye() } - ), - Button( - className: "btn btn-ghost btn-sm", - onClick: () => _onEditPatient?.Invoke(patient.Id), - children: new[] { Icons.Edit() } - ), - } - ); - - private static string GetInitials(Patient patient) => - FirstChar(patient.GivenName) + FirstChar(patient.FamilyName); - - private static string FirstChar(string s) - { - if (string.IsNullOrEmpty(s)) - return ""; - return s.Substring(0, 1).ToUpper(); - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Pages/PractitionersPage.cs b/Samples/Dashboard/Dashboard.Web/Pages/PractitionersPage.cs deleted file mode 100644 index de1e92d5..00000000 --- a/Samples/Dashboard/Dashboard.Web/Pages/PractitionersPage.cs +++ /dev/null @@ -1,432 +0,0 @@ -using System; -using System.Linq; -using Dashboard.Api; -using Dashboard.Components; -using Dashboard.Models; -using Dashboard.React; -using static Dashboard.React.Elements; -using static Dashboard.React.Hooks; - -namespace Dashboard.Pages -{ - /// - /// Practitioners page state class. - /// - public class PractitionersState - { - /// List of practitioners. - public Practitioner[] Practitioners { get; set; } - - /// Whether loading. - public bool Loading { get; set; } - - /// Error message if any. - public string Error { get; set; } - - /// Current specialty filter. - public string SpecialtyFilter { get; set; } - } - - /// - /// Practitioners list page. - /// - public static class PractitionersPage - { - /// - /// Renders the practitioners page. - /// - public static ReactElement Render() - { - var stateResult = UseState( - new PractitionersState - { - Practitioners = new Practitioner[0], - Loading = true, - Error = null, - SpecialtyFilter = null, - } - ); - - var state = stateResult.State; - var setState = stateResult.SetState; - - UseEffect( - () => - { - LoadPractitioners(setState); - }, - new object[0] - ); - - ReactElement content; - if (state.Loading) - { - content = RenderLoadingGrid(); - } - else if (state.Error != null) - { - content = RenderError(state.Error); - } - else if (state.Practitioners.Length == 0) - { - content = RenderEmpty(); - } - else - { - content = RenderPractitionerGrid(state.Practitioners); - } - - return Div( - className: "page", - children: new[] - { - // Page header - Div( - className: "page-header flex justify-between items-center", - children: new[] - { - Div( - children: new[] - { - H( - 2, - className: "page-title", - children: new[] { Text("Practitioners") } - ), - P( - className: "page-description", - children: new[] - { - Text( - "Manage healthcare providers from the Scheduling domain" - ), - } - ), - } - ), - Button( - className: "btn btn-primary", - children: new[] { Icons.Plus(), Text("Add Practitioner") } - ), - } - ), - // Filters - Div( - className: "card mb-6", - children: new[] - { - Div( - className: "flex gap-4", - children: new[] - { - Div( - className: "input-group", - children: new[] - { - Label( - className: "input-label", - children: new[] { Text("Filter by Specialty") } - ), - Select( - className: "input", - value: state.SpecialtyFilter ?? "", - onChange: specialty => - FilterBySpecialty(specialty, setState), - children: new[] - { - Option("", "All Specialties"), - Option("Cardiology", "Cardiology"), - Option("Dermatology", "Dermatology"), - Option("Family Medicine", "Family Medicine"), - Option( - "Internal Medicine", - "Internal Medicine" - ), - Option("Neurology", "Neurology"), - Option("Oncology", "Oncology"), - Option("Pediatrics", "Pediatrics"), - Option("Psychiatry", "Psychiatry"), - Option("Surgery", "Surgery"), - } - ), - } - ), - Div(className: "flex-1"), - Button( - className: "btn btn-secondary", - onClick: () => LoadPractitioners(setState), - children: new[] { Icons.Refresh(), Text("Refresh") } - ), - } - ), - } - ), - // Content - content, - } - ); - } - - private static async void LoadPractitioners(Action setState) - { - try - { - var practitioners = await ApiClient.GetPractitionersAsync(); - setState( - new PractitionersState - { - Practitioners = practitioners, - Loading = false, - Error = null, - SpecialtyFilter = null, - } - ); - } - catch (Exception ex) - { - setState( - new PractitionersState - { - Practitioners = new Practitioner[0], - Loading = false, - Error = ex.Message, - SpecialtyFilter = null, - } - ); - } - } - - private static async void FilterBySpecialty( - string specialty, - Action setState - ) - { - if (string.IsNullOrEmpty(specialty)) - { - LoadPractitioners(setState); - return; - } - - try - { - var practitioners = await ApiClient.SearchPractitionersAsync(specialty); - setState( - new PractitionersState - { - Practitioners = practitioners, - Loading = false, - Error = null, - SpecialtyFilter = specialty, - } - ); - } - catch (Exception ex) - { - setState( - new PractitionersState - { - Practitioners = new Practitioner[0], - Loading = false, - Error = ex.Message, - SpecialtyFilter = specialty, - } - ); - } - } - - private static ReactElement RenderError(string message) => - Div( - className: "card", - style: new { borderLeft = "4px solid var(--error)" }, - children: new[] - { - Div( - className: "flex items-center gap-3 p-4", - children: new[] - { - Icons.X(), - Text("Error loading practitioners: " + message), - } - ), - } - ); - - private static ReactElement RenderEmpty() => - Div( - className: "card", - children: new[] - { - Div( - className: "empty-state", - children: new[] - { - Icons.UserDoctor(), - H( - 4, - className: "empty-state-title", - children: new[] { Text("No Practitioners") } - ), - P( - className: "empty-state-description", - children: new[] - { - Text( - "No practitioners found. Add a new practitioner to get started." - ), - } - ), - Button( - className: "btn btn-primary mt-4", - children: new[] { Icons.Plus(), Text("Add Practitioner") } - ), - } - ), - } - ); - - private static ReactElement RenderLoadingGrid() => - Div( - className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6", - children: Enumerable - .Range(0, 6) - .Select(i => - Div( - className: "card", - children: new[] - { - Div( - className: "skeleton", - style: new - { - width = "80px", - height = "80px", - borderRadius = "50%", - } - ), - Div( - className: "skeleton mt-4", - style: new { width = "60%", height = "20px" } - ), - Div( - className: "skeleton mt-2", - style: new { width = "40%", height = "16px" } - ), - Div( - className: "skeleton mt-4", - style: new { width = "100%", height = "16px" } - ), - } - ) - ) - .ToArray() - ); - - private static ReactElement RenderPractitionerGrid(Practitioner[] practitioners) => - Div( - className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6", - children: practitioners.Select(RenderPractitionerCard).ToArray() - ); - - private static ReactElement RenderPractitionerCard(Practitioner practitioner) => - Div( - className: "card", - children: new[] - { - // Header - Div( - className: "flex items-start gap-4", - children: new[] - { - Div( - className: "avatar avatar-xl", - children: new[] { Text(GetInitials(practitioner)) } - ), - Div( - className: "flex-1", - children: new[] - { - H( - 4, - className: "font-semibold", - children: new[] - { - Text( - "Dr. " - + practitioner.NameGiven - + " " - + practitioner.NameFamily - ), - } - ), - Span( - className: "badge badge-teal mt-1", - children: new[] - { - Text(practitioner.Specialty ?? "General"), - } - ), - Div( - className: "flex items-center gap-2 mt-2", - children: new[] - { - Span( - className: "status-dot " - + (practitioner.Active ? "active" : "inactive") - ), - Text(practitioner.Active ? "Available" : "Unavailable"), - } - ), - } - ), - } - ), - // Details - Div( - className: "mt-4 pt-4 border-t border-gray-200", - children: new[] - { - RenderDetail("ID", practitioner.Identifier), - RenderDetail("Qualification", practitioner.Qualification ?? "N/A"), - RenderDetail("Email", practitioner.TelecomEmail ?? "N/A"), - RenderDetail("Phone", practitioner.TelecomPhone ?? "N/A"), - } - ), - // Actions - Div( - className: "flex gap-2 mt-4", - children: new[] - { - Button( - className: "btn btn-primary btn-sm flex-1", - children: new[] { Icons.Calendar(), Text("View Schedule") } - ), - Button( - className: "btn btn-secondary btn-sm", - children: new[] { Icons.Edit() } - ), - } - ), - } - ); - - private static ReactElement RenderDetail(string label, string value) => - Div( - className: "flex justify-between py-1", - children: new[] - { - Span(className: "text-sm text-gray-500", children: new[] { Text(label) }), - Span(className: "text-sm font-medium", children: new[] { Text(value) }), - } - ); - - private static string GetInitials(Practitioner p) => - FirstChar(p.NameGiven) + FirstChar(p.NameFamily); - - private static string FirstChar(string s) - { - if (string.IsNullOrEmpty(s)) - return ""; - return s.Substring(0, 1).ToUpper(); - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/Program.cs b/Samples/Dashboard/Dashboard.Web/Program.cs deleted file mode 100644 index 933be859..00000000 --- a/Samples/Dashboard/Dashboard.Web/Program.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Dashboard.Api; -using Dashboard.React; -using H5; - -namespace Dashboard -{ - /// - /// Application entry point. - /// - public static class Program - { - /// - /// Main entry point - called when H5 script loads. - /// - public static void Main() - { - // Configure API endpoints - // Default to local development URLs - var clinicalUrl = GetConfigValue("CLINICAL_API_URL", "http://localhost:5080"); - var schedulingUrl = GetConfigValue("SCHEDULING_API_URL", "http://localhost:5001"); - - ApiClient.Configure(clinicalUrl, schedulingUrl); - - // Configure ICD-10 API endpoint - var icd10Url = GetConfigValue("ICD10_API_URL", "http://localhost:5090"); - ApiClient.ConfigureIcd10(icd10Url); - - // Set authentication token - single token with both clinician and scheduler roles - // Token is inlined to avoid H5 static initialization timing issues - // All-zeros signing key, expires 2035 - var authToken = GetConfigValue( - "AUTH_TOKEN", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXNoYm9hcmQtdXNlciIsImp0aSI6IjE1MTMwYTg0LTY4NTktNGNmMy05MjA3LTMyMGJhYWRiNzhjNSIsInJvbGVzIjpbImNsaW5pY2lhbiIsInNjaGVkdWxlciJdLCJleHAiOjIwODE5MjIxMDQsImlhdCI6MTc2NjM4OTMwNH0.mk66XyKaLWukzZOmGNwss74lSlXobt6Em0NoEbXRdKU" - ); - ApiClient.SetTokens(authToken, authToken); - - // Log startup - Log("Healthcare Dashboard starting..."); - Log("Clinical API: " + clinicalUrl); - Log("Scheduling API: " + schedulingUrl); - Log("ICD-10 API: " + icd10Url); - - // Hide loading screen - HideLoadingScreen(); - - // Render the React application - ReactInterop.RenderApp(App.Render()); - - Log("Dashboard initialized successfully!"); - } - - private static string GetConfigValue(string key, string defaultValue) - { - // Try to get from window config object if available - var windowConfig = Script.Get("window", "dashboardConfig"); - if (windowConfig != null) - { - var value = Script.Get(windowConfig, key); - if (!string.IsNullOrEmpty(value)) - { - return value; - } - } - - return defaultValue; - } - - private static void HideLoadingScreen() - { - var loadingScreen = Script.Call("document.getElementById", "loading-screen"); - if (loadingScreen != null) - { - Script.Write("loadingScreen.classList.add('hidden')"); - } - } - - private static void Log(string message) => - Script.Call("console.log", "[Dashboard] " + message); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/React/Elements.cs b/Samples/Dashboard/Dashboard.Web/React/Elements.cs deleted file mode 100644 index 8db4a498..00000000 --- a/Samples/Dashboard/Dashboard.Web/React/Elements.cs +++ /dev/null @@ -1,461 +0,0 @@ -using System; -using H5; - -namespace Dashboard.React -{ - /// - /// HTML element factory methods for React. - /// - public static class Elements - { - /// - /// Creates a div element. - /// - public static ReactElement Div( - string className = null, - string id = null, - object style = null, - Action onClick = null, - params ReactElement[] children - ) => CreateElement("div", className, id, style, onClick, children); - - /// - /// Creates a span element. - /// - public static ReactElement Span( - string className = null, - string id = null, - object style = null, - params ReactElement[] children - ) => CreateElement("span", className, id, style, null, children); - - /// - /// Creates a paragraph element. - /// - public static ReactElement P( - string className = null, - object style = null, - params ReactElement[] children - ) => CreateElement("p", className, null, style, null, children); - - /// - /// Creates a heading element (h1-h6). - /// - public static ReactElement H( - int level, - string className = null, - params ReactElement[] children - ) => CreateElement("h" + level, className, null, null, null, children); - - /// - /// Creates a button element. - /// - public static ReactElement Button( - string className = null, - Action onClick = null, - bool disabled = false, - string type = "button", - params ReactElement[] children - ) - { - Action clickHandler = null; - if (onClick != null) - { - clickHandler = e => - { - Script.Write("e.stopPropagation()"); - onClick(); - }; - } - var props = new - { - className = className, - onClick = clickHandler, - disabled = disabled, - type = type, - }; - return Script.Call("React.createElement", "button", props, children); - } - - /// - /// Creates an input element. - /// - public static ReactElement Input( - string className = null, - string type = "text", - string value = null, - string placeholder = null, - Action onChange = null, - Action onKeyDown = null, - bool disabled = false - ) - { - Action changeHandler = null; - if (onChange != null) - { - changeHandler = e => - onChange(Script.Get(Script.Get(e, "target"), "value")); - } - Action keyDownHandler = null; - if (onKeyDown != null) - { - keyDownHandler = e => onKeyDown(Script.Get(e, "key")); - } - var props = new - { - className = className, - type = type, - value = value, - placeholder = placeholder, - onChange = changeHandler, - onKeyDown = keyDownHandler, - disabled = disabled, - }; - return Script.Call("React.createElement", "input", props); - } - - /// - /// Creates a text node. - /// - public static ReactElement Text(string content) => - Script.Call("React.createElement", "span", null, content); - - /// - /// Creates an image element. - /// - public static ReactElement Img( - string src, - string alt = null, - string className = null, - object style = null - ) - { - var props = new - { - src = src, - alt = alt, - className = className, - style = style, - }; - return Script.Call("React.createElement", "img", props); - } - - /// - /// Creates a link element. - /// - public static ReactElement A( - string href, - string className = null, - string target = null, - Action onClick = null, - params ReactElement[] children - ) - { - Action clickHandler = null; - if (onClick != null) - { - clickHandler = e => - { - Script.Write("e.preventDefault()"); - onClick(); - }; - } - var props = new - { - href = href, - className = className, - target = target, - onClick = clickHandler, - }; - return Script.Call("React.createElement", "a", props, children); - } - - /// - /// Creates a nav element. - /// - public static ReactElement Nav(string className = null, params ReactElement[] children) => - CreateElement("nav", className, null, null, null, children); - - /// - /// Creates a header element. - /// - public static ReactElement Header( - string className = null, - params ReactElement[] children - ) => CreateElement("header", className, null, null, null, children); - - /// - /// Creates a main element. - /// - public static ReactElement Main(string className = null, params ReactElement[] children) => - CreateElement("main", className, null, null, null, children); - - /// - /// Creates an aside element. - /// - public static ReactElement Aside(string className = null, params ReactElement[] children) => - CreateElement("aside", className, null, null, null, children); - - /// - /// Creates a section element. - /// - public static ReactElement Section( - string className = null, - params ReactElement[] children - ) => CreateElement("section", className, null, null, null, children); - - /// - /// Creates an article element. - /// - public static ReactElement Article( - string className = null, - params ReactElement[] children - ) => CreateElement("article", className, null, null, null, children); - - /// - /// Creates a footer element. - /// - public static ReactElement Footer( - string className = null, - params ReactElement[] children - ) => CreateElement("footer", className, null, null, null, children); - - /// - /// Creates a table element. - /// - public static ReactElement Table(string className = null, params ReactElement[] children) => - CreateElement("table", className, null, null, null, children); - - /// - /// Creates a thead element. - /// - public static ReactElement THead(params ReactElement[] children) => - Script.Call("React.createElement", "thead", null, children); - - /// - /// Creates a tbody element. - /// - public static ReactElement TBody(params ReactElement[] children) => - Script.Call("React.createElement", "tbody", null, children); - - /// - /// Creates a tr element. - /// - public static ReactElement Tr( - string className = null, - Action onClick = null, - params ReactElement[] children - ) - { - Action clickHandler = null; - if (onClick != null) - { - clickHandler = _ => onClick(); - } - var props = new { className = className, onClick = clickHandler }; - return Script.Call("React.createElement", "tr", props, children); - } - - /// - /// Creates a th element. - /// - public static ReactElement Th(string className = null, params ReactElement[] children) => - CreateElement("th", className, null, null, null, children); - - /// - /// Creates a td element. - /// - public static ReactElement Td(string className = null, params ReactElement[] children) => - CreateElement("td", className, null, null, null, children); - - /// - /// Creates an unordered list element. - /// - public static ReactElement Ul(string className = null, params ReactElement[] children) => - CreateElement("ul", className, null, null, null, children); - - /// - /// Creates a list item element. - /// - public static ReactElement Li( - string className = null, - Action onClick = null, - params ReactElement[] children - ) => CreateElement("li", className, null, null, onClick, children); - - /// - /// Creates a form element. - /// - public static ReactElement Form( - string className = null, - Action onSubmit = null, - params ReactElement[] children - ) - { - Action submitHandler = null; - if (onSubmit != null) - { - submitHandler = e => - { - Script.Write("e.preventDefault()"); - onSubmit(); - }; - } - var props = new { className = className, onSubmit = submitHandler }; - return Script.Call("React.createElement", "form", props, children); - } - - /// - /// Creates a label element. - /// - public static ReactElement Label( - string htmlFor = null, - string className = null, - params ReactElement[] children - ) - { - var props = new { htmlFor = htmlFor, className = className }; - return Script.Call("React.createElement", "label", props, children); - } - - /// - /// Creates a select element. - /// - public static ReactElement Select( - string className = null, - string value = null, - Action onChange = null, - params ReactElement[] children - ) - { - Action changeHandler = null; - if (onChange != null) - { - changeHandler = e => - onChange(Script.Get(Script.Get(e, "target"), "value")); - } - var props = new - { - className = className, - value = value, - onChange = changeHandler, - }; - return Script.Call("React.createElement", "select", props, children); - } - - /// - /// Creates an option element. - /// - public static ReactElement Option(string value, string label) - { - var props = new { value = value }; - return Script.Call("React.createElement", "option", props, label); - } - - /// - /// Creates a textarea element. - /// - public static ReactElement TextArea( - string className = null, - string value = null, - string placeholder = null, - int rows = 0, - Action onChange = null - ) - { - Action changeHandler = null; - if (onChange != null) - { - changeHandler = e => - onChange(Script.Get(Script.Get(e, "target"), "value")); - } - var props = new - { - className = className, - value = value, - placeholder = placeholder, - rows = rows > 0 ? (object)rows : null, - onChange = changeHandler, - }; - return Script.Call("React.createElement", "textarea", props); - } - - /// - /// Creates an SVG element. - /// - public static ReactElement Svg( - string className = null, - int width = 0, - int height = 0, - string viewBox = null, - string fill = null, - params ReactElement[] children - ) - { - var props = new - { - className = className, - width = width > 0 ? (object)width : null, - height = height > 0 ? (object)height : null, - viewBox = viewBox, - fill = fill, - }; - return Script.Call("React.createElement", "svg", props, children); - } - - /// - /// Creates a path element for SVG. - /// - public static ReactElement Path( - string d, - string fill = null, - string stroke = null, - int strokeWidth = 0 - ) - { - var props = new - { - d = d, - fill = fill, - stroke = stroke, - strokeWidth = strokeWidth > 0 ? (object)strokeWidth : null, - }; - return Script.Call("React.createElement", "path", props); - } - - /// - /// Creates a React Fragment. - /// - public static ReactElement Fragment(params ReactElement[] children) => - Script.Call( - "React.createElement", - Script.Get("React", "Fragment"), - null, - children - ); - - private static ReactElement CreateElement( - string tag, - string className, - string id, - object style, - Action onClick, - ReactElement[] children - ) - { - Action clickHandler = null; - if (onClick != null) - { - clickHandler = _ => onClick(); - } - var props = new - { - className = className, - id = id, - style = style, - onClick = clickHandler, - }; - return Script.Call("React.createElement", tag, props, children); - } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/React/Hooks.cs b/Samples/Dashboard/Dashboard.Web/React/Hooks.cs deleted file mode 100644 index 1945d9c2..00000000 --- a/Samples/Dashboard/Dashboard.Web/React/Hooks.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using H5; - -namespace Dashboard.React -{ - /// - /// State tuple for useState hook. - /// - public class StateResult - { - /// Current state value. - public T State { get; set; } - - /// State setter function. - public Action SetState { get; set; } - } - - /// - /// State tuple for useState hook with functional update. - /// - public class StateFuncResult - { - /// Current state value. - public T State { get; set; } - - /// State setter function. - public Action> SetState { get; set; } - } - - /// - /// React hooks wrapper for H5. - /// - public static class Hooks - { - /// - /// React useState hook - manages component state. - /// - public static StateResult UseState(T initialValue) - { - var result = Script.Call("React.useState", initialValue); - var state = (T)result[0]; - var setState = (Action)result[1]; - return new StateResult { State = state, SetState = setState }; - } - - /// - /// React useState hook with functional update. - /// - public static StateFuncResult UseStateFunc(T initialValue) - { - var result = Script.Call("React.useState", initialValue); - var state = (T)result[0]; - var setState = (Action>)result[1]; - return new StateFuncResult { State = state, SetState = setState }; - } - - /// - /// React useEffect hook - manages side effects. - /// - public static void UseEffect(Action effect, object[] deps = null) => - Script.Call( - "React.useEffect", - (Func)( - () => - { - effect(); - return null; - } - ), - deps - ); - - /// - /// React useEffect hook with cleanup function. - /// - public static void UseEffect(Action effect, Func cleanup, object[] deps = null) => - Script.Call( - "React.useEffect", - (Func)( - () => - { - effect(); - return cleanup(); - } - ), - deps - ); - - /// - /// React useRef hook - creates a mutable ref object. - /// - public static RefObject UseRef(T initialValue = default(T)) => - Script.Call>("React.useRef", initialValue); - - /// - /// React useMemo hook - memoizes expensive computations. - /// - public static T UseMemo(Func factory, object[] deps) => - Script.Call("React.useMemo", factory, deps); - - /// - /// React useCallback hook - memoizes callback functions. - /// Note: In C# 7.2, we cannot use Delegate constraint, so callback must be cast appropriately. - /// - public static T UseCallback(T callback, object[] deps) => - Script.Call("React.useCallback", callback, deps); - - /// - /// React useContext hook - consumes a React context. - /// - public static T UseContext(object context) => - Script.Call("React.useContext", context); - } -} diff --git a/Samples/Dashboard/Dashboard.Web/React/ReactInterop.cs b/Samples/Dashboard/Dashboard.Web/React/ReactInterop.cs deleted file mode 100644 index bca6c9fb..00000000 --- a/Samples/Dashboard/Dashboard.Web/React/ReactInterop.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using H5; -using static H5.Core.dom; - -namespace Dashboard.React -{ - /// - /// Core React interop types and functions for H5. - /// - public static class ReactInterop - { - /// - /// Creates a React element using React.createElement. - /// - public static ReactElement CreateElement( - string type, - object props = null, - params object[] children - ) => Script.Call("React.createElement", type, props, children); - - /// - /// Creates a React element from a component function. - /// - public static ReactElement CreateElement( - Func component, - object props = null, - params object[] children - ) => Script.Call("React.createElement", component, props, children); - - /// - /// Creates the React root and renders the application. - /// - public static void RenderApp(ReactElement element, string containerId = "root") - { - var container = document.getElementById(containerId); - var root = Script.Call("ReactDOM.createRoot", container); - root.Render(element); - } - } - - /// - /// React element type - represents a virtual DOM node. - /// - [External] - [Name("Object")] - public class ReactElement { } - - /// - /// React root for concurrent rendering. - /// - [External] - public class Root - { - /// - /// Renders a React element into the root. - /// - public extern void Render(ReactElement element); - } - - /// - /// React ref object for accessing DOM elements. - /// - [External] - [Name("Object")] - public class RefObject - { - /// - /// Current value of the ref. - /// - public extern T Current { get; set; } - } -} diff --git a/Samples/Dashboard/Dashboard.Web/h5.json b/Samples/Dashboard/Dashboard.Web/h5.json deleted file mode 100644 index db178072..00000000 --- a/Samples/Dashboard/Dashboard.Web/h5.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "output": "wwwroot/js", - "fileName": "Dashboard", - "generateSourceMap": true, - "combineScripts": true, - "fileNameCasing": "CamelCase", - "html": { - "disabled": true - } -} diff --git a/Samples/Dashboard/Dashboard.Web/wwwroot/css/base.css b/Samples/Dashboard/Dashboard.Web/wwwroot/css/base.css deleted file mode 100644 index dd0ecd44..00000000 --- a/Samples/Dashboard/Dashboard.Web/wwwroot/css/base.css +++ /dev/null @@ -1,636 +0,0 @@ -/* Medical Dashboard - Premium Base Styles */ -/* Inspired by Wellmetrix & CareIQ Designs */ - -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - font-size: 16px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - scroll-behavior: smooth; -} - -body { - font-family: var(--font-family); - font-size: var(--font-size-base); - line-height: var(--line-height-normal); - color: var(--gray-900); - background: var(--gray-50); - background-image: var(--gradient-mesh); - background-attachment: fixed; - min-height: 100vh; - overflow-x: hidden; -} - -/* Premium Background with Animated Mesh */ -body::before { - content: ''; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: - radial-gradient(ellipse 80% 50% at 20% -20%, rgba(59, 130, 246, 0.12), transparent), - radial-gradient(ellipse 60% 40% at 80% 10%, rgba(20, 184, 166, 0.1), transparent), - radial-gradient(ellipse 50% 50% at 10% 90%, rgba(139, 92, 246, 0.08), transparent); - pointer-events: none; - z-index: -1; -} - -/* Typography */ -h1, h2, h3, h4, h5, h6 { - font-weight: var(--font-weight-semibold); - line-height: var(--line-height-tight); - color: var(--gray-900); - letter-spacing: var(--letter-spacing-tight); -} - -h1 { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - letter-spacing: var(--letter-spacing-tighter); -} - -h2 { - font-size: var(--font-size-3xl); - font-weight: var(--font-weight-bold); -} - -h3 { - font-size: var(--font-size-2xl); -} - -h4 { - font-size: var(--font-size-xl); -} - -h5 { - font-size: var(--font-size-lg); -} - -h6 { - font-size: var(--font-size-md); - font-weight: var(--font-weight-medium); -} - -p { - margin-bottom: var(--space-4); - color: var(--gray-600); -} - -a { - color: var(--primary-blue); - text-decoration: none; - transition: color var(--transition-fast); -} - -a:hover { - color: var(--primary-blue-dark); -} - -strong, b { - font-weight: var(--font-weight-semibold); -} - -small { - font-size: var(--font-size-sm); -} - -/* Focus states for accessibility */ -:focus-visible { - outline: 2px solid var(--primary-blue); - outline-offset: 2px; - border-radius: var(--radius-sm); -} - -/* Selection */ -::selection { - background: var(--primary-blue-light); - color: var(--white); -} - -/* Scrollbar styling - Refined */ -::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--gray-300); - border-radius: var(--radius-full); - border: 2px solid transparent; - background-clip: content-box; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--gray-400); - border: 2px solid transparent; - background-clip: content-box; -} - -/* Firefox scrollbar */ -* { - scrollbar-width: thin; - scrollbar-color: var(--gray-300) transparent; -} - -/* Utility: screen reader only */ -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -/* Utility: truncate text */ -.truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.line-clamp-2 { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.line-clamp-3 { - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} - -/* Utility: flex layouts */ -.flex { display: flex; } -.inline-flex { display: inline-flex; } -.flex-col { flex-direction: column; } -.flex-row { flex-direction: row; } -.flex-row-reverse { flex-direction: row-reverse; } -.items-center { align-items: center; } -.items-start { align-items: flex-start; } -.items-end { align-items: flex-end; } -.items-stretch { align-items: stretch; } -.justify-center { justify-content: center; } -.justify-between { justify-content: space-between; } -.justify-end { justify-content: flex-end; } -.justify-start { justify-content: flex-start; } -.gap-0 { gap: 0; } -.gap-1 { gap: var(--space-1); } -.gap-2 { gap: var(--space-2); } -.gap-3 { gap: var(--space-3); } -.gap-4 { gap: var(--space-4); } -.gap-5 { gap: var(--space-5); } -.gap-6 { gap: var(--space-6); } -.gap-8 { gap: var(--space-8); } -.gap-10 { gap: var(--space-10); } -.flex-1 { flex: 1; } -.flex-auto { flex: auto; } -.flex-none { flex: none; } -.flex-wrap { flex-wrap: wrap; } -.flex-nowrap { flex-wrap: nowrap; } -.flex-shrink-0 { flex-shrink: 0; } -.flex-grow { flex-grow: 1; } - -/* Utility: grid layouts */ -.grid { display: grid; } -.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } -.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } -.grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } -.grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); } -.col-span-2 { grid-column: span 2 / span 2; } -.col-span-3 { grid-column: span 3 / span 3; } -.col-span-full { grid-column: 1 / -1; } - -@media (min-width: 640px) { - .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} - -@media (min-width: 768px) { - .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } -} - -@media (min-width: 1024px) { - .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } - .lg\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } -} - -@media (min-width: 1280px) { - .xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } - .xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } - .xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); } -} - -/* Utility: spacing */ -.p-0 { padding: 0; } -.p-1 { padding: var(--space-1); } -.p-2 { padding: var(--space-2); } -.p-3 { padding: var(--space-3); } -.p-4 { padding: var(--space-4); } -.p-5 { padding: var(--space-5); } -.p-6 { padding: var(--space-6); } -.p-8 { padding: var(--space-8); } -.p-10 { padding: var(--space-10); } - -.px-2 { padding-left: var(--space-2); padding-right: var(--space-2); } -.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); } -.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); } -.px-5 { padding-left: var(--space-5); padding-right: var(--space-5); } -.px-6 { padding-left: var(--space-6); padding-right: var(--space-6); } -.px-8 { padding-left: var(--space-8); padding-right: var(--space-8); } - -.py-1 { padding-top: var(--space-1); padding-bottom: var(--space-1); } -.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); } -.py-3 { padding-top: var(--space-3); padding-bottom: var(--space-3); } -.py-4 { padding-top: var(--space-4); padding-bottom: var(--space-4); } -.py-5 { padding-top: var(--space-5); padding-bottom: var(--space-5); } -.py-6 { padding-top: var(--space-6); padding-bottom: var(--space-6); } - -.m-0 { margin: 0; } -.m-auto { margin: auto; } -.mx-auto { margin-left: auto; margin-right: auto; } - -.mb-0 { margin-bottom: 0; } -.mb-1 { margin-bottom: var(--space-1); } -.mb-2 { margin-bottom: var(--space-2); } -.mb-3 { margin-bottom: var(--space-3); } -.mb-4 { margin-bottom: var(--space-4); } -.mb-5 { margin-bottom: var(--space-5); } -.mb-6 { margin-bottom: var(--space-6); } -.mb-8 { margin-bottom: var(--space-8); } - -.mt-0 { margin-top: 0; } -.mt-1 { margin-top: var(--space-1); } -.mt-2 { margin-top: var(--space-2); } -.mt-3 { margin-top: var(--space-3); } -.mt-4 { margin-top: var(--space-4); } -.mt-5 { margin-top: var(--space-5); } -.mt-6 { margin-top: var(--space-6); } -.mt-8 { margin-top: var(--space-8); } - -.ml-auto { margin-left: auto; } -.mr-auto { margin-right: auto; } - -/* Utility: text */ -.text-2xs { font-size: var(--font-size-2xs); } -.text-xs { font-size: var(--font-size-xs); } -.text-sm { font-size: var(--font-size-sm); } -.text-base { font-size: var(--font-size-base); } -.text-md { font-size: var(--font-size-md); } -.text-lg { font-size: var(--font-size-lg); } -.text-xl { font-size: var(--font-size-xl); } -.text-2xl { font-size: var(--font-size-2xl); } -.text-3xl { font-size: var(--font-size-3xl); } -.text-4xl { font-size: var(--font-size-4xl); } -.text-5xl { font-size: var(--font-size-5xl); } - -.font-light { font-weight: var(--font-weight-light); } -.font-normal { font-weight: var(--font-weight-normal); } -.font-medium { font-weight: var(--font-weight-medium); } -.font-semibold { font-weight: var(--font-weight-semibold); } -.font-bold { font-weight: var(--font-weight-bold); } -.font-extrabold { font-weight: var(--font-weight-extrabold); } - -.text-center { text-align: center; } -.text-left { text-align: left; } -.text-right { text-align: right; } - -.uppercase { text-transform: uppercase; } -.lowercase { text-transform: lowercase; } -.capitalize { text-transform: capitalize; } - -.tracking-tight { letter-spacing: var(--letter-spacing-tight); } -.tracking-wide { letter-spacing: var(--letter-spacing-wide); } -.tracking-wider { letter-spacing: var(--letter-spacing-wider); } - -.leading-none { line-height: var(--line-height-none); } -.leading-tight { line-height: var(--line-height-tight); } -.leading-normal { line-height: var(--line-height-normal); } -.leading-relaxed { line-height: var(--line-height-relaxed); } - -/* Text colors */ -.text-white { color: var(--white); } -.text-gray-400 { color: var(--gray-400); } -.text-gray-500 { color: var(--gray-500); } -.text-gray-600 { color: var(--gray-600); } -.text-gray-700 { color: var(--gray-700); } -.text-gray-800 { color: var(--gray-800); } -.text-gray-900 { color: var(--gray-900); } -.text-primary { color: var(--primary-blue); } -.text-primary-dark { color: var(--primary-blue-dark); } -.text-teal { color: var(--teal-bold); } -.text-success { color: var(--success); } -.text-warning { color: var(--warning); } -.text-error { color: var(--error); } - -/* Utility: backgrounds */ -.bg-white { background-color: var(--white); } -.bg-transparent { background-color: transparent; } -.bg-gray-25 { background-color: var(--gray-25); } -.bg-gray-50 { background-color: var(--gray-50); } -.bg-gray-100 { background-color: var(--gray-100); } -.bg-gray-200 { background-color: var(--gray-200); } -.bg-primary { background-color: var(--primary-blue); } -.bg-primary-subtle { background-color: var(--primary-blue-subtle); } -.bg-primary-dark { background-color: var(--primary-blue-dark); } -.bg-teal { background-color: var(--teal-bold); } -.bg-teal-soft { background-color: var(--teal-soft); } -.bg-success { background-color: var(--success); } -.bg-success-light { background-color: var(--success-light); } -.bg-warning { background-color: var(--warning); } -.bg-warning-light { background-color: var(--warning-light); } -.bg-error { background-color: var(--error); } -.bg-error-light { background-color: var(--error-light); } - -/* Gradient backgrounds */ -.bg-gradient-primary { background: var(--gradient-primary); } -.bg-gradient-teal { background: var(--gradient-teal); } -.bg-gradient-warm { background: var(--gradient-warm); } -.bg-gradient-subtle { background: var(--gradient-subtle); } - -/* Utility: rounded corners */ -.rounded-none { border-radius: 0; } -.rounded-sm { border-radius: var(--radius-sm); } -.rounded { border-radius: var(--radius-md); } -.rounded-md { border-radius: var(--radius-md); } -.rounded-lg { border-radius: var(--radius-lg); } -.rounded-xl { border-radius: var(--radius-xl); } -.rounded-2xl { border-radius: var(--radius-2xl); } -.rounded-3xl { border-radius: var(--radius-3xl); } -.rounded-full { border-radius: var(--radius-full); } - -/* Utility: shadows */ -.shadow-none { box-shadow: none; } -.shadow-xs { box-shadow: var(--shadow-xs); } -.shadow-sm { box-shadow: var(--shadow-sm); } -.shadow { box-shadow: var(--shadow-md); } -.shadow-md { box-shadow: var(--shadow-md); } -.shadow-lg { box-shadow: var(--shadow-lg); } -.shadow-xl { box-shadow: var(--shadow-xl); } -.shadow-2xl { box-shadow: var(--shadow-2xl); } -.shadow-card { box-shadow: var(--shadow-card); } -.shadow-floating { box-shadow: var(--shadow-floating); } - -/* Utility: borders */ -.border { border: 1px solid var(--border-color); } -.border-0 { border: none; } -.border-t { border-top: 1px solid var(--border-color); } -.border-b { border-bottom: 1px solid var(--border-color); } -.border-l { border-left: 1px solid var(--border-color); } -.border-r { border-right: 1px solid var(--border-color); } -.border-gray-100 { border-color: var(--gray-100); } -.border-gray-200 { border-color: var(--gray-200); } -.border-gray-300 { border-color: var(--gray-300); } -.border-transparent { border-color: transparent; } - -/* Utility: width/height */ -.w-full { width: 100%; } -.w-auto { width: auto; } -.w-fit { width: fit-content; } -.h-full { height: 100%; } -.h-auto { height: auto; } -.h-screen { height: 100vh; } -.min-h-screen { min-height: 100vh; } -.min-w-0 { min-width: 0; } -.max-w-full { max-width: 100%; } - -/* Utility: positioning */ -.relative { position: relative; } -.absolute { position: absolute; } -.fixed { position: fixed; } -.sticky { position: sticky; } -.inset-0 { top: 0; right: 0; bottom: 0; left: 0; } -.top-0 { top: 0; } -.right-0 { right: 0; } -.bottom-0 { bottom: 0; } -.left-0 { left: 0; } - -/* Utility: display */ -.hidden { display: none; } -.block { display: block; } -.inline-block { display: inline-block; } -.inline { display: inline; } - -/* Utility: overflow */ -.overflow-hidden { overflow: hidden; } -.overflow-auto { overflow: auto; } -.overflow-x-auto { overflow-x: auto; } -.overflow-y-auto { overflow-y: auto; } -.overflow-visible { overflow: visible; } - -/* Utility: opacity */ -.opacity-0 { opacity: 0; } -.opacity-50 { opacity: 0.5; } -.opacity-75 { opacity: 0.75; } -.opacity-100 { opacity: 1; } - -/* Utility: visibility */ -.visible { visibility: visible; } -.invisible { visibility: hidden; } - -/* Utility: cursor */ -.cursor-pointer { cursor: pointer; } -.cursor-default { cursor: default; } -.cursor-not-allowed { cursor: not-allowed; } - -/* Utility: pointer events */ -.pointer-events-none { pointer-events: none; } -.pointer-events-auto { pointer-events: auto; } - -/* Utility: user select */ -.select-none { user-select: none; } -.select-text { user-select: text; } -.select-all { user-select: all; } - -/* Animation keyframes */ -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(16px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fadeInDown { - from { - opacity: 0; - transform: translateY(-16px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slideInRight { - from { - opacity: 0; - transform: translateX(-20px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} - -@keyframes float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-8px); } -} - -@keyframes glow { - 0%, 100% { box-shadow: 0 0 5px var(--primary-blue-glow); } - 50% { box-shadow: 0 0 20px var(--primary-blue-glow), 0 0 30px var(--primary-blue-glow); } -} - -.animate-fadeIn { animation: fadeIn var(--transition-normal) ease-out; } -.animate-fadeInUp { animation: fadeInUp var(--transition-slow) ease-out; } -.animate-fadeInDown { animation: fadeInDown var(--transition-slow) ease-out; } -.animate-slideInRight { animation: slideInRight var(--transition-slow) ease-out; } -.animate-scaleIn { animation: scaleIn var(--transition-normal) ease-out; } -.animate-pulse { animation: pulse 2s ease-in-out infinite; } -.animate-spin { animation: spin 1s linear infinite; } -.animate-shimmer { animation: shimmer 2s linear infinite; } -.animate-float { animation: float 3s ease-in-out infinite; } - -/* Transition utilities */ -.transition-none { transition: none; } -.transition-all { transition: all var(--transition-normal); } -.transition-colors { transition: color var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast); } -.transition-opacity { transition: opacity var(--transition-normal); } -.transition-transform { transition: transform var(--transition-normal); } -.transition-shadow { transition: box-shadow var(--transition-normal); } - -/* Transform utilities */ -.transform { transform: translateZ(0); } -.scale-95 { transform: scale(0.95); } -.scale-100 { transform: scale(1); } -.scale-105 { transform: scale(1.05); } -.-translate-y-1 { transform: translateY(-4px); } -.translate-y-0 { transform: translateY(0); } - -/* Hover utilities */ -.hover\:scale-102:hover { transform: scale(1.02); } -.hover\:scale-105:hover { transform: scale(1.05); } -.hover\:-translate-y-1:hover { transform: translateY(-4px); } -.hover\:shadow-lg:hover { box-shadow: var(--shadow-lg); } -.hover\:shadow-xl:hover { box-shadow: var(--shadow-xl); } -.hover\:shadow-card-hover:hover { box-shadow: var(--shadow-card-hover); } - -/* Icons */ -.icon { - width: 20px; - height: 20px; - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.icon-xs { width: 14px; height: 14px; } -.icon-sm { width: 16px; height: 16px; } -.icon-md { width: 20px; height: 20px; } -.icon-lg { width: 24px; height: 24px; } -.icon-xl { width: 32px; height: 32px; } -.icon-2xl { width: 40px; height: 40px; } -.icon-3xl { width: 48px; height: 48px; } - -/* Google Charts container styling */ -.chart-container { - width: 100%; - min-height: 300px; - position: relative; -} - -.chart-container-sm { - min-height: 200px; -} - -.chart-container-lg { - min-height: 400px; -} - -/* Responsive visibility */ -@media (max-width: 639px) { - .sm\:hidden { display: none; } -} - -@media (max-width: 767px) { - .md\:hidden { display: none; } -} - -@media (max-width: 1023px) { - .lg\:hidden { display: none; } -} - -@media (min-width: 640px) { - .hidden.sm\:block { display: block; } - .hidden.sm\:flex { display: flex; } -} - -@media (min-width: 768px) { - .hidden.md\:block { display: block; } - .hidden.md\:flex { display: flex; } -} - -@media (min-width: 1024px) { - .hidden.lg\:block { display: block; } - .hidden.lg\:flex { display: flex; } -} diff --git a/Samples/Dashboard/Dashboard.Web/wwwroot/css/components.css b/Samples/Dashboard/Dashboard.Web/wwwroot/css/components.css deleted file mode 100644 index a719b686..00000000 --- a/Samples/Dashboard/Dashboard.Web/wwwroot/css/components.css +++ /dev/null @@ -1,1890 +0,0 @@ -/* Medical Dashboard - Premium Component Styles */ -/* Inspired by Wellmetrix & CareIQ Designs */ - -/* === PREMIUM CARD - Glassmorphic === */ -.card { - position: relative; - background: var(--glass-bg-strong); - backdrop-filter: blur(var(--glass-blur)); - -webkit-backdrop-filter: blur(var(--glass-blur)); - border: 1px solid var(--glass-border); - border-radius: var(--radius-2xl); - padding: var(--space-6); - box-shadow: var(--shadow-card); - transition: all var(--transition-normal); - overflow: hidden; -} - -.card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.8), transparent); - opacity: 0.6; -} - -.card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-card-hover); - border-color: rgba(255,255,255,0.6); -} - -.card-solid { - background: var(--white); - backdrop-filter: none; - border: 1px solid var(--gray-100); -} - -.card-elevated { - background: var(--white); - backdrop-filter: none; - box-shadow: var(--shadow-lg); -} - -.card-interactive { - cursor: pointer; -} - -.card-interactive:hover { - transform: translateY(-4px); -} - -.card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-5); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--gray-100); -} - -.card-title { - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - margin: 0; - letter-spacing: var(--letter-spacing-tight); -} - -.card-subtitle { - font-size: var(--font-size-sm); - color: var(--gray-500); - margin-top: var(--space-1); -} - -.card-actions { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.card-body { - flex: 1; -} - -.card-footer { - margin-top: var(--space-5); - padding-top: var(--space-4); - border-top: 1px solid var(--gray-100); -} - -/* === METRIC CARD - Premium Stats === */ -.metric-card { - position: relative; - background: var(--white); - border-radius: var(--radius-2xl); - padding: var(--space-6); - box-shadow: var(--shadow-card); - transition: all var(--transition-normal); - overflow: hidden; - min-height: var(--card-min-height); -} - -.metric-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: var(--gradient-primary); - opacity: 0; - transition: opacity var(--transition-normal); -} - -.metric-card:hover { - transform: translateY(-4px); - box-shadow: var(--shadow-card-hover); -} - -.metric-card:hover::before { - opacity: 1; -} - -.metric-card.accent-blue::before { background: var(--gradient-primary); opacity: 1; } -.metric-card.accent-teal::before { background: var(--gradient-teal); opacity: 1; } -.metric-card.accent-warm::before { background: var(--gradient-warm); opacity: 1; } -.metric-card.accent-cool::before { background: var(--gradient-cool); opacity: 1; } - -.metric-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: var(--space-4); -} - -.metric-icon { - width: 52px; - height: 52px; - border-radius: var(--radius-xl); - display: flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-2xl); - transition: transform var(--transition-normal); -} - -.metric-card:hover .metric-icon { - transform: scale(1.05); -} - -.metric-icon.blue { - background: linear-gradient(135deg, var(--primary-blue-subtle) 0%, rgba(59, 130, 246, 0.15) 100%); - color: var(--primary-blue); -} - -.metric-icon.teal { - background: linear-gradient(135deg, var(--teal-soft) 0%, rgba(20, 184, 166, 0.15) 100%); - color: var(--teal-dark); -} - -.metric-icon.success { - background: linear-gradient(135deg, var(--success-light) 0%, rgba(34, 197, 94, 0.15) 100%); - color: var(--success); -} - -.metric-icon.warning { - background: linear-gradient(135deg, var(--warning-light) 0%, rgba(245, 158, 11, 0.15) 100%); - color: var(--warning); -} - -.metric-icon.coral { - background: linear-gradient(135deg, var(--accent-coral-light) 0%, rgba(249, 115, 22, 0.15) 100%); - color: var(--accent-coral); -} - -.metric-icon.violet { - background: linear-gradient(135deg, var(--accent-violet-light) 0%, rgba(139, 92, 246, 0.15) 100%); - color: var(--accent-violet); -} - -.metric-content { - flex: 1; -} - -.metric-value { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--gray-900); - line-height: var(--line-height-none); - letter-spacing: var(--letter-spacing-tight); - margin-bottom: var(--space-1); -} - -.metric-value-sm { - font-size: var(--font-size-3xl); -} - -.metric-label { - font-size: var(--font-size-sm); - color: var(--gray-500); - font-weight: var(--font-weight-medium); - margin-bottom: var(--space-3); -} - -.metric-trend { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-full); -} - -.metric-trend.up { - color: var(--success); - background: var(--success-light); -} - -.metric-trend.down { - color: var(--error); - background: var(--error-light); -} - -.metric-trend.neutral { - color: var(--gray-500); - background: var(--gray-100); -} - -.metric-mini-chart { - height: 40px; - margin-top: var(--space-3); -} - -/* === STAT CARD - Compact Version === */ -.stat-card { - display: flex; - align-items: center; - gap: var(--space-4); - background: var(--white); - border-radius: var(--radius-xl); - padding: var(--space-4) var(--space-5); - box-shadow: var(--shadow-sm); - transition: all var(--transition-normal); -} - -.stat-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.stat-icon { - width: 44px; - height: 44px; - border-radius: var(--radius-lg); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.stat-content { - flex: 1; - min-width: 0; -} - -.stat-value { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); - color: var(--gray-900); - line-height: var(--line-height-tight); -} - -.stat-label { - font-size: var(--font-size-sm); - color: var(--gray-500); -} - -/* === BUTTONS - Premium Style === */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: var(--space-2-5) var(--space-5); - font-family: inherit; - font-size: var(--font-size-base); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-tight); - border: none; - border-radius: var(--radius-lg); - cursor: pointer; - transition: all var(--transition-fast); - white-space: nowrap; - text-decoration: none; -} - -.btn:focus-visible { - outline: 2px solid var(--primary-blue); - outline-offset: 2px; -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none !important; -} - -.btn-primary { - background: var(--gradient-primary); - color: var(--white); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); -} - -.btn-primary:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); -} - -.btn-primary:active:not(:disabled) { - transform: translateY(0); -} - -.btn-secondary { - background: var(--gray-100); - color: var(--gray-700); -} - -.btn-secondary:hover:not(:disabled) { - background: var(--gray-200); -} - -.btn-outline { - background: transparent; - border: 1px solid var(--gray-300); - color: var(--gray-700); -} - -.btn-outline:hover:not(:disabled) { - background: var(--gray-50); - border-color: var(--gray-400); -} - -.btn-ghost { - background: transparent; - color: var(--gray-600); -} - -.btn-ghost:hover:not(:disabled) { - background: var(--gray-100); - color: var(--gray-900); -} - -.btn-danger { - background: var(--gradient-warm); - color: var(--white); - box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); -} - -.btn-danger:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); -} - -.btn-success { - background: var(--gradient-teal); - color: var(--white); - box-shadow: 0 2px 8px rgba(20, 184, 166, 0.3); -} - -.btn-success:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(20, 184, 166, 0.4); -} - -.btn-white { - background: var(--white); - color: var(--gray-700); - box-shadow: var(--shadow-sm); -} - -.btn-white:hover:not(:disabled) { - box-shadow: var(--shadow-md); -} - -.btn-xs { padding: var(--space-1) var(--space-2); font-size: var(--font-size-xs); border-radius: var(--radius-md); } -.btn-sm { padding: var(--space-1-5) var(--space-3); font-size: var(--font-size-sm); } -.btn-lg { padding: var(--space-3) var(--space-6); font-size: var(--font-size-md); } -.btn-xl { padding: var(--space-4) var(--space-8); font-size: var(--font-size-lg); border-radius: var(--radius-xl); } - -.btn-icon { - padding: var(--space-2); - width: 40px; - height: 40px; -} - -.btn-icon.btn-sm { - width: 32px; - height: 32px; - padding: var(--space-1-5); -} - -.btn-icon.btn-lg { - width: 48px; - height: 48px; - padding: var(--space-3); -} - -/* Button Group */ -.btn-group { - display: inline-flex; - border-radius: var(--radius-lg); - overflow: hidden; - box-shadow: var(--shadow-sm); -} - -.btn-group .btn { - border-radius: 0; - border-right: 1px solid rgba(0,0,0,0.1); -} - -.btn-group .btn:last-child { - border-right: none; -} - -/* === INPUTS - Modern Style === */ -.input { - width: 100%; - padding: var(--space-3) var(--space-4); - font-family: inherit; - font-size: var(--font-size-base); - line-height: var(--line-height-tight); - color: var(--gray-900); - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-lg); - transition: all var(--transition-fast); -} - -.input:focus { - outline: none; - border-color: var(--primary-blue); - box-shadow: 0 0 0 4px var(--primary-blue-glow); -} - -.input:disabled { - background: var(--gray-50); - color: var(--gray-400); - cursor: not-allowed; -} - -.input::placeholder { - color: var(--gray-400); -} - -.input-error { - border-color: var(--error); -} - -.input-error:focus { - box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15); -} - -.input-success { - border-color: var(--success); -} - -.input-success:focus { - box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.15); -} - -.input-sm { - padding: var(--space-2) var(--space-3); - font-size: var(--font-size-sm); -} - -.input-lg { - padding: var(--space-4) var(--space-5); - font-size: var(--font-size-md); -} - -.input-group { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.input-label { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--gray-700); -} - -.input-helper { - font-size: var(--font-size-sm); - color: var(--gray-500); -} - -.input-error-message { - font-size: var(--font-size-sm); - color: var(--error); -} - -/* Input with Icon */ -.input-with-icon { - position: relative; -} - -.input-with-icon .input { - padding-left: var(--space-11); -} - -.input-with-icon .input-icon { - position: absolute; - left: var(--space-4); - top: 50%; - transform: translateY(-50%); - color: var(--gray-400); - pointer-events: none; -} - -.input-with-icon-right .input { - padding-right: var(--space-11); -} - -.input-with-icon-right .input-icon { - left: auto; - right: var(--space-4); -} - -/* === SEARCH INPUT === */ -.search-box { - position: relative; - width: 100%; -} - -.search-box .input { - padding-left: var(--space-11); - background: var(--gray-50); - border-color: transparent; -} - -.search-box .input:focus { - background: var(--white); - border-color: var(--primary-blue); -} - -.search-box .search-icon { - position: absolute; - left: var(--space-4); - top: 50%; - transform: translateY(-50%); - color: var(--gray-400); -} - -.search-input { - position: relative; -} - -.search-input .input { - padding-left: var(--space-10); -} - -.search-input .search-icon { - position: absolute; - left: var(--space-3); - top: 50%; - transform: translateY(-50%); - color: var(--gray-400); -} - -/* === SELECT === */ -.select { - appearance: none; - width: 100%; - padding: var(--space-3) var(--space-10) var(--space-3) var(--space-4); - font-family: inherit; - font-size: var(--font-size-base); - color: var(--gray-900); - background: var(--white) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") no-repeat right var(--space-4) center; - border: 1px solid var(--gray-200); - border-radius: var(--radius-lg); - cursor: pointer; - transition: all var(--transition-fast); -} - -.select:focus { - outline: none; - border-color: var(--primary-blue); - box-shadow: 0 0 0 4px var(--primary-blue-glow); -} - -/* === BADGE === */ -.badge { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2-5); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - line-height: var(--line-height-tight); - border-radius: var(--radius-full); - white-space: nowrap; -} - -.badge-primary { - background: var(--primary-blue-subtle); - color: var(--primary-blue-dark); -} - -.badge-secondary { - background: var(--gray-200); - color: var(--gray-700); -} - -.badge-success { - background: var(--success-light); - color: var(--success-dark); -} - -.badge-warning { - background: var(--warning-light); - color: var(--warning-dark); -} - -.badge-error { - background: var(--error-light); - color: var(--error-dark); -} - -.badge-danger { - background: var(--error-light); - color: var(--error-dark); -} - -.badge-info { - background: var(--info-light); - color: var(--info-dark); -} - -.badge-gray { - background: var(--gray-100); - color: var(--gray-700); -} - -.badge-teal { - background: var(--teal-soft); - color: var(--teal-dark); -} - -.badge-violet { - background: var(--accent-violet-light); - color: var(--accent-violet); -} - -.badge-coral { - background: var(--accent-coral-light); - color: var(--accent-coral); -} - -.badge-outline { - background: transparent; - border: 1px solid currentColor; -} - -.badge-lg { - padding: var(--space-1-5) var(--space-3); - font-size: var(--font-size-sm); -} - -.badge-dot { - padding-left: var(--space-2); -} - -.badge-dot::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -/* === STATUS DOT === */ -.status-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; -} - -.status-dot.active { - background: var(--success); - box-shadow: 0 0 0 3px var(--success-light); -} - -.status-dot.inactive { - background: var(--gray-400); -} - -.status-dot.pending { - background: var(--warning); - box-shadow: 0 0 0 3px var(--warning-light); -} - -.status-dot.error { - background: var(--error); - box-shadow: 0 0 0 3px var(--error-light); -} - -.status-dot.pulse { - animation: statusPulse 2s ease-in-out infinite; -} - -@keyframes statusPulse { - 0%, 100% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.1); opacity: 0.8; } -} - -/* === AVATAR === */ -.avatar { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-weight: var(--font-weight-semibold); - background: var(--gradient-primary); - color: var(--white); - flex-shrink: 0; - overflow: hidden; -} - -.avatar-xs { width: 24px; height: 24px; font-size: var(--font-size-2xs); } -.avatar-sm { width: 32px; height: 32px; font-size: var(--font-size-xs); } -.avatar-md { width: 40px; height: 40px; font-size: var(--font-size-sm); } -.avatar-lg { width: 48px; height: 48px; font-size: var(--font-size-md); } -.avatar-xl { width: 64px; height: 64px; font-size: var(--font-size-xl); } -.avatar-2xl { width: 80px; height: 80px; font-size: var(--font-size-2xl); } - -.avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.avatar-teal { background: var(--gradient-teal); } -.avatar-warm { background: var(--gradient-warm); } -.avatar-violet { background: linear-gradient(135deg, var(--accent-violet) 0%, #6d28d9 100%); } -.avatar-blue { background: var(--primary-blue-subtle); color: var(--primary-blue-dark); } - -.avatar-group { - display: flex; -} - -.avatar-group .avatar { - border: 2px solid var(--white); - margin-left: -8px; -} - -.avatar-group .avatar:first-child { - margin-left: 0; -} - -/* === TABLE === */ -.table-container { - overflow-x: auto; - border-radius: var(--radius-xl); - background: var(--white); - box-shadow: var(--shadow-card); -} - -.table { - width: 100%; - border-collapse: collapse; -} - -.table th, -.table td { - padding: var(--space-4) var(--space-5); - text-align: left; - border-bottom: 1px solid var(--gray-100); -} - -.table th { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--gray-600); - background: var(--gray-50); - text-transform: uppercase; - letter-spacing: var(--letter-spacing-wide); -} - -.table th:first-child { - border-radius: var(--radius-xl) 0 0 0; -} - -.table th:last-child { - border-radius: 0 var(--radius-xl) 0 0; -} - -.table tbody tr { - transition: background var(--transition-fast); -} - -.table tbody tr:hover { - background: var(--gray-50); -} - -.table tbody tr:last-child td { - border-bottom: none; -} - -.table tbody tr:last-child td:first-child { - border-radius: 0 0 0 var(--radius-xl); -} - -.table tbody tr:last-child td:last-child { - border-radius: 0 0 var(--radius-xl) 0; -} - -.table-compact th, -.table-compact td { - padding: var(--space-3) var(--space-4); -} - -.table-striped tbody tr:nth-child(even) { - background: var(--gray-25); -} - -.table-action { - display: flex; - gap: var(--space-2); - justify-content: flex-end; -} - -/* === TABS === */ -.tabs { - display: flex; - gap: var(--space-1); - background: var(--gray-100); - padding: var(--space-1); - border-radius: var(--radius-lg); - width: fit-content; -} - -.tab { - padding: var(--space-2) var(--space-4); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--gray-600); - background: transparent; - border: none; - border-radius: var(--radius-md); - cursor: pointer; - transition: all var(--transition-fast); -} - -.tab:hover { - color: var(--gray-900); -} - -.tab.active { - color: var(--gray-900); - background: var(--white); - box-shadow: var(--shadow-sm); -} - -.tabs-underline { - background: transparent; - padding: 0; - border-bottom: 1px solid var(--gray-200); - border-radius: 0; - gap: var(--space-6); -} - -.tabs-underline .tab { - padding: var(--space-3) var(--space-1); - border-radius: 0; - border-bottom: 2px solid transparent; - margin-bottom: -1px; -} - -.tabs-underline .tab.active { - background: transparent; - color: var(--primary-blue); - border-bottom-color: var(--primary-blue); - box-shadow: none; -} - -/* === DROPDOWN === */ -.dropdown { - position: relative; - display: inline-block; -} - -.dropdown-menu { - position: absolute; - top: calc(100% + var(--space-2)); - right: 0; - min-width: 200px; - background: var(--white); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-floating); - padding: var(--space-2); - z-index: var(--z-dropdown); - opacity: 0; - visibility: hidden; - transform: translateY(-8px) scale(0.95); - transition: all var(--transition-fast); -} - -.dropdown.open .dropdown-menu, -.dropdown:focus-within .dropdown-menu { - opacity: 1; - visibility: visible; - transform: translateY(0) scale(1); -} - -.dropdown-item { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2-5) var(--space-3); - font-size: var(--font-size-base); - color: var(--gray-700); - border-radius: var(--radius-md); - cursor: pointer; - transition: all var(--transition-fast); -} - -.dropdown-item:hover { - background: var(--gray-100); - color: var(--gray-900); -} - -.dropdown-item.danger { - color: var(--error); -} - -.dropdown-item.danger:hover { - background: var(--error-light); -} - -.dropdown-divider { - height: 1px; - background: var(--gray-100); - margin: var(--space-2) 0; -} - -/* === MODAL === */ -.modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - z-index: var(--z-modal-backdrop); - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-6); - opacity: 0; - visibility: hidden; - transition: all var(--transition-normal); -} - -.modal-backdrop.open { - opacity: 1; - visibility: visible; -} - -.modal { - background: var(--white); - border-radius: var(--radius-2xl); - box-shadow: var(--shadow-2xl); - max-width: 540px; - width: 100%; - max-height: 90vh; - overflow: hidden; - transform: translateY(20px) scale(0.95); - transition: all var(--transition-normal); -} - -.modal-backdrop.open .modal { - transform: translateY(0) scale(1); -} - -.modal-sm { max-width: 400px; } -.modal-lg { max-width: 720px; } -.modal-xl { max-width: 960px; } -.modal-full { max-width: calc(100vw - var(--space-12)); max-height: calc(100vh - var(--space-12)); } - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-5) var(--space-6); - border-bottom: 1px solid var(--gray-100); -} - -.modal-title { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); -} - -.modal-close { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - background: var(--gray-100); - border: none; - border-radius: var(--radius-lg); - cursor: pointer; - color: var(--gray-500); - transition: all var(--transition-fast); -} - -.modal-close:hover { - background: var(--gray-200); - color: var(--gray-700); -} - -.modal-body { - padding: var(--space-6); - overflow-y: auto; - max-height: calc(90vh - 140px); -} - -.modal-footer { - display: flex; - justify-content: flex-end; - gap: var(--space-3); - padding: var(--space-4) var(--space-6); - border-top: 1px solid var(--gray-100); - background: var(--gray-50); -} - -/* === LOADING STATES === */ -.spinner { - width: 24px; - height: 24px; - border: 2px solid var(--gray-200); - border-top-color: var(--primary-blue); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -.spinner-sm { width: 16px; height: 16px; border-width: 2px; } -.spinner-lg { width: 40px; height: 40px; border-width: 3px; } -.spinner-xl { width: 56px; height: 56px; border-width: 4px; } - -.spinner-white { - border-color: rgba(255,255,255,0.3); - border-top-color: var(--white); -} - -.skeleton { - background: linear-gradient(90deg, var(--gray-100) 25%, var(--gray-50) 50%, var(--gray-100) 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite linear; - border-radius: var(--radius-md); -} - -.skeleton-text { - height: 16px; - margin-bottom: var(--space-2); -} - -.skeleton-title { - height: 24px; - width: 60%; - margin-bottom: var(--space-3); -} - -.skeleton-avatar { - width: 40px; - height: 40px; - border-radius: 50%; -} - -.skeleton-card { - height: 120px; - border-radius: var(--radius-xl); -} - -@keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} - -/* === EMPTY STATE === */ -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-16) var(--space-6); - text-align: center; -} - -.empty-state-icon { - width: 96px; - height: 96px; - margin-bottom: var(--space-6); - color: var(--gray-300); - opacity: 0.8; -} - -.empty-state-icon .icon, -.empty-state-icon svg { - width: 100%; - height: 100%; -} - -.empty-state-title { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - margin-bottom: var(--space-2); -} - -.empty-state-description { - color: var(--gray-500); - max-width: 360px; - margin-bottom: var(--space-6); -} - -/* === TOOLTIP === */ -.tooltip { - position: relative; - display: inline-block; -} - -.tooltip-content { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - padding: var(--space-2) var(--space-3); - font-size: var(--font-size-sm); - color: var(--white); - background: var(--gray-900); - border-radius: var(--radius-md); - white-space: nowrap; - z-index: var(--z-tooltip); - opacity: 0; - visibility: hidden; - transition: all var(--transition-fast); - pointer-events: none; -} - -.tooltip-content::after { - content: ''; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - border: 6px solid transparent; - border-top-color: var(--gray-900); -} - -.tooltip:hover .tooltip-content { - opacity: 1; - visibility: visible; -} - -/* === PROGRESS BAR === */ -.progress { - width: 100%; - height: 8px; - background: var(--gray-100); - border-radius: var(--radius-full); - overflow: hidden; -} - -.progress-bar { - height: 100%; - background: var(--gradient-primary); - border-radius: var(--radius-full); - transition: width var(--transition-slow); -} - -.progress-bar.success { background: var(--gradient-teal); } -.progress-bar.warning { background: var(--gradient-warm); } -.progress-bar.error { background: linear-gradient(135deg, var(--error) 0%, var(--error-dark) 100%); } - -.progress-sm { height: 4px; } -.progress-lg { height: 12px; } - -/* === ALERT === */ -.alert { - display: flex; - align-items: flex-start; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); - border-radius: var(--radius-lg); - border: 1px solid; -} - -.alert-icon { - flex-shrink: 0; - width: 20px; - height: 20px; -} - -.alert-content { - flex: 1; -} - -.alert-title { - font-weight: var(--font-weight-semibold); - margin-bottom: var(--space-1); -} - -.alert-description { - font-size: var(--font-size-sm); - opacity: 0.9; -} - -.alert-info { - background: var(--info-light); - border-color: var(--info); - color: var(--info-dark); -} - -.alert-success { - background: var(--success-light); - border-color: var(--success); - color: var(--success-dark); -} - -.alert-warning { - background: var(--warning-light); - border-color: var(--warning); - color: var(--warning-dark); -} - -.alert-error { - background: var(--error-light); - border-color: var(--error); - color: var(--error-dark); -} - -/* === TOGGLE === */ -.toggle { - position: relative; - display: inline-flex; - width: 44px; - height: 24px; - cursor: pointer; -} - -.toggle input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - inset: 0; - background: var(--gray-300); - border-radius: var(--radius-full); - transition: all var(--transition-fast); -} - -.toggle-slider::before { - content: ''; - position: absolute; - width: 18px; - height: 18px; - left: 3px; - bottom: 3px; - background: var(--white); - border-radius: 50%; - transition: all var(--transition-fast); - box-shadow: var(--shadow-sm); -} - -.toggle input:checked + .toggle-slider { - background: var(--primary-blue); -} - -.toggle input:checked + .toggle-slider::before { - transform: translateX(20px); -} - -.toggle input:focus-visible + .toggle-slider { - box-shadow: 0 0 0 3px var(--primary-blue-glow); -} - -/* === DIVIDER === */ -.divider { - height: 1px; - background: var(--gray-200); - margin: var(--space-6) 0; -} - -.divider-vertical { - width: 1px; - height: auto; - align-self: stretch; - margin: 0 var(--space-4); -} - -/* === NOTIFICATION DOT === */ -.notification-dot { - position: absolute; - top: -2px; - right: -2px; - width: 10px; - height: 10px; - background: var(--error); - border: 2px solid var(--white); - border-radius: 50%; -} - -.notification-count { - position: absolute; - top: -6px; - right: -6px; - min-width: 18px; - height: 18px; - padding: 0 var(--space-1); - font-size: var(--font-size-2xs); - font-weight: var(--font-weight-bold); - color: var(--white); - background: var(--error); - border: 2px solid var(--white); - border-radius: var(--radius-full); - display: flex; - align-items: center; - justify-content: center; -} - -/* === ACTIVITY/LIST ITEM === */ -.activity-item { - display: flex; - align-items: flex-start; - gap: var(--space-4); - padding: var(--space-4) 0; - border-bottom: 1px solid var(--gray-100); -} - -.activity-item:last-child { - border-bottom: none; -} - -.activity-icon { - width: 40px; - height: 40px; - border-radius: var(--radius-lg); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.activity-content { - flex: 1; - min-width: 0; -} - -.activity-title { - font-weight: var(--font-weight-medium); - color: var(--gray-900); - margin-bottom: var(--space-0-5); -} - -.activity-description { - font-size: var(--font-size-sm); - color: var(--gray-500); -} - -.activity-time { - font-size: var(--font-size-sm); - color: var(--gray-400); - white-space: nowrap; -} - -/* === QUICK ACTIONS === */ -.quick-action { - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: var(--space-4) var(--space-5); - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-xl); - font-weight: var(--font-weight-medium); - color: var(--gray-700); - cursor: pointer; - transition: all var(--transition-fast); -} - -.quick-action:hover { - border-color: var(--primary-blue); - color: var(--primary-blue); - box-shadow: var(--shadow-md); -} - -.quick-action-primary { - background: var(--gradient-primary); - border-color: transparent; - color: var(--white); -} - -.quick-action-primary:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); - color: var(--white); -} - -/* === CALENDAR === */ -.calendar-grid-container { - padding: var(--space-4); -} - -.calendar-grid-header { - margin-bottom: var(--space-2); -} - -.calendar-header-cell { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--gray-500); - text-transform: uppercase; - letter-spacing: var(--letter-spacing-wide); -} - -.calendar-grid { - gap: var(--space-1); -} - -.calendar-cell { - min-height: 100px; - padding: var(--space-2); - background: var(--white); - border: 1px solid var(--gray-100); - border-radius: var(--radius-lg); - cursor: pointer; - transition: all var(--transition-fast); - display: flex; - flex-direction: column; -} - -.calendar-cell:hover { - border-color: var(--primary-blue); - box-shadow: var(--shadow-sm); -} - -.calendar-cell.empty { - background: var(--gray-50); - border-color: transparent; - cursor: default; -} - -.calendar-cell.empty:hover { - border-color: transparent; - box-shadow: none; -} - -.calendar-cell.today { - border-color: var(--primary-blue); - background: var(--primary-blue-subtle); -} - -.calendar-cell.selected { - border-color: var(--primary-blue); - box-shadow: 0 0 0 2px var(--primary-blue-glow); -} - -.calendar-cell.has-appointments .calendar-day-number { - font-weight: var(--font-weight-bold); -} - -.calendar-day-number { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--gray-700); - margin-bottom: var(--space-1); -} - -.calendar-cell.today .calendar-day-number { - color: var(--primary-blue-dark); - font-weight: var(--font-weight-bold); -} - -.calendar-appointments-preview { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - margin-top: auto; -} - -.calendar-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.calendar-dot.blue { - background: var(--primary-blue); -} - -.calendar-dot.teal { - background: var(--teal-dark); -} - -.calendar-dot.green { - background: var(--success); -} - -.calendar-dot.red { - background: var(--error); -} - -.calendar-dot.gray { - background: var(--gray-400); -} - -.calendar-more-indicator { - font-size: var(--font-size-2xs); - color: var(--gray-500); - font-weight: var(--font-weight-medium); -} - -.calendar-details-panel { - padding: 0; - overflow: hidden; -} - -.calendar-appointment-item { - background: var(--gray-50); - transition: all var(--transition-fast); -} - -.calendar-appointment-item:hover { - background: var(--gray-100); -} - -.space-y-3 > * + * { - margin-top: var(--space-3); -} - -/* === LOGIN PAGE === */ -.login-page { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, var(--primary-blue-subtle) 0%, var(--teal-soft) 50%, var(--white) 100%); - padding: var(--space-4); -} - -.login-card { - background: var(--white); - border-radius: var(--radius-2xl); - box-shadow: var(--shadow-xl); - padding: var(--space-8); - width: 100%; - max-width: 400px; -} - -.login-header { - text-align: center; - margin-bottom: var(--space-8); -} - -.login-logo { - width: 64px; - height: 64px; - background: linear-gradient(135deg, var(--primary-blue) 0%, var(--teal-bold) 100%); - border-radius: var(--radius-xl); - display: flex; - align-items: center; - justify-content: center; - margin: 0 auto var(--space-4); -} - -.login-logo .icon { - width: 32px; - height: 32px; - color: var(--white); -} - -.login-header h1 { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - margin: 0 0 var(--space-2); -} - -.login-header p { - font-size: var(--font-size-sm); - color: var(--gray-500); - margin: 0; -} - -.login-error { - background: var(--error-bg); - border: 1px solid var(--error); - color: var(--error); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-lg); - margin-bottom: var(--space-4); - font-size: var(--font-size-sm); -} - -.login-success { - background: var(--success-bg); - border: 1px solid var(--success); - color: var(--success); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-lg); - margin-bottom: var(--space-4); - font-size: var(--font-size-sm); -} - -.login-btn { - width: 100%; - margin-top: var(--space-4); -} - -.login-footer { - text-align: center; - margin-top: var(--space-6); - padding-top: var(--space-6); - border-top: 1px solid var(--gray-100); -} - -.login-footer p { - font-size: var(--font-size-sm); - color: var(--gray-500); - margin: 0; -} - -.link-btn { - background: none; - border: none; - color: var(--primary-blue); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - cursor: pointer; - padding: 0; - text-decoration: underline; -} - -.link-btn:hover { - color: var(--primary-blue-dark); -} - - -/* === USER MENU DROPDOWN === */ -.user-menu { - position: relative; -} - -.user-menu-trigger { - cursor: pointer; - border: 2px solid transparent; - transition: all var(--transition-fast); -} - -.user-menu-trigger:hover { - border-color: var(--primary-blue); -} - -.user-dropdown { - position: absolute; - top: calc(100% + var(--space-2)); - right: 0; - min-width: 220px; - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-lg); - z-index: 1000; - overflow: hidden; - animation: dropdownFadeIn 0.15s ease-out; -} - -@keyframes dropdownFadeIn { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.user-dropdown-header { - padding: var(--space-4); - border-bottom: 1px solid var(--gray-100); -} - -.user-dropdown-name { - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - font-size: var(--font-size-base); -} - -.user-dropdown-email { - font-size: var(--font-size-sm); - color: var(--gray-500); - margin-top: var(--space-1); -} - -.user-dropdown-divider { - height: 1px; - background: var(--gray-100); -} - -.user-dropdown-item { - display: flex; - align-items: center; - gap: var(--space-3); - width: 100%; - padding: var(--space-3) var(--space-4); - border: none; - background: none; - font-size: var(--font-size-base); - color: var(--gray-700); - cursor: pointer; - transition: all var(--transition-fast); - text-align: left; -} - -.user-dropdown-item:hover { - background: var(--gray-50); -} - -.user-dropdown-item .icon { - width: 18px; - height: 18px; -} - -.user-dropdown-logout { - color: var(--red-600, #dc2626); -} - -.user-dropdown-logout:hover { - background: var(--red-50, #fef2f2); -} - -/* === CLINICAL CODING PAGE === */ -.clinical-coding-page .page-header-icon { - color: var(--white); -} - -.clinical-coding-page .page-header-icon .icon { - width: 28px; - height: 28px; -} - -.search-input-lg { - position: relative; -} - -.search-input-lg .input { - padding-left: var(--space-12); - font-size: var(--font-size-lg); -} - -.search-input-lg .search-icon { - position: absolute; - left: var(--space-4); - top: 50%; - transform: translateY(-50%); - color: var(--gray-400); -} - -.search-input-lg .search-icon .icon { - width: 24px; - height: 24px; -} - -.code-results-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: var(--space-4); -} - -.code-card { - padding: var(--space-5); - transition: all var(--transition-fast); -} - -.code-card:hover { - transform: translateY(-4px); - box-shadow: var(--shadow-lg); -} - -.hover-lift { - transition: transform var(--transition-fast), box-shadow var(--transition-fast); -} - -.hover-lift:hover { - transform: translateY(-2px); -} - -.line-clamp-2 { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.whitespace-pre-wrap { - white-space: pre-wrap; -} - -.checkbox { - width: 18px; - height: 18px; - accent-color: var(--primary-blue); - cursor: pointer; -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -/* === SEARCH RESULT TABLE ROW WITH HOVER TOOLTIP === */ -.search-result-row { - cursor: pointer; - transition: background var(--transition-fast); -} - -.search-result-row:hover { - background: var(--primary-blue-subtle); -} - -.result-description-cell { - position: relative; - max-width: 400px; -} - -.result-description-cell > span { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.result-tooltip { - position: absolute; - left: 0; - top: 100%; - z-index: 100; - min-width: 320px; - max-width: 400px; - padding: var(--space-4); - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); - opacity: 0; - visibility: hidden; - transform: translateY(8px); - transition: all var(--transition-fast); - pointer-events: none; -} - -.result-description-cell:hover .result-tooltip { - opacity: 1; - visibility: visible; - transform: translateY(4px); -} - -.result-tooltip::before { - content: ''; - position: absolute; - top: -8px; - left: 24px; - border: 8px solid transparent; - border-bottom-color: var(--white); - border-top: none; -} - diff --git a/Samples/Dashboard/Dashboard.Web/wwwroot/css/layout.css b/Samples/Dashboard/Dashboard.Web/wwwroot/css/layout.css deleted file mode 100644 index 61db9268..00000000 --- a/Samples/Dashboard/Dashboard.Web/wwwroot/css/layout.css +++ /dev/null @@ -1,1460 +0,0 @@ -/* Medical Dashboard - Premium Layout Styles */ -/* Inspired by Wellmetrix & CareIQ Designs */ - -/* === APP CONTAINER === */ -.app { - display: flex; - min-height: 100vh; -} - -/* === SIDEBAR === */ -.sidebar { - position: fixed; - left: 0; - top: 0; - bottom: 0; - width: var(--sidebar-width); - background: var(--white); - border-right: 1px solid var(--gray-100); - box-shadow: var(--shadow-lg); - display: flex; - flex-direction: column; - z-index: var(--z-fixed); - transition: all var(--transition-slow); -} - -.sidebar.collapsed { - width: var(--sidebar-collapsed-width); -} - -.sidebar-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-5) var(--space-6); - border-bottom: 1px solid var(--gray-100); - min-height: var(--header-height); -} - -.sidebar-logo { - display: flex; - align-items: center; - gap: var(--space-3); - text-decoration: none; - color: var(--gray-900); -} - -.sidebar-logo-icon { - width: 44px; - height: 44px; - background: var(--gradient-primary); - border-radius: var(--radius-xl); - display: flex; - align-items: center; - justify-content: center; - color: var(--white); - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - flex-shrink: 0; - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); -} - -.sidebar-logo-text { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - white-space: nowrap; - overflow: hidden; - letter-spacing: var(--letter-spacing-tight); - background: var(--gradient-primary); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.sidebar.collapsed .sidebar-logo-text { - display: none; -} - -.sidebar-nav { - flex: 1; - padding: var(--space-5) var(--space-4); - overflow-y: auto; -} - -.nav-section { - margin-bottom: var(--space-8); -} - -.nav-section-title { - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - color: var(--gray-400); - text-transform: uppercase; - letter-spacing: var(--letter-spacing-wider); - padding: 0 var(--space-4); - margin-bottom: var(--space-3); -} - -.sidebar.collapsed .nav-section-title { - display: none; -} - -.nav-item { - position: relative; - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-lg); - color: var(--gray-600); - text-decoration: none; - cursor: pointer; - transition: all var(--transition-fast); - margin-bottom: var(--space-1); - font-weight: var(--font-weight-medium); -} - -.nav-item:hover { - background: var(--gray-50); - color: var(--gray-900); -} - -.nav-item.active { - background: var(--primary-blue-subtle); - color: var(--primary-blue-dark); -} - -.nav-item.active::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 4px; - height: 24px; - background: var(--gradient-primary); - border-radius: 0 var(--radius-full) var(--radius-full) 0; -} - -.nav-item-icon { - width: 22px; - height: 22px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.nav-item-text { - font-size: var(--font-size-base); - white-space: nowrap; - overflow: hidden; -} - -.sidebar.collapsed .nav-item-text { - display: none; -} - -.nav-item-badge { - margin-left: auto; - padding: var(--space-0-5) var(--space-2); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-bold); - background: var(--error); - color: var(--white); - border-radius: var(--radius-full); - min-width: 20px; - text-align: center; -} - -.sidebar.collapsed .nav-item-badge { - display: none; -} - -.sidebar-footer { - padding: var(--space-4); - padding-bottom: var(--space-6); - border-top: 1px solid var(--gray-100); - margin-top: auto; -} - -.sidebar-user { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3); - border-radius: var(--radius-xl); - cursor: pointer; - transition: all var(--transition-fast); - background: var(--gray-50); -} - -.sidebar-user:hover { - background: var(--gray-100); -} - -.sidebar-user-info { - flex: 1; - overflow: hidden; -} - -.sidebar.collapsed .sidebar-user-info { - display: none; -} - -.sidebar-user-name { - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.sidebar-user-role { - font-size: var(--font-size-sm); - color: var(--gray-500); -} - -.sidebar-logout-btn { - margin-top: var(--space-3); - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - background: var(--red-50, #fef2f2); - color: var(--red-600, #dc2626); - border: 1px solid var(--red-200, #fecaca); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-lg); - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: all var(--transition-fast); -} - -.sidebar-logout-btn:hover { - background: var(--red-100, #fee2e2); - border-color: var(--red-300, #fca5a5); -} - -.sidebar.collapsed .sidebar-logout-btn span { - display: none; -} - -.sidebar.collapsed .sidebar-logout-btn { - padding: var(--space-2); -} - -.sidebar-toggle { - position: absolute; - right: -14px; - top: 50%; - transform: translateY(-50%); - width: 28px; - height: 28px; - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: var(--shadow-md); - transition: all var(--transition-fast); - z-index: 1; - color: var(--gray-500); -} - -.sidebar-toggle:hover { - background: var(--gray-50); - color: var(--gray-700); - box-shadow: var(--shadow-lg); -} - -/* === MAIN CONTENT === */ -.main-wrapper { - flex: 1; - margin-left: var(--sidebar-width); - min-width: 0; - transition: margin-left var(--transition-slow); - display: flex; - flex-direction: column; -} - -.sidebar.collapsed ~ .main-wrapper { - margin-left: var(--sidebar-collapsed-width); -} - -/* === HEADER === */ -.header { - position: sticky; - top: 0; - height: var(--header-height); - background: var(--glass-bg-strong); - backdrop-filter: blur(var(--glass-blur)); - -webkit-backdrop-filter: blur(var(--glass-blur)); - border-bottom: 1px solid var(--glass-border); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 var(--space-8); - z-index: var(--z-sticky); -} - -.header-left { - display: flex; - align-items: center; - gap: var(--space-6); -} - -.header-title { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - letter-spacing: var(--letter-spacing-tight); -} - -.header-breadcrumb { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--font-size-sm); - color: var(--gray-500); -} - -.header-breadcrumb-separator { - color: var(--gray-300); -} - -.header-breadcrumb-link { - color: var(--gray-500); - text-decoration: none; - transition: color var(--transition-fast); -} - -.header-breadcrumb-link:hover { - color: var(--primary-blue); -} - -.header-breadcrumb-current { - color: var(--gray-900); - font-weight: var(--font-weight-medium); -} - -.header-right { - display: flex; - align-items: center; - gap: var(--space-4); -} - -.header-search { - position: relative; - width: 320px; - display: flex; - align-items: center; -} - -.header-search .icon { - position: absolute; - left: var(--space-4); - color: var(--gray-400); - pointer-events: none; - z-index: 1; -} - -.header-search .input { - padding-left: var(--space-11); - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-xl); -} - -.header-search .input:focus { - background: var(--white); - border-color: var(--primary-blue); - box-shadow: 0 0 0 4px var(--primary-blue-glow); -} - -.header-actions { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.header-action-btn { - position: relative; - width: 44px; - height: 44px; - display: flex; - align-items: center; - justify-content: center; - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-xl); - color: var(--gray-600); - cursor: pointer; - transition: all var(--transition-fast); -} - -.header-action-btn:hover { - background: var(--gray-50); - border-color: var(--gray-300); - color: var(--gray-900); -} - -.header-action-badge { - position: absolute; - top: 8px; - right: 8px; - width: 10px; - height: 10px; - background: var(--error); - border-radius: 50%; - border: 2px solid var(--white); -} - -.header-user { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-3); - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-xl); - cursor: pointer; - transition: all var(--transition-fast); -} - -.header-user:hover { - background: var(--gray-50); - border-color: var(--gray-300); -} - -/* === MAIN CONTENT AREA === */ -.main-content { - flex: 1; - padding: var(--space-8); - max-width: var(--content-max-width); -} - -.page-header { - margin-bottom: var(--space-8); -} - -.page-title { - font-size: var(--font-size-3xl); - font-weight: var(--font-weight-bold); - color: var(--gray-900); - margin-bottom: var(--space-2); - letter-spacing: var(--letter-spacing-tight); -} - -.page-description { - font-size: var(--font-size-md); - color: var(--gray-500); - margin-bottom: 0; -} - -.page-actions { - display: flex; - align-items: center; - gap: var(--space-3); - margin-top: var(--space-4); -} - -/* === DASHBOARD GRID === */ -.dashboard-grid { - display: grid; - gap: var(--space-6); -} - -.dashboard-grid.metrics { - grid-template-columns: repeat(4, 1fr); -} - -.dashboard-grid.charts { - grid-template-columns: repeat(2, 1fr); -} - -.dashboard-grid.mixed { - grid-template-columns: 2fr 1fr; -} - -.dashboard-grid.practitioners { - grid-template-columns: repeat(3, 1fr); -} - -.dashboard-section { - margin-bottom: var(--space-8); -} - -.dashboard-section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-5); -} - -.dashboard-section-title { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); -} - -.dashboard-section-link { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--primary-blue); - text-decoration: none; - display: flex; - align-items: center; - gap: var(--space-1); - transition: color var(--transition-fast); -} - -.dashboard-section-link:hover { - color: var(--primary-blue-dark); -} - -/* === DASHBOARD PAGE === */ -.dashboard-page { - max-width: var(--content-max-width); -} - -.dashboard-welcome { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-8); -} - -.welcome-title { - font-size: var(--font-size-3xl); - font-weight: var(--font-weight-bold); - color: var(--gray-900); - letter-spacing: var(--letter-spacing-tight); -} - -.welcome-actions { - display: flex; - align-items: center; - gap: var(--space-4); -} - -.date-filter { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2-5) var(--space-4); - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-xl); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--gray-700); - cursor: pointer; - transition: all var(--transition-fast); -} - -.date-filter:hover { - border-color: var(--gray-300); - box-shadow: var(--shadow-sm); -} - -/* === DASHBOARD METRICS ROW === */ -.dashboard-metrics-row { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--space-6); - margin-bottom: var(--space-8); -} - -/* === RICH METRIC CARD === */ -.metric-card-rich { - background: var(--white); - border-radius: var(--radius-2xl); - padding: var(--space-6); - box-shadow: var(--shadow-card); - border: 1px solid var(--gray-100); - transition: all var(--transition-normal); -} - -.metric-card-rich:hover { - box-shadow: var(--shadow-card-hover); - transform: translateY(-2px); -} - -.metric-card-header { - display: flex; - align-items: center; - gap: var(--space-3); - margin-bottom: var(--space-4); -} - -.metric-card-icon { - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-lg); -} - -.metric-card-icon.blue { - background: var(--primary-blue-subtle); - color: var(--primary-blue); -} - -.metric-card-icon.teal { - background: var(--teal-soft); - color: var(--teal-dark); -} - -.metric-card-icon.coral { - background: var(--accent-coral-light); - color: var(--accent-coral); -} - -.metric-card-icon.violet { - background: var(--accent-violet-light); - color: var(--accent-violet); -} - -.metric-card-title { - flex: 1; - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--gray-600); -} - -.metric-card-link { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--primary-blue); - text-decoration: none; - transition: color var(--transition-fast); -} - -.metric-card-link:hover { - color: var(--primary-blue-dark); -} - -.metric-card-body { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.metric-card-value-row { - display: flex; - align-items: baseline; - gap: var(--space-3); -} - -.metric-card-value { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--gray-900); - line-height: var(--line-height-none); - letter-spacing: var(--letter-spacing-tight); -} - -.metric-card-change { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-full); -} - -.metric-card-change.up { - background: var(--success-light); - color: var(--success); -} - -.metric-card-change.down { - background: var(--error-light); - color: var(--error); -} - -.metric-card-breakdown { - display: flex; - flex-wrap: wrap; - gap: var(--space-4); -} - -.metric-breakdown-item { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--font-size-sm); - color: var(--gray-600); -} - -.metric-breakdown-dot { - width: 10px; - height: 10px; - border-radius: 50%; -} - -/* === DASHBOARD MAIN GRID === */ -.dashboard-main-grid { - display: grid; - grid-template-columns: 2fr 1fr; - gap: var(--space-6); -} - -/* === CARD HEADER VARIANTS === */ -.card-header-left { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.view-more-link { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--primary-blue); - text-decoration: none; -} - -.view-more-link:hover { - color: var(--primary-blue-dark); -} - -/* === CALENDAR STRIP === */ -.calendar-strip { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-4) 0; - margin-bottom: var(--space-5); - border-bottom: 1px solid var(--gray-100); - overflow-x: auto; -} - -.calendar-nav { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: 50%; - cursor: pointer; - color: var(--gray-600); - transition: all var(--transition-fast); - flex-shrink: 0; -} - -.calendar-nav:hover { - background: var(--gray-50); - border-color: var(--gray-300); - color: var(--gray-900); -} - -.calendar-day-btn { - width: 44px; - height: 44px; - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - border-radius: 50%; - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--gray-700); - cursor: pointer; - transition: all var(--transition-fast); - flex-shrink: 0; -} - -.calendar-day-btn:hover { - background: var(--gray-100); -} - -.calendar-day-btn.today { - background: var(--gradient-primary); - color: var(--white); - font-weight: var(--font-weight-semibold); -} - -.calendar-day-btn.selected { - background: var(--primary-blue-subtle); - color: var(--primary-blue-dark); -} - -.calendar-day-btn.has-events { - position: relative; -} - -.calendar-day-btn.has-events::after { - content: ''; - position: absolute; - bottom: 6px; - width: 5px; - height: 5px; - background: var(--primary-blue); - border-radius: 50%; -} - -/* === APPOINTMENT LEGEND === */ -.appointment-legend { - display: flex; - align-items: center; - gap: var(--space-5); - margin-left: auto; -} - -.legend-item { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--font-size-sm); - color: var(--gray-600); -} - -.legend-dot { - width: 10px; - height: 10px; - border-radius: 50%; -} - -.legend-dot.available { - background: var(--teal-soft); - border: 2px solid var(--teal-bold); -} - -.legend-dot.selected { - background: var(--primary-blue); -} - -.legend-dot.unavailable { - background: var(--gray-300); -} - -/* === APPOINTMENTS TABLE === */ -.appointments-table { - width: 100%; - border-collapse: collapse; -} - -.appointments-table th { - text-align: left; - padding: var(--space-4) var(--space-5); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - color: var(--gray-500); - text-transform: uppercase; - letter-spacing: var(--letter-spacing-wide); - border-bottom: 1px solid var(--gray-200); -} - -.appointments-table td { - padding: var(--space-4) var(--space-5); - font-size: var(--font-size-sm); - color: var(--gray-700); - border-bottom: 1px solid var(--gray-100); -} - -.appointments-table tbody tr { - transition: background var(--transition-fast); -} - -.appointments-table tbody tr:hover { - background: var(--gray-50); -} - -.patient-cell { - display: flex; - align-items: center; - gap: var(--space-3); - font-weight: var(--font-weight-medium); - color: var(--gray-900); -} - -.status-badge { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-full); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); -} - -.status-badge.confirmed { - background: var(--success-light); - color: var(--success); -} - -.status-badge.booked { - background: var(--primary-blue-subtle); - color: var(--primary-blue-dark); -} - -.status-badge.pending { - background: var(--warning-light); - color: var(--warning); -} - -.status-badge.cancelled { - background: var(--error-light); - color: var(--error); -} - -.action-buttons { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.btn-icon-sm { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - background: var(--gray-100); - border: none; - border-radius: var(--radius-lg); - cursor: pointer; - font-size: var(--font-size-sm); - color: var(--gray-600); - transition: all var(--transition-fast); -} - -.btn-icon-sm:hover { - background: var(--gray-200); - color: var(--gray-900); -} - -.btn-icon-sm.call { - background: var(--primary-blue-subtle); - color: var(--primary-blue); -} - -.btn-icon-sm.call:hover { - background: var(--primary-blue); - color: var(--white); -} - -/* === APPOINTMENT REQUESTS === */ -.requests-list { - display: flex; - flex-direction: column; - gap: var(--space-4); -} - -.appointment-request { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-4); - background: var(--gray-50); - border-radius: var(--radius-xl); - transition: all var(--transition-fast); -} - -.appointment-request:hover { - background: var(--gray-100); -} - -.appointment-request-info { - display: flex; - align-items: center; - gap: var(--space-4); -} - -.appointment-request-details { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.appointment-request-name { - font-weight: var(--font-weight-semibold); - color: var(--gray-900); -} - -.appointment-request-type { - font-size: var(--font-size-sm); - color: var(--gray-600); -} - -.appointment-request-time { - display: flex; - align-items: center; - gap: var(--space-1); - font-size: var(--font-size-sm); - color: var(--gray-500); -} - -.appointment-request-actions { - display: flex; - gap: var(--space-2); -} - -.btn-icon-circle { - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - border: none; - border-radius: 50%; - font-size: var(--font-size-lg); - cursor: pointer; - transition: all var(--transition-fast); -} - -.btn-icon-circle.reject { - background: var(--gray-200); - color: var(--gray-600); -} - -.btn-icon-circle.reject:hover { - background: var(--error-light); - color: var(--error); -} - -.btn-icon-circle.accept { - background: var(--gradient-primary); - color: var(--white); -} - -.btn-icon-circle.accept:hover { - transform: scale(1.05); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); -} - -/* === QUICK ACTIONS === */ -.quick-actions-card { - margin-top: var(--space-5); -} - -.quick-actions-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-4); -} - -.quick-action-btn { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-2); - padding: var(--space-5); - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-xl); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--gray-700); - cursor: pointer; - transition: all var(--transition-fast); -} - -.quick-action-btn:hover { - border-color: var(--primary-blue); - color: var(--primary-blue); - box-shadow: var(--shadow-md); -} - -.quick-action-btn.primary { - background: var(--gradient-primary); - border-color: transparent; - color: var(--white); -} - -.quick-action-btn.primary:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); -} - -.quick-action-btn .icon { - width: 28px; - height: 28px; -} - -/* === DASHBOARD CHARTS SECTION === */ -.dashboard-charts-section { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-6); - margin-top: var(--space-8); -} - -.dashboard-charts-section .card { - background: var(--glass-bg-strong); - backdrop-filter: blur(var(--glass-blur)); -} - -.dashboard-charts-section .card-body { - padding: var(--space-4); -} - -/* Google Charts Container */ -.google-chart-container { - width: 100%; - min-height: 200px; - border-radius: var(--radius-lg); - overflow: hidden; -} - -.google-chart-container svg { - border-radius: var(--radius-lg); -} - -/* === CHART WRAPPER === */ -.chart-wrapper { - background: var(--white); - border-radius: var(--radius-2xl); - padding: var(--space-6); - box-shadow: var(--shadow-card); -} - -.chart-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-5); -} - -.chart-title { - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); -} - -.chart-subtitle { - font-size: var(--font-size-sm); - color: var(--gray-500); - margin-top: var(--space-1); -} - -.chart-body { - min-height: 300px; -} - -.chart-footer { - margin-top: var(--space-4); - padding-top: var(--space-4); - border-top: 1px solid var(--gray-100); -} - -/* === DONUT CHART === */ -.donut-chart-container { - display: flex; - justify-content: center; - align-items: center; - margin: var(--space-4) 0; -} - -.donut-legend { - display: flex; - flex-wrap: wrap; - gap: var(--space-4); - justify-content: center; - margin-top: var(--space-4); -} - -.donut-legend-item { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-1); -} - -.donut-legend-item span { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--gray-700); -} - -/* === PATIENT BREAKDOWN BARS === */ -.patient-breakdown-bars { - display: flex; - flex-direction: column; - gap: var(--space-3); - margin-top: var(--space-4); -} - -.breakdown-bar-item { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.breakdown-bar-label { - font-size: var(--font-size-sm); - color: var(--gray-600); - min-width: 100px; -} - -.breakdown-bar-value { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - min-width: 40px; - text-align: right; -} - -.breakdown-bar { - flex: 1; - height: 8px; - background: var(--gray-100); - border-radius: var(--radius-full); - overflow: hidden; -} - -.breakdown-bar-fill { - height: 100%; - border-radius: var(--radius-full); - transition: width var(--transition-slow); -} - -/* === DATA LIST === */ -.data-list { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.data-list-item { - display: flex; - align-items: center; - gap: var(--space-4); - padding: var(--space-5); - background: var(--white); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-sm); - transition: all var(--transition-fast); - cursor: pointer; - border: 1px solid var(--gray-100); -} - -.data-list-item:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); - border-color: var(--gray-200); -} - -.data-list-item-content { - flex: 1; - min-width: 0; -} - -.data-list-item-title { - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - margin-bottom: var(--space-1); -} - -.data-list-item-subtitle { - font-size: var(--font-size-sm); - color: var(--gray-500); -} - -.data-list-item-meta { - font-size: var(--font-size-sm); - color: var(--gray-400); - text-align: right; -} - -/* === PRACTITIONER CARD === */ -.practitioner-card { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: var(--space-6); -} - -.practitioner-avatar { - margin-bottom: var(--space-4); -} - -.practitioner-name { - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); - color: var(--gray-900); - margin-bottom: var(--space-1); -} - -.practitioner-specialty { - font-size: var(--font-size-sm); - color: var(--gray-600); - margin-bottom: var(--space-3); -} - -.practitioner-meta { - display: flex; - gap: var(--space-2); - flex-wrap: wrap; - justify-content: center; -} - -.practitioner-edit-btn { - position: absolute; - top: var(--space-3); - right: var(--space-3); - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-lg); - cursor: pointer; - color: var(--gray-500); - transition: all var(--transition-fast); - opacity: 0; -} - -.practitioner-card:hover .practitioner-edit-btn { - opacity: 1; -} - -.practitioner-edit-btn:hover { - background: var(--primary-blue-subtle); - border-color: var(--primary-blue); - color: var(--primary-blue); -} - -/* === RESPONSIVE === */ -@media (max-width: 1440px) { - .dashboard-metrics-row { - grid-template-columns: repeat(4, 1fr); - } -} - -@media (max-width: 1280px) { - .dashboard-metrics-row { - grid-template-columns: repeat(2, 1fr); - } - - .dashboard-grid.metrics { - grid-template-columns: repeat(2, 1fr); - } - - .dashboard-grid.charts, - .dashboard-grid.mixed { - grid-template-columns: 1fr; - } - - .dashboard-grid.practitioners { - grid-template-columns: repeat(2, 1fr); - } - - .dashboard-main-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 1024px) { - .sidebar { - transform: translateX(-100%); - } - - .sidebar.open { - transform: translateX(0); - } - - .main-wrapper { - margin-left: 0; - } - - .sidebar.collapsed ~ .main-wrapper { - margin-left: 0; - } - - .header { - padding: 0 var(--space-4); - } - - .header-search { - display: none; - } -} - -@media (max-width: 768px) { - .dashboard-metrics-row, - .dashboard-grid.metrics, - .dashboard-grid.practitioners { - grid-template-columns: 1fr; - } - - .dashboard-welcome { - flex-direction: column; - align-items: flex-start; - gap: var(--space-4); - } - - .header { - padding: 0 var(--space-4); - height: 64px; - } - - .main-content { - padding: var(--space-4); - } - - .page-title { - font-size: var(--font-size-2xl); - } - - .quick-actions-grid { - grid-template-columns: 1fr; - } -} - -/* === MOBILE OVERLAY === */ -.sidebar-overlay { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - z-index: calc(var(--z-fixed) - 1); - opacity: 0; - visibility: hidden; - transition: all var(--transition-normal); -} - -@media (max-width: 1024px) { - .sidebar-overlay { - display: block; - } - - .sidebar.open ~ .sidebar-overlay { - opacity: 1; - visibility: visible; - } -} - -/* === MOBILE MENU BUTTON === */ -.mobile-menu-btn { - display: none; - width: 44px; - height: 44px; - align-items: center; - justify-content: center; - background: var(--white); - border: 1px solid var(--gray-200); - border-radius: var(--radius-lg); - color: var(--gray-600); - cursor: pointer; -} - -@media (max-width: 1024px) { - .mobile-menu-btn { - display: flex; - } -} diff --git a/Samples/Dashboard/Dashboard.Web/wwwroot/css/variables.css b/Samples/Dashboard/Dashboard.Web/wwwroot/css/variables.css deleted file mode 100644 index fd6ca9cc..00000000 --- a/Samples/Dashboard/Dashboard.Web/wwwroot/css/variables.css +++ /dev/null @@ -1,203 +0,0 @@ -/* Medical Dashboard Design System - Premium CSS Variables */ -/* Inspired by Wellmetrix & CareIQ - Modern Healthcare Aesthetic */ - -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); - -:root { - /* === PRIMARY COLORS - Rich Blue Palette === */ - --primary-blue-dark: #1e40af; - --primary-blue: #3b82f6; - --primary-blue-light: #60a5fa; - --primary-blue-subtle: #eff6ff; - --primary-blue-glow: rgba(59, 130, 246, 0.25); - - /* === TEAL ACCENT - Fresh & Medical === */ - --teal-soft: #ccfbf1; - --teal-medium: #2dd4bf; - --teal-bold: #14b8a6; - --teal-dark: #0d9488; - --teal-glow: rgba(20, 184, 166, 0.2); - - /* === ACCENT COLORS - Visual Interest === */ - --accent-coral: #f97316; - --accent-coral-light: #ffedd5; - --accent-violet: #8b5cf6; - --accent-violet-light: #ede9fe; - --accent-rose: #f43f5e; - --accent-rose-light: #ffe4e6; - --accent-amber: #f59e0b; - --accent-amber-light: #fef3c7; - - /* === NEUTRALS - Refined Grays === */ - --white: #ffffff; - --gray-25: #fcfcfd; - --gray-50: #f9fafb; - --gray-100: #f3f4f6; - --gray-200: #e5e7eb; - --gray-300: #d1d5db; - --gray-400: #9ca3af; - --gray-500: #6b7280; - --gray-600: #4b5563; - --gray-700: #374151; - --gray-800: #1f2937; - --gray-900: #111827; - --gray-950: #030712; - --black: #000000; - - /* === SEMANTIC COLORS - Enhanced === */ - --success: #22c55e; - --success-light: #dcfce7; - --success-dark: #16a34a; - --warning: #f59e0b; - --warning-light: #fef3c7; - --warning-dark: #d97706; - --error: #ef4444; - --error-light: #fee2e2; - --error-dark: #dc2626; - --info: #3b82f6; - --info-light: #dbeafe; - --info-dark: #2563eb; - - /* === PREMIUM GRADIENTS === */ - --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); - --gradient-teal: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%); - --gradient-warm: linear-gradient(135deg, #f97316 0%, #ea580c 100%); - --gradient-cool: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); - --gradient-subtle: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); - --gradient-glass: linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%); - --gradient-hero: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); - --gradient-mesh: - radial-gradient(at 40% 20%, rgba(59, 130, 246, 0.15) 0px, transparent 50%), - radial-gradient(at 80% 0%, rgba(20, 184, 166, 0.1) 0px, transparent 50%), - radial-gradient(at 0% 50%, rgba(139, 92, 246, 0.08) 0px, transparent 50%), - radial-gradient(at 80% 50%, rgba(249, 115, 22, 0.06) 0px, transparent 50%), - radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.1) 0px, transparent 50%); - - /* === GLASSMORPHISM - Premium Effects === */ - --glass-bg: rgba(255, 255, 255, 0.6); - --glass-bg-strong: rgba(255, 255, 255, 0.85); - --glass-bg-subtle: rgba(255, 255, 255, 0.4); - --glass-border: rgba(255, 255, 255, 0.5); - --glass-border-subtle: rgba(255, 255, 255, 0.2); - --glass-shadow: rgba(0, 0, 0, 0.04); - --glass-blur: 20px; - --glass-blur-strong: 40px; - - /* === SHADOWS - Layered Depth === */ - --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.03); - --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.15); - --shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.04); - --shadow-glow-blue: 0 0 20px rgba(59, 130, 246, 0.3); - --shadow-glow-teal: 0 0 20px rgba(20, 184, 166, 0.3); - --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 6px 16px rgba(0, 0, 0, 0.06); - --shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 12px 28px rgba(0, 0, 0, 0.1); - --shadow-floating: 0 20px 40px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.08); - - /* === SPACING (8px base) === */ - --space-0: 0; - --space-px: 1px; - --space-0-5: 0.125rem; /* 2px */ - --space-1: 0.25rem; /* 4px */ - --space-1-5: 0.375rem; /* 6px */ - --space-2: 0.5rem; /* 8px */ - --space-2-5: 0.625rem; /* 10px */ - --space-3: 0.75rem; /* 12px */ - --space-3-5: 0.875rem; /* 14px */ - --space-4: 1rem; /* 16px */ - --space-5: 1.25rem; /* 20px */ - --space-6: 1.5rem; /* 24px */ - --space-7: 1.75rem; /* 28px */ - --space-8: 2rem; /* 32px */ - --space-9: 2.25rem; /* 36px */ - --space-10: 2.5rem; /* 40px */ - --space-11: 2.75rem; /* 44px */ - --space-12: 3rem; /* 48px */ - --space-14: 3.5rem; /* 56px */ - --space-16: 4rem; /* 64px */ - --space-20: 5rem; /* 80px */ - --space-24: 6rem; /* 96px */ - - /* === BORDER RADIUS - Softer Curves === */ - --radius-none: 0; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 18px; - --radius-2xl: 24px; - --radius-3xl: 32px; - --radius-full: 9999px; - - /* === TYPOGRAPHY - Inter Font === */ - --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace; - - --font-size-2xs: 0.625rem; /* 10px */ - --font-size-xs: 0.6875rem; /* 11px */ - --font-size-sm: 0.8125rem; /* 13px */ - --font-size-base: 0.9375rem; /* 15px */ - --font-size-md: 1rem; /* 16px */ - --font-size-lg: 1.125rem; /* 18px */ - --font-size-xl: 1.25rem; /* 20px */ - --font-size-2xl: 1.5rem; /* 24px */ - --font-size-3xl: 1.875rem; /* 30px */ - --font-size-4xl: 2.25rem; /* 36px */ - --font-size-5xl: 3rem; /* 48px */ - --font-size-6xl: 3.75rem; /* 60px */ - - --font-weight-light: 300; - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; - --font-weight-extrabold: 800; - - --line-height-none: 1; - --line-height-tight: 1.2; - --line-height-snug: 1.375; - --line-height-normal: 1.5; - --line-height-relaxed: 1.625; - --line-height-loose: 2; - - --letter-spacing-tighter: -0.05em; - --letter-spacing-tight: -0.025em; - --letter-spacing-normal: 0; - --letter-spacing-wide: 0.025em; - --letter-spacing-wider: 0.05em; - --letter-spacing-widest: 0.1em; - - /* === LAYOUT === */ - --sidebar-width: 280px; - --sidebar-collapsed-width: 80px; - --header-height: 72px; - --content-max-width: 1600px; - --card-min-height: 120px; - - /* === TRANSITIONS - Smooth & Refined === */ - --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-slower: 500ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55); - --transition-spring: 400ms cubic-bezier(0.175, 0.885, 0.32, 1.275); - - /* === Z-INDEX === */ - --z-base: 0; - --z-dropdown: 100; - --z-sticky: 200; - --z-fixed: 300; - --z-modal-backdrop: 400; - --z-modal: 500; - --z-popover: 600; - --z-tooltip: 700; - --z-notification: 800; - - /* === BORDERS === */ - --border-width: 1px; - --border-color: var(--gray-200); - --border-color-light: var(--gray-100); - --border-color-dark: var(--gray-300); -} diff --git a/Samples/Dashboard/Dashboard.Web/wwwroot/index.html b/Samples/Dashboard/Dashboard.Web/wwwroot/index.html deleted file mode 100644 index 2b551b34..00000000 --- a/Samples/Dashboard/Dashboard.Web/wwwroot/index.html +++ /dev/null @@ -1,3623 +0,0 @@ - - - - - - - Healthcare Dashboard - - - - - - - - - - - - - - - - - - - - - -
- -
Healthcare Dashboard
-
-
- - -
- - - - - - - - - - - diff --git a/Samples/Dashboard/Directory.Build.props b/Samples/Dashboard/Directory.Build.props deleted file mode 100644 index 403e9313..00000000 --- a/Samples/Dashboard/Directory.Build.props +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - false - false - false - false - false - - - - diff --git a/Samples/Dashboard/run-e2e-tests.sh b/Samples/Dashboard/run-e2e-tests.sh deleted file mode 100755 index 895b85d1..00000000 --- a/Samples/Dashboard/run-e2e-tests.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash -set -e - -# Dashboard E2E Test Script -# This script replicates the CI workflow for running Dashboard E2E tests locally - -echo "==========================================" -echo "Dashboard E2E Test Runner" -echo "==========================================" - -# Change to project root -cd "$(dirname "$0")/../.." - -# Check if we're in the right directory -if [ ! -f "DataProvider.sln" ]; then - echo "Error: Must run from project root or Samples/Dashboard directory" - exit 1 -fi - -echo "" -echo "=== Step 1: Setup .NET ===" -dotnet --version - -echo "" -echo "=== Step 2: Restore .NET tools ===" -dotnet tool restore - -echo "" -echo "=== Step 3: Setup Node.js ===" -node --version || true -npm --version || true - -echo "" -echo "=== Step 4: Restore NuGet packages ===" -dotnet restore Samples/Dashboard/Dashboard.Web -dotnet restore Samples/Dashboard/Dashboard.Integration.Tests - -echo "" -echo "=== Step 5: Build Dashboard.Web (downloads React vendor files) ===" -dotnet build Samples/Dashboard/Dashboard.Web -c Release --no-restore - -echo "" -echo "=== Step 6: Build Sync projects (required for Sync E2E tests) ===" -dotnet build Samples/Clinical/Clinical.Sync -c Release -dotnet build Samples/Scheduling/Scheduling.Sync -c Release - -echo "" -echo "=== Step 7: Build ICD-10 API (required for ICD-10 E2E tests) ===" -dotnet build Samples/ICD10/ICD10.Api/ICD10.Api.csproj -c Release - -echo "" -echo "=== Step 8: Build Integration Tests (includes wwwroot copy) ===" -dotnet build Samples/Dashboard/Dashboard.Integration.Tests -c Release - -echo "" -echo "=== Step 9: Install Playwright browsers ===" -# Install Playwright CLI if not already installed -if ! command -v playwright &> /dev/null; then - dotnet tool install --global Microsoft.Playwright.CLI || true -fi -# Install Chromium browser -playwright install --with-deps chromium 2>/dev/null || true - -echo "" -echo "=== Step 10: Verify wwwroot files ===" -echo "=== Dashboard.Web wwwroot (source) ===" -ls -la Samples/Dashboard/Dashboard.Web/wwwroot/js/ 2>/dev/null || echo "Dashboard.Web js folder not found" -ls -la Samples/Dashboard/Dashboard.Web/wwwroot/js/vendor/ 2>/dev/null || echo "Dashboard.Web vendor folder not found" -echo "=== Integration Tests wwwroot (output) ===" -ls -la Samples/Dashboard/Dashboard.Integration.Tests/bin/Release/net10.0/wwwroot/js/ 2>/dev/null || echo "Integration Tests js folder not found" -ls -la Samples/Dashboard/Dashboard.Integration.Tests/bin/Release/net10.0/wwwroot/js/vendor/ 2>/dev/null || echo "Integration Tests vendor folder not found" - -echo "" -echo "=== Step 11: Run E2E Tests ===" -dotnet test Samples/Dashboard/Dashboard.Integration.Tests -c Release --no-build --verbosity normal - -echo "" -echo "==========================================" -echo "E2E Tests Complete" -echo "==========================================" diff --git a/Samples/Dashboard/spec.md b/Samples/Dashboard/spec.md deleted file mode 100644 index 43f138d7..00000000 --- a/Samples/Dashboard/spec.md +++ /dev/null @@ -1,116 +0,0 @@ -# Medical Dashboard Spec - -## Overview - -Medical dashboard using React (vanilla JS, no JSX) served by ASP.NET Core. Connects to Clinical.Api and Scheduling.Api microservices. - -**Future**: Offline-first sync client using IndexedDB for occasionally-connected mode. - -## Architecture - -``` -Dashboard.Web/ -├── wwwroot/ -│ ├── index.html # React app (vanilla JS) -│ ├── js/vendor/ # Bundled React 18 -│ └── css/ # Styles -├── App.cs # ASP.NET Core host -└── Program.cs # Entry point - -Dashboard.Integration.Tests/ # Playwright E2E tests -``` - -## React Loading: Bundled Vendor Files (NOT CDN) - -**Decision**: React loaded from `wwwroot/js/vendor/`, not CDN. - -**Why**: -1. **Offline/occasionally-connected**: Medical facilities have restricted networks. Future sync client will use IndexedDB for offline operation - can't depend on CDN. -2. **Deterministic E2E testing**: Playwright tests work reliably without network. -3. **HIPAA**: Minimize external network calls. -4. **Version pinning**: Exact version in source control. - -**Why NOT npm/bundler**: Sample project. No webpack complexity needed. - -**Update React**: -```bash -curl -o wwwroot/js/vendor/react.development.js https://unpkg.com/react@18/umd/react.development.js -curl -o wwwroot/js/vendor/react-dom.development.js https://unpkg.com/react-dom@18/umd/react-dom.development.js -``` - -## Color Palette - -| Token | Hex | Usage | -|-------|-----|-------| -| `--primary-500` | `#00BCD4` | Teal primary | -| `--secondary-500` | `#2E4450` | Deep slate | -| `--accent-500` | `#FF6B6B` | Actions/alerts | -| `--success` | `#4CAF50` | Success | -| `--error` | `#F44336` | Errors | - -**NO PURPLE.** - -## APIs - -- Clinical.Api: `http://localhost:5080` - Patients, Encounters, Conditions -- Scheduling.Api: `http://localhost:5001` - Practitioners, Appointments - -## Navigation: Hash-Based Routing with Browser History Integration - -**Decision**: Full browser history integration via hash-based routing (`#view` or `#view/edit/id`). - -**Implementation**: -1. **URL reflects state**: Navigating updates `window.location.hash` (e.g., `#patients`, `#patients/edit/123`) -2. **Browser back/forward work**: `history.pushState` on navigate, `popstate` listener restores state -3. **Deep linking works**: Opening `#patients/edit/123` directly loads that view -4. **Cancel buttons use `history.back()`**: In-app cancel mirrors browser back button behavior - -**Why**: -1. **UX expectation**: Users expect browser back to work in web apps -2. **Deep linking**: Bookmarkable URLs to specific views -3. **Testable**: E2E tests can verify navigation via URL changes - -**Route Format**: -- `#dashboard` - Dashboard view -- `#patients` - Patient list -- `#patients/edit/{id}` - Edit specific patient -- `#appointments` - Appointments list -- `#practitioners` - Practitioners list - -**Cancel/Back Button Behavior**: -In-app "Cancel" buttons call `window.history.back()` so they behave identically to the browser back button. This ensures consistent navigation regardless of how the user chooses to go back. - -## Sync Dashboard - -Administrative dashboard for monitoring and managing sync operations across microservices. - -**Permission Required**: `sync:admin` - Only users with this permission can access sync dashboard features. - -**Features**: -1. **Sync Status Overview**: Real-time status of sync operations per microservice (Clinical.Api, Scheduling.Api) -2. **Sync Records Browser**: View, filter, and search sync records by: - - Microservice (source system) - - Sync record ID - - Status (available) - records in the sync log ready for clients to pull - - Date range -3. **Sync Log Inspection**: View captured changes in the sync log - -**Note**: The server doesn't track per-client sync status. Clients track their own position using `fromVersion` parameter. Records in the sync log have status "available" meaning they're captured and ready for any client to pull. - -**Route**: `#sync` (requires `sync:admin` permission) - -**API Endpoints** (to be implemented in each microservice): -- `GET /sync/status` - Current sync state -- `GET /sync/records?service={}&status={}&search={}` - Paginated sync records -- `POST /sync/records/{id}/retry` - Retry failed record - -## Future: Offline Sync Client - -The dashboard will implement a sync client for occasionally-connected operation: - -1. **IndexedDB storage**: Local patient/appointment cache -2. **Change tracking**: Queue mutations when offline -3. **Sync protocol**: Reconcile with server when connected -4. **Conflict resolution**: Last-write-wins or manual merge - -This is why bundled vendor files matter - the app must work without any network. diff --git a/Samples/Healthcare.Sync.http b/Samples/Healthcare.Sync.http deleted file mode 100644 index 969c86bd..00000000 --- a/Samples/Healthcare.Sync.http +++ /dev/null @@ -1,95 +0,0 @@ -@PatientService = http://localhost:5080 -@AppointmentService = http://localhost:5081 - -### PatientService - list patients -GET {{PatientService}}/fhir/Patient -Accept: application/json - -### PatientService - search by query string -GET {{PatientService}}/fhir/Patient/_search?q=smith -Accept: application/json - -### PatientService - create patient -POST {{PatientService}}/fhir/Patient -Content-Type: application/json - -{ - "active": true, - "givenName": "Alice", - "familyName": "Smith", - "birthDate": "1990-01-01", - "gender": "female", - "phone": "+1-555-0101", - "email": "alice.smith@example.org", - "addressLine": "123 Main St", - "city": "Seattle", - "state": "WA", - "postalCode": "98101", - "country": "USA" -} - -### PatientService - patient clinical records -GET {{PatientService}}/fhir/Patient/{{patientId}}/records -Accept: application/json - -### PatientService - add clinical record -POST {{PatientService}}/fhir/Patient/{{patientId}}/records -Content-Type: application/json - -{ - "patientId": "{{patientId}}", - "visitDate": "2024-03-01", - "chiefComplaint": "Follow up", - "diagnosis": "Hypertension", - "treatment": "Lifestyle changes", - "prescriptions": null, - "notes": "Patient doing well", - "providerId": null -} - -### AppointmentService - list practitioners -GET {{AppointmentService}}/Practitioner -Accept: application/json - -### AppointmentService - create practitioner -POST {{AppointmentService}}/Practitioner -Content-Type: application/json - -{ - "identifier": "NPI-0001", - "nameFamily": "Doe", - "nameGiven": "John", - "qualification": "MD", - "specialty": "cardiology", - "telecomEmail": "john.doe@example.org", - "telecomPhone": "+1-555-0000" -} - -### AppointmentService - list appointments -GET {{AppointmentService}}/Appointment -Accept: application/json - -### AppointmentService - create appointment -POST {{AppointmentService}}/Appointment -Content-Type: application/json - -{ - "serviceCategory": "exam", - "serviceType": "consult", - "reasonCode": "follow-up", - "priority": "routine", - "description": "Follow-up appointment", - "start": "2024-04-01T09:00:00Z", - "end": "2024-04-01T09:30:00Z", - "patientReference": "Patient/{{patientId}}", - "practitionerReference": "Practitioner/{{practitionerId}}", - "comment": "Initial consult" -} - -### Sync - pull patient changes -GET {{PatientService}}/sync/changes?fromVersion=0&limit=50 -Accept: application/json - -### Sync - pull appointment changes -GET {{AppointmentService}}/sync/changes?fromVersion=0&limit=50 -Accept: application/json diff --git a/Samples/ICD10/.gitignore b/Samples/ICD10/.gitignore deleted file mode 100644 index 09530ffb..00000000 --- a/Samples/ICD10/.gitignore +++ /dev/null @@ -1,21 +0,0 @@ -# Generated files -*.generated.sql -*.db -Generated/ - -# Python -__pycache__/ -*.pyc -.env -venv/ -.venv/ - -# Build outputs -bin/ -obj/ - -# IDE -.idea/ -.vs/ -*.user -icd10cm.db.backup diff --git a/Samples/ICD10/ICD10.Api.Tests/AchiEndpointTests.cs b/Samples/ICD10/ICD10.Api.Tests/AchiEndpointTests.cs deleted file mode 100644 index 39a0655c..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/AchiEndpointTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -namespace ICD10.Api.Tests; - -/// -/// E2E tests for ACHI procedure endpoints - REAL database, NO mocks. -/// -public sealed class AchiEndpointTests : IClassFixture -{ - private readonly HttpClient _client; - - public AchiEndpointTests(ICD10ApiFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task GetAchiBlocks_ReturnsOk() - { - var response = await _client.GetAsync("/api/achi/blocks"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task GetAchiBlocks_ReturnsSeededBlocks() - { - var response = await _client.GetAsync("/api/achi/blocks"); - var blocks = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(blocks); - Assert.NotEmpty(blocks); - Assert.Contains(blocks, b => b.GetProperty("BlockNumber").GetString() == "1820"); - } - - [Fact] - public async Task GetAchiCodesByBlock_ReturnsOk() - { - var response = await _client.GetAsync("/api/achi/blocks/achi-blk-1/codes"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task GetAchiCodesByBlock_ReturnsCodes() - { - var response = await _client.GetAsync("/api/achi/blocks/achi-blk-1/codes"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.NotEmpty(codes); - Assert.Contains(codes, c => c.GetProperty("Code").GetString() == "38497-00"); - } - - [Fact] - public async Task GetAchiCodeByCode_ReturnsOk_WhenCodeExists() - { - var response = await _client.GetAsync("/api/achi/codes/38497-00"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task GetAchiCodeByCode_ReturnsCodeDetails() - { - var response = await _client.GetAsync("/api/achi/codes/38497-00"); - var code = await response.Content.ReadFromJsonAsync(); - - Assert.Equal("38497-00", code.GetProperty("Code").GetString()); - Assert.Contains( - "angiography", - code.GetProperty("ShortDescription").GetString()!, - StringComparison.OrdinalIgnoreCase - ); - } - - [Fact] - public async Task GetAchiCodeByCode_ReturnsNotFound_WhenCodeNotExists() - { - var response = await _client.GetAsync("/api/achi/codes/99999-99"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task GetAchiCodeByCode_ReturnsFhirFormat_WhenRequested() - { - var response = await _client.GetAsync("/api/achi/codes/38497-00?format=fhir"); - var fhir = await response.Content.ReadFromJsonAsync(); - - Assert.Equal("CodeSystem", fhir.GetProperty("ResourceType").GetString()); - Assert.Equal("http://hl7.org/fhir/sid/achi", fhir.GetProperty("Url").GetString()); - } - - [Fact] - public async Task SearchAchiCodes_ReturnsOk() - { - var response = await _client.GetAsync("/api/achi/codes?q=coronary"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task SearchAchiCodes_FindsMatchingCodes() - { - var response = await _client.GetAsync("/api/achi/codes?q=coronary"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.NotEmpty(codes); - Assert.Contains(codes, c => c.GetProperty("Code").GetString() == "38497-00"); - } - - [Fact] - public async Task SearchAchiCodes_RespectsLimit() - { - var response = await _client.GetAsync("/api/achi/codes?q=a&limit=1"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.True(codes.Length <= 1, "Expected at most 1 result with limit=1"); - } -} diff --git a/Samples/ICD10/ICD10.Api.Tests/ChapterCategoryTests.cs b/Samples/ICD10/ICD10.Api.Tests/ChapterCategoryTests.cs deleted file mode 100644 index 3d28dbe8..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/ChapterCategoryTests.cs +++ /dev/null @@ -1,265 +0,0 @@ -namespace ICD10.Api.Tests; - -/// -/// E2E tests proving Chapter and Category fields are returned in search results. -/// These tests verify the ICD-10 hierarchy information is properly included. -/// -public sealed class ChapterCategoryTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly ICD10ApiFactory _factory; - - /// - /// Constructor receives shared API factory. - /// - public ChapterCategoryTests(ICD10ApiFactory factory) - { - _factory = factory; - _client = factory.CreateClient(); - } - - /// - /// CRITICAL TEST: Search results include Chapter field for ICD-10 codes. - /// - [Fact] - public async Task Search_ReturnsChapterField_ForAllResults() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "diabetes type 2", Limit = 5 } - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - Assert.True(results.GetArrayLength() > 0, "Should return at least one result"); - - foreach (var item in results.EnumerateArray()) - { - Assert.True( - item.TryGetProperty("Chapter", out var chapter), - "Each result should have Chapter field" - ); - var chapterValue = chapter.GetString(); - Assert.False( - string.IsNullOrEmpty(chapterValue), - $"Chapter should not be empty for code {item.GetProperty("Code").GetString()}" - ); - } - } - - /// - /// CRITICAL TEST: Search results include ChapterTitle field for ICD-10 codes. - /// - [Fact] - public async Task Search_ReturnsChapterTitleField_ForAllResults() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "heart attack", Limit = 5 } - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - Assert.True(results.GetArrayLength() > 0, "Should return at least one result"); - - foreach (var item in results.EnumerateArray()) - { - Assert.True( - item.TryGetProperty("ChapterTitle", out var chapterTitle), - "Each result should have ChapterTitle field" - ); - var titleValue = chapterTitle.GetString(); - Assert.False( - string.IsNullOrEmpty(titleValue), - $"ChapterTitle should not be empty for code {item.GetProperty("Code").GetString()}" - ); - } - } - - /// - /// CRITICAL TEST: Search results include Category field for ICD-10 codes. - /// - [Fact] - public async Task Search_ReturnsCategoryField_ForAllResults() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "pneumonia lung infection", Limit = 5 } - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - Assert.True(results.GetArrayLength() > 0, "Should return at least one result"); - - foreach (var item in results.EnumerateArray()) - { - Assert.True( - item.TryGetProperty("Category", out var category), - "Each result should have Category field" - ); - var categoryValue = category.GetString(); - Assert.False( - string.IsNullOrEmpty(categoryValue), - $"Category should not be empty for code {item.GetProperty("Code").GetString()}" - ); - } - } - - /// - /// CRITICAL TEST: Category is first 3 characters of ICD-10 code. - /// - [Fact] - public async Task Search_CategoryMatchesCodePrefix() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "fracture broken bone", Limit = 10 } - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - var code = item.GetProperty("Code").GetString()!; - var category = item.GetProperty("Category").GetString()!; - - // Category should be first 3 chars of code (uppercase) - var expectedCategory = - code.Length >= 3 ? code[..3].ToUpperInvariant() : code.ToUpperInvariant(); - - Assert.True( - expectedCategory == category, - $"Category '{category}' should match first 3 chars of code '{code}'" - ); - } - } - - /// - /// CRITICAL TEST: Chapter number is valid for known ICD-10 code prefixes. - /// E codes (Endocrine) should be Chapter 4. - /// - [Fact] - public async Task Search_DiabetesCodes_HaveChapter4() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "diabetes mellitus type 2", Limit = 10 } - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - var diabetesCodes = new List<(string Code, string Chapter)>(); - foreach (var item in results.EnumerateArray()) - { - var code = item.GetProperty("Code").GetString()!; - var chapter = item.GetProperty("Chapter").GetString()!; - - // E10-E14 are diabetes codes, should be Chapter 4 - if (code.StartsWith("E1", StringComparison.OrdinalIgnoreCase)) - { - diabetesCodes.Add((code, chapter)); - } - } - - Assert.True( - diabetesCodes.Count > 0, - "Should find at least one E1x (diabetes) code in results" - ); - - foreach (var (code, chapter) in diabetesCodes) - { - Assert.True( - chapter == "4", - $"Diabetes code {code} should be Chapter 4 (Endocrine), got Chapter {chapter}" - ); - } - } - - /// - /// CRITICAL TEST: Chapter titles are descriptive and match WHO ICD-10 chapters. - /// - [Fact] - public async Task Search_ChapterTitles_AreDescriptive() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "infection bacterial viral", Limit = 10 } - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - var code = item.GetProperty("Code").GetString()!; - var chapterTitle = item.GetProperty("ChapterTitle").GetString()!; - - // Chapter titles should be descriptive (more than 5 chars) - Assert.True( - chapterTitle.Length > 5, - $"ChapterTitle '{chapterTitle}' for code {code} should be descriptive" - ); - - // A/B codes (infections) should have "infectious" in chapter title - if ( - code.StartsWith('A') - || code.StartsWith('a') - || code.StartsWith('B') - || code.StartsWith('b') - ) - { - Assert.True( - chapterTitle.Contains("infectious", StringComparison.OrdinalIgnoreCase), - $"Code {code} chapter should mention 'infectious', got '{chapterTitle}'" - ); - } - } - } - - private void SkipIfEmbeddingServiceUnavailable() - { - if (!_factory.EmbeddingServiceAvailable) - { - Assert.Fail( - "EMBEDDING SERVICE NOT RUNNING! " - + "Start it with: ./scripts/Dependencies/start.sh " - + "(localhost:8000 must be available for RAG E2E tests)" - ); - } - } -} diff --git a/Samples/ICD10/ICD10.Api.Tests/ChapterEndpointTests.cs b/Samples/ICD10/ICD10.Api.Tests/ChapterEndpointTests.cs deleted file mode 100644 index e4f8be69..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/ChapterEndpointTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -namespace ICD10.Api.Tests; - -/// -/// E2E tests for ICD-10-CM chapter endpoints - REAL database, NO mocks. -/// -public sealed class ChapterEndpointTests : IClassFixture -{ - private readonly HttpClient _client; - - public ChapterEndpointTests(ICD10ApiFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task GetChapters_ReturnsOk() - { - var response = await _client.GetAsync("/api/icd10/chapters"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task GetChapters_ReturnsChaptersFromDatabase() - { - var response = await _client.GetAsync("/api/icd10/chapters"); - var chapters = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(chapters); - Assert.True( - chapters.Length >= 20, - $"Expected at least 20 chapters from ICD-10-CM, got {chapters.Length}" - ); - // ICD-10-CM uses numeric chapter numbers (1, 2, 3... not Roman numerals) - Assert.Contains(chapters, c => c.GetProperty("ChapterNumber").GetString() == "1"); - Assert.Contains(chapters, c => c.GetProperty("ChapterNumber").GetString() == "18"); - } - - [Fact] - public async Task GetChapters_ChaptersHaveRequiredFields() - { - var response = await _client.GetAsync("/api/icd10/chapters"); - var chapters = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(chapters); - Assert.NotEmpty(chapters); - - var chapter = chapters[0]; - Assert.True(chapter.TryGetProperty("Id", out _), "Missing Id"); - Assert.True(chapter.TryGetProperty("ChapterNumber", out _), "Missing ChapterNumber"); - Assert.True(chapter.TryGetProperty("Title", out _), "Missing Title"); - Assert.True(chapter.TryGetProperty("CodeRangeStart", out _), "Missing CodeRangeStart"); - Assert.True(chapter.TryGetProperty("CodeRangeEnd", out _), "Missing CodeRangeEnd"); - } - - [Fact] - public async Task GetBlocksByChapter_ReturnsOk_WhenChapterExists() - { - // First get a real chapter ID from the database - var chaptersResponse = await _client.GetAsync("/api/icd10/chapters"); - var chapters = await chaptersResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(chapters); - Assert.NotEmpty(chapters); - - var chapterId = chapters[0].GetProperty("Id").GetString(); - var response = await _client.GetAsync($"/api/icd10/chapters/{chapterId}/blocks"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task GetBlocksByChapter_ReturnsBlocks() - { - // Get chapter 1 (Infectious diseases) which has block A00-A09 - var chaptersResponse = await _client.GetAsync("/api/icd10/chapters"); - var chapters = await chaptersResponse.Content.ReadFromJsonAsync(); - var chapter1 = chapters!.First(c => c.GetProperty("ChapterNumber").GetString() == "1"); - var chapterId = chapter1.GetProperty("Id").GetString(); - - var response = await _client.GetAsync($"/api/icd10/chapters/{chapterId}/blocks"); - var blocks = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(blocks); - Assert.NotEmpty(blocks); - Assert.Contains(blocks, b => b.GetProperty("BlockCode").GetString() == "A00-A09"); - } - - [Fact] - public async Task GetBlocksByChapter_ReturnsEmptyArray_WhenChapterNotExists() - { - var response = await _client.GetAsync("/api/icd10/chapters/nonexistent-uuid/blocks"); - var blocks = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(blocks); - Assert.Empty(blocks); - } -} diff --git a/Samples/ICD10/ICD10.Api.Tests/CodeLookupTests.cs b/Samples/ICD10/ICD10.Api.Tests/CodeLookupTests.cs deleted file mode 100644 index fca81cc0..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/CodeLookupTests.cs +++ /dev/null @@ -1,301 +0,0 @@ -namespace ICD10.Api.Tests; - -/// -/// E2E tests for ICD-10-CM code lookup endpoints - REAL database, NO mocks. -/// -public sealed class CodeLookupTests : IClassFixture -{ - private readonly HttpClient _client; - - public CodeLookupTests(ICD10ApiFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task GetCodeByCode_ReturnsOk_WhenCodeExists() - { - var response = await _client.GetAsync("/api/icd10/codes/A00.0"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task GetCodeByCode_ReturnsCodeDetails() - { - var response = await _client.GetAsync("/api/icd10/codes/A00.0"); - var code = await response.Content.ReadFromJsonAsync(); - - Assert.Equal("A00.0", code.GetProperty("Code").GetString()); - Assert.Contains("Cholera", code.GetProperty("ShortDescription").GetString()); - // Chapter number is numeric in ICD-10-CM XML (1, not I) - Assert.Equal("1", code.GetProperty("ChapterNumber").GetString()); - } - - [Fact] - public async Task GetCodeByCode_ReturnsNotFound_WhenCodeNotExists() - { - var response = await _client.GetAsync("/api/icd10/codes/INVALID99"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task GetCodeByCode_ReturnsFhirFormat_WhenRequested() - { - // Use A00.0 which exists in the database (R07.4 doesn't exist in ICD-10-CM 2025) - var response = await _client.GetAsync("/api/icd10/codes/A00.0?format=fhir"); - var content = await response.Content.ReadAsStringAsync(); - var fhir = JsonSerializer.Deserialize(content); - - Assert.Equal("CodeSystem", fhir.GetProperty("ResourceType").GetString()); - Assert.Equal("http://hl7.org/fhir/sid/icd-10", fhir.GetProperty("Url").GetString()); - Assert.Equal("A00.0", fhir.GetProperty("Concept").GetProperty("Code").GetString()); - } - - [Fact] - public async Task SearchCodes_ReturnsOk() - { - var response = await _client.GetAsync("/api/icd10/codes?q=cholera"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task SearchCodes_FindsMatchingCodes() - { - var response = await _client.GetAsync("/api/icd10/codes?q=cholera"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.NotEmpty(codes); - Assert.Contains(codes, c => c.GetProperty("Code").GetString() == "A00.0"); - } - - [Fact] - public async Task SearchCodes_RespectsLimit() - { - var response = await _client.GetAsync("/api/icd10/codes?q=a&limit=1"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.True(codes.Length <= 1, "Expected at most 1 result with limit=1"); - } - - [Fact] - public async Task SearchCodes_SearchesByCode() - { - var response = await _client.GetAsync("/api/icd10/codes?q=R07"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.Contains( - codes, - c => c.GetProperty("Code").GetString()!.StartsWith("R07", StringComparison.Ordinal) - ); - } - - [Fact] - public async Task SearchCodes_ReturnsEmptyArray_WhenNoMatch() - { - var response = await _client.GetAsync("/api/icd10/codes?q=zzznomatch"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.Empty(codes); - } - - [Fact] - public async Task GetCategoriesByBlock_ReturnsCategories() - { - // First get a real block ID from the database - var chaptersResponse = await _client.GetAsync("/api/icd10/chapters"); - var chapters = await chaptersResponse.Content.ReadFromJsonAsync(); - var chapter1 = chapters!.First(c => c.GetProperty("ChapterNumber").GetString() == "1"); - var chapterId = chapter1.GetProperty("Id").GetString(); - - var blocksResponse = await _client.GetAsync($"/api/icd10/chapters/{chapterId}/blocks"); - var blocks = await blocksResponse.Content.ReadFromJsonAsync(); - var blockA00 = blocks!.First(b => b.GetProperty("BlockCode").GetString() == "A00-A09"); - var blockId = blockA00.GetProperty("Id").GetString(); - - var response = await _client.GetAsync($"/api/icd10/blocks/{blockId}/categories"); - var categories = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(categories); - Assert.NotEmpty(categories); - Assert.Contains(categories, c => c.GetProperty("CategoryCode").GetString() == "A00"); - } - - [Fact] - public async Task GetCodesByCategory_ReturnsCodes() - { - // First get a real category ID from the database - var chaptersResponse = await _client.GetAsync("/api/icd10/chapters"); - var chapters = await chaptersResponse.Content.ReadFromJsonAsync(); - var chapter1 = chapters!.First(c => c.GetProperty("ChapterNumber").GetString() == "1"); - var chapterId = chapter1.GetProperty("Id").GetString(); - - var blocksResponse = await _client.GetAsync($"/api/icd10/chapters/{chapterId}/blocks"); - var blocks = await blocksResponse.Content.ReadFromJsonAsync(); - var blockA00 = blocks!.First(b => b.GetProperty("BlockCode").GetString() == "A00-A09"); - var blockId = blockA00.GetProperty("Id").GetString(); - - var categoriesResponse = await _client.GetAsync($"/api/icd10/blocks/{blockId}/categories"); - var categories = await categoriesResponse.Content.ReadFromJsonAsync(); - var catA00 = categories!.First(c => c.GetProperty("CategoryCode").GetString() == "A00"); - var categoryId = catA00.GetProperty("Id").GetString(); - - var response = await _client.GetAsync($"/api/icd10/categories/{categoryId}/codes"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.NotEmpty(codes); - Assert.Contains(codes, c => c.GetProperty("Code").GetString() == "A00.0"); - } - - // ========================================================================= - // ICD-10-CM LOOKUP TESTS (codes returned by RAG search) - // ========================================================================= - - [Fact] - public async Task Icd10Cm_GetCodeByCode_ReturnsOk_WhenCodeExists() - { - var response = await _client.GetAsync("/api/icd10/codes/I10"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Icd10Cm_GetCodeByCode_ReturnsAllDetails() - { - var response = await _client.GetAsync("/api/icd10/codes/I10"); - var code = await response.Content.ReadFromJsonAsync(); - - // Verify ALL required fields are present and populated - Assert.Equal("I10", code.GetProperty("Code").GetString()); - Assert.False( - string.IsNullOrEmpty(code.GetProperty("ShortDescription").GetString()), - "ShortDescription must not be empty" - ); - Assert.False( - string.IsNullOrEmpty(code.GetProperty("LongDescription").GetString()), - "LongDescription must not be empty" - ); - Assert.True(code.TryGetProperty("Billable", out _), "Billable property must exist"); - Assert.True(code.TryGetProperty("Id", out _), "Id property must exist"); - - // Verify specific content - Assert.Contains( - "hypertension", - code.GetProperty("ShortDescription").GetString(), - StringComparison.OrdinalIgnoreCase - ); - } - - [Fact] - public async Task Icd10Cm_GetCodeByCode_ReturnsNotFound_WhenCodeNotExists() - { - var response = await _client.GetAsync("/api/icd10/codes/INVALID99"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task Icd10Cm_GetCodeByCode_ReturnsFhirFormat_WhenRequested() - { - var response = await _client.GetAsync("/api/icd10/codes/I10?format=fhir"); - var fhir = await response.Content.ReadFromJsonAsync(); - - Assert.Equal("CodeSystem", fhir.GetProperty("ResourceType").GetString()); - Assert.Equal("http://hl7.org/fhir/sid/icd-10", fhir.GetProperty("Url").GetString()); - Assert.Equal("I10", fhir.GetProperty("Concept").GetProperty("Code").GetString()); - } - - [Fact] - public async Task Icd10Cm_SearchCodes_ReturnsOk() - { - var response = await _client.GetAsync("/api/icd10/codes?q=hypertension"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Icd10Cm_SearchCodes_FindsMatchingCodes() - { - var response = await _client.GetAsync("/api/icd10/codes?q=hypertension"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.NotEmpty(codes); - Assert.Contains(codes, c => c.GetProperty("Code").GetString() == "I10"); - } - - [Fact] - public async Task Icd10Cm_SearchCodes_RespectsLimit() - { - var response = await _client.GetAsync("/api/icd10/codes?q=a&limit=1"); - var codes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(codes); - Assert.True(codes.Length <= 1, "Expected at most 1 result with limit=1"); - } - - [Fact] - public async Task Icd10Cm_LookupCodeReturnedByRagSearch_Succeeds() - { - // This test verifies the critical bug fix: - // Codes returned by RAG search (from icd10_code_embedding) MUST be lookupable - // via /api/icd10/codes/{code} - - // I21.11 exists in the database - var response = await _client.GetAsync("/api/icd10/codes/I21.11"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var code = await response.Content.ReadFromJsonAsync(); - Assert.Equal("I21.11", code.GetProperty("Code").GetString()); - Assert.Contains( - "myocardial infarction", - code.GetProperty("ShortDescription").GetString(), - StringComparison.OrdinalIgnoreCase - ); - } - - [Fact] - public async Task Icd10Cm_LookupCommonCodes_AllSucceed() - { - // Verify common ICD-10-CM codes can be looked up (these all exist in CDC 2025 data) - var commonCodes = new[] - { - ("A00.0", "cholera"), - ("E10.9", "diabetes"), - ("E11.9", "diabetes"), - ("I10", "hypertension"), - ("I21.0", "myocardial infarction"), - ("I21.11", "myocardial infarction"), - ("I21.4", "myocardial infarction"), - ("J06.9", "respiratory infection"), - ("R06.00", "dyspnea"), - ("R07.9", "chest pain"), // R07.4 doesn't exist, R07.9 does - ("R07.89", "chest pain"), - }; - - foreach (var (code, expectedDescription) in commonCodes) - { - var response = await _client.GetAsync($"/api/icd10/codes/{code}"); - Assert.True( - response.StatusCode == HttpStatusCode.OK, - $"Lookup failed for code {code}: {response.StatusCode}" - ); - - var result = await response.Content.ReadFromJsonAsync(); - Assert.Equal(code, result.GetProperty("Code").GetString()); - Assert.Contains( - expectedDescription, - result.GetProperty("ShortDescription").GetString(), - StringComparison.OrdinalIgnoreCase - ); - } - } -} diff --git a/Samples/ICD10/ICD10.Api.Tests/GlobalUsings.cs b/Samples/ICD10/ICD10.Api.Tests/GlobalUsings.cs deleted file mode 100644 index 4d6becba..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/GlobalUsings.cs +++ /dev/null @@ -1,7 +0,0 @@ -global using System; -global using System.Net; -global using System.Net.Http; -global using System.Net.Http.Json; -global using System.Text.Json; -global using System.Threading.Tasks; -global using Xunit; diff --git a/Samples/ICD10/ICD10.Api.Tests/HealthEndpointTests.cs b/Samples/ICD10/ICD10.Api.Tests/HealthEndpointTests.cs deleted file mode 100644 index b05c6604..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/HealthEndpointTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ICD10.Api.Tests; - -/// -/// E2E tests for health check endpoint. -/// -public sealed class HealthEndpointTests : IClassFixture -{ - private readonly HttpClient _client; - - public HealthEndpointTests(ICD10ApiFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task Health_ReturnsOk() - { - var response = await _client.GetAsync("/health"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Health_ReturnsHealthyStatus() - { - var response = await _client.GetAsync("/health"); - var health = await response.Content.ReadFromJsonAsync(); - - Assert.Equal("healthy", health.GetProperty("Status").GetString()); - Assert.Equal("ICD10.Api", health.GetProperty("Service").GetString()); - } -} diff --git a/Samples/ICD10/ICD10.Api.Tests/ICD10.Api.Tests.csproj b/Samples/ICD10/ICD10.Api.Tests/ICD10.Api.Tests.csproj deleted file mode 100644 index fcb64e05..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/ICD10.Api.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - Library - true - ICD10.Api.Tests - CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859;CA1849;CA2234;CA1812;CA2007;CA2000;xUnit1030 - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/Samples/ICD10/ICD10.Api.Tests/ICD10ApiFactory.cs b/Samples/ICD10/ICD10.Api.Tests/ICD10ApiFactory.cs deleted file mode 100644 index 5dde0120..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/ICD10ApiFactory.cs +++ /dev/null @@ -1,133 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Migration; -using Migration.Postgres; -using Npgsql; - -namespace ICD10.Api.Tests; - -/// -/// WebApplicationFactory for ICD10.Api e2e testing. -/// Creates an isolated PostgreSQL test database per factory instance, -/// creates schema from YAML, and seeds reference data. -/// -public sealed class ICD10ApiFactory : WebApplicationFactory -{ - private readonly string _dbName; - private readonly string _connectionString; - - private static readonly string BaseConnectionString = - Environment.GetEnvironmentVariable("ICD10_TEST_CONNECTION_STRING") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme"; - - /// - /// Creates a new instance with an isolated PostgreSQL test database, - /// schema from YAML migration, and seeded reference data. - /// - public ICD10ApiFactory() - { - _dbName = $"test_icd10_{Guid.NewGuid():N}"; - - using (var adminConn = new NpgsqlConnection(BaseConnectionString)) - { - adminConn.Open(); - using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {_dbName}"; - createCmd.ExecuteNonQuery(); - } - - _connectionString = BaseConnectionString.Replace( - "Database=postgres", - $"Database={_dbName}" - ); - - // Create schema and seed data BEFORE the app starts. - // When app starts, DatabaseSetup.Initialize detects tables exist and skips. - using var conn = new NpgsqlConnection(_connectionString); - conn.Open(); - - // Enable pgvector extension - using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = "CREATE EXTENSION IF NOT EXISTS vector"; - cmd.ExecuteNonQuery(); - } - - // Create schema from YAML using Migration library - var apiDir = Path.GetDirectoryName(typeof(Program).Assembly.Location)!; - var yamlPath = Path.Combine(apiDir, "icd10-schema.yaml"); - var schema = SchemaYamlSerializer.FromYamlFile(yamlPath); - PostgresDdlGenerator.MigrateSchema(conn, schema); - - // Seed reference data - TestDataSeeder.Seed(conn); - - // Seed embeddings from embedding service (if available) - TestDataSeeder.SeedEmbeddings(conn); - } - - /// - /// Gets the connection string for direct access in tests if needed. - /// - public string ConnectionString => _connectionString; - - /// - /// Checks if the embedding service at localhost:8000 is available. - /// - public bool EmbeddingServiceAvailable - { - get - { - try - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; - var response = client.GetAsync("http://localhost:8000/health").Result; - return response.IsSuccessStatusCode; - } - catch - { - return false; - } - } - } - - /// - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseSetting("ConnectionStrings:Postgres", _connectionString); - builder.UseSetting("EmbeddingService:BaseUrl", "http://localhost:8000"); - builder.UseEnvironment("Development"); - - var apiAssembly = typeof(Program).Assembly; - var contentRoot = Path.GetDirectoryName(apiAssembly.Location)!; - builder.UseContentRoot(contentRoot); - } - - /// - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing) - { - try - { - using var adminConn = new NpgsqlConnection(BaseConnectionString); - adminConn.Open(); - - using var terminateCmd = adminConn.CreateCommand(); - terminateCmd.CommandText = - $"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{_dbName}'"; - terminateCmd.ExecuteNonQuery(); - - using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {_dbName}"; - dropCmd.ExecuteNonQuery(); - } - catch - { - // Ignore cleanup errors - } - } - } -} diff --git a/Samples/ICD10/ICD10.Api.Tests/SearchEndpointTests.cs b/Samples/ICD10/ICD10.Api.Tests/SearchEndpointTests.cs deleted file mode 100644 index e1007c05..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/SearchEndpointTests.cs +++ /dev/null @@ -1,565 +0,0 @@ -namespace ICD10.Api.Tests; - -/// -/// E2E tests for RAG semantic search endpoint - REAL embedding service, NO mocks. -/// These tests require the Docker embedding service running at localhost:8000. -/// Start it with: ./scripts/Dependencies/start.sh -/// -public sealed class SearchEndpointTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly ICD10ApiFactory _factory; - - public SearchEndpointTests(ICD10ApiFactory factory) - { - _factory = factory; - _client = factory.CreateClient(); - } - - [Fact] - public async Task Search_ReturnsServiceUnavailable_WhenEmbeddingServiceDown() - { - // This test verifies the error handling when embedding service is unavailable - // Skip if service IS available - we want to test the failure case separately - if (_factory.EmbeddingServiceAvailable) - { - // Service is up, so we can't test the failure case here - // This is expected in normal E2E testing - return; - } - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "chest pain", Limit = 5 } - ); - - // Should return 500 with problem details when embedding service is down - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - } - - [Fact] - public async Task Search_ReturnsOk_WhenEmbeddingServiceAvailable() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "chest pain", Limit = 5 } - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Search_ReturnsResults_ForChestPainQuery() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "chest pain", Limit = 10 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - - Assert.True( - result.TryGetProperty("Results", out var results), - "Response should have Results property" - ); - Assert.True(results.GetArrayLength() > 0, "Should return at least one result"); - } - - [Fact] - public async Task Search_ReturnsChestPainCodes_ForChestPainQuery() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "chest pain with shortness of breath", Limit = 10 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - // Semantic search should rank chest pain (R07.x) and dyspnea (R06.x) codes highly - var codes = new List(); - foreach (var item in results.EnumerateArray()) - { - codes.Add(item.GetProperty("Code").GetString()!); - } - - // At least one chest pain or shortness of breath code should be in top results - var hasRelevantCode = codes.Any(c => - c.StartsWith("R07", StringComparison.Ordinal) - || // Chest pain - c.StartsWith("R06", StringComparison.Ordinal) - || // Dyspnea/breathing problems - c.StartsWith("I21", StringComparison.Ordinal) // Heart attack (also chest pain related) - ); - - Assert.True( - hasRelevantCode, - $"Expected chest pain/dyspnea codes in top results. Got: {string.Join(", ", codes)}" - ); - } - - [Fact] - public async Task Search_ReturnsResultsWithConfidenceScores() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "diabetes", Limit = 5 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - Assert.True( - item.TryGetProperty("Confidence", out var confidence), - "Each result should have Confidence score" - ); - var score = confidence.GetDouble(); - Assert.True( - score >= -1 && score <= 1, - $"Confidence should be valid cosine similarity: {score}" - ); - } - } - - [Fact] - public async Task Search_ResultsAreRankedByConfidence() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "heart attack myocardial infarction", Limit = 10 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - var confidences = new List(); - foreach (var item in results.EnumerateArray()) - { - confidences.Add(item.GetProperty("Confidence").GetDouble()); - } - - // Verify results are sorted in descending order by confidence - for (var i = 0; i < confidences.Count - 1; i++) - { - Assert.True( - confidences[i] >= confidences[i + 1], - $"Results should be sorted by confidence descending. Got {confidences[i]} followed by {confidences[i + 1]}" - ); - } - } - - [Fact] - public async Task Search_RespectsLimitParameter() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "pain", Limit = 3 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - Assert.True( - results.GetArrayLength() <= 3, - $"Should respect limit=3, got {results.GetArrayLength()} results" - ); - } - - [Fact] - public async Task Search_ReturnsFhirFormat_WhenRequested() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new - { - Query = "pneumonia lung infection", - Limit = 5, - Format = "fhir", - } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - - Assert.Equal("Bundle", result.GetProperty("ResourceType").GetString()); - Assert.Equal("searchset", result.GetProperty("Type").GetString()); - Assert.True(result.TryGetProperty("Total", out _), "FHIR response should have Total"); - Assert.True( - result.TryGetProperty("Entry", out var entries), - "FHIR response should have Entry array" - ); - - if (entries.GetArrayLength() > 0) - { - var firstEntry = entries[0]; - Assert.True( - firstEntry.TryGetProperty("Resource", out var resource), - "Entry should have Resource" - ); - Assert.Equal("CodeSystem", resource.GetProperty("ResourceType").GetString()); - Assert.True( - firstEntry.TryGetProperty("Search", out var search), - "Entry should have Search" - ); - Assert.True(search.TryGetProperty("Score", out _), "Search should have Score"); - } - } - - [Fact] - public async Task Search_IncludesModelInfo_InResponse() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "hypertension high blood pressure", Limit = 5 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - - Assert.True(result.TryGetProperty("Model", out var model), "Response should include Model"); - Assert.Equal("MedEmbed-Small-v0.1", model.GetString()); - Assert.True(result.TryGetProperty("Query", out var query), "Response should echo Query"); - Assert.Equal("hypertension high blood pressure", query.GetString()); - } - - [Fact] - public async Task Search_SemanticallySimilarQueries_ReturnSimilarResults() - { - SkipIfEmbeddingServiceUnavailable(); - - // Two semantically similar queries should return overlapping results - // Using diabetes queries which the MedEmbed model handles consistently - var response1 = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "diabetes mellitus type 2", Limit = 5 } - ); - var response2 = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "type 2 diabetes", Limit = 5 } - ); - - var content1 = await response1.Content.ReadAsStringAsync(); - var content2 = await response2.Content.ReadAsStringAsync(); - var result1 = JsonSerializer.Deserialize(content1); - var result2 = JsonSerializer.Deserialize(content2); - - var codes1 = GetCodesFromResults(result1.GetProperty("Results")); - var codes2 = GetCodesFromResults(result2.GetProperty("Results")); - - // There should be overlap in results for semantically similar queries - var overlap = codes1.Intersect(codes2).ToList(); - Assert.True( - overlap.Count > 0, - $"Semantically similar queries should return overlapping results. Query1: [{string.Join(", ", codes1)}], Query2: [{string.Join(", ", codes2)}]" - ); - } - - [Fact] - public async Task Search_ReturnsDescriptions_ForAllResults() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "headache migraine", Limit = 5 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - Assert.True(item.TryGetProperty("Code", out var code), "Result should have Code"); - Assert.False(string.IsNullOrEmpty(code.GetString()), "Code should not be empty"); - - Assert.True( - item.TryGetProperty("Description", out var desc), - "Result should have Description" - ); - Assert.False(string.IsNullOrEmpty(desc.GetString()), "Description should not be empty"); - } - } - - [Fact] - public async Task Search_TopResult_IsSemanticallySimilar_ForSpecificQuery() - { - SkipIfEmbeddingServiceUnavailable(); - - // Search for a very specific medical term - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "acute myocardial infarction heart attack", Limit = 10 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - Assert.True(results.GetArrayLength() >= 1, "Should return at least one result"); - - // Note: With test data using identical fake embeddings, semantic ranking won't work. - // In production with real embeddings, the top result would be heart-related. - // Here we verify that heart-related codes (I21.x) are AT LEAST present in results. - var codes = new List(); - foreach (var item in results.EnumerateArray()) - { - codes.Add(item.GetProperty("Code").GetString()!); - } - - var hasHeartRelatedCode = codes.Any(c => - c.StartsWith("I21", StringComparison.Ordinal) - || c.StartsWith("I22", StringComparison.Ordinal) - ); - - Assert.True( - hasHeartRelatedCode, - $"Results should include heart-related I21.x/I22.x codes. Got: {string.Join(", ", codes)}" - ); - } - - // ========================================================================= - // CHAPTER AND CATEGORY TESTS - // ========================================================================= - - [Fact] - public async Task Search_ReturnsChapterInfo_ForAllIcd10CmResults() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "chest pain", Limit = 10 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - var codeType = item.GetProperty("CodeType").GetString(); - if (codeType == "ICD10") - { - Assert.True( - item.TryGetProperty("Chapter", out var chapter), - "ICD10 result should have Chapter" - ); - Assert.False( - string.IsNullOrEmpty(chapter.GetString()), - "Chapter should not be empty for ICD10 codes" - ); - - Assert.True( - item.TryGetProperty("ChapterTitle", out var chapterTitle), - "ICD10 result should have ChapterTitle" - ); - Assert.False( - string.IsNullOrEmpty(chapterTitle.GetString()), - "ChapterTitle should not be empty for ICD10 codes" - ); - } - } - } - - [Fact] - public async Task Search_ReturnsCategoryInfo_ForAllIcd10CmResults() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "diabetes", Limit = 10 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - var codeType = item.GetProperty("CodeType").GetString(); - if (codeType == "ICD10") - { - Assert.True( - item.TryGetProperty("Category", out var category), - "ICD10 result should have Category" - ); - Assert.False( - string.IsNullOrEmpty(category.GetString()), - "Category should not be empty for ICD10 codes" - ); - - // Category should be first 3 characters of the code - var code = item.GetProperty("Code").GetString()!; - var expectedCategory = code.Length >= 3 ? code[..3] : code; - Assert.Equal( - expectedCategory.ToUpperInvariant(), - category.GetString()!.ToUpperInvariant() - ); - } - } - } - - [Fact] - public async Task Search_ChapterNumber_MatchesCodePrefix() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "respiratory breathing", Limit = 10 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - var codeType = item.GetProperty("CodeType").GetString(); - if (codeType != "ICD10") - continue; - - var code = item.GetProperty("Code").GetString()!; - var chapter = item.GetProperty("Chapter").GetString()!; - - // Verify chapter is valid (non-empty string) - Assert.False( - string.IsNullOrEmpty(chapter), - $"Chapter should not be empty for code {code}" - ); - - // Verify chapter matches known ICD-10-CM structure - var firstChar = char.ToUpperInvariant(code[0]); - // J codes (respiratory) should be chapter 10 - if (firstChar == 'J') - { - Assert.Equal("10", chapter); - } - // R codes (symptoms) should be chapter 18 - else if (firstChar == 'R') - { - Assert.Equal("18", chapter); - } - // I codes (circulatory) should be chapter 9 - else if (firstChar == 'I') - { - Assert.Equal("9", chapter); - } - } - } - - [Fact] - public async Task Search_ChapterTitle_IsDescriptive() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new { Query = "heart disease", Limit = 5 } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - var codeType = item.GetProperty("CodeType").GetString(); - if (codeType != "ICD10") - continue; - - var code = item.GetProperty("Code").GetString()!; - var chapterTitle = item.GetProperty("ChapterTitle").GetString()!; - - // I codes should have circulatory chapter title - if (char.ToUpperInvariant(code[0]) == 'I') - { - Assert.Contains("circulatory", chapterTitle, StringComparison.OrdinalIgnoreCase); - } - } - } - - [Fact] - public async Task Search_AchiCodes_HaveEmptyChapterAndCategory() - { - SkipIfEmbeddingServiceUnavailable(); - - var response = await _client.PostAsJsonAsync( - "/api/search", - new - { - Query = "heart procedure", - Limit = 20, - IncludeAchi = true, - } - ); - - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content); - var results = result.GetProperty("Results"); - - foreach (var item in results.EnumerateArray()) - { - var codeType = item.GetProperty("CodeType").GetString(); - if (codeType == "ACHI") - { - // ACHI codes don't have ICD-10-CM chapter/category structure - var chapter = item.GetProperty("Chapter").GetString(); - var category = item.GetProperty("Category").GetString(); - - Assert.True(string.IsNullOrEmpty(chapter), "ACHI codes should have empty Chapter"); - Assert.True( - string.IsNullOrEmpty(category), - "ACHI codes should have empty Category" - ); - } - } - } - - private void SkipIfEmbeddingServiceUnavailable() - { - if (!_factory.EmbeddingServiceAvailable) - { - Assert.Fail( - "EMBEDDING SERVICE NOT RUNNING! " - + "Start it with: ./scripts/Dependencies/start.sh " - + "(localhost:8000 must be available for RAG E2E tests)" - ); - } - } - - private static List GetCodesFromResults(JsonElement results) - { - var codes = new List(); - foreach (var item in results.EnumerateArray()) - { - codes.Add(item.GetProperty("Code").GetString()!); - } - return codes; - } -} diff --git a/Samples/ICD10/ICD10.Api.Tests/TestDataSeeder.cs b/Samples/ICD10/ICD10.Api.Tests/TestDataSeeder.cs deleted file mode 100644 index 35949a7e..00000000 --- a/Samples/ICD10/ICD10.Api.Tests/TestDataSeeder.cs +++ /dev/null @@ -1,709 +0,0 @@ -using Npgsql; - -namespace ICD10.Api.Tests; - -/// -/// Seeds ICD-10 reference data into a PostgreSQL test database. -/// All column names are lowercase to match PostgresDdlGenerator output. -/// -internal static class TestDataSeeder -{ - internal static void Seed(NpgsqlConnection conn) - { - SeedChapters(conn); - SeedBlocks(conn); - SeedCategories(conn); - SeedCodes(conn); - SeedAchiBlocks(conn); - SeedAchiCodes(conn); - } - - /// - /// Seeds embeddings by calling the embedding service at localhost:8000. - /// If the service is unavailable, silently returns (search tests will fail via skip check). - /// - internal static void SeedEmbeddings(NpgsqlConnection conn) - { - var icdItems = new (string EmbId, string CodeId, string Text)[] - { - ( - "emb-a00-0", - "code-a00-0", - "ICD-10 A00.0: Cholera due to Vibrio cholerae 01, biovar cholerae" - ), - ( - "emb-e10-9", - "code-e10-9", - "ICD-10 E10.9: Type 1 diabetes mellitus without complications" - ), - ( - "emb-e11-9", - "code-e11-9", - "ICD-10 E11.9: Type 2 diabetes mellitus without complications" - ), - ( - "emb-i10", - "code-i10", - "ICD-10 I10: Essential (primary) hypertension, high blood pressure" - ), - ( - "emb-i21-0", - "code-i21-0", - "ICD-10 I21.0: ST elevation myocardial infarction, heart attack, anterior wall" - ), - ( - "emb-i21-11", - "code-i21-11", - "ICD-10 I21.11: ST elevation myocardial infarction, heart attack, right coronary artery" - ), - ( - "emb-i21-4", - "code-i21-4", - "ICD-10 I21.4: Non-ST elevation myocardial infarction, NSTEMI, heart attack" - ), - ( - "emb-j06-9", - "code-j06-9", - "ICD-10 J06.9: Acute upper respiratory infection, unspecified" - ), - ( - "emb-r06-00", - "code-r06-00", - "ICD-10 R06.00: Dyspnea, shortness of breath, breathing difficulty" - ), - ("emb-r07-9", "code-r07-9", "ICD-10 R07.9: Chest pain, unspecified, thoracic pain"), - ("emb-r07-89", "code-r07-89", "ICD-10 R07.89: Other chest pain, thoracic pain"), - ( - "emb-a00-1", - "code-a00-1", - "ICD-10 A00.1: Cholera due to Vibrio cholerae 01, biovar eltor" - ), - ("emb-m54-5", "code-m54-5", "ICD-10 M54.5: Low back pain, lumbago, dorsalgia"), - ( - "emb-s72-00", - "code-s72-00", - "ICD-10 S72.00: Fracture of unspecified part of neck of femur, hip fracture" - ), - }; - - var achiItems = new (string EmbId, string CodeId, string Text)[] - { - ( - "emb-achi-38497", - "achi-38497-00", - "ACHI 38497-00: Coronary angiography, heart catheterization" - ), - ( - "emb-achi-38503", - "achi-38503-00", - "ACHI 38503-00: Percutaneous insertion of coronary artery stent, heart procedure" - ), - ( - "emb-achi-90661", - "achi-90661-00", - "ACHI 90661-00: Appendicectomy, appendix removal surgery" - ), - ( - "emb-achi-30571", - "achi-30571-00", - "ACHI 30571-00: Cholecystectomy, gallbladder removal surgery" - ), - }; - - try - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; - - var healthCheck = client - .GetAsync("http://localhost:8000/health") - .GetAwaiter() - .GetResult(); - if (!healthCheck.IsSuccessStatusCode) - return; - - var allTexts = icdItems - .Select(t => t.Text) - .Concat(achiItems.Select(t => t.Text)) - .ToList(); - - var batchResponse = client - .PostAsJsonAsync("http://localhost:8000/embed/batch", new { texts = allTexts }) - .GetAwaiter() - .GetResult(); - - if (!batchResponse.IsSuccessStatusCode) - return; - - var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var batchResult = batchResponse - .Content.ReadFromJsonAsync(jsonOptions) - .GetAwaiter() - .GetResult(); - - if (batchResult is null || batchResult.Embeddings.Count != allTexts.Count) - return; - - InsertEmbeddings( - conn: conn, - table: "icd10_code_embedding", - items: icdItems, - embeddings: batchResult.Embeddings, - offset: 0 - ); - - InsertEmbeddings( - conn: conn, - table: "achi_code_embedding", - items: achiItems, - embeddings: batchResult.Embeddings, - offset: icdItems.Length - ); - } - catch - { - // Embedding service unavailable - search tests will be skipped - } - } - - private static void InsertEmbeddings( - NpgsqlConnection conn, - string table, - (string EmbId, string CodeId, string Text)[] items, - List> embeddings, - int offset - ) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = $""" - INSERT INTO "public"."{table}" ("id", "codeid", "embedding", "embeddingmodel") - VALUES (@id, @codeid, @embedding, @model) - """; - - var pId = cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlTypes.NpgsqlDbType.Text)); - var pCodeId = cmd.Parameters.Add( - new NpgsqlParameter("@codeid", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pEmbedding = cmd.Parameters.Add( - new NpgsqlParameter("@embedding", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pModel = cmd.Parameters.Add( - new NpgsqlParameter("@model", NpgsqlTypes.NpgsqlDbType.Text) - ); - - cmd.Prepare(); - - for (var i = 0; i < items.Length; i++) - { - pId.Value = items[i].EmbId; - pCodeId.Value = items[i].CodeId; - pEmbedding.Value = - "[" - + string.Join( - ",", - embeddings[offset + i] - .Select(f => f.ToString(System.Globalization.CultureInfo.InvariantCulture)) - ) - + "]"; - pModel.Value = "MedEmbed-Small-v0.1"; - cmd.ExecuteNonQuery(); - } - } - - private sealed record BatchEmbeddingResponse( - List> Embeddings, - string Model, - int Dimensions, - int Count - ); - - private static void SeedChapters(NpgsqlConnection conn) - { - // All 21 ICD-10-CM chapters with numeric chapter numbers - var chapters = new (string Id, string Number, string Title, string Start, string End)[] - { - ("ch-01", "1", "Certain infectious and parasitic diseases", "A00", "B99"), - ("ch-02", "2", "Neoplasms", "C00", "D49"), - ("ch-03", "3", "Diseases of the blood and blood-forming organs", "D50", "D89"), - ("ch-04", "4", "Endocrine, nutritional and metabolic diseases", "E00", "E89"), - ("ch-05", "5", "Mental, behavioral and neurodevelopmental disorders", "F01", "F99"), - ("ch-06", "6", "Diseases of the nervous system", "G00", "G99"), - ("ch-07", "7", "Diseases of the eye and adnexa", "H00", "H59"), - ("ch-08", "8", "Diseases of the ear and mastoid process", "H60", "H95"), - ("ch-09", "9", "Diseases of the circulatory system", "I00", "I99"), - ("ch-10", "10", "Diseases of the respiratory system", "J00", "J99"), - ("ch-11", "11", "Diseases of the digestive system", "K00", "K95"), - ("ch-12", "12", "Diseases of the skin and subcutaneous tissue", "L00", "L99"), - ( - "ch-13", - "13", - "Diseases of the musculoskeletal system and connective tissue", - "M00", - "M99" - ), - ("ch-14", "14", "Diseases of the genitourinary system", "N00", "N99"), - ("ch-15", "15", "Pregnancy, childbirth and the puerperium", "O00", "O9A"), - ("ch-16", "16", "Certain conditions originating in the perinatal period", "P00", "P96"), - ("ch-17", "17", "Congenital malformations and chromosomal abnormalities", "Q00", "Q99"), - ( - "ch-18", - "18", - "Symptoms, signs and abnormal clinical and laboratory findings", - "R00", - "R99" - ), - ( - "ch-19", - "19", - "Injury, poisoning and certain other consequences of external causes", - "S00", - "T88" - ), - ("ch-20", "20", "External causes of morbidity", "V00", "Y99"), - ( - "ch-21", - "21", - "Factors influencing health status and contact with health services", - "Z00", - "Z99" - ), - }; - - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO "public"."icd10_chapter" ("id", "chapternumber", "title", "coderangestart", "coderangeend") - VALUES (@id, @num, @title, @start, @end) - """; - - var pId = cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlTypes.NpgsqlDbType.Text)); - var pNum = cmd.Parameters.Add(new NpgsqlParameter("@num", NpgsqlTypes.NpgsqlDbType.Text)); - var pTitle = cmd.Parameters.Add( - new NpgsqlParameter("@title", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pStart = cmd.Parameters.Add( - new NpgsqlParameter("@start", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pEnd = cmd.Parameters.Add(new NpgsqlParameter("@end", NpgsqlTypes.NpgsqlDbType.Text)); - - cmd.Prepare(); - - foreach (var (id, number, title, start, end) in chapters) - { - pId.Value = id; - pNum.Value = number; - pTitle.Value = title; - pStart.Value = start; - pEnd.Value = end; - cmd.ExecuteNonQuery(); - } - } - - private static void SeedBlocks(NpgsqlConnection conn) - { - var blocks = new ( - string Id, - string ChapterId, - string BlockCode, - string Title, - string Start, - string End - )[] - { - ("blk-a00-a09", "ch-01", "A00-A09", "Intestinal infectious diseases", "A00", "A09"), - ("blk-e08-e13", "ch-04", "E08-E13", "Diabetes mellitus", "E08", "E13"), - ("blk-g40-g47", "ch-06", "G40-G47", "Episodic and paroxysmal disorders", "G40", "G47"), - ("blk-h53-h54", "ch-07", "H53-H54", "Visual disturbances and blindness", "H53", "H54"), - ("blk-i10-i1a", "ch-09", "I10-I1A", "Hypertensive diseases", "I10", "I1A"), - ("blk-i20-i25", "ch-09", "I20-I25", "Ischaemic heart diseases", "I20", "I25"), - ("blk-j00-j06", "ch-10", "J00-J06", "Acute upper respiratory infections", "J00", "J06"), - ( - "blk-r00-r09", - "ch-18", - "R00-R09", - "Symptoms and signs involving the circulatory and respiratory systems", - "R00", - "R09" - ), - ("blk-j09-j18", "ch-10", "J09-J18", "Influenza and pneumonia", "J09", "J18"), - ("blk-r50-r69", "ch-18", "R50-R69", "General symptoms and signs", "R50", "R69"), - ("blk-m50-m54", "ch-13", "M50-M54", "Other dorsopathies", "M50", "M54"), - ( - "blk-q50-q56", - "ch-17", - "Q50-Q56", - "Congenital malformations of genital organs", - "Q50", - "Q56" - ), - ("blk-s70-s79", "ch-19", "S70-S79", "Injuries to the hip and thigh", "S70", "S79"), - }; - - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO "public"."icd10_block" ("id", "chapterid", "blockcode", "title", "coderangestart", "coderangeend") - VALUES (@id, @chid, @code, @title, @start, @end) - """; - - var pId = cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlTypes.NpgsqlDbType.Text)); - var pChId = cmd.Parameters.Add(new NpgsqlParameter("@chid", NpgsqlTypes.NpgsqlDbType.Text)); - var pCode = cmd.Parameters.Add(new NpgsqlParameter("@code", NpgsqlTypes.NpgsqlDbType.Text)); - var pTitle = cmd.Parameters.Add( - new NpgsqlParameter("@title", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pStart = cmd.Parameters.Add( - new NpgsqlParameter("@start", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pEnd = cmd.Parameters.Add(new NpgsqlParameter("@end", NpgsqlTypes.NpgsqlDbType.Text)); - - cmd.Prepare(); - - foreach (var (id, chapterId, blockCode, title, start, end) in blocks) - { - pId.Value = id; - pChId.Value = chapterId; - pCode.Value = blockCode; - pTitle.Value = title; - pStart.Value = start; - pEnd.Value = end; - cmd.ExecuteNonQuery(); - } - } - - private static void SeedCategories(NpgsqlConnection conn) - { - var categories = new (string Id, string BlockId, string CategoryCode, string Title)[] - { - ("cat-a00", "blk-a00-a09", "A00", "Cholera"), - ("cat-e10", "blk-e08-e13", "E10", "Type 1 diabetes mellitus"), - ("cat-e11", "blk-e08-e13", "E11", "Type 2 diabetes mellitus"), - ("cat-g43", "blk-g40-g47", "G43", "Migraine"), - ("cat-h53", "blk-h53-h54", "H53", "Visual disturbances"), - ("cat-i10", "blk-i10-i1a", "I10", "Essential (primary) hypertension"), - ("cat-i21", "blk-i20-i25", "I21", "Acute myocardial infarction"), - ( - "cat-j06", - "blk-j00-j06", - "J06", - "Acute upper respiratory infections of multiple and unspecified sites" - ), - ("cat-r06", "blk-r00-r09", "R06", "Abnormalities of breathing"), - ("cat-r07", "blk-r00-r09", "R07", "Pain in throat and chest"), - ("cat-j18", "blk-j09-j18", "J18", "Pneumonia, unspecified organism"), - ("cat-r10", "blk-r00-r09", "R10", "Abdominal and pelvic pain"), - ("cat-m54", "blk-m50-m54", "M54", "Dorsalgia"), - ("cat-q53", "blk-q50-q56", "Q53", "Undescended and ectopic testicle"), - ("cat-s72", "blk-s70-s79", "S72", "Fracture of femur"), - }; - - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO "public"."icd10_category" ("id", "blockid", "categorycode", "title") - VALUES (@id, @bid, @code, @title) - """; - - var pId = cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlTypes.NpgsqlDbType.Text)); - var pBid = cmd.Parameters.Add(new NpgsqlParameter("@bid", NpgsqlTypes.NpgsqlDbType.Text)); - var pCode = cmd.Parameters.Add(new NpgsqlParameter("@code", NpgsqlTypes.NpgsqlDbType.Text)); - var pTitle = cmd.Parameters.Add( - new NpgsqlParameter("@title", NpgsqlTypes.NpgsqlDbType.Text) - ); - - cmd.Prepare(); - - foreach (var (id, blockId, categoryCode, title) in categories) - { - pId.Value = id; - pBid.Value = blockId; - pCode.Value = categoryCode; - pTitle.Value = title; - cmd.ExecuteNonQuery(); - } - } - - private static void SeedCodes(NpgsqlConnection conn) - { - // All codes required by tests (Id, CategoryId, Code, Short, Long, Synonyms) - var codes = new ( - string Id, - string CategoryId, - string Code, - string Short, - string Long, - string Synonyms - )[] - { - ( - "code-a00-0", - "cat-a00", - "A00.0", - "Cholera due to Vibrio cholerae 01, biovar cholerae", - "Cholera due to Vibrio cholerae 01, biovar cholerae", - "" - ), - ( - "code-e10-9", - "cat-e10", - "E10.9", - "Type 1 diabetes mellitus without complications", - "Type 1 diabetes mellitus without complications", - "juvenile diabetes" - ), - ( - "code-e11-9", - "cat-e11", - "E11.9", - "Type 2 diabetes mellitus without complications", - "Type 2 diabetes mellitus without complications", - "adult-onset diabetes; non-insulin-dependent diabetes" - ), - ( - "code-g43-909", - "cat-g43", - "G43.909", - "Migraine, unspecified, not intractable", - "Migraine, unspecified, not intractable, without status migrainosus", - "Hemicrania; sick headache" - ), - ( - "code-h53-481", - "cat-h53", - "H53.481", - "Generalized contraction of visual field, left eye", - "Generalized contraction of visual field, left eye", - "" - ), - ( - "code-i10", - "cat-i10", - "I10", - "Essential (primary) hypertension", - "Essential (primary) hypertension", - "benign hypertension; high blood pressure" - ), - ( - "code-i21-0", - "cat-i21", - "I21.0", - "Acute transmural myocardial infarction of anterior wall", - "ST elevation (STEMI) myocardial infarction involving left main coronary artery", - "" - ), - ( - "code-i21-11", - "cat-i21", - "I21.11", - "ST elevation (STEMI) myocardial infarction involving right coronary artery", - "ST elevation (STEMI) myocardial infarction involving right coronary artery", - "" - ), - ( - "code-i21-4", - "cat-i21", - "I21.4", - "Acute subendocardial myocardial infarction", - "Non-ST elevation (NSTEMI) myocardial infarction", - "" - ), - ( - "code-j06-9", - "cat-j06", - "J06.9", - "Acute upper respiratory infection, unspecified", - "Acute upper respiratory infection, unspecified", - "" - ), - ( - "code-j18-9", - "cat-j18", - "J18.9", - "Pneumonia, unspecified organism", - "Pneumonia, unspecified organism", - "" - ), - ( - "code-r06-00", - "cat-r06", - "R06.00", - "Dyspnea, unspecified", - "Dyspnea, unspecified", - "" - ), - ("code-r06-02", "cat-r06", "R06.02", "Shortness of breath", "Shortness of breath", ""), - ( - "code-r07-9", - "cat-r07", - "R07.9", - "Chest pain, unspecified", - "Chest pain, unspecified", - "" - ), - ("code-r07-89", "cat-r07", "R07.89", "Other chest pain", "Other chest pain", ""), - // Additional codes for search tests - ( - "code-a00-1", - "cat-a00", - "A00.1", - "Cholera due to Vibrio cholerae 01, biovar eltor", - "Cholera due to Vibrio cholerae 01, biovar eltor", - "" - ), - ("code-m54-5", "cat-m54", "M54.5", "Low back pain", "Low back pain", ""), - ( - "code-m54-50", - "cat-m54", - "M54.50", - "Low back pain, unspecified", - "Low back pain, unspecified", - "lumbago; lumbar pain" - ), - ( - "code-q53-1", - "cat-q53", - "Q53.1", - "Undescended testicle, unilateral", - "Undescended testicle, unilateral", - "" - ), - ( - "code-s72-00", - "cat-s72", - "S72.00", - "Fracture of neck of femur, closed", - "Fracture of unspecified part of neck of femur, closed", - "" - ), - }; - - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO "public"."icd10_code" - ("id", "categoryid", "code", "shortdescription", "longdescription", - "inclusionterms", "exclusionterms", "codealso", "codefirst", "synonyms", - "billable", "effectivefrom", "effectiveto", "edition") - VALUES (@id, @catid, @code, @short, @long, - '', '', '', '', @synonyms, - 1, '2025-07-01', '', '2025') - """; - - var pId = cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlTypes.NpgsqlDbType.Text)); - var pCatId = cmd.Parameters.Add( - new NpgsqlParameter("@catid", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pCode = cmd.Parameters.Add(new NpgsqlParameter("@code", NpgsqlTypes.NpgsqlDbType.Text)); - var pShort = cmd.Parameters.Add( - new NpgsqlParameter("@short", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pLong = cmd.Parameters.Add(new NpgsqlParameter("@long", NpgsqlTypes.NpgsqlDbType.Text)); - var pSynonyms = cmd.Parameters.Add( - new NpgsqlParameter("@synonyms", NpgsqlTypes.NpgsqlDbType.Text) - ); - - cmd.Prepare(); - - foreach (var (id, categoryId, code, shortDesc, longDesc, synonyms) in codes) - { - pId.Value = id; - pCatId.Value = categoryId; - pCode.Value = code; - pShort.Value = shortDesc; - pLong.Value = longDesc; - pSynonyms.Value = synonyms; - cmd.ExecuteNonQuery(); - } - } - - private static void SeedAchiBlocks(NpgsqlConnection conn) - { - var blocks = new (string Id, string BlockNumber, string Title, string Start, string End)[] - { - ("achi-blk-1", "1820", "Procedures on heart", "38497-00", "38503-00"), - ("achi-blk-2", "0926", "Procedures on appendix", "90661-00", "90661-00"), - ( - "achi-blk-3", - "0965", - "Procedures on gallbladder and biliary tract", - "30571-00", - "30575-00" - ), - }; - - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO "public"."achi_block" ("id", "blocknumber", "title", "coderangestart", "coderangeend") - VALUES (@id, @num, @title, @start, @end) - """; - - var pId = cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlTypes.NpgsqlDbType.Text)); - var pNum = cmd.Parameters.Add(new NpgsqlParameter("@num", NpgsqlTypes.NpgsqlDbType.Text)); - var pTitle = cmd.Parameters.Add( - new NpgsqlParameter("@title", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pStart = cmd.Parameters.Add( - new NpgsqlParameter("@start", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pEnd = cmd.Parameters.Add(new NpgsqlParameter("@end", NpgsqlTypes.NpgsqlDbType.Text)); - - cmd.Prepare(); - - foreach (var (id, number, title, start, end) in blocks) - { - pId.Value = id; - pNum.Value = number; - pTitle.Value = title; - pStart.Value = start; - pEnd.Value = end; - cmd.ExecuteNonQuery(); - } - } - - private static void SeedAchiCodes(NpgsqlConnection conn) - { - var codes = new (string Id, string BlockId, string Code, string Short, string Long)[] - { - ( - "achi-38497-00", - "achi-blk-1", - "38497-00", - "Coronary angiography", - "Coronary angiography" - ), - ( - "achi-38503-00", - "achi-blk-1", - "38503-00", - "Percutaneous insertion of coronary artery stent", - "Percutaneous insertion of coronary artery stent" - ), - ("achi-90661-00", "achi-blk-2", "90661-00", "Appendicectomy", "Appendicectomy"), - ("achi-30571-00", "achi-blk-3", "30571-00", "Cholecystectomy", "Cholecystectomy"), - }; - - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO "public"."achi_code" - ("id", "blockid", "code", "shortdescription", "longdescription", - "billable", "effectivefrom", "effectiveto", "edition") - VALUES (@id, @bid, @code, @short, @long, - 1, '2025-07-01', '', '13') - """; - - var pId = cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlTypes.NpgsqlDbType.Text)); - var pBid = cmd.Parameters.Add(new NpgsqlParameter("@bid", NpgsqlTypes.NpgsqlDbType.Text)); - var pCode = cmd.Parameters.Add(new NpgsqlParameter("@code", NpgsqlTypes.NpgsqlDbType.Text)); - var pShort = cmd.Parameters.Add( - new NpgsqlParameter("@short", NpgsqlTypes.NpgsqlDbType.Text) - ); - var pLong = cmd.Parameters.Add(new NpgsqlParameter("@long", NpgsqlTypes.NpgsqlDbType.Text)); - - cmd.Prepare(); - - foreach (var (id, blockId, code, shortDesc, longDesc) in codes) - { - pId.Value = id; - pBid.Value = blockId; - pCode.Value = code; - pShort.Value = shortDesc; - pLong.Value = longDesc; - cmd.ExecuteNonQuery(); - } - } -} diff --git a/Samples/ICD10/ICD10.Api/.gitignore b/Samples/ICD10/ICD10.Api/.gitignore deleted file mode 100644 index d4e3eaf9..00000000 --- a/Samples/ICD10/ICD10.Api/.gitignore +++ /dev/null @@ -1 +0,0 @@ -onnx_model/model.onnx diff --git a/Samples/ICD10/ICD10.Api/DataProvider.json b/Samples/ICD10/ICD10.Api/DataProvider.json deleted file mode 100644 index ffff0991..00000000 --- a/Samples/ICD10/ICD10.Api/DataProvider.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "queries": [ - { - "name": "GetChapters", - "sqlFile": "Queries/GetChapters.generated.sql" - }, - { - "name": "GetBlocksByChapter", - "sqlFile": "Queries/GetBlocksByChapter.generated.sql" - }, - { - "name": "GetCategoriesByBlock", - "sqlFile": "Queries/GetCategoriesByBlock.generated.sql" - }, - { - "name": "GetCodesByCategory", - "sqlFile": "Queries/GetCodesByCategory.generated.sql" - }, - { - "name": "GetCodeByCode", - "sqlFile": "Queries/GetCodeByCode.generated.sql" - }, - { - "name": "GetAchiBlocks", - "sqlFile": "Queries/GetAchiBlocks.generated.sql" - }, - { - "name": "GetAchiCodesByBlock", - "sqlFile": "Queries/GetAchiCodesByBlock.generated.sql" - }, - { - "name": "GetAchiCodeByCode", - "sqlFile": "Queries/GetAchiCodeByCode.generated.sql" - }, - { - "name": "GetCodeEmbedding", - "sqlFile": "Queries/GetCodeEmbedding.generated.sql" - }, - { - "name": "GetAllCodeEmbeddings", - "sqlFile": "Queries/GetAllCodeEmbeddings.generated.sql" - }, - { - "name": "SearchAchiCodes", - "sqlFile": "Queries/SearchAchiCodes.sql" - }, - { - "name": "SearchIcd10Codes", - "sqlFile": "Queries/SearchIcd10Codes.sql" - } - ], - "tables": [ - { - "schema": "main", - "name": "icd10_chapter", - "generateInsert": false, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "icd10_block", - "generateInsert": false, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "icd10_category", - "generateInsert": false, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "icd10_code", - "generateInsert": false, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "icd10_code_embedding", - "generateInsert": false, - "generateUpdate": true, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "achi_block", - "generateInsert": false, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "achi_code", - "generateInsert": false, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "achi_code_embedding", - "generateInsert": false, - "generateUpdate": true, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "user_search_history", - "generateInsert": false, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - } - ], - "connectionString": "Data Source=icd10.db" -} diff --git a/Samples/ICD10/ICD10.Api/DatabaseSetup.cs b/Samples/ICD10/ICD10.Api/DatabaseSetup.cs deleted file mode 100644 index e9fc36ac..00000000 --- a/Samples/ICD10/ICD10.Api/DatabaseSetup.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Migration; -using Migration.Postgres; -using InitError = Outcome.Result.Error; -using InitOk = Outcome.Result.Ok; -using InitResult = Outcome.Result; - -namespace ICD10.Api; - -/// -/// Database initialization for ICD10.Api using Migration tool. -/// -internal static class DatabaseSetup -{ - /// - /// Creates the database schema using Migration. - /// - public static InitResult Initialize(NpgsqlConnection connection, ILogger logger) - { - try - { - // Enable pgvector extension for vector similarity search - using (var extCmd = connection.CreateCommand()) - { - extCmd.CommandText = "CREATE EXTENSION IF NOT EXISTS vector"; - extCmd.ExecuteNonQuery(); - logger.Log(LogLevel.Information, "Enabled pgvector extension"); - } - - // Check if tables already exist (e.g., in test scenarios) - using (var checkCmd = connection.CreateCommand()) - { - checkCmd.CommandText = - "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'icd10_chapter'"; - var count = Convert.ToInt64( - checkCmd.ExecuteScalar(), - System.Globalization.CultureInfo.InvariantCulture - ); - if (count > 0) - { - logger.Log( - LogLevel.Information, - "ICD-10 database schema already exists, skipping initialization" - ); - - // Ensure vector indexes exist even if schema already created - EnsureVectorIndexes(connection, logger); - return new InitOk(true); - } - } - - var yamlPath = Path.Combine(AppContext.BaseDirectory, "icd10-schema.yaml"); - var schema = SchemaYamlSerializer.FromYamlFile(yamlPath); - - foreach (var table in schema.Tables) - { - var ddl = PostgresDdlGenerator.Generate(new CreateTableOperation(table)); - using var cmd = connection.CreateCommand(); - cmd.CommandText = ddl; - cmd.ExecuteNonQuery(); - logger.Log(LogLevel.Debug, "Created table {TableName}", table.Name); - } - - // Create vector indexes for fast similarity search - EnsureVectorIndexes(connection, logger); - - logger.Log(LogLevel.Information, "Created ICD-10 database schema from YAML"); - return new InitOk(true); - } - catch (Exception ex) - { - logger.Log(LogLevel.Error, ex, "Failed to create ICD-10 database schema"); - return new InitError($"Failed to create ICD-10 database schema: {ex.Message}"); - } - } - - /// - /// Creates vector indexes for fast similarity search using pgvector. - /// Embeddings are stored as JSON text, cast to vector at query time. - /// Index uses IVFFlat for approximate nearest neighbor search. - /// - private static void EnsureVectorIndexes(NpgsqlConnection connection, ILogger logger) - { - try - { - // ICD-10 embedding vector index (384 dimensions for MedEmbed-small) - using (var cmd = connection.CreateCommand()) - { - // Check if we have any embeddings to index - cmd.CommandText = "SELECT COUNT(*) FROM icd10_code_embedding"; - var count = Convert.ToInt64( - cmd.ExecuteScalar(), - System.Globalization.CultureInfo.InvariantCulture - ); - - if (count > 0) - { - // Create IVFFlat index for fast approximate nearest neighbor search - // lists = sqrt(row_count) is a good default - var lists = Math.Max(100, (int)Math.Sqrt(count)); - cmd.CommandText = $""" - CREATE INDEX IF NOT EXISTS idx_icd10_embedding_vector - ON icd10_code_embedding - USING ivfflat (("embedding"::vector(384)) vector_cosine_ops) - WITH (lists = {lists}) - """; - cmd.ExecuteNonQuery(); - logger.Log( - LogLevel.Information, - "Created IVFFlat vector index on icd10_code_embedding ({Lists} lists)", - lists - ); - } - } - - // ACHI embedding vector index - using (var cmd = connection.CreateCommand()) - { - cmd.CommandText = "SELECT COUNT(*) FROM achi_code_embedding"; - var count = Convert.ToInt64( - cmd.ExecuteScalar(), - System.Globalization.CultureInfo.InvariantCulture - ); - - if (count > 0) - { - var lists = Math.Max(10, (int)Math.Sqrt(count)); - cmd.CommandText = $""" - CREATE INDEX IF NOT EXISTS idx_achi_embedding_vector - ON achi_code_embedding - USING ivfflat (("embedding"::vector(384)) vector_cosine_ops) - WITH (lists = {lists}) - """; - cmd.ExecuteNonQuery(); - logger.Log( - LogLevel.Information, - "Created IVFFlat vector index on achi_code_embedding ({Lists} lists)", - lists - ); - } - } - } - catch (Exception ex) - { - // Vector indexes are optional - search will still work, just slower - logger.Log( - LogLevel.Warning, - ex, - "Could not create vector indexes (search will be slower)" - ); - } - } -} diff --git a/Samples/ICD10/ICD10.Api/GlobalUsings.cs b/Samples/ICD10/ICD10.Api/GlobalUsings.cs deleted file mode 100644 index dce7ddf5..00000000 --- a/Samples/ICD10/ICD10.Api/GlobalUsings.cs +++ /dev/null @@ -1,102 +0,0 @@ -global using System; -global using System.Collections.Immutable; -global using Generated; -global using Microsoft.Extensions.Logging; -global using Npgsql; -global using Outcome; -global using GetAchiBlocksError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetAchiBlocks query result type aliases -global using GetAchiBlocksOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetAchiCodeByCodeError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetAchiCodeByCode query result type aliases -global using GetAchiCodeByCodeOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetAchiCodesByBlockError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetAchiCodesByBlock query result type aliases -global using GetAchiCodesByBlockOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetBlocksByChapterError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetBlocksByChapter query result type aliases -global using GetBlocksByChapterOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetCategoriesByBlockError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetCategoriesByBlock query result type aliases -global using GetCategoriesByBlockOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetChaptersError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetChapters query result type aliases -global using GetChaptersOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetCodeByCodeError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetCodeByCode query result type aliases -global using GetCodeByCodeOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetCodesByCategoryError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetCodesByCategory query result type aliases -global using GetCodesByCategoryOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using SearchAchiCodesError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// SearchAchiCodes query result type aliases -global using SearchAchiCodesOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using SearchIcd10CodesError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// SearchIcd10Codes query result type aliases -global using SearchIcd10CodesOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; diff --git a/Samples/ICD10/ICD10.Api/ICD10.Api.csproj b/Samples/ICD10/ICD10.Api/ICD10.Api.csproj deleted file mode 100644 index d2435bb2..00000000 --- a/Samples/ICD10/ICD10.Api/ICD10.Api.csproj +++ /dev/null @@ -1,82 +0,0 @@ - - - Exe - CA1515;CA2100;RS1035;CA1508;CA2234 - true - - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - PreserveNewest - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/ICD10/ICD10.Api/Program.cs b/Samples/ICD10/ICD10.Api/Program.cs deleted file mode 100644 index ba50c33f..00000000 --- a/Samples/ICD10/ICD10.Api/Program.cs +++ /dev/null @@ -1,772 +0,0 @@ -#pragma warning disable IDE0037 // Use inferred member name - prefer explicit for clarity in API responses -#pragma warning disable CA1812 // Avoid uninstantiated internal classes - records are instantiated by JSON deserialization - -using System.Collections.Frozen; -using System.Text.Json; -using ICD10.Api; -using Microsoft.AspNetCore.Http.Json; -using InitError = Outcome.Result.Error; - -var builder = WebApplication.CreateBuilder(args); - -// Configure JSON to use PascalCase property names -builder.Services.Configure(options => -{ - options.SerializerOptions.PropertyNamingPolicy = null; -}); - -// Add CORS for dashboard - allow any origin for testing -builder.Services.AddCors(options => -{ - options.AddPolicy( - "Dashboard", - policy => - { - policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); - } - ); -}); - -// Register connection factory - defers connection string validation to service resolution -builder.Services.AddSingleton>(sp => -{ - var config = sp.GetRequiredService(); - var connStr = - config.GetConnectionString("Postgres") - ?? throw new InvalidOperationException( - "PostgreSQL connection string 'Postgres' is required" - ); - return () => - { - var conn = new NpgsqlConnection(connStr); - conn.Open(); - return conn; - }; -}); - -// Embedding service (Docker container) -var embeddingServiceUrl = - builder.Configuration["EmbeddingService:BaseUrl"] ?? "http://localhost:8000"; -builder.Services.AddHttpClient( - "EmbeddingService", - client => - { - client.BaseAddress = new Uri(embeddingServiceUrl); - client.Timeout = TimeSpan.FromSeconds(30); - } -); - -// Gatekeeper configuration for authorization -var gatekeeperUrl = builder.Configuration["Gatekeeper:BaseUrl"] ?? "http://localhost:5002"; -var signingKeyBase64 = builder.Configuration["Jwt:SigningKey"]; -var signingKey = string.IsNullOrEmpty(signingKeyBase64) - ? ImmutableArray.Create(new byte[32]) - : ImmutableArray.Create(Convert.FromBase64String(signingKeyBase64)); - -builder.Services.AddHttpClient( - "Gatekeeper", - client => - { - client.BaseAddress = new Uri(gatekeeperUrl); - client.Timeout = TimeSpan.FromSeconds(5); - } -); - -var app = builder.Build(); - -// Initialize database schema using connection string from configuration -var dbConnectionString = - app.Configuration.GetConnectionString("Postgres") - ?? throw new InvalidOperationException("PostgreSQL connection string 'Postgres' is required"); -using (var conn = new NpgsqlConnection(dbConnectionString)) -{ - conn.Open(); - if (DatabaseSetup.Initialize(conn, app.Logger) is InitError initErr) - Environment.FailFast(initErr.Value); -} - -app.UseCors("Dashboard"); - -// Note: Func for embedding service is registered in DI below - -// ============================================================================ -// ICD-10 HIERARCHICAL BROWSE ENDPOINTS -// ============================================================================ - -var icdGroup = app.MapGroup("/api/icd10").WithTags("ICD-10"); - -icdGroup.MapGet( - "/chapters", - async (Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetChaptersAsync().ConfigureAwait(false); - return result switch - { - GetChaptersOk(var chapters) => Results.Ok(chapters), - GetChaptersError(var err) => Results.Problem(err.Message), - }; - } -); - -icdGroup.MapGet( - "/chapters/{chapterId}/blocks", - async (string chapterId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetBlocksByChapterAsync(chapterId).ConfigureAwait(false); - return result switch - { - GetBlocksByChapterOk(var blocks) => Results.Ok(blocks), - GetBlocksByChapterError(var err) => Results.Problem(err.Message), - }; - } -); - -icdGroup.MapGet( - "/blocks/{blockId}/categories", - async (string blockId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetCategoriesByBlockAsync(blockId).ConfigureAwait(false); - return result switch - { - GetCategoriesByBlockOk(var categories) => Results.Ok(categories), - GetCategoriesByBlockError(var err) => Results.Problem(err.Message), - }; - } -); - -icdGroup.MapGet( - "/categories/{categoryId}/codes", - async (string categoryId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetCodesByCategoryAsync(categoryId).ConfigureAwait(false); - return result switch - { - GetCodesByCategoryOk(var codes) => Results.Ok(codes), - GetCodesByCategoryError(var err) => Results.Problem(err.Message), - }; - } -); - -// ============================================================================ -// ICD-10 CODE LOOKUP ENDPOINTS -// ============================================================================ - -icdGroup.MapGet( - "/codes/{code}", - async (string code, string? format, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetCodeByCodeAsync(code).ConfigureAwait(false); - return result switch - { - GetCodeByCodeOk(var codes) when codes.Count > 0 => format == "fhir" - ? Results.Ok(ToFhirCodeSystem(codes[0])) - : Results.Ok(EnrichCodeWithDerivedHierarchy(codes[0])), - GetCodeByCodeOk => Results.NotFound(), - GetCodeByCodeError(var err) => Results.Problem(err.Message), - }; - } -); - -icdGroup.MapGet( - "/codes", - async (string q, int? limit, Func getConn) => - { - using var conn = getConn(); - var searchLimit = limit ?? 20; - var searchTerm = $"%{q}%"; - - var result = await conn.SearchIcd10CodesAsync(term: searchTerm, limit: searchLimit) - .ConfigureAwait(false); - return result switch - { - SearchIcd10CodesOk(var codes) => Results.Ok(codes.Select(EnrichSearchResult).ToList()), - SearchIcd10CodesError(var err) => Results.Problem(err.Message), - }; - } -); - -// ============================================================================ -// ACHI PROCEDURE ENDPOINTS -// ============================================================================ - -var achiGroup = app.MapGroup("/api/achi").WithTags("ACHI"); - -achiGroup.MapGet( - "/blocks", - async (Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetAchiBlocksAsync().ConfigureAwait(false); - return result switch - { - GetAchiBlocksOk(var blocks) => Results.Ok(blocks), - GetAchiBlocksError(var err) => Results.Problem(err.Message), - }; - } -); - -achiGroup.MapGet( - "/blocks/{blockId}/codes", - async (string blockId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetAchiCodesByBlockAsync(blockId).ConfigureAwait(false); - return result switch - { - GetAchiCodesByBlockOk(var codes) => Results.Ok(codes), - GetAchiCodesByBlockError(var err) => Results.Problem(err.Message), - }; - } -); - -achiGroup.MapGet( - "/codes/{code}", - async (string code, string? format, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetAchiCodeByCodeAsync(code).ConfigureAwait(false); - return result switch - { - GetAchiCodeByCodeOk(var codes) when codes.Count > 0 => format == "fhir" - ? Results.Ok(ToFhirProcedure(codes[0])) - : Results.Ok(codes[0]), - GetAchiCodeByCodeOk => Results.NotFound(), - GetAchiCodeByCodeError(var err) => Results.Problem(err.Message), - }; - } -); - -achiGroup.MapGet( - "/codes", - async (string q, int? limit, Func getConn) => - { - using var conn = getConn(); - var searchLimit = limit ?? 20; - var searchTerm = $"%{q}%"; - - var result = await conn.SearchAchiCodesAsync(term: searchTerm, limit: searchLimit) - .ConfigureAwait(false); - return result switch - { - SearchAchiCodesOk(var codes) => Results.Ok(codes), - SearchAchiCodesError(var err) => Results.Problem(err.Message), - }; - } -); - -// ============================================================================ -// RAG SEARCH ENDPOINT - CALLS DOCKER EMBEDDING SERVICE -// ============================================================================ - -app.MapPost( - "/api/search", - async ( - RagSearchRequest request, - Func getConn, - IHttpClientFactory httpClientFactory - ) => - { - var limit = request.Limit ?? 10; - - // Get embedding from Docker service - var embeddingClient = httpClientFactory.CreateClient("EmbeddingService"); - var embeddingResponse = await embeddingClient - .PostAsJsonAsync("/embed", new { text = request.Query }) - .ConfigureAwait(false); - - if (!embeddingResponse.IsSuccessStatusCode) - { - return Results.Problem("Embedding service unavailable"); - } - - var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var embeddingResult = await embeddingResponse - .Content.ReadFromJsonAsync(jsonOptions) - .ConfigureAwait(false); - - if (embeddingResult is null) - { - return Results.Problem("Invalid embedding response"); - } - - // Convert embedding to pgvector format: [0.1,0.2,0.3,...] - var vectorString = - "[" - + string.Join( - ",", - embeddingResult.Embedding.Select(f => - f.ToString(System.Globalization.CultureInfo.InvariantCulture) - ) - ) - + "]"; - - using var conn = getConn(); - var icdResults = new List(); - - // Use pgvector for fast similarity search IN THE DATABASE - // Scope the reader to ensure it's disposed before ACHI query - { - var icdCmd = conn.CreateCommand(); - await using (icdCmd.ConfigureAwait(false)) - { - icdCmd.CommandText = """ - SELECT c."code", c."shortdescription", c."longdescription", - c."inclusionterms", c."exclusionterms", c."codealso", c."codefirst", - 1 - (e."embedding"::vector <=> @queryVector::vector) as similarity - FROM icd10_code c - JOIN icd10_code_embedding e ON c."id" = e."codeid" - ORDER BY e."embedding"::vector <=> @queryVector::vector - LIMIT @limit - """; - icdCmd.Parameters.AddWithValue("@queryVector", vectorString); - icdCmd.Parameters.AddWithValue("@limit", request.IncludeAchi ? limit : limit); - - var icdReader = await icdCmd.ExecuteReaderAsync().ConfigureAwait(false); - await using (icdReader.ConfigureAwait(false)) - { - while (await icdReader.ReadAsync().ConfigureAwait(false)) - { - var code = icdReader.GetString(0); - var (chapterNum, chapterTitle) = Icd10Chapters.GetChapter(code); - var category = Icd10Chapters.GetCategory(code); - - // Read nullable fields with async null checks - var inclusionNull = await icdReader.IsDBNullAsync(3).ConfigureAwait(false); - var exclusionNull = await icdReader.IsDBNullAsync(4).ConfigureAwait(false); - var codeAlsoNull = await icdReader.IsDBNullAsync(5).ConfigureAwait(false); - var codeFirstNull = await icdReader.IsDBNullAsync(6).ConfigureAwait(false); - - icdResults.Add( - new SearchResult( - Code: code, - Description: icdReader.GetString(1), - LongDescription: icdReader.GetString(2), - Confidence: icdReader.GetDouble(7), - CodeType: "ICD10", - Chapter: chapterNum, - ChapterTitle: chapterTitle, - Category: category, - InclusionTerms: inclusionNull ? "" : icdReader.GetString(3), - ExclusionTerms: exclusionNull ? "" : icdReader.GetString(4), - CodeAlso: codeAlsoNull ? "" : icdReader.GetString(5), - CodeFirst: codeFirstNull ? "" : icdReader.GetString(6) - ) - ); - } - } - } - } // icdReader and icdCmd disposed here - - // Include ACHI if requested (also using pgvector) - var achiResults = new List(); - if (request.IncludeAchi) - { - var achiCmd = conn.CreateCommand(); - await using (achiCmd.ConfigureAwait(false)) - { - achiCmd.CommandText = """ - SELECT c."code", c."shortdescription", c."longdescription", - 1 - (e."embedding"::vector <=> @queryVector::vector) as similarity - FROM achi_code c - JOIN achi_code_embedding e ON c."id" = e."codeid" - ORDER BY e."embedding"::vector <=> @queryVector::vector - LIMIT @limit - """; - achiCmd.Parameters.AddWithValue("@queryVector", vectorString); - achiCmd.Parameters.AddWithValue("@limit", limit); - - var achiReader = await achiCmd.ExecuteReaderAsync().ConfigureAwait(false); - await using (achiReader.ConfigureAwait(false)) - { - while (await achiReader.ReadAsync().ConfigureAwait(false)) - { - achiResults.Add( - new SearchResult( - Code: achiReader.GetString(0), - Description: achiReader.GetString(1), - LongDescription: achiReader.GetString(2), - Confidence: achiReader.GetDouble(3), - CodeType: "ACHI", - Chapter: "", - ChapterTitle: "", - Category: "", - InclusionTerms: "", - ExclusionTerms: "", - CodeAlso: "", - CodeFirst: "" - ) - ); - } - } - } // achiCmd disposed here - } - - // Combine and rank all results - var rankedResults = icdResults - .Concat(achiResults) - .OrderByDescending(r => r.Confidence) - .Take(limit) - .ToList(); - - var response = - request.Format == "fhir" - ? (object) - new - { - ResourceType = "Bundle", - Type = "searchset", - Total = rankedResults.Count, - Entry = rankedResults - .Select(r => new - { - Resource = new - { - ResourceType = "CodeSystem", - Url = "http://hl7.org/fhir/sid/icd-10", - Concept = new { Code = r.Code, Display = r.Description }, - }, - Search = new { Score = r.Confidence }, - }) - .ToList(), - } - : new - { - Results = rankedResults, - Query = request.Query, - Model = "MedEmbed-Small-v0.1", - }; - - return Results.Ok(response); - } -); - -app.MapGet( - "/health", - (Func getConn) => - { - using var conn = getConn(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM icd10_code"; - var count = Convert.ToInt64( - cmd.ExecuteScalar(), - System.Globalization.CultureInfo.InvariantCulture - ); - - return count > 0 - ? Results.Ok( - new - { - Status = "healthy", - Service = "ICD10.Api", - CodesLoaded = count, - } - ) - : Results.Json( - new - { - Status = "unhealthy", - Service = "ICD10.Api", - Error = "No ICD-10 codes loaded", - }, - statusCode: 503 - ); - } -); - -app.Run(); - -// ============================================================================ -// HELPER METHODS -// ============================================================================ - -// Enriches a code record with derived hierarchy info when DB values are null. -// Uses Icd10Chapters to derive chapter/category from code prefix. -static GetCodeByCode EnrichCodeWithDerivedHierarchy(GetCodeByCode code) -{ - var (chapterNum, chapterTitle) = string.IsNullOrEmpty(code.ChapterNumber) - ? Icd10Chapters.GetChapter(code.Code) - : (code.ChapterNumber, code.ChapterTitle ?? ""); - - var categoryCode = string.IsNullOrEmpty(code.CategoryCode) - ? Icd10Chapters.GetCategory(code.Code) - : code.CategoryCode; - - // Derive block from category when not in DB - use category code as pseudo-block - var (blockCode, blockTitle) = string.IsNullOrEmpty(code.BlockCode) - ? Icd10Chapters.GetBlock(code.Code) - : (code.BlockCode, code.BlockTitle ?? ""); - - return code with - { - CategoryCode = categoryCode, - CategoryTitle = code.CategoryTitle ?? "", - BlockCode = blockCode, - BlockTitle = blockTitle, - ChapterNumber = chapterNum, - ChapterTitle = chapterTitle, - }; -} - -static object ToFhirCodeSystem(GetCodeByCode code) => - new - { - ResourceType = "CodeSystem", - Url = "http://hl7.org/fhir/sid/icd-10", - Version = "13", - Concept = new - { - Code = code.Code, - Display = code.ShortDescription, - Definition = code.LongDescription, - }, - Property = new[] - { - new { Code = "chapter", ValueString = code.ChapterNumber }, - new { Code = "block", ValueString = code.BlockCode }, - new { Code = "category", ValueString = code.CategoryCode }, - }, - }; - -static object ToFhirProcedure(GetAchiCodeByCode code) => - new - { - ResourceType = "CodeSystem", - Url = "http://hl7.org/fhir/sid/achi", - Concept = new - { - Code = code.Code, - Display = code.ShortDescription, - Definition = code.LongDescription, - }, - Property = new[] { new { Code = "block", ValueString = code.BlockNumber } }, - }; - -// Enriches search result with derived hierarchy when DB values are null. -static object EnrichSearchResult(SearchIcd10Codes code) -{ - var codeValue = code.Code ?? ""; - var (derivedChapNum, derivedChapTitle) = string.IsNullOrEmpty(code.ChapterNumber) - ? Icd10Chapters.GetChapter(codeValue) - : (code.ChapterNumber, code.ChapterTitle ?? ""); - var derivedCatCode = string.IsNullOrEmpty(code.CategoryCode) - ? Icd10Chapters.GetCategory(codeValue) - : code.CategoryCode; - var (derivedBlockCode, derivedBlockTitle) = string.IsNullOrEmpty(code.BlockCode) - ? Icd10Chapters.GetBlock(codeValue) - : (code.BlockCode, code.BlockTitle ?? ""); - - return new - { - Id = code.Id ?? "", - Code = codeValue, - ShortDescription = code.ShortDescription ?? "", - LongDescription = code.LongDescription ?? "", - Billable = code.Billable, - CategoryCode = derivedCatCode, - CategoryTitle = code.CategoryTitle ?? "", - BlockCode = derivedBlockCode, - BlockTitle = derivedBlockTitle, - ChapterNumber = derivedChapNum, - ChapterTitle = derivedChapTitle, - InclusionTerms = code.InclusionTerms ?? "", - ExclusionTerms = code.ExclusionTerms ?? "", - CodeAlso = code.CodeAlso ?? "", - CodeFirst = code.CodeFirst ?? "", - Synonyms = code.Synonyms ?? "", - Edition = code.Edition ?? "", - }; -} - -namespace ICD10.Api -{ - /// - /// Request for RAG search. - /// - internal sealed record RagSearchRequest( - string Query, - int? Limit, - bool IncludeAchi, - string? Format - ); - - /// - /// Response from embedding service. - /// - internal sealed record EmbeddingResponse( - ImmutableArray Embedding, - string Model, - int Dimensions - ); - - /// - /// Semantic search result with code type, hierarchy, and clinical details. - /// - internal sealed record SearchResult( - string Code, - string Description, - string LongDescription, - double Confidence, - string CodeType, - string Chapter, - string ChapterTitle, - string Category, - string InclusionTerms, - string ExclusionTerms, - string CodeAlso, - string CodeFirst - ); - - /// - /// ICD-10 chapter lookup based on code prefix. - /// Official WHO/CDC chapter ranges. - /// - internal static class Icd10Chapters - { - private static readonly FrozenDictionary< - string, - (string Number, string Title) - > ChapterLookup = new Dictionary - { - { "A", ("1", "Certain infectious and parasitic diseases") }, - { "B", ("1", "Certain infectious and parasitic diseases") }, - { "C", ("2", "Neoplasms") }, - { "D0", ("2", "Neoplasms") }, - { "D1", ("2", "Neoplasms") }, - { "D2", ("2", "Neoplasms") }, - { "D3", ("2", "Neoplasms") }, - { "D4", ("2", "Neoplasms") }, - { "D5", ("3", "Diseases of the blood and blood-forming organs") }, - { "D6", ("3", "Diseases of the blood and blood-forming organs") }, - { "D7", ("3", "Diseases of the blood and blood-forming organs") }, - { "D8", ("3", "Diseases of the blood and blood-forming organs") }, - { "D89", ("3", "Diseases of the blood and blood-forming organs") }, - { "E", ("4", "Endocrine, nutritional and metabolic diseases") }, - { "F", ("5", "Mental, behavioral and neurodevelopmental disorders") }, - { "G", ("6", "Diseases of the nervous system") }, - { "H0", ("7", "Diseases of the eye and adnexa") }, - { "H1", ("7", "Diseases of the eye and adnexa") }, - { "H2", ("7", "Diseases of the eye and adnexa") }, - { "H3", ("7", "Diseases of the eye and adnexa") }, - { "H4", ("7", "Diseases of the eye and adnexa") }, - { "H5", ("7", "Diseases of the eye and adnexa") }, - { "H6", ("8", "Diseases of the ear and mastoid process") }, - { "H7", ("8", "Diseases of the ear and mastoid process") }, - { "H8", ("8", "Diseases of the ear and mastoid process") }, - { "H9", ("8", "Diseases of the ear and mastoid process") }, - { "I", ("9", "Diseases of the circulatory system") }, - { "J", ("10", "Diseases of the respiratory system") }, - { "K", ("11", "Diseases of the digestive system") }, - { "L", ("12", "Diseases of the skin and subcutaneous tissue") }, - { "M", ("13", "Diseases of the musculoskeletal system and connective tissue") }, - { "N", ("14", "Diseases of the genitourinary system") }, - { "O", ("15", "Pregnancy, childbirth and the puerperium") }, - { "P", ("16", "Certain conditions originating in the perinatal period") }, - { "Q", ("17", "Congenital malformations and chromosomal abnormalities") }, - { "R", ("18", "Symptoms, signs and abnormal clinical findings") }, - { "S", ("19", "Injury, poisoning and external causes") }, - { "T", ("19", "Injury, poisoning and external causes") }, - { "V", ("20", "External causes of morbidity") }, - { "W", ("20", "External causes of morbidity") }, - { "X", ("20", "External causes of morbidity") }, - { "Y", ("20", "External causes of morbidity") }, - { "Z", ("21", "Factors influencing health status and contact with health services") }, - }.ToFrozenDictionary(); - - /// - /// Gets the chapter number and title for an ICD-10 code. - /// - public static (string Number, string Title) GetChapter(string code) - { - if (string.IsNullOrEmpty(code)) - { - return ("", ""); - } - - // Try 2-character prefix first (for D codes with specific ranges) - if ( - code.Length >= 2 - && ChapterLookup.TryGetValue(code[..2].ToUpperInvariant(), out var chapter2) - ) - { - return chapter2; - } - - // Fall back to 1-character prefix - if ( - code.Length >= 1 - && ChapterLookup.TryGetValue(code[..1].ToUpperInvariant(), out var chapter1) - ) - { - return chapter1; - } - - return ("", ""); - } - - /// - /// Gets the category (first 3 characters) for an ICD-10 code. - /// - public static string GetCategory(string code) => - string.IsNullOrEmpty(code) ? "" - : code.Length >= 3 ? code[..3].ToUpperInvariant() - : code.ToUpperInvariant(); - - /// - /// Gets the block code and title for an ICD-10 code. - /// Derives block range from category prefix. - /// - public static (string Code, string Title) GetBlock(string code) - { - if (string.IsNullOrEmpty(code) || code.Length < 3) - { - return ("", ""); - } - - var category = code[..3].ToUpperInvariant(); - - // Common ICD-10-CM block ranges (simplified) - return category switch - { - // Eye blocks (Chapter 7) - var c - when c.StartsWith("H53", StringComparison.Ordinal) - || c.StartsWith("H54", StringComparison.Ordinal) => ( - "H53-H54", - "Visual disturbances and blindness" - ), - var c - when c.StartsWith("H49", StringComparison.Ordinal) - || c.StartsWith("H50", StringComparison.Ordinal) - || c.StartsWith("H51", StringComparison.Ordinal) - || c.StartsWith("H52", StringComparison.Ordinal) => ( - "H49-H52", - "Disorders of ocular muscles and binocular movement" - ), - // Congenital malformations (Chapter 17) - var c - when c.StartsWith("Q50", StringComparison.Ordinal) - || c.StartsWith("Q51", StringComparison.Ordinal) - || c.StartsWith("Q52", StringComparison.Ordinal) - || c.StartsWith("Q53", StringComparison.Ordinal) - || c.StartsWith("Q54", StringComparison.Ordinal) - || c.StartsWith("Q55", StringComparison.Ordinal) - || c.StartsWith("Q56", StringComparison.Ordinal) => ( - "Q50-Q56", - "Congenital malformations of genital organs" - ), - // Default: use category as pseudo-block - _ => (category, ""), - }; - } - } - - /// - /// Program entry point marker for WebApplicationFactory. - /// - public partial class Program { } -} diff --git a/Samples/ICD10/ICD10.Api/Properties/launchSettings.json b/Samples/ICD10/ICD10.Api/Properties/launchSettings.json deleted file mode 100644 index 28dabd58..00000000 --- a/Samples/ICD10/ICD10.Api/Properties/launchSettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "profiles": { - "ICD10.Api": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5090", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ConnectionStrings__Postgres": "Host=localhost;Database=icd10;Username=icd10;Password=changeme" - } - } - } -} diff --git a/Samples/ICD10/ICD10.Api/Queries/GetAchiBlocks.lql b/Samples/ICD10/ICD10.Api/Queries/GetAchiBlocks.lql deleted file mode 100644 index d44d878c..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetAchiBlocks.lql +++ /dev/null @@ -1,3 +0,0 @@ -achi_block -|> select(achi_block.Id, achi_block.BlockNumber, achi_block.Title, achi_block.CodeRangeStart, achi_block.CodeRangeEnd, achi_block.LastUpdated, achi_block.VersionId) -|> order_by(achi_block.BlockNumber) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetAchiCodeByCode.lql b/Samples/ICD10/ICD10.Api/Queries/GetAchiCodeByCode.lql deleted file mode 100644 index 825d487d..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetAchiCodeByCode.lql +++ /dev/null @@ -1,4 +0,0 @@ -achi_code -|> left_join(achi_block, on = achi_code.BlockId = achi_block.Id) -|> filter(fn(row) => row.achi_code.Code = @code) -|> select(achi_code.Id, achi_code.BlockId, achi_code.Code, achi_code.ShortDescription, achi_code.LongDescription, achi_code.Billable, achi_code.EffectiveFrom, achi_code.EffectiveTo, achi_code.Edition, achi_code.LastUpdated, achi_code.VersionId, achi_block.BlockNumber, achi_block.Title AS BlockTitle) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetAchiCodesByBlock.lql b/Samples/ICD10/ICD10.Api/Queries/GetAchiCodesByBlock.lql deleted file mode 100644 index 509b6008..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetAchiCodesByBlock.lql +++ /dev/null @@ -1,4 +0,0 @@ -achi_code -|> filter(fn(row) => row.achi_code.BlockId = @blockId) -|> select(achi_code.Id, achi_code.BlockId, achi_code.Code, achi_code.ShortDescription, achi_code.LongDescription, achi_code.Billable, achi_code.EffectiveFrom, achi_code.EffectiveTo, achi_code.Edition, achi_code.LastUpdated, achi_code.VersionId) -|> order_by(achi_code.Code) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetAllCodeEmbeddings.lql b/Samples/ICD10/ICD10.Api/Queries/GetAllCodeEmbeddings.lql deleted file mode 100644 index 5a55360a..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetAllCodeEmbeddings.lql +++ /dev/null @@ -1,4 +0,0 @@ -icd10_code_embedding -|> join(icd10_code, on = icd10_code_embedding.CodeId = icd10_code.Id) -|> select(icd10_code_embedding.Id, icd10_code_embedding.CodeId, icd10_code_embedding.Embedding, icd10_code_embedding.EmbeddingModel, icd10_code.Code, icd10_code.ShortDescription, icd10_code.LongDescription, icd10_code.InclusionTerms, icd10_code.ExclusionTerms, icd10_code.CodeAlso, icd10_code.CodeFirst) -|> order_by(icd10_code.Code) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetBlocksByChapter.lql b/Samples/ICD10/ICD10.Api/Queries/GetBlocksByChapter.lql deleted file mode 100644 index fd6731aa..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetBlocksByChapter.lql +++ /dev/null @@ -1,4 +0,0 @@ -icd10_block -|> filter(fn(row) => row.icd10_block.ChapterId = @chapterId) -|> select(icd10_block.Id, icd10_block.ChapterId, icd10_block.BlockCode, icd10_block.Title, icd10_block.CodeRangeStart, icd10_block.CodeRangeEnd, icd10_block.LastUpdated, icd10_block.VersionId) -|> order_by(icd10_block.BlockCode) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetCategoriesByBlock.lql b/Samples/ICD10/ICD10.Api/Queries/GetCategoriesByBlock.lql deleted file mode 100644 index 991e7c9f..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetCategoriesByBlock.lql +++ /dev/null @@ -1,4 +0,0 @@ -icd10_category -|> filter(fn(row) => row.icd10_category.BlockId = @blockId) -|> select(icd10_category.Id, icd10_category.BlockId, icd10_category.CategoryCode, icd10_category.Title, icd10_category.LastUpdated, icd10_category.VersionId) -|> order_by(icd10_category.CategoryCode) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetChapters.lql b/Samples/ICD10/ICD10.Api/Queries/GetChapters.lql deleted file mode 100644 index 857d4f7c..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetChapters.lql +++ /dev/null @@ -1,3 +0,0 @@ -icd10_chapter -|> select(icd10_chapter.Id, icd10_chapter.ChapterNumber, icd10_chapter.Title, icd10_chapter.CodeRangeStart, icd10_chapter.CodeRangeEnd, icd10_chapter.LastUpdated, icd10_chapter.VersionId) -|> order_by(icd10_chapter.ChapterNumber) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetCodeByCode.lql b/Samples/ICD10/ICD10.Api/Queries/GetCodeByCode.lql deleted file mode 100644 index bc0ff63b..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetCodeByCode.lql +++ /dev/null @@ -1,6 +0,0 @@ -icd10_code -|> left_join(icd10_category, on = icd10_code.CategoryId = icd10_category.Id) -|> left_join(icd10_block, on = icd10_category.BlockId = icd10_block.Id) -|> left_join(icd10_chapter, on = icd10_block.ChapterId = icd10_chapter.Id) -|> filter(fn(row) => row.icd10_code.Code = @code) -|> select(icd10_code.Id, icd10_code.CategoryId, icd10_code.Code, icd10_code.ShortDescription, icd10_code.LongDescription, icd10_code.InclusionTerms, icd10_code.ExclusionTerms, icd10_code.CodeAlso, icd10_code.CodeFirst, icd10_code.Synonyms, icd10_code.Billable, icd10_code.EffectiveFrom, icd10_code.EffectiveTo, icd10_code.Edition, icd10_code.LastUpdated, icd10_code.VersionId, icd10_category.CategoryCode, icd10_category.Title AS CategoryTitle, icd10_block.BlockCode, icd10_block.Title AS BlockTitle, icd10_chapter.ChapterNumber, icd10_chapter.Title AS ChapterTitle) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetCodeEmbedding.lql b/Samples/ICD10/ICD10.Api/Queries/GetCodeEmbedding.lql deleted file mode 100644 index a03e63d5..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetCodeEmbedding.lql +++ /dev/null @@ -1,4 +0,0 @@ -icd10_code_embedding -|> join(icd10_code, on = icd10_code_embedding.CodeId = icd10_code.Id) -|> filter(fn(row) => row.icd10_code.Code = @code) -|> select(icd10_code_embedding.Id, icd10_code_embedding.CodeId, icd10_code_embedding.Embedding, icd10_code_embedding.EmbeddingModel, icd10_code_embedding.LastUpdated, icd10_code.Code, icd10_code.ShortDescription) diff --git a/Samples/ICD10/ICD10.Api/Queries/GetCodesByCategory.lql b/Samples/ICD10/ICD10.Api/Queries/GetCodesByCategory.lql deleted file mode 100644 index d56ccd02..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/GetCodesByCategory.lql +++ /dev/null @@ -1,4 +0,0 @@ -icd10_code -|> filter(fn(row) => row.icd10_code.CategoryId = @categoryId) -|> select(icd10_code.Id, icd10_code.CategoryId, icd10_code.Code, icd10_code.ShortDescription, icd10_code.LongDescription, icd10_code.InclusionTerms, icd10_code.ExclusionTerms, icd10_code.CodeAlso, icd10_code.CodeFirst, icd10_code.Synonyms, icd10_code.Billable, icd10_code.EffectiveFrom, icd10_code.EffectiveTo, icd10_code.Edition, icd10_code.LastUpdated, icd10_code.VersionId) -|> order_by(icd10_code.Code) diff --git a/Samples/ICD10/ICD10.Api/Queries/SearchAchiCodes.sql b/Samples/ICD10/ICD10.Api/Queries/SearchAchiCodes.sql deleted file mode 100644 index c60dbed9..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/SearchAchiCodes.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT Id, BlockId, Code, ShortDescription, LongDescription, Billable -FROM achi_code -WHERE Code ILIKE @term OR ShortDescription ILIKE @term OR LongDescription ILIKE @term -ORDER BY Code -LIMIT @limit diff --git a/Samples/ICD10/ICD10.Api/Queries/SearchIcd10Codes.sql b/Samples/ICD10/ICD10.Api/Queries/SearchIcd10Codes.sql deleted file mode 100644 index 214060c5..00000000 --- a/Samples/ICD10/ICD10.Api/Queries/SearchIcd10Codes.sql +++ /dev/null @@ -1,12 +0,0 @@ -SELECT c.Id, c.Code, c.ShortDescription, c.LongDescription, c.Billable, - cat.CategoryCode, cat.Title AS CategoryTitle, - b.BlockCode, b.Title AS BlockTitle, - ch.ChapterNumber, ch.Title AS ChapterTitle, - c.InclusionTerms, c.ExclusionTerms, c.CodeAlso, c.CodeFirst, c.Synonyms, c.Edition -FROM icd10_code c -LEFT JOIN icd10_category cat ON c.CategoryId = cat.Id -LEFT JOIN icd10_block b ON cat.BlockId = b.Id -LEFT JOIN icd10_chapter ch ON b.ChapterId = ch.Id -WHERE c.Code ILIKE @term OR c.ShortDescription ILIKE @term OR c.LongDescription ILIKE @term -ORDER BY c.Code -LIMIT @limit diff --git a/Samples/ICD10/ICD10.Api/Vocabularies/base_uncased.txt b/Samples/ICD10/ICD10.Api/Vocabularies/base_uncased.txt deleted file mode 100644 index 09e4b5e1..00000000 --- a/Samples/ICD10/ICD10.Api/Vocabularies/base_uncased.txt +++ /dev/null @@ -1,30522 +0,0 @@ -[PAD] -[unused0] -[unused1] -[unused2] -[unused3] -[unused4] -[unused5] -[unused6] -[unused7] -[unused8] -[unused9] -[unused10] -[unused11] -[unused12] -[unused13] -[unused14] -[unused15] -[unused16] -[unused17] -[unused18] -[unused19] -[unused20] -[unused21] -[unused22] -[unused23] -[unused24] -[unused25] -[unused26] -[unused27] -[unused28] -[unused29] -[unused30] -[unused31] -[unused32] -[unused33] -[unused34] -[unused35] -[unused36] -[unused37] -[unused38] -[unused39] -[unused40] -[unused41] -[unused42] -[unused43] -[unused44] -[unused45] -[unused46] -[unused47] -[unused48] -[unused49] -[unused50] -[unused51] -[unused52] -[unused53] -[unused54] -[unused55] -[unused56] -[unused57] -[unused58] -[unused59] -[unused60] -[unused61] -[unused62] -[unused63] -[unused64] -[unused65] -[unused66] -[unused67] -[unused68] -[unused69] -[unused70] -[unused71] -[unused72] -[unused73] -[unused74] -[unused75] -[unused76] -[unused77] -[unused78] -[unused79] -[unused80] -[unused81] -[unused82] -[unused83] -[unused84] -[unused85] -[unused86] -[unused87] -[unused88] -[unused89] -[unused90] -[unused91] -[unused92] -[unused93] -[unused94] -[unused95] -[unused96] -[unused97] -[unused98] -[UNK] -[CLS] -[SEP] -[MASK] -[unused99] -[unused100] -[unused101] -[unused102] -[unused103] -[unused104] -[unused105] -[unused106] -[unused107] -[unused108] -[unused109] -[unused110] -[unused111] -[unused112] -[unused113] -[unused114] -[unused115] -[unused116] -[unused117] -[unused118] -[unused119] -[unused120] -[unused121] -[unused122] -[unused123] -[unused124] -[unused125] -[unused126] -[unused127] -[unused128] -[unused129] -[unused130] -[unused131] -[unused132] -[unused133] -[unused134] -[unused135] -[unused136] -[unused137] -[unused138] -[unused139] -[unused140] -[unused141] -[unused142] -[unused143] -[unused144] -[unused145] -[unused146] -[unused147] -[unused148] -[unused149] -[unused150] -[unused151] -[unused152] -[unused153] -[unused154] -[unused155] -[unused156] -[unused157] -[unused158] -[unused159] -[unused160] -[unused161] -[unused162] -[unused163] -[unused164] -[unused165] -[unused166] -[unused167] -[unused168] -[unused169] -[unused170] -[unused171] -[unused172] -[unused173] -[unused174] -[unused175] -[unused176] -[unused177] -[unused178] -[unused179] -[unused180] -[unused181] -[unused182] -[unused183] -[unused184] -[unused185] -[unused186] -[unused187] -[unused188] -[unused189] -[unused190] -[unused191] -[unused192] -[unused193] -[unused194] -[unused195] -[unused196] -[unused197] -[unused198] -[unused199] -[unused200] -[unused201] -[unused202] -[unused203] -[unused204] -[unused205] -[unused206] -[unused207] -[unused208] -[unused209] -[unused210] -[unused211] -[unused212] -[unused213] -[unused214] -[unused215] -[unused216] -[unused217] -[unused218] -[unused219] -[unused220] -[unused221] -[unused222] -[unused223] -[unused224] -[unused225] -[unused226] -[unused227] -[unused228] -[unused229] -[unused230] -[unused231] -[unused232] -[unused233] -[unused234] -[unused235] -[unused236] -[unused237] -[unused238] -[unused239] -[unused240] -[unused241] -[unused242] -[unused243] -[unused244] -[unused245] -[unused246] -[unused247] -[unused248] -[unused249] -[unused250] -[unused251] -[unused252] -[unused253] -[unused254] -[unused255] -[unused256] -[unused257] -[unused258] -[unused259] -[unused260] -[unused261] -[unused262] -[unused263] -[unused264] -[unused265] -[unused266] -[unused267] -[unused268] -[unused269] -[unused270] -[unused271] -[unused272] -[unused273] -[unused274] -[unused275] -[unused276] -[unused277] -[unused278] -[unused279] -[unused280] -[unused281] -[unused282] -[unused283] -[unused284] -[unused285] -[unused286] -[unused287] -[unused288] -[unused289] -[unused290] -[unused291] -[unused292] -[unused293] -[unused294] -[unused295] -[unused296] -[unused297] -[unused298] -[unused299] -[unused300] -[unused301] -[unused302] -[unused303] -[unused304] -[unused305] -[unused306] -[unused307] -[unused308] -[unused309] -[unused310] -[unused311] -[unused312] -[unused313] -[unused314] -[unused315] -[unused316] -[unused317] -[unused318] -[unused319] -[unused320] -[unused321] -[unused322] -[unused323] -[unused324] -[unused325] -[unused326] -[unused327] -[unused328] -[unused329] -[unused330] -[unused331] -[unused332] -[unused333] -[unused334] -[unused335] -[unused336] -[unused337] -[unused338] -[unused339] -[unused340] -[unused341] -[unused342] -[unused343] -[unused344] -[unused345] -[unused346] -[unused347] -[unused348] -[unused349] -[unused350] -[unused351] -[unused352] -[unused353] -[unused354] -[unused355] -[unused356] -[unused357] -[unused358] -[unused359] -[unused360] -[unused361] -[unused362] -[unused363] -[unused364] -[unused365] -[unused366] -[unused367] -[unused368] -[unused369] -[unused370] -[unused371] -[unused372] -[unused373] -[unused374] -[unused375] -[unused376] -[unused377] -[unused378] -[unused379] -[unused380] -[unused381] -[unused382] -[unused383] -[unused384] -[unused385] -[unused386] -[unused387] -[unused388] -[unused389] -[unused390] -[unused391] -[unused392] -[unused393] -[unused394] -[unused395] -[unused396] -[unused397] -[unused398] -[unused399] -[unused400] -[unused401] -[unused402] -[unused403] -[unused404] -[unused405] -[unused406] -[unused407] -[unused408] -[unused409] -[unused410] -[unused411] -[unused412] -[unused413] -[unused414] -[unused415] -[unused416] -[unused417] -[unused418] -[unused419] -[unused420] -[unused421] -[unused422] -[unused423] -[unused424] -[unused425] -[unused426] -[unused427] -[unused428] -[unused429] -[unused430] -[unused431] -[unused432] -[unused433] -[unused434] -[unused435] -[unused436] -[unused437] -[unused438] -[unused439] -[unused440] -[unused441] -[unused442] -[unused443] -[unused444] -[unused445] -[unused446] -[unused447] -[unused448] -[unused449] -[unused450] -[unused451] -[unused452] -[unused453] -[unused454] -[unused455] -[unused456] -[unused457] -[unused458] -[unused459] -[unused460] -[unused461] -[unused462] -[unused463] -[unused464] -[unused465] -[unused466] -[unused467] -[unused468] -[unused469] -[unused470] -[unused471] -[unused472] -[unused473] -[unused474] -[unused475] -[unused476] -[unused477] -[unused478] -[unused479] -[unused480] -[unused481] -[unused482] -[unused483] -[unused484] -[unused485] -[unused486] -[unused487] -[unused488] -[unused489] -[unused490] -[unused491] -[unused492] -[unused493] -[unused494] -[unused495] -[unused496] -[unused497] -[unused498] -[unused499] -[unused500] -[unused501] -[unused502] -[unused503] -[unused504] -[unused505] -[unused506] -[unused507] -[unused508] -[unused509] -[unused510] -[unused511] -[unused512] -[unused513] -[unused514] -[unused515] -[unused516] -[unused517] -[unused518] -[unused519] -[unused520] -[unused521] -[unused522] -[unused523] -[unused524] -[unused525] -[unused526] -[unused527] -[unused528] -[unused529] -[unused530] -[unused531] -[unused532] -[unused533] -[unused534] -[unused535] -[unused536] -[unused537] -[unused538] -[unused539] -[unused540] -[unused541] -[unused542] -[unused543] -[unused544] -[unused545] -[unused546] -[unused547] -[unused548] -[unused549] -[unused550] -[unused551] -[unused552] -[unused553] -[unused554] -[unused555] -[unused556] -[unused557] -[unused558] -[unused559] -[unused560] -[unused561] -[unused562] -[unused563] -[unused564] -[unused565] -[unused566] -[unused567] -[unused568] -[unused569] -[unused570] -[unused571] -[unused572] -[unused573] -[unused574] -[unused575] -[unused576] -[unused577] -[unused578] -[unused579] -[unused580] -[unused581] -[unused582] -[unused583] -[unused584] -[unused585] -[unused586] -[unused587] -[unused588] -[unused589] -[unused590] -[unused591] -[unused592] -[unused593] -[unused594] -[unused595] -[unused596] -[unused597] -[unused598] -[unused599] -[unused600] -[unused601] -[unused602] -[unused603] -[unused604] -[unused605] -[unused606] -[unused607] -[unused608] -[unused609] -[unused610] -[unused611] -[unused612] -[unused613] -[unused614] -[unused615] -[unused616] -[unused617] -[unused618] -[unused619] -[unused620] -[unused621] -[unused622] -[unused623] -[unused624] -[unused625] -[unused626] -[unused627] -[unused628] -[unused629] -[unused630] -[unused631] -[unused632] -[unused633] -[unused634] -[unused635] -[unused636] -[unused637] -[unused638] -[unused639] -[unused640] -[unused641] -[unused642] -[unused643] -[unused644] -[unused645] -[unused646] -[unused647] -[unused648] -[unused649] -[unused650] -[unused651] -[unused652] -[unused653] -[unused654] -[unused655] -[unused656] -[unused657] -[unused658] -[unused659] -[unused660] -[unused661] -[unused662] -[unused663] -[unused664] -[unused665] -[unused666] -[unused667] -[unused668] -[unused669] -[unused670] -[unused671] -[unused672] -[unused673] -[unused674] -[unused675] -[unused676] -[unused677] -[unused678] -[unused679] -[unused680] -[unused681] -[unused682] -[unused683] -[unused684] -[unused685] -[unused686] -[unused687] -[unused688] -[unused689] -[unused690] -[unused691] -[unused692] -[unused693] -[unused694] -[unused695] -[unused696] -[unused697] -[unused698] -[unused699] -[unused700] -[unused701] -[unused702] -[unused703] -[unused704] -[unused705] -[unused706] -[unused707] -[unused708] -[unused709] -[unused710] -[unused711] -[unused712] -[unused713] -[unused714] -[unused715] -[unused716] -[unused717] -[unused718] -[unused719] -[unused720] -[unused721] -[unused722] -[unused723] -[unused724] -[unused725] -[unused726] -[unused727] -[unused728] -[unused729] -[unused730] -[unused731] -[unused732] -[unused733] -[unused734] -[unused735] -[unused736] -[unused737] -[unused738] -[unused739] -[unused740] -[unused741] -[unused742] -[unused743] -[unused744] -[unused745] -[unused746] -[unused747] -[unused748] -[unused749] -[unused750] -[unused751] -[unused752] -[unused753] -[unused754] -[unused755] -[unused756] -[unused757] -[unused758] -[unused759] -[unused760] -[unused761] -[unused762] -[unused763] -[unused764] -[unused765] -[unused766] -[unused767] -[unused768] -[unused769] -[unused770] -[unused771] -[unused772] -[unused773] -[unused774] -[unused775] -[unused776] -[unused777] -[unused778] -[unused779] -[unused780] -[unused781] -[unused782] -[unused783] -[unused784] -[unused785] -[unused786] -[unused787] -[unused788] -[unused789] -[unused790] -[unused791] -[unused792] -[unused793] -[unused794] -[unused795] -[unused796] -[unused797] -[unused798] -[unused799] -[unused800] -[unused801] -[unused802] -[unused803] -[unused804] -[unused805] -[unused806] -[unused807] -[unused808] -[unused809] -[unused810] -[unused811] -[unused812] -[unused813] -[unused814] -[unused815] -[unused816] -[unused817] -[unused818] -[unused819] -[unused820] -[unused821] -[unused822] -[unused823] -[unused824] -[unused825] -[unused826] -[unused827] -[unused828] -[unused829] -[unused830] -[unused831] -[unused832] -[unused833] -[unused834] -[unused835] -[unused836] -[unused837] -[unused838] -[unused839] -[unused840] -[unused841] -[unused842] -[unused843] -[unused844] -[unused845] -[unused846] -[unused847] -[unused848] -[unused849] -[unused850] -[unused851] -[unused852] -[unused853] -[unused854] -[unused855] -[unused856] -[unused857] -[unused858] -[unused859] -[unused860] -[unused861] -[unused862] -[unused863] -[unused864] -[unused865] -[unused866] -[unused867] -[unused868] -[unused869] -[unused870] -[unused871] -[unused872] -[unused873] -[unused874] -[unused875] -[unused876] -[unused877] -[unused878] -[unused879] -[unused880] -[unused881] -[unused882] -[unused883] -[unused884] -[unused885] -[unused886] -[unused887] -[unused888] -[unused889] -[unused890] -[unused891] -[unused892] -[unused893] -[unused894] -[unused895] -[unused896] -[unused897] -[unused898] -[unused899] -[unused900] -[unused901] -[unused902] -[unused903] -[unused904] -[unused905] -[unused906] -[unused907] -[unused908] -[unused909] -[unused910] -[unused911] -[unused912] -[unused913] -[unused914] -[unused915] -[unused916] -[unused917] -[unused918] -[unused919] -[unused920] -[unused921] -[unused922] -[unused923] -[unused924] -[unused925] -[unused926] -[unused927] -[unused928] -[unused929] -[unused930] -[unused931] -[unused932] -[unused933] -[unused934] -[unused935] -[unused936] -[unused937] -[unused938] -[unused939] -[unused940] -[unused941] -[unused942] -[unused943] -[unused944] -[unused945] -[unused946] -[unused947] -[unused948] -[unused949] -[unused950] -[unused951] -[unused952] -[unused953] -[unused954] -[unused955] -[unused956] -[unused957] -[unused958] -[unused959] -[unused960] -[unused961] -[unused962] -[unused963] -[unused964] -[unused965] -[unused966] -[unused967] -[unused968] -[unused969] -[unused970] -[unused971] -[unused972] -[unused973] -[unused974] -[unused975] -[unused976] -[unused977] -[unused978] -[unused979] -[unused980] -[unused981] -[unused982] -[unused983] -[unused984] -[unused985] -[unused986] -[unused987] -[unused988] -[unused989] -[unused990] -[unused991] -[unused992] -[unused993] -! -" -# -$ -% -& -' -( -) -* -+ -, -- -. -/ -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -: -; -< -= -> -? -@ -[ -\ -] -^ -_ -` -a -b -c -d -e -f -g -h -i -j -k -l -m -n -o -p -q -r -s -t -u -v -w -x -y -z -{ -| -} -~ -¡ -¢ -£ -¤ -Â¥ -¦ -§ -¨ -© -ª -« -¬ -® -° -± -² -³ -´ -µ -¶ -· -¹ -º -» -¼ -½ -¾ -¿ -× -ß -æ -ð -÷ -ø -þ -Ä‘ -ħ -ı -Å‚ -Å‹ -Å“ -Æ’ -ɐ -É‘ -É’ -É” -É• -É™ -É› -É¡ -É£ -ɨ -ɪ -É« -ɬ -ɯ -ɲ -É´ -ɹ -ɾ -Ê€ -ʁ -Ê‚ -ʃ -ʉ -ÊŠ -Ê‹ -ÊŒ -ÊŽ -ʐ -Ê‘ -Ê’ -Ê” -ʰ -ʲ -ʳ -Ê· -ʸ -Ê» -ʼ -ʾ -Ê¿ -ˈ -ː -Ë¡ -Ë¢ -Ë£ -ˤ -α -β -γ -δ -ε -ζ -η -θ -ι -κ -λ -μ -ν -ξ -ο -Ï€ -ρ -Ï‚ -σ -Ï„ -Ï… -φ -χ -ψ -ω -а -б -в -г -д -е -ж -з -и -к -л -м -н -о -п -Ñ€ -с -Ñ‚ -у -Ñ„ -Ñ… -ц -ч -ш -щ -ÑŠ -Ñ‹ -ÑŒ -э -ÑŽ -я -Ñ’ -Ñ” -Ñ– -ј -Ñ™ -Ñš -Ñ› -ӏ -Õ¡ -Õ¢ -Õ£ -Õ¤ -Õ¥ -Õ© -Õ« -Õ¬ -Õ¯ -Õ° -Õ´ -Õµ -Õ¶ -Õ¸ -Õº -Õ½ -Õ¾ -Õ¿ -Ö€ -Ö‚ -Ö„ -Ö¾ -א -ב -×’ -ד -×” -ו -×– -×— -ט -×™ -ך -×› -ל -ם -מ -ן -× -ס -×¢ -×£ -פ -×¥ -צ -×§ -ר -ש -ת -ØŒ -Ø¡ -ا -ب -Ø© -ت -Ø« -ج -Ø­ -Ø® -د -ذ -ر -ز -س -Ø´ -ص -ض -Ø· -ظ -ع -غ -Ù€ -ف -Ù‚ -Ùƒ -Ù„ -Ù… -Ù† -Ù‡ -Ùˆ -Ù‰ -ÙŠ -Ù¹ -Ù¾ -Ú† -Ú© -Ú¯ -Úº -Ú¾ -ہ -ÛŒ -Û’ -अ -आ -उ -ए -क -ख -ग -च -ज -ट -ड -ण -त -थ -द -ध -न -प -ब -भ -म -य -र -ल -व -श -ष -स -ह -ा -ि -ी -ो -। -॥ -ং -অ -আ -ই -উ -এ -ও -ক -খ -গ -চ -ছ -জ -ট -ড -ণ -ত -থ -দ -ধ -ন -প -ব -ভ -ম -য -র -ল -শ -ষ -স -হ -া -ি -à§€ -ে -க -ச -ட -த -ந -ன -ப -à®® -ய -à®° -ல -ள -வ -ா -ி -ு -ே -ை -ನ -ರ -ಾ -à¶š -ය -à¶» -à¶½ -à·€ -ා -ก -ง -ต -ท -น -พ -ม -ย -ร -ล -ว -ส -อ -า -เ -་ -། -ག -ང -ད -ན -པ -བ -མ -འ-ར -ལ -ས -မ -ა -ბ -გ -დ -ე -ვ -თ -ი -კ -ლ -მ -ნ -ო -რ-ს -ტ -უ -á„€ -á„‚ -ᄃ -á„… -ᄆ -ᄇ -ᄉ -ᄊ -á„‹ -ᄌ -ᄎ -ᄏ -ᄐ -á„‘ -á„’ -á…¡ -á…¢ -á…¥ -á…¦ -á…§ -á…© -á…ª -á…­ -á…® -á…¯ -á…² -á…³ -á…´ -á…µ -ᆨ -ᆫ -ᆯ -ᆷ -ᆸ -ᆼ -á´¬ -á´® -á´° -á´µ -á´º -áµ€ -ᵃ -ᵇ -ᵈ -ᵉ -ᵍ -ᵏ -ᵐ -áµ’ -áµ– -áµ— -ᵘ -áµ¢ -áµ£ -ᵤ -áµ¥ -á¶œ -á¶ -‐ -‑ -‒ -– -— -― -‖ -‘ -’ -‚ -“ -” -„ -†-‡ -• -… -‰ -′ -″ -› -‿ -⁄ -⁰ -ⁱ -⁴ -⁵ -⁶ -⁷ -⁸ -⁹ -⁺ -⁻ -ⁿ -â‚€ -₁ -â‚‚ -₃ -â‚„ -â‚… -₆ -₇ -₈ -₉ -₊ -₍ -₎ -ₐ -â‚‘ -â‚’ -â‚“ -â‚• -â‚– -â‚— -ₘ -â‚™ -ₚ -â‚› -ₜ -₤ -â‚© -€ -₱ -₹ -â„“ -â„– -ℝ -â„¢ -â…“ -â…” -← -↑ -→ -↓ -↔ -↦ -⇄ -⇌ -⇒ -∂ -∅ -∆ -∇ -∈ -− -∗ -∘ -√ -∞ -∧ -∨ -∩ -∪ -≈ -≡ -≤ -≥ -⊂ -⊆ -⊕ -⊗ -â‹… -─ -│ -â– -â–ª -● -★ -☆ -☉ -â™ -♣ -♥ -♦ -â™­ -♯ -⟨ -⟩ -â±¼ -⺩ -⺼ -â½¥ -、 -。 -〈 -〉 -《 -》 -「 -」 -『 -』 -〜 -あ -い -う -え -お -か -き -く -け -こ -さ -し -す -せ -そ -た -ち -っ -つ -て -と -な -に -ぬ -ね -の -は -ひ -ふ -へ -ほ -ま -み -ã‚€ -め -ã‚‚ -ã‚„ -ゆ -よ -ら -り -ã‚‹ -れ -ろ -ã‚’ -ã‚“ -ã‚¡ -ã‚¢ -ã‚£ -イ -ウ -ã‚§ -エ -オ -ã‚« -ã‚­ -ク -ケ -コ -サ -ã‚· -ス -ã‚» -ã‚¿ -チ -ッ -ツ -テ -ト -ナ -ニ -ノ -ハ -ヒ -フ -ヘ -ホ -マ -ミ -ム-メ -モ -ャ -ュ -ョ -ラ -リ -ル -レ -ロ -ワ -ン -・ -ー -一 -三 -上 -下 -不 -世 -中 -主 -ä¹… -之 -也 -事 -二 -五 -井 -京 -人 -亻 -仁 -介 -代 -ä»® -伊 -会 -佐 -侍 -保 -ä¿¡ -健 -å…ƒ -å…‰ -å…« -å…¬ -内 -出 -分 -前 -劉 -力 -åŠ -勝 -北 -区 -十 -千 -南 -博 -原 -口 -古 -史 -司 -合 -吉 -同 -名 -å’Œ -å›— -å›› -国 -國 -土 -地 -坂 -城 -å ‚ -å ´ -士 -夏 -外 -大 -天 -太 -夫 -奈 -女 -子 -å­¦ -宀 -宇 -安 -å®— -定 -宣 -å®® -å®¶ -宿 -寺 -å°‡ -小 -å°š -å±± -岡 -å³¶ -å´Ž -川 -å·ž -å·¿ -帝 -å¹³ -å¹´ -幸 -广 -弘 -å¼µ -å½³ -後 -御 -å¾· -心 -å¿„ -å¿— -å¿ -æ„› -成 -我 -戦 -戸 -手 -扌 -政 -æ–‡ -æ–° -æ–¹ -æ—¥ -明 -星 -春 -昭 -智 -曲 -書 -月 -有 -朝 -木 -本 -李 -村 -東 -松 -æž— -森 -楊 -樹 -æ©‹ -æ­Œ -æ­¢ -æ­£ -æ­¦ -比 -氏 -æ°‘ -æ°´ -æ°µ -æ°· -æ°¸ -江 -æ²¢ -æ²³ -æ²» -法 -æµ· -清 -æ¼¢ -瀬 -火 -版 -犬 -王 -生 -ç”° -ç”· -ç–’ -発 -白 -çš„ -皇 -ç›® -相 -省 -真 -石 -示 -社 -神 -福 -禾 -ç§€ -ç§‹ -空 -ç«‹ -ç« -竹 -ç³¹ -美 -義 -耳 -良 -艹 -花 -英 -華 -葉 -è—¤ -行 -è¡— -西 -見 -訁 -語 -è°· -貝 -è²´ -車 -軍 -è¾¶ -道 -郎 -郡 -部 -都 -里 -野 -金 -鈴 -镇 -é•· -é–€ -é–“ -阝 -阿 -陳 -陽 -雄 -青 -面 -風 -食 -香 -馬 -高 -龍 -龸 -fi -fl -! -( -) -, -- -. -/ -: -? -~ -the -of -and -in -to -was -he -is -as -for -on -with -that -it -his -by -at -from -her -##s -she -you -had -an -were -but -be -this -are -not -my -they -one -which -or -have -him -me -first -all -also -their -has -up -who -out -been -when -after -there -into -new -two -its -##a -time -would -no -what -about -said -we -over -then -other -so -more -##e -can -if -like -back -them -only -some -could -##i -where -just -##ing -during -before -##n -do -##o -made -school -through -than -now -years -most -world -may -between -down -well -three -##d -year -while -will -##ed -##r -##y -later -##t -city -under -around -did -such -being -used -state -people -part -know -against -your -many -second -university -both -national -##er -these -don -known -off -way -until -re -how -even -get -head -... -didn -##ly -team -american -because -de -##l -born -united -film -since -still -long -work -south -us -became -any -high -again -day -family -see -right -man -eyes -house -season -war -states -including -took -life -north -same -each -called -name -much -place -however -go -four -group -another -found -won -area -here -going -10 -away -series -left -home -music -best -make -hand -number -company -several -never -last -john -000 -very -album -take -end -good -too -following -released -game -played -little -began -district -##m -old -want -those -side -held -own -early -county -ll -league -use -west -##u -face -think -##es -2010 -government -##h -march -came -small -general -town -june -##on -line -based -something -##k -september -thought -looked -along -international -2011 -air -july -club -went -january -october -our -august -april -york -12 -few -2012 -2008 -east -show -member -college -2009 -father -public -##us -come -men -five -set -station -church -##c -next -former -november -room -party -located -december -2013 -age -got -2007 -##g -system -let -love -2006 -though -every -2014 -look -song -water -century -without -body -black -night -within -great -women -single -ve -building -large -population -river -named -band -white -started -##an -once -15 -20 -should -18 -2015 -service -top -built -british -open -death -king -moved -local -times -children -february -book -why -11 -door -need -president -order -final -road -wasn -although -due -major -died -village -third -knew -2016 -asked -turned -st -wanted -say -##p -together -received -main -son -served -different -##en -behind -himself -felt -members -power -football -law -voice -play -##in -near -park -history -30 -having -2005 -16 -##man -saw -mother -##al -army -point -front -help -english -street -art -late -hands -games -award -##ia -young -14 -put -published -country -division -across -told -13 -often -ever -french -london -center -six -red -2017 -led -days -include -light -25 -find -tell -among -species -really -according -central -half -2004 -form -original -gave -office -making -enough -lost -full -opened -must -included -live -given -german -player -run -business -woman -community -cup -might -million -land -2000 -court -development -17 -short -round -ii -km -seen -class -story -always -become -sure -research -almost -director -council -la -##2 -career -things -using -island -##z -couldn -car -##is -24 -close -force -##1 -better -free -support -control -field -students -2003 -education -married -##b -nothing -worked -others -record -big -inside -level -anything -continued -give -james -##3 -military -established -non -returned -feel -does -title -written -thing -feet -william -far -co -association -hard -already -2002 -##ra -championship -human -western -100 -##na -department -hall -role -various -production -21 -19 -heart -2001 -living -fire -version -##ers -##f -television -royal -##4 -produced -working -act -case -society -region -present -radio -period -looking -least -total -keep -england -wife -program -per -brother -mind -special -22 -##le -am -works -soon -##6 -political -george -services -taken -created -##7 -further -able -reached -david -union -joined -upon -done -important -social -information -either -##ic -##x -appeared -position -ground -lead -rock -dark -election -23 -board -france -hair -course -arms -site -police -girl -instead -real -sound -##v -words -moment -##te -someone -##8 -summer -project -announced -san -less -wrote -past -followed -##5 -blue -founded -al -finally -india -taking -records -america -##ne -1999 -design -considered -northern -god -stop -battle -toward -european -outside -described -track -today -playing -language -28 -call -26 -heard -professional -low -australia -miles -california -win -yet -green -##ie -trying -blood -##ton -southern -science -maybe -everything -match -square -27 -mouth -video -race -recorded -leave -above -##9 -daughter -points -space -1998 -museum -change -middle -common -##0 -move -tv -post -##ta -lake -seven -tried -elected -closed -ten -paul -minister -##th -months -start -chief -return -canada -person -sea -release -similar -modern -brought -rest -hit -formed -mr -##la -1997 -floor -event -doing -thomas -1996 -robert -care -killed -training -star -week -needed -turn -finished -railway -rather -news -health -sent -example -ran -term -michael -coming -currently -yes -forces -despite -gold -areas -50 -stage -fact -29 -dead -says -popular -2018 -originally -germany -probably -developed -result -pulled -friend -stood -money -running -mi -signed -word -songs -child -eventually -met -tour -average -teams -minutes -festival -current -deep -kind -1995 -decided -usually -eastern -seemed -##ness -episode -bed -added -table -indian -private -charles -route -available -idea -throughout -centre -addition -appointed -style -1994 -books -eight -construction -press -mean -wall -friends -remained -schools -study -##ch -##um -institute -oh -chinese -sometimes -events -possible -1992 -australian -type -brown -forward -talk -process -food -debut -seat -performance -committee -features -character -arts -herself -else -lot -strong -russian -range -hours -peter -arm -##da -morning -dr -sold -##ry -quickly -directed -1993 -guitar -china -##w -31 -list -##ma -performed -media -uk -players -smile -##rs -myself -40 -placed -coach -province -towards -wouldn -leading -whole -boy -official -designed -grand -census -##el -europe -attack -japanese -henry -1991 -##re -##os -cross -getting -alone -action -lower -network -wide -washington -japan -1990 -hospital -believe -changed -sister -##ar -hold -gone -sir -hadn -ship -##ka -studies -academy -shot -rights -below -base -bad -involved -kept -largest -##ist -bank -future -especially -beginning -mark -movement -section -female -magazine -plan -professor -lord -longer -##ian -sat -walked -hill -actually -civil -energy -model -families -size -thus -aircraft -completed -includes -data -captain -##or -fight -vocals -featured -richard -bridge -fourth -1989 -officer -stone -hear -##ism -means -medical -groups -management -self -lips -competition -entire -lived -technology -leaving -federal -tournament -bit -passed -hot -independent -awards -kingdom -mary -spent -fine -doesn -reported -##ling -jack -fall -raised -itself -stay -true -studio -1988 -sports -replaced -paris -systems -saint -leader -theatre -whose -market -capital -parents -spanish -canadian -earth -##ity -cut -degree -writing -bay -christian -awarded -natural -higher -bill -##as -coast -provided -previous -senior -ft -valley -organization -stopped -onto -countries -parts -conference -queen -security -interest -saying -allowed -master -earlier -phone -matter -smith -winning -try -happened -moving -campaign -los -##ley -breath -nearly -mid -1987 -certain -girls -date -italian -african -standing -fell -artist -##ted -shows -deal -mine -industry -1986 -##ng -everyone -republic -provide -collection -library -student -##ville -primary -owned -older -via -heavy -1st -makes -##able -attention -anyone -africa -##ri -stated -length -ended -fingers -command -staff -skin -foreign -opening -governor -okay -medal -kill -sun -cover -job -1985 -introduced -chest -hell -feeling -##ies -success -meet -reason -standard -meeting -novel -1984 -trade -source -buildings -##land -rose -guy -goal -##ur -chapter -native -husband -previously -unit -limited -entered -weeks -producer -operations -mountain -takes -covered -forced -related -roman -complete -successful -key -texas -cold -##ya -channel -1980 -traditional -films -dance -clear -approximately -500 -nine -van -prince -question -active -tracks -ireland -regional -silver -author -personal -sense -operation -##ine -economic -1983 -holding -twenty -isbn -additional -speed -hour -edition -regular -historic -places -whom -shook -movie -km² -secretary -prior -report -chicago -read -foundation -view -engine -scored -1982 -units -ask -airport -property -ready -immediately -lady -month -listed -contract -##de -manager -themselves -lines -##ki -navy -writer -meant -##ts -runs -##ro -practice -championships -singer -glass -commission -required -forest -starting -culture -generally -giving -access -attended -test -couple -stand -catholic -martin -caught -executive -##less -eye -##ey -thinking -chair -quite -shoulder -1979 -hope -decision -plays -defeated -municipality -whether -structure -offered -slowly -pain -ice -direction -##ion -paper -mission -1981 -mostly -200 -noted -individual -managed -nature -lives -plant -##ha -helped -except -studied -computer -figure -relationship -issue -significant -loss -die -smiled -gun -ago -highest -1972 -##am -male -bring -goals -mexico -problem -distance -commercial -completely -location -annual -famous -drive -1976 -neck -1978 -surface -caused -italy -understand -greek -highway -wrong -hotel -comes -appearance -joseph -double -issues -musical -companies -castle -income -review -assembly -bass -initially -parliament -artists -experience -1974 -particular -walk -foot -engineering -talking -window -dropped -##ter -miss -baby -boys -break -1975 -stars -edge -remember -policy -carried -train -stadium -bar -sex -angeles -evidence -##ge -becoming -assistant -soviet -1977 -upper -step -wing -1970 -youth -financial -reach -##ll -actor -numerous -##se -##st -nodded -arrived -##ation -minute -##nt -believed -sorry -complex -beautiful -victory -associated -temple -1968 -1973 -chance -perhaps -metal -##son -1945 -bishop -##et -lee -launched -particularly -tree -le -retired -subject -prize -contains -yeah -theory -empire -##ce -suddenly -waiting -trust -recording -##to -happy -terms -camp -champion -1971 -religious -pass -zealand -names -2nd -port -ancient -tom -corner -represented -watch -legal -anti -justice -cause -watched -brothers -45 -material -changes -simply -response -louis -fast -##ting -answer -60 -historical -1969 -stories -straight -create -feature -increased -rate -administration -virginia -el -activities -cultural -overall -winner -programs -basketball -legs -guard -beyond -cast -doctor -mm -flight -results -remains -cost -effect -winter -##ble -larger -islands -problems -chairman -grew -commander -isn -1967 -pay -failed -selected -hurt -fort -box -regiment -majority -journal -35 -edward -plans -##ke -##ni -shown -pretty -irish -characters -directly -scene -likely -operated -allow -spring -##j -junior -matches -looks -mike -houses -fellow -##tion -beach -marriage -##ham -##ive -rules -oil -65 -florida -expected -nearby -congress -sam -peace -recent -iii -wait -subsequently -cell -##do -variety -serving -agreed -please -poor -joe -pacific -attempt -wood -democratic -piece -prime -##ca -rural -mile -touch -appears -township -1964 -1966 -soldiers -##men -##ized -1965 -pennsylvania -closer -fighting -claimed -score -jones -physical -editor -##ous -filled -genus -specific -sitting -super -mom -##va -therefore -supported -status -fear -cases -store -meaning -wales -minor -spain -tower -focus -vice -frank -follow -parish -separate -golden -horse -fifth -remaining -branch -32 -presented -stared -##id -uses -secret -forms -##co -baseball -exactly -##ck -choice -note -discovered -travel -composed -truth -russia -ball -color -kiss -dad -wind -continue -ring -referred -numbers -digital -greater -##ns -metres -slightly -direct -increase -1960 -responsible -crew -rule -trees -troops -##no -broke -goes -individuals -hundred -weight -creek -sleep -memory -defense -provides -ordered -code -value -jewish -windows -1944 -safe -judge -whatever -corps -realized -growing -pre -##ga -cities -alexander -gaze -lies -spread -scott -letter -showed -situation -mayor -transport -watching -workers -extended -##li -expression -normal -##ment -chart -multiple -border -##ba -host -##ner -daily -mrs -walls -piano -##ko -heat -cannot -##ate -earned -products -drama -era -authority -seasons -join -grade -##io -sign -difficult -machine -1963 -territory -mainly -##wood -stations -squadron -1962 -stepped -iron -19th -##led -serve -appear -sky -speak -broken -charge -knowledge -kilometres -removed -ships -article -campus -simple -##ty -pushed -britain -##ve -leaves -recently -cd -soft -boston -latter -easy -acquired -poland -##sa -quality -officers -presence -planned -nations -mass -broadcast -jean -share -image -influence -wild -offer -emperor -electric -reading -headed -ability -promoted -yellow -ministry -1942 -throat -smaller -politician -##by -latin -spoke -cars -williams -males -lack -pop -80 -##ier -acting -seeing -consists -##ti -estate -1961 -pressure -johnson -newspaper -jr -chris -olympics -online -conditions -beat -elements -walking -vote -##field -needs -carolina -text -featuring -global -block -shirt -levels -francisco -purpose -females -et -dutch -duke -ahead -gas -twice -safety -serious -turning -highly -lieutenant -firm -maria -amount -mixed -daniel -proposed -perfect -agreement -affairs -3rd -seconds -contemporary -paid -1943 -prison -save -kitchen -label -administrative -intended -constructed -academic -nice -teacher -races -1956 -formerly -corporation -ben -nation -issued -shut -1958 -drums -housing -victoria -seems -opera -1959 -graduated -function -von -mentioned -picked -build -recognized -shortly -protection -picture -notable -exchange -elections -1980s -loved -percent -racing -fish -elizabeth -garden -volume -hockey -1941 -beside -settled -##ford -1940 -competed -replied -drew -1948 -actress -marine -scotland -steel -glanced -farm -steve -1957 -risk -tonight -positive -magic -singles -effects -gray -screen -dog -##ja -residents -bus -sides -none -secondary -literature -polish -destroyed -flying -founder -households -1939 -lay -reserve -usa -gallery -##ler -1946 -industrial -younger -approach -appearances -urban -ones -1950 -finish -avenue -powerful -fully -growth -page -honor -jersey -projects -advanced -revealed -basic -90 -infantry -pair -equipment -visit -33 -evening -search -grant -effort -solo -treatment -buried -republican -primarily -bottom -owner -1970s -israel -gives -jim -dream -bob -remain -spot -70 -notes -produce -champions -contact -ed -soul -accepted -ways -del -##ally -losing -split -price -capacity -basis -trial -questions -##ina -1955 -20th -guess -officially -memorial -naval -initial -##ization -whispered -median -engineer -##ful -sydney -##go -columbia -strength -300 -1952 -tears -senate -00 -card -asian -agent -1947 -software -44 -draw -warm -supposed -com -pro -##il -transferred -leaned -##at -candidate -escape -mountains -asia -potential -activity -entertainment -seem -traffic -jackson -murder -36 -slow -product -orchestra -haven -agency -bbc -taught -website -comedy -unable -storm -planning -albums -rugby -environment -scientific -grabbed -protect -##hi -boat -typically -1954 -1953 -damage -principal -divided -dedicated -mount -ohio -##berg -pick -fought -driver -##der -empty -shoulders -sort -thank -berlin -prominent -account -freedom -necessary -efforts -alex -headquarters -follows -alongside -des -simon -andrew -suggested -operating -learning -steps -1949 -sweet -technical -begin -easily -34 -teeth -speaking -settlement -scale -##sh -renamed -ray -max -enemy -semi -joint -compared -##rd -scottish -leadership -analysis -offers -georgia -pieces -captured -animal -deputy -guest -organized -##lin -tony -combined -method -challenge -1960s -huge -wants -battalion -sons -rise -crime -types -facilities -telling -path -1951 -platform -sit -1990s -##lo -tells -assigned -rich -pull -##ot -commonly -alive -##za -letters -concept -conducted -wearing -happen -bought -becomes -holy -gets -ocean -defeat -languages -purchased -coffee -occurred -titled -##q -declared -applied -sciences -concert -sounds -jazz -brain -##me -painting -fleet -tax -nick -##ius -michigan -count -animals -leaders -episodes -##line -content -##den -birth -##it -clubs -64 -palace -critical -refused -fair -leg -laughed -returning -surrounding -participated -formation -lifted -pointed -connected -rome -medicine -laid -taylor -santa -powers -adam -tall -shared -focused -knowing -yards -entrance -falls -##wa -calling -##ad -sources -chosen -beneath -resources -yard -##ite -nominated -silence -zone -defined -##que -gained -thirty -38 -bodies -moon -##ard -adopted -christmas -widely -register -apart -iran -premier -serves -du -unknown -parties -##les -generation -##ff -continues -quick -fields -brigade -quiet -teaching -clothes -impact -weapons -partner -flat -theater -supreme -1938 -37 -relations -##tor -plants -suffered -1936 -wilson -kids -begins -##age -1918 -seats -armed -internet -models -worth -laws -400 -communities -classes -background -knows -thanks -quarter -reaching -humans -carry -killing -format -kong -hong -setting -75 -architecture -disease -railroad -inc -possibly -wish -arthur -thoughts -harry -doors -density -##di -crowd -illinois -stomach -tone -unique -reports -anyway -##ir -liberal -der -vehicle -thick -dry -drug -faced -largely -facility -theme -holds -creation -strange -colonel -##mi -revolution -bell -politics -turns -silent -rail -relief -independence -combat -shape -write -determined -sales -learned -4th -finger -oxford -providing -1937 -heritage -fiction -situated -designated -allowing -distribution -hosted -##est -sight -interview -estimated -reduced -##ria -toronto -footballer -keeping -guys -damn -claim -motion -sport -sixth -stayed -##ze -en -rear -receive -handed -twelve -dress -audience -granted -brazil -##well -spirit -##ated -noticed -etc -olympic -representative -eric -tight -trouble -reviews -drink -vampire -missing -roles -ranked -newly -household -finals -wave -critics -##ee -phase -massachusetts -pilot -unlike -philadelphia -bright -guns -crown -organizations -roof -42 -respectively -clearly -tongue -marked -circle -fox -korea -bronze -brian -expanded -sexual -supply -yourself -inspired -labour -fc -##ah -reference -vision -draft -connection -brand -reasons -1935 -classic -driving -trip -jesus -cells -entry -1920 -neither -trail -claims -atlantic -orders -labor -nose -afraid -identified -intelligence -calls -cancer -attacked -passing -stephen -positions -imperial -grey -jason -39 -sunday -48 -swedish -avoid -extra -uncle -message -covers -allows -surprise -materials -fame -hunter -##ji -1930 -citizens -figures -davis -environmental -confirmed -shit -titles -di -performing -difference -acts -attacks -##ov -existing -votes -opportunity -nor -shop -entirely -trains -opposite -pakistan -##pa -develop -resulted -representatives -actions -reality -pressed -##ish -barely -wine -conversation -faculty -northwest -ends -documentary -nuclear -stock -grace -sets -eat -alternative -##ps -bag -resulting -creating -surprised -cemetery -1919 -drop -finding -sarah -cricket -streets -tradition -ride -1933 -exhibition -target -ear -explained -rain -composer -injury -apartment -municipal -educational -occupied -netherlands -clean -billion -constitution -learn -1914 -maximum -classical -francis -lose -opposition -jose -ontario -bear -core -hills -rolled -ending -drawn -permanent -fun -##tes -##lla -lewis -sites -chamber -ryan -##way -scoring -height -1934 -##house -lyrics -staring -55 -officials -1917 -snow -oldest -##tic -orange -##ger -qualified -interior -apparently -succeeded -thousand -dinner -lights -existence -fans -heavily -41 -greatest -conservative -send -bowl -plus -enter -catch -##un -economy -duty -1929 -speech -authorities -princess -performances -versions -shall -graduate -pictures -effective -remembered -poetry -desk -crossed -starring -starts -passenger -sharp -##ant -acres -ass -weather -falling -rank -fund -supporting -check -adult -publishing -heads -cm -southeast -lane -##burg -application -bc -##ura -les -condition -transfer -prevent -display -ex -regions -earl -federation -cool -relatively -answered -besides -1928 -obtained -portion -##town -mix -##ding -reaction -liked -dean -express -peak -1932 -##tte -counter -religion -chain -rare -miller -convention -aid -lie -vehicles -mobile -perform -squad -wonder -lying -crazy -sword -##ping -attempted -centuries -weren -philosophy -category -##ize -anna -interested -47 -sweden -wolf -frequently -abandoned -kg -literary -alliance -task -entitled -##ay -threw -promotion -factory -tiny -soccer -visited -matt -fm -achieved -52 -defence -internal -persian -43 -methods -##ging -arrested -otherwise -cambridge -programming -villages -elementary -districts -rooms -criminal -conflict -worry -trained -1931 -attempts -waited -signal -bird -truck -subsequent -programme -##ol -ad -49 -communist -details -faith -sector -patrick -carrying -laugh -##ss -controlled -korean -showing -origin -fuel -evil -1927 -##ent -brief -identity -darkness -address -pool -missed -publication -web -planet -ian -anne -wings -invited -##tt -briefly -standards -kissed -##be -ideas -climate -causing -walter -worse -albert -articles -winners -desire -aged -northeast -dangerous -gate -doubt -1922 -wooden -multi -##ky -poet -rising -funding -46 -communications -communication -violence -copies -prepared -ford -investigation -skills -1924 -pulling -electronic -##ak -##ial -##han -containing -ultimately -offices -singing -understanding -restaurant -tomorrow -fashion -christ -ward -da -pope -stands -5th -flow -studios -aired -commissioned -contained -exist -fresh -americans -##per -wrestling -approved -kid -employed -respect -suit -1925 -angel -asking -increasing -frame -angry -selling -1950s -thin -finds -##nd -temperature -statement -ali -explain -inhabitants -towns -extensive -narrow -51 -jane -flowers -images -promise -somewhere -object -fly -closely -##ls -1912 -bureau -cape -1926 -weekly -presidential -legislative -1921 -##ai -##au -launch -founding -##ny -978 -##ring -artillery -strike -un -institutions -roll -writers -landing -chose -kevin -anymore -pp -##ut -attorney -fit -dan -billboard -receiving -agricultural -breaking -sought -dave -admitted -lands -mexican -##bury -charlie -specifically -hole -iv -howard -credit -moscow -roads -accident -1923 -proved -wear -struck -hey -guards -stuff -slid -expansion -1915 -cat -anthony -##kin -melbourne -opposed -sub -southwest -architect -failure -plane -1916 -##ron -map -camera -tank -listen -regarding -wet -introduction -metropolitan -link -ep -fighter -inch -grown -gene -anger -fixed -buy -dvd -khan -domestic -worldwide -chapel -mill -functions -examples -##head -developing -1910 -turkey -hits -pocket -antonio -papers -grow -unless -circuit -18th -concerned -attached -journalist -selection -journey -converted -provincial -painted -hearing -aren -bands -negative -aside -wondered -knight -lap -survey -ma -##ow -noise -billy -##ium -shooting -guide -bedroom -priest -resistance -motor -homes -sounded -giant -##mer -150 -scenes -equal -comic -patients -hidden -solid -actual -bringing -afternoon -touched -funds -wedding -consisted -marie -canal -sr -kim -treaty -turkish -recognition -residence -cathedral -broad -knees -incident -shaped -fired -norwegian -handle -cheek -contest -represent -##pe -representing -beauty -##sen -birds -advantage -emergency -wrapped -drawing -notice -pink -broadcasting -##ong -somehow -bachelor -seventh -collected -registered -establishment -alan -assumed -chemical -personnel -roger -retirement -jeff -portuguese -wore -tied -device -threat -progress -advance -##ised -banks -hired -manchester -nfl -teachers -structures -forever -##bo -tennis -helping -saturday -sale -applications -junction -hip -incorporated -neighborhood -dressed -ceremony -##ds -influenced -hers -visual -stairs -decades -inner -kansas -hung -hoped -gain -scheduled -downtown -engaged -austria -clock -norway -certainly -pale -protected -1913 -victor -employees -plate -putting -surrounded -##ists -finishing -blues -tropical -##ries -minnesota -consider -philippines -accept -54 -retrieved -1900 -concern -anderson -properties -institution -gordon -successfully -vietnam -##dy -backing -outstanding -muslim -crossing -folk -producing -usual -demand -occurs -observed -lawyer -educated -##ana -kelly -string -pleasure -budget -items -quietly -colorado -philip -typical -##worth -derived -600 -survived -asks -mental -##ide -56 -jake -jews -distinguished -ltd -1911 -sri -extremely -53 -athletic -loud -thousands -worried -shadow -transportation -horses -weapon -arena -importance -users -tim -objects -contributed -dragon -douglas -aware -senator -johnny -jordan -sisters -engines -flag -investment -samuel -shock -capable -clark -row -wheel -refers -session -familiar -biggest -wins -hate -maintained -drove -hamilton -request -expressed -injured -underground -churches -walker -wars -tunnel -passes -stupid -agriculture -softly -cabinet -regarded -joining -indiana -##ea -##ms -push -dates -spend -behavior -woods -protein -gently -chase -morgan -mention -burning -wake -combination -occur -mirror -leads -jimmy -indeed -impossible -singapore -paintings -covering -##nes -soldier -locations -attendance -sell -historian -wisconsin -invasion -argued -painter -diego -changing -egypt -##don -experienced -inches -##ku -missouri -vol -grounds -spoken -switzerland -##gan -reform -rolling -ha -forget -massive -resigned -burned -allen -tennessee -locked -values -improved -##mo -wounded -universe -sick -dating -facing -pack -purchase -user -##pur -moments -##ul -merged -anniversary -1908 -coal -brick -understood -causes -dynasty -queensland -establish -stores -crisis -promote -hoping -views -cards -referee -extension -##si -raise -arizona -improve -colonial -formal -charged -##rt -palm -lucky -hide -rescue -faces -95 -feelings -candidates -juan -##ell -goods -6th -courses -weekend -59 -luke -cash -fallen -##om -delivered -affected -installed -carefully -tries -swiss -hollywood -costs -lincoln -responsibility -##he -shore -file -proper -normally -maryland -assistance -jump -constant -offering -friendly -waters -persons -realize -contain -trophy -800 -partnership -factor -58 -musicians -cry -bound -oregon -indicated -hero -houston -medium -##ure -consisting -somewhat -##ara -57 -cycle -##che -beer -moore -frederick -gotten -eleven -worst -weak -approached -arranged -chin -loan -universal -bond -fifteen -pattern -disappeared -##ney -translated -##zed -lip -arab -capture -interests -insurance -##chi -shifted -cave -prix -warning -sections -courts -coat -plot -smell -feed -golf -favorite -maintain -knife -vs -voted -degrees -finance -quebec -opinion -translation -manner -ruled -operate -productions -choose -musician -discovery -confused -tired -separated -stream -techniques -committed -attend -ranking -kings -throw -passengers -measure -horror -fan -mining -sand -danger -salt -calm -decade -dam -require -runner -##ik -rush -associate -greece -##ker -rivers -consecutive -matthew -##ski -sighed -sq -documents -steam -edited -closing -tie -accused -1905 -##ini -islamic -distributed -directors -organisation -bruce -7th -breathing -mad -lit -arrival -concrete -taste -08 -composition -shaking -faster -amateur -adjacent -stating -1906 -twin -flew -##ran -tokyo -publications -##tone -obviously -ridge -storage -1907 -carl -pages -concluded -desert -driven -universities -ages -terminal -sequence -borough -250 -constituency -creative -cousin -economics -dreams -margaret -notably -reduce -montreal -mode -17th -ears -saved -jan -vocal -##ica -1909 -andy -##jo -riding -roughly -threatened -##ise -meters -meanwhile -landed -compete -repeated -grass -czech -regularly -charges -tea -sudden -appeal -##ung -solution -describes -pierre -classification -glad -parking -##ning -belt -physics -99 -rachel -add -hungarian -participate -expedition -damaged -gift -childhood -85 -fifty -##red -mathematics -jumped -letting -defensive -mph -##ux -##gh -testing -##hip -hundreds -shoot -owners -matters -smoke -israeli -kentucky -dancing -mounted -grandfather -emma -designs -profit -argentina -##gs -truly -li -lawrence -cole -begun -detroit -willing -branches -smiling -decide -miami -enjoyed -recordings -##dale -poverty -ethnic -gay -##bi -gary -arabic -09 -accompanied -##one -##ons -fishing -determine -residential -acid -##ary -alice -returns -starred -mail -##ang -jonathan -strategy -##ue -net -forty -cook -businesses -equivalent -commonwealth -distinct -ill -##cy -seriously -##ors -##ped -shift -harris -replace -rio -imagine -formula -ensure -##ber -additionally -scheme -conservation -occasionally -purposes -feels -favor -##and -##ore -1930s -contrast -hanging -hunt -movies -1904 -instruments -victims -danish -christopher -busy -demon -sugar -earliest -colony -studying -balance -duties -##ks -belgium -slipped -carter -05 -visible -stages -iraq -fifa -##im -commune -forming -zero -07 -continuing -talked -counties -legend -bathroom -option -tail -clay -daughters -afterwards -severe -jaw -visitors -##ded -devices -aviation -russell -kate -##vi -entering -subjects -##ino -temporary -swimming -forth -smooth -ghost -audio -bush -operates -rocks -movements -signs -eddie -##tz -ann -voices -honorary -06 -memories -dallas -pure -measures -racial -promised -66 -harvard -ceo -16th -parliamentary -indicate -benefit -flesh -dublin -louisiana -1902 -1901 -patient -sleeping -1903 -membership -coastal -medieval -wanting -element -scholars -rice -62 -limit -survive -makeup -rating -definitely -collaboration -obvious -##tan -boss -ms -baron -birthday -linked -soil -diocese -##lan -ncaa -##mann -offensive -shell -shouldn -waist -##tus -plain -ross -organ -resolution -manufacturing -adding -relative -kennedy -98 -whilst -moth -marketing -gardens -crash -72 -heading -partners -credited -carlos -moves -cable -##zi -marshall -##out -depending -bottle -represents -rejected -responded -existed -04 -jobs -denmark -lock -##ating -treated -graham -routes -talent -commissioner -drugs -secure -tests -reign -restored -photography -##gi -contributions -oklahoma -designer -disc -grin -seattle -robin -paused -atlanta -unusual -##gate -praised -las -laughing -satellite -hungary -visiting -##sky -interesting -factors -deck -poems -norman -##water -stuck -speaker -rifle -domain -premiered -##her -dc -comics -actors -01 -reputation -eliminated -8th -ceiling -prisoners -script -##nce -leather -austin -mississippi -rapidly -admiral -parallel -charlotte -guilty -tools -gender -divisions -fruit -##bs -laboratory -nelson -fantasy -marry -rapid -aunt -tribe -requirements -aspects -suicide -amongst -adams -bone -ukraine -abc -kick -sees -edinburgh -clothing -column -rough -gods -hunting -broadway -gathered -concerns -##ek -spending -ty -12th -snapped -requires -solar -bones -cavalry -##tta -iowa -drinking -waste -index -franklin -charity -thompson -stewart -tip -flash -landscape -friday -enjoy -singh -poem -listening -##back -eighth -fred -differences -adapted -bomb -ukrainian -surgery -corporate -masters -anywhere -##more -waves -odd -sean -portugal -orleans -dick -debate -kent -eating -puerto -cleared -96 -expect -cinema -97 -guitarist -blocks -electrical -agree -involving -depth -dying -panel -struggle -##ged -peninsula -adults -novels -emerged -vienna -metro -debuted -shoes -tamil -songwriter -meets -prove -beating -instance -heaven -scared -sending -marks -artistic -passage -superior -03 -significantly -shopping -##tive -retained -##izing -malaysia -technique -cheeks -##ola -warren -maintenance -destroy -extreme -allied -120 -appearing -##yn -fill -advice -alabama -qualifying -policies -cleveland -hat -battery -smart -authors -10th -soundtrack -acted -dated -lb -glance -equipped -coalition -funny -outer -ambassador -roy -possibility -couples -campbell -dna -loose -ethan -supplies -1898 -gonna -88 -monster -##res -shake -agents -frequency -springs -dogs -practices -61 -gang -plastic -easier -suggests -gulf -blade -exposed -colors -industries -markets -pan -nervous -electoral -charts -legislation -ownership -##idae -mac -appointment -shield -copy -assault -socialist -abbey -monument -license -throne -employment -jay -93 -replacement -charter -cloud -powered -suffering -accounts -oak -connecticut -strongly -wright -colour -crystal -13th -context -welsh -networks -voiced -gabriel -jerry -##cing -forehead -mp -##ens -manage -schedule -totally -remix -##ii -forests -occupation -print -nicholas -brazilian -strategic -vampires -engineers -76 -roots -seek -correct -instrumental -und -alfred -backed -hop -##des -stanley -robinson -traveled -wayne -welcome -austrian -achieve -67 -exit -rates -1899 -strip -whereas -##cs -sing -deeply -adventure -bobby -rick -jamie -careful -components -cap -useful -personality -knee -##shi -pushing -hosts -02 -protest -ca -ottoman -symphony -##sis -63 -boundary -1890 -processes -considering -considerable -tons -##work -##ft -##nia -cooper -trading -dear -conduct -91 -illegal -apple -revolutionary -holiday -definition -harder -##van -jacob -circumstances -destruction -##lle -popularity -grip -classified -liverpool -donald -baltimore -flows -seeking -honour -approval -92 -mechanical -till -happening -statue -critic -increasingly -immediate -describe -commerce -stare -##ster -indonesia -meat -rounds -boats -baker -orthodox -depression -formally -worn -naked -claire -muttered -sentence -11th -emily -document -77 -criticism -wished -vessel -spiritual -bent -virgin -parker -minimum -murray -lunch -danny -printed -compilation -keyboards -false -blow -belonged -68 -raising -78 -cutting -##board -pittsburgh -##up -9th -shadows -81 -hated -indigenous -jon -15th -barry -scholar -ah -##zer -oliver -##gy -stick -susan -meetings -attracted -spell -romantic -##ver -ye -1895 -photo -demanded -customers -##ac -1896 -logan -revival -keys -modified -commanded -jeans -##ious -upset -raw -phil -detective -hiding -resident -vincent -##bly -experiences -diamond -defeating -coverage -lucas -external -parks -franchise -helen -bible -successor -percussion -celebrated -il -lift -profile -clan -romania -##ied -mills -##su -nobody -achievement -shrugged -fault -1897 -rhythm -initiative -breakfast -carbon -700 -69 -lasted -violent -74 -wound -ken -killer -gradually -filmed -°c -dollars -processing -94 -remove -criticized -guests -sang -chemistry -##vin -legislature -disney -##bridge -uniform -escaped -integrated -proposal -purple -denied -liquid -karl -influential -morris -nights -stones -intense -experimental -twisted -71 -84 -##ld -pace -nazi -mitchell -ny -blind -reporter -newspapers -14th -centers -burn -basin -forgotten -surviving -filed -collections -monastery -losses -manual -couch -description -appropriate -merely -tag -missions -sebastian -restoration -replacing -triple -73 -elder -julia -warriors -benjamin -julian -convinced -stronger -amazing -declined -versus -merchant -happens -output -finland -bare -barbara -absence -ignored -dawn -injuries -##port -producers -##ram -82 -luis -##ities -kw -admit -expensive -electricity -nba -exception -symbol -##ving -ladies -shower -sheriff -characteristics -##je -aimed -button -ratio -effectively -summit -angle -jury -bears -foster -vessels -pants -executed -evans -dozen -advertising -kicked -patrol -1889 -competitions -lifetime -principles -athletics -##logy -birmingham -sponsored -89 -rob -nomination -1893 -acoustic -##sm -creature -longest -##tra -credits -harbor -dust -josh -##so -territories -milk -infrastructure -completion -thailand -indians -leon -archbishop -##sy -assist -pitch -blake -arrangement -girlfriend -serbian -operational -hence -sad -scent -fur -dj -sessions -hp -refer -rarely -##ora -exists -1892 -##ten -scientists -dirty -penalty -burst -portrait -seed -79 -pole -limits -rival -1894 -stable -alpha -grave -constitutional -alcohol -arrest -flower -mystery -devil -architectural -relationships -greatly -habitat -##istic -larry -progressive -remote -cotton -##ics -##ok -preserved -reaches -##ming -cited -86 -vast -scholarship -decisions -cbs -joy -teach -1885 -editions -knocked -eve -searching -partly -participation -gap -animated -fate -excellent -##ett -na -87 -alternate -saints -youngest -##ily -climbed -##ita -##tors -suggest -##ct -discussion -staying -choir -lakes -jacket -revenue -nevertheless -peaked -instrument -wondering -annually -managing -neil -1891 -signing -terry -##ice -apply -clinical -brooklyn -aim -catherine -fuck -farmers -figured -ninth -pride -hugh -evolution -ordinary -involvement -comfortable -shouted -tech -encouraged -taiwan -representation -sharing -##lia -##em -panic -exact -cargo -competing -fat -cried -83 -1920s -occasions -pa -cabin -borders -utah -marcus -##isation -badly -muscles -##ance -victorian -transition -warner -bet -permission -##rin -slave -terrible -similarly -shares -seth -uefa -possession -medals -benefits -colleges -lowered -perfectly -mall -transit -##ye -##kar -publisher -##ened -harrison -deaths -elevation -##ae -asleep -machines -sigh -ash -hardly -argument -occasion -parent -leo -decline -1888 -contribution -##ua -concentration -1000 -opportunities -hispanic -guardian -extent -emotions -hips -mason -volumes -bloody -controversy -diameter -steady -mistake -phoenix -identify -violin -##sk -departure -richmond -spin -funeral -enemies -1864 -gear -literally -connor -random -sergeant -grab -confusion -1865 -transmission -informed -op -leaning -sacred -suspended -thinks -gates -portland -luck -agencies -yours -hull -expert -muscle -layer -practical -sculpture -jerusalem -latest -lloyd -statistics -deeper -recommended -warrior -arkansas -mess -supports -greg -eagle -1880 -recovered -rated -concerts -rushed -##ano -stops -eggs -files -premiere -keith -##vo -delhi -turner -pit -affair -belief -paint -##zing -mate -##ach -##ev -victim -##ology -withdrew -bonus -styles -fled -##ud -glasgow -technologies -funded -nbc -adaptation -##ata -portrayed -cooperation -supporters -judges -bernard -justin -hallway -ralph -##ick -graduating -controversial -distant -continental -spider -bite -##ho -recognize -intention -mixing -##ese -egyptian -bow -tourism -suppose -claiming -tiger -dominated -participants -vi -##ru -nurse -partially -tape -##rum -psychology -##rn -essential -touring -duo -voting -civilian -emotional -channels -##king -apparent -hebrew -1887 -tommy -carrier -intersection -beast -hudson -##gar -##zo -lab -nova -bench -discuss -costa -##ered -detailed -behalf -drivers -unfortunately -obtain -##lis -rocky -##dae -siege -friendship -honey -##rian -1861 -amy -hang -posted -governments -collins -respond -wildlife -preferred -operator -##po -laura -pregnant -videos -dennis -suspected -boots -instantly -weird -automatic -businessman -alleged -placing -throwing -ph -mood -1862 -perry -venue -jet -remainder -##lli -##ci -passion -biological -boyfriend -1863 -dirt -buffalo -ron -segment -fa -abuse -##era -genre -thrown -stroke -colored -stress -exercise -displayed -##gen -struggled -##tti -abroad -dramatic -wonderful -thereafter -madrid -component -widespread -##sed -tale -citizen -todd -monday -1886 -vancouver -overseas -forcing -crying -descent -##ris -discussed -substantial -ranks -regime -1870 -provinces -switch -drum -zane -ted -tribes -proof -lp -cream -researchers -volunteer -manor -silk -milan -donated -allies -venture -principle -delivery -enterprise -##ves -##ans -bars -traditionally -witch -reminded -copper -##uk -pete -inter -links -colin -grinned -elsewhere -competitive -frequent -##oy -scream -##hu -tension -texts -submarine -finnish -defending -defend -pat -detail -1884 -affiliated -stuart -themes -villa -periods -tool -belgian -ruling -crimes -answers -folded -licensed -resort -demolished -hans -lucy -1881 -lion -traded -photographs -writes -craig -##fa -trials -generated -beth -noble -debt -percentage -yorkshire -erected -ss -viewed -grades -confidence -ceased -islam -telephone -retail -##ible -chile -m² -roberts -sixteen -##ich -commented -hampshire -innocent -dual -pounds -checked -regulations -afghanistan -sung -rico -liberty -assets -bigger -options -angels -relegated -tribute -wells -attending -leaf -##yan -butler -romanian -forum -monthly -lisa -patterns -gmina -##tory -madison -hurricane -rev -##ians -bristol -##ula -elite -valuable -disaster -democracy -awareness -germans -freyja -##ins -loop -absolutely -paying -populations -maine -sole -prayer -spencer -releases -doorway -bull -##ani -lover -midnight -conclusion -##sson -thirteen -lily -mediterranean -##lt -nhl -proud -sample -##hill -drummer -guinea -##ova -murphy -climb -##ston -instant -attributed -horn -ain -railways -steven -##ao -autumn -ferry -opponent -root -traveling -secured -corridor -stretched -tales -sheet -trinity -cattle -helps -indicates -manhattan -murdered -fitted -1882 -gentle -grandmother -mines -shocked -vegas -produces -##light -caribbean -##ou -belong -continuous -desperate -drunk -historically -trio -waved -raf -dealing -nathan -bat -murmured -interrupted -residing -scientist -pioneer -harold -aaron -##net -delta -attempting -minority -mini -believes -chorus -tend -lots -eyed -indoor -load -shots -updated -jail -##llo -concerning -connecting -wealth -##ved -slaves -arrive -rangers -sufficient -rebuilt -##wick -cardinal -flood -muhammad -whenever -relation -runners -moral -repair -viewers -arriving -revenge -punk -assisted -bath -fairly -breathe -lists -innings -illustrated -whisper -nearest -voters -clinton -ties -ultimate -screamed -beijing -lions -andre -fictional -gathering -comfort -radar -suitable -dismissed -hms -ban -pine -wrist -atmosphere -voivodeship -bid -timber -##ned -##nan -giants -##ane -cameron -recovery -uss -identical -categories -switched -serbia -laughter -noah -ensemble -therapy -peoples -touching -##off -locally -pearl -platforms -everywhere -ballet -tables -lanka -herbert -outdoor -toured -derek -1883 -spaces -contested -swept -1878 -exclusive -slight -connections -##dra -winds -prisoner -collective -bangladesh -tube -publicly -wealthy -thai -##ys -isolated -select -##ric -insisted -pen -fortune -ticket -spotted -reportedly -animation -enforcement -tanks -110 -decides -wider -lowest -owen -##time -nod -hitting -##hn -gregory -furthermore -magazines -fighters -solutions -##ery -pointing -requested -peru -reed -chancellor -knights -mask -worker -eldest -flames -reduction -1860 -volunteers -##tis -reporting -##hl -wire -advisory -endemic -origins -settlers -pursue -knock -consumer -1876 -eu -compound -creatures -mansion -sentenced -ivan -deployed -guitars -frowned -involves -mechanism -kilometers -perspective -shops -maps -terminus -duncan -alien -fist -bridges -##pers -heroes -fed -derby -swallowed -##ros -patent -sara -illness -characterized -adventures -slide -hawaii -jurisdiction -##op -organised -##side -adelaide -walks -biology -se -##ties -rogers -swing -tightly -boundaries -##rie -prepare -implementation -stolen -##sha -certified -colombia -edwards -garage -##mm -recalled -##ball -rage -harm -nigeria -breast -##ren -furniture -pupils -settle -##lus -cuba -balls -client -alaska -21st -linear -thrust -celebration -latino -genetic -terror -##cia -##ening -lightning -fee -witness -lodge -establishing -skull -##ique -earning -hood -##ei -rebellion -wang -sporting -warned -missile -devoted -activist -porch -worship -fourteen -package -1871 -decorated -##shire -housed -##ock -chess -sailed -doctors -oscar -joan -treat -garcia -harbour -jeremy -##ire -traditions -dominant -jacques -##gon -##wan -relocated -1879 -amendment -sized -companion -simultaneously -volleyball -spun -acre -increases -stopping -loves -belongs -affect -drafted -tossed -scout -battles -1875 -filming -shoved -munich -tenure -vertical -romance -pc -##cher -argue -##ical -craft -ranging -www -opens -honest -tyler -yesterday -virtual -##let -muslims -reveal -snake -immigrants -radical -screaming -speakers -firing -saving -belonging -ease -lighting -prefecture -blame -farmer -hungry -grows -rubbed -beam -sur -subsidiary -##cha -armenian -sao -dropping -conventional -##fer -microsoft -reply -qualify -spots -1867 -sweat -festivals -##ken -immigration -physician -discover -exposure -sandy -explanation -isaac -implemented -##fish -hart -initiated -connect -stakes -presents -heights -householder -pleased -tourist -regardless -slip -closest -##ction -surely -sultan -brings -riley -preparation -aboard -slammed -baptist -experiment -ongoing -interstate -organic -playoffs -##ika -1877 -130 -##tar -hindu -error -tours -tier -plenty -arrangements -talks -trapped -excited -sank -ho -athens -1872 -denver -welfare -suburb -athletes -trick -diverse -belly -exclusively -yelled -1868 -##med -conversion -##ette -1874 -internationally -computers -conductor -abilities -sensitive -hello -dispute -measured -globe -rocket -prices -amsterdam -flights -tigers -inn -municipalities -emotion -references -3d -##mus -explains -airlines -manufactured -pm -archaeological -1873 -interpretation -devon -comment -##ites -settlements -kissing -absolute -improvement -suite -impressed -barcelona -sullivan -jefferson -towers -jesse -julie -##tin -##lu -grandson -hi -gauge -regard -rings -interviews -trace -raymond -thumb -departments -burns -serial -bulgarian -scores -demonstrated -##ix -1866 -kyle -alberta -underneath -romanized -##ward -relieved -acquisition -phrase -cliff -reveals -han -cuts -merger -custom -##dar -nee -gilbert -graduation -##nts -assessment -cafe -difficulty -demands -swung -democrat -jennifer -commons -1940s -grove -##yo -completing -focuses -sum -substitute -bearing -stretch -reception -##py -reflected -essentially -destination -pairs -##ched -survival -resource -##bach -promoting -doubles -messages -tear -##down -##fully -parade -florence -harvey -incumbent -partial -framework -900 -pedro -frozen -procedure -olivia -controls -##mic -shelter -personally -temperatures -##od -brisbane -tested -sits -marble -comprehensive -oxygen -leonard -##kov -inaugural -iranian -referring -quarters -attitude -##ivity -mainstream -lined -mars -dakota -norfolk -unsuccessful -##° -explosion -helicopter -congressional -##sing -inspector -bitch -seal -departed -divine -##ters -coaching -examination -punishment -manufacturer -sink -columns -unincorporated -signals -nevada -squeezed -dylan -dining -photos -martial -manuel -eighteen -elevator -brushed -plates -ministers -ivy -congregation -##len -slept -specialized -taxes -curve -restricted -negotiations -likes -statistical -arnold -inspiration -execution -bold -intermediate -significance -margin -ruler -wheels -gothic -intellectual -dependent -listened -eligible -buses -widow -syria -earn -cincinnati -collapsed -recipient -secrets -accessible -philippine -maritime -goddess -clerk -surrender -breaks -playoff -database -##ified -##lon -ideal -beetle -aspect -soap -regulation -strings -expand -anglo -shorter -crosses -retreat -tough -coins -wallace -directions -pressing -##oon -shipping -locomotives -comparison -topics -nephew -##mes -distinction -honors -travelled -sierra -ibn -##over -fortress -sa -recognised -carved -1869 -clients -##dan -intent -##mar -coaches -describing -bread -##ington -beaten -northwestern -##ona -merit -youtube -collapse -challenges -em -historians -objective -submitted -virus -attacking -drake -assume -##ere -diseases -marc -stem -leeds -##cus -##ab -farming -glasses -##lock -visits -nowhere -fellowship -relevant -carries -restaurants -experiments -101 -constantly -bases -targets -shah -tenth -opponents -verse -territorial -##ira -writings -corruption -##hs -instruction -inherited -reverse -emphasis -##vic -employee -arch -keeps -rabbi -watson -payment -uh -##ala -nancy -##tre -venice -fastest -sexy -banned -adrian -properly -ruth -touchdown -dollar -boards -metre -circles -edges -favour -comments -ok -travels -liberation -scattered -firmly -##ular -holland -permitted -diesel -kenya -den -originated -##ral -demons -resumed -dragged -rider -##rus -servant -blinked -extend -torn -##ias -##sey -input -meal -everybody -cylinder -kinds -camps -##fe -bullet -logic -##wn -croatian -evolved -healthy -fool -chocolate -wise -preserve -pradesh -##ess -respective -1850 -##ew -chicken -artificial -gross -corresponding -convicted -cage -caroline -dialogue -##dor -narrative -stranger -mario -br -christianity -failing -trent -commanding -buddhist -1848 -maurice -focusing -yale -bike -altitude -##ering -mouse -revised -##sley -veteran -##ig -pulls -theology -crashed -campaigns -legion -##ability -drag -excellence -customer -cancelled -intensity -excuse -##lar -liga -participating -contributing -printing -##burn -variable -##rk -curious -bin -legacy -renaissance -##my -symptoms -binding -vocalist -dancer -##nie -grammar -gospel -democrats -ya -enters -sc -diplomatic -hitler -##ser -clouds -mathematical -quit -defended -oriented -##heim -fundamental -hardware -impressive -equally -convince -confederate -guilt -chuck -sliding -##ware -magnetic -narrowed -petersburg -bulgaria -otto -phd -skill -##ama -reader -hopes -pitcher -reservoir -hearts -automatically -expecting -mysterious -bennett -extensively -imagined -seeds -monitor -fix -##ative -journalism -struggling -signature -ranch -encounter -photographer -observation -protests -##pin -influences -##hr -calendar -##all -cruz -croatia -locomotive -hughes -naturally -shakespeare -basement -hook -uncredited -faded -theories -approaches -dare -phillips -filling -fury -obama -##ain -efficient -arc -deliver -min -raid -breeding -inducted -leagues -efficiency -axis -montana -eagles -##ked -supplied -instructions -karen -picking -indicating -trap -anchor -practically -christians -tomb -vary -occasional -electronics -lords -readers -newcastle -faint -innovation -collect -situations -engagement -160 -claude -mixture -##feld -peer -tissue -logo -lean -##ration -°f -floors -##ven -architects -reducing -##our -##ments -rope -1859 -ottawa -##har -samples -banking -declaration -proteins -resignation -francois -saudi -advocate -exhibited -armor -twins -divorce -##ras -abraham -reviewed -jo -temporarily -matrix -physically -pulse -curled -##ena -difficulties -bengal -usage -##ban -annie -riders -certificate -##pi -holes -warsaw -distinctive -jessica -##mon -mutual -1857 -customs -circular -eugene -removal -loaded -mere -vulnerable -depicted -generations -dame -heir -enormous -lightly -climbing -pitched -lessons -pilots -nepal -ram -google -preparing -brad -louise -renowned -##â‚‚ -liam -##ably -plaza -shaw -sophie -brilliant -bills -##bar -##nik -fucking -mainland -server -pleasant -seized -veterans -jerked -fail -beta -brush -radiation -stored -warmth -southeastern -nate -sin -raced -berkeley -joke -athlete -designation -trunk -##low -roland -qualification -archives -heels -artwork -receives -judicial -reserves -##bed -woke -installation -abu -floating -fake -lesser -excitement -interface -concentrated -addressed -characteristic -amanda -saxophone -monk -auto -##bus -releasing -egg -dies -interaction -defender -ce -outbreak -glory -loving -##bert -sequel -consciousness -http -awake -ski -enrolled -##ress -handling -rookie -brow -somebody -biography -warfare -amounts -contracts -presentation -fabric -dissolved -challenged -meter -psychological -lt -elevated -rally -accurate -##tha -hospitals -undergraduate -specialist -venezuela -exhibit -shed -nursing -protestant -fluid -structural -footage -jared -consistent -prey -##ska -succession -reflect -exile -lebanon -wiped -suspect -shanghai -resting -integration -preservation -marvel -variant -pirates -sheep -rounded -capita -sailing -colonies -manuscript -deemed -variations -clarke -functional -emerging -boxing -relaxed -curse -azerbaijan -heavyweight -nickname -editorial -rang -grid -tightened -earthquake -flashed -miguel -rushing -##ches -improvements -boxes -brooks -180 -consumption -molecular -felix -societies -repeatedly -variation -aids -civic -graphics -professionals -realm -autonomous -receiver -delayed -workshop -militia -chairs -trump -canyon -##point -harsh -extending -lovely -happiness -##jan -stake -eyebrows -embassy -wellington -hannah -##ella -sony -corners -bishops -swear -cloth -contents -xi -namely -commenced -1854 -stanford -nashville -courage -graphic -commitment -garrison -##bin -hamlet -clearing -rebels -attraction -literacy -cooking -ruins -temples -jenny -humanity -celebrate -hasn -freight -sixty -rebel -bastard -##art -newton -##ada -deer -##ges -##ching -smiles -delaware -singers -##ets -approaching -assists -flame -##ph -boulevard -barrel -planted -##ome -pursuit -##sia -consequences -posts -shallow -invitation -rode -depot -ernest -kane -rod -concepts -preston -topic -chambers -striking -blast -arrives -descendants -montgomery -ranges -worlds -##lay -##ari -span -chaos -praise -##ag -fewer -1855 -sanctuary -mud -fbi -##ions -programmes -maintaining -unity -harper -bore -handsome -closure -tournaments -thunder -nebraska -linda -facade -puts -satisfied -argentine -dale -cork -dome -panama -##yl -1858 -tasks -experts -##ates -feeding -equation -##las -##ida -##tu -engage -bryan -##ax -um -quartet -melody -disbanded -sheffield -blocked -gasped -delay -kisses -maggie -connects -##non -sts -poured -creator -publishers -##we -guided -ellis -extinct -hug -gaining -##ord -complicated -##bility -poll -clenched -investigate -##use -thereby -quantum -spine -cdp -humor -kills -administered -semifinals -##du -encountered -ignore -##bu -commentary -##maker -bother -roosevelt -140 -plains -halfway -flowing -cultures -crack -imprisoned -neighboring -airline -##ses -##view -##mate -##ec -gather -wolves -marathon -transformed -##ill -cruise -organisations -carol -punch -exhibitions -numbered -alarm -ratings -daddy -silently -##stein -queens -colours -impression -guidance -liu -tactical -##rat -marshal -della -arrow -##ings -rested -feared -tender -owns -bitter -advisor -escort -##ides -spare -farms -grants -##ene -dragons -encourage -colleagues -cameras -##und -sucked -pile -spirits -prague -statements -suspension -landmark -fence -torture -recreation -bags -permanently -survivors -pond -spy -predecessor -bombing -coup -##og -protecting -transformation -glow -##lands -##book -dug -priests -andrea -feat -barn -jumping -##chen -##ologist -##con -casualties -stern -auckland -pipe -serie -revealing -ba -##bel -trevor -mercy -spectrum -yang -consist -governing -collaborated -possessed -epic -comprises -blew -shane -##ack -lopez -honored -magical -sacrifice -judgment -perceived -hammer -mtv -baronet -tune -das -missionary -sheets -350 -neutral -oral -threatening -attractive -shade -aims -seminary -##master -estates -1856 -michel -wounds -refugees -manufacturers -##nic -mercury -syndrome -porter -##iya -##din -hamburg -identification -upstairs -purse -widened -pause -cared -breathed -affiliate -santiago -prevented -celtic -fisher -125 -recruited -byzantine -reconstruction -farther -##mp -diet -sake -au -spite -sensation -##ert -blank -separation -105 -##hon -vladimir -armies -anime -##lie -accommodate -orbit -cult -sofia -archive -##ify -##box -founders -sustained -disorder -honours -northeastern -mia -crops -violet -threats -blanket -fires -canton -followers -southwestern -prototype -voyage -assignment -altered -moderate -protocol -pistol -##eo -questioned -brass -lifting -1852 -math -authored -##ual -doug -dimensional -dynamic -##san -1851 -pronounced -grateful -quest -uncomfortable -boom -presidency -stevens -relating -politicians -chen -barrier -quinn -diana -mosque -tribal -cheese -palmer -portions -sometime -chester -treasure -wu -bend -download -millions -reforms -registration -##osa -consequently -monitoring -ate -preliminary -brandon -invented -ps -eaten -exterior -intervention -ports -documented -log -displays -lecture -sally -favourite -##itz -vermont -lo -invisible -isle -breed -##ator -journalists -relay -speaks -backward -explore -midfielder -actively -stefan -procedures -cannon -blond -kenneth -centered -servants -chains -libraries -malcolm -essex -henri -slavery -##hal -facts -fairy -coached -cassie -cats -washed -cop -##fi -announcement -item -2000s -vinyl -activated -marco -frontier -growled -curriculum -##das -loyal -accomplished -leslie -ritual -kenny -##00 -vii -napoleon -hollow -hybrid -jungle -stationed -friedrich -counted -##ulated -platinum -theatrical -seated -col -rubber -glen -1840 -diversity -healing -extends -id -provisions -administrator -columbus -##oe -tributary -te -assured -org -##uous -prestigious -examined -lectures -grammy -ronald -associations -bailey -allan -essays -flute -believing -consultant -proceedings -travelling -1853 -kit -kerala -yugoslavia -buddy -methodist -##ith -burial -centres -batman -##nda -discontinued -bo -dock -stockholm -lungs -severely -##nk -citing -manga -##ugh -steal -mumbai -iraqi -robot -celebrity -bride -broadcasts -abolished -pot -joel -overhead -franz -packed -reconnaissance -johann -acknowledged -introduce -handled -doctorate -developments -drinks -alley -palestine -##nis -##aki -proceeded -recover -bradley -grain -patch -afford -infection -nationalist -legendary -##ath -interchange -virtually -gen -gravity -exploration -amber -vital -wishes -powell -doctrine -elbow -screenplay -##bird -contribute -indonesian -pet -creates -##com -enzyme -kylie -discipline -drops -manila -hunger -##ien -layers -suffer -fever -bits -monica -keyboard -manages -##hood -searched -appeals -##bad -testament -grande -reid -##war -beliefs -congo -##ification -##dia -si -requiring -##via -casey -1849 -regret -streak -rape -depends -syrian -sprint -pound -tourists -upcoming -pub -##xi -tense -##els -practiced -echo -nationwide -guild -motorcycle -liz -##zar -chiefs -desired -elena -bye -precious -absorbed -relatives -booth -pianist -##mal -citizenship -exhausted -wilhelm -##ceae -##hed -noting -quarterback -urge -hectares -##gue -ace -holly -##tal -blonde -davies -parked -sustainable -stepping -twentieth -airfield -galaxy -nest -chip -##nell -tan -shaft -paulo -requirement -##zy -paradise -tobacco -trans -renewed -vietnamese -##cker -##ju -suggesting -catching -holmes -enjoying -md -trips -colt -holder -butterfly -nerve -reformed -cherry -bowling -trailer -carriage -goodbye -appreciate -toy -joshua -interactive -enabled -involve -##kan -collar -determination -bunch -facebook -recall -shorts -superintendent -episcopal -frustration -giovanni -nineteenth -laser -privately -array -circulation -##ovic -armstrong -deals -painful -permit -discrimination -##wi -aires -retiring -cottage -ni -##sta -horizon -ellen -jamaica -ripped -fernando -chapters -playstation -patron -lecturer -navigation -behaviour -genes -georgian -export -solomon -rivals -swift -seventeen -rodriguez -princeton -independently -sox -1847 -arguing -entity -casting -hank -criteria -oakland -geographic -milwaukee -reflection -expanding -conquest -dubbed -##tv -halt -brave -brunswick -doi -arched -curtis -divorced -predominantly -somerset -streams -ugly -zoo -horrible -curved -buenos -fierce -dictionary -vector -theological -unions -handful -stability -chan -punjab -segments -##lly -altar -ignoring -gesture -monsters -pastor -##stone -thighs -unexpected -operators -abruptly -coin -compiled -associates -improving -migration -pin -##ose -compact -collegiate -reserved -##urs -quarterfinals -roster -restore -assembled -hurry -oval -##cies -1846 -flags -martha -##del -victories -sharply -##rated -argues -deadly -neo -drawings -symbols -performer -##iel -griffin -restrictions -editing -andrews -java -journals -arabia -compositions -dee -pierce -removing -hindi -casino -runway -civilians -minds -nasa -hotels -##zation -refuge -rent -retain -potentially -conferences -suburban -conducting -##tto -##tions -##tle -descended -massacre -##cal -ammunition -terrain -fork -souls -counts -chelsea -durham -drives -cab -##bank -perth -realizing -palestinian -finn -simpson -##dal -betty -##ule -moreover -particles -cardinals -tent -evaluation -extraordinary -##oid -inscription -##works -wednesday -chloe -maintains -panels -ashley -trucks -##nation -cluster -sunlight -strikes -zhang -##wing -dialect -canon -##ap -tucked -##ws -collecting -##mas -##can -##sville -maker -quoted -evan -franco -aria -buying -cleaning -eva -closet -provision -apollo -clinic -rat -##ez -necessarily -ac -##gle -##ising -venues -flipped -cent -spreading -trustees -checking -authorized -##sco -disappointed -##ado -notion -duration -trumpet -hesitated -topped -brussels -rolls -theoretical -hint -define -aggressive -repeat -wash -peaceful -optical -width -allegedly -mcdonald -strict -copyright -##illa -investors -mar -jam -witnesses -sounding -miranda -michelle -privacy -hugo -harmony -##pp -valid -lynn -glared -nina -102 -headquartered -diving -boarding -gibson -##ncy -albanian -marsh -routine -dealt -enhanced -er -intelligent -substance -targeted -enlisted -discovers -spinning -observations -pissed -smoking -rebecca -capitol -visa -varied -costume -seemingly -indies -compensation -surgeon -thursday -arsenal -westminster -suburbs -rid -anglican -##ridge -knots -foods -alumni -lighter -fraser -whoever -portal -scandal -##ray -gavin -advised -instructor -flooding -terrorist -##ale -teenage -interim -senses -duck -teen -thesis -abby -eager -overcome -##ile -newport -glenn -rises -shame -##cc -prompted -priority -forgot -bomber -nicolas -protective -360 -cartoon -katherine -breeze -lonely -trusted -henderson -richardson -relax -banner -candy -palms -remarkable -##rio -legends -cricketer -essay -ordained -edmund -rifles -trigger -##uri -##away -sail -alert -1830 -audiences -penn -sussex -siblings -pursued -indianapolis -resist -rosa -consequence -succeed -avoided -1845 -##ulation -inland -##tie -##nna -counsel -profession -chronicle -hurried -##una -eyebrow -eventual -bleeding -innovative -cure -##dom -committees -accounting -con -scope -hardy -heather -tenor -gut -herald -codes -tore -scales -wagon -##oo -luxury -tin -prefer -fountain -triangle -bonds -darling -convoy -dried -traced -beings -troy -accidentally -slam -findings -smelled -joey -lawyers -outcome -steep -bosnia -configuration -shifting -toll -brook -performers -lobby -philosophical -construct -shrine -aggregate -boot -cox -phenomenon -savage -insane -solely -reynolds -lifestyle -##ima -nationally -holdings -consideration -enable -edgar -mo -mama -##tein -fights -relegation -chances -atomic -hub -conjunction -awkward -reactions -currency -finale -kumar -underwent -steering -elaborate -gifts -comprising -melissa -veins -reasonable -sunshine -chi -solve -trails -inhabited -elimination -ethics -huh -ana -molly -consent -apartments -layout -marines -##ces -hunters -bulk -##oma -hometown -##wall -##mont -cracked -reads -neighbouring -withdrawn -admission -wingspan -damned -anthology -lancashire -brands -batting -forgive -cuban -awful -##lyn -104 -dimensions -imagination -##ade -dante -##ship -tracking -desperately -goalkeeper -##yne -groaned -workshops -confident -burton -gerald -milton -circus -uncertain -slope -copenhagen -sophia -fog -philosopher -portraits -accent -cycling -varying -gripped -larvae -garrett -specified -scotia -mature -luther -kurt -rap -##kes -aerial -750 -ferdinand -heated -es -transported -##shan -safely -nonetheless -##orn -##gal -motors -demanding -##sburg -startled -##brook -ally -generate -caps -ghana -stained -demo -mentions -beds -ap -afterward -diary -##bling -utility -##iro -richards -1837 -conspiracy -conscious -shining -footsteps -observer -cyprus -urged -loyalty -developer -probability -olive -upgraded -gym -miracle -insects -graves -1844 -ourselves -hydrogen -amazon -katie -tickets -poets -##pm -planes -##pan -prevention -witnessed -dense -jin -randy -tang -warehouse -monroe -bang -archived -elderly -investigations -alec -granite -mineral -conflicts -controlling -aboriginal -carlo -##zu -mechanics -stan -stark -rhode -skirt -est -##berry -bombs -respected -##horn -imposed -limestone -deny -nominee -memphis -grabbing -disabled -##als -amusement -aa -frankfurt -corn -referendum -varies -slowed -disk -firms -unconscious -incredible -clue -sue -##zhou -twist -##cio -joins -idaho -chad -developers -computing -destroyer -103 -mortal -tucker -kingston -choices -yu -carson -1800 -os -whitney -geneva -pretend -dimension -staged -plateau -maya -##une -freestyle -##bc -rovers -hiv -##ids -tristan -classroom -prospect -##hus -honestly -diploma -lied -thermal -auxiliary -feast -unlikely -iata -##tel -morocco -pounding -treasury -lithuania -considerably -1841 -dish -1812 -geological -matching -stumbled -destroying -marched -brien -advances -cake -nicole -belle -settling -measuring -directing -##mie -tuesday -bassist -capabilities -stunned -fraud -torpedo -##list -##phone -anton -wisdom -surveillance -ruined -##ulate -lawsuit -healthcare -theorem -halls -trend -aka -horizontal -dozens -acquire -lasting -swim -hawk -gorgeous -fees -vicinity -decrease -adoption -tactics -##ography -pakistani -##ole -draws -##hall -willie -burke -heath -algorithm -integral -powder -elliott -brigadier -jackie -tate -varieties -darker -##cho -lately -cigarette -specimens -adds -##ree -##ensis -##inger -exploded -finalist -cia -murders -wilderness -arguments -nicknamed -acceptance -onwards -manufacture -robertson -jets -tampa -enterprises -blog -loudly -composers -nominations -1838 -ai -malta -inquiry -automobile -hosting -viii -rays -tilted -grief -museums -strategies -furious -euro -equality -cohen -poison -surrey -wireless -governed -ridiculous -moses -##esh -##room -vanished -##ito -barnes -attract -morrison -istanbul -##iness -absent -rotation -petition -janet -##logical -satisfaction -custody -deliberately -observatory -comedian -surfaces -pinyin -novelist -strictly -canterbury -oslo -monks -embrace -ibm -jealous -photograph -continent -dorothy -marina -doc -excess -holden -allegations -explaining -stack -avoiding -lance -storyline -majesty -poorly -spike -dos -bradford -raven -travis -classics -proven -voltage -pillow -fists -butt -1842 -interpreted -##car -1839 -gage -telegraph -lens -promising -expelled -casual -collector -zones -##min -silly -nintendo -##kh -##bra -downstairs -chef -suspicious -afl -flies -vacant -uganda -pregnancy -condemned -lutheran -estimates -cheap -decree -saxon -proximity -stripped -idiot -deposits -contrary -presenter -magnus -glacier -im -offense -edwin -##ori -upright -##long -bolt -##ois -toss -geographical -##izes -environments -delicate -marking -abstract -xavier -nails -windsor -plantation -occurring -equity -saskatchewan -fears -drifted -sequences -vegetation -revolt -##stic -1843 -sooner -fusion -opposing -nato -skating -1836 -secretly -ruin -lease -##oc -edit -##nne -flora -anxiety -ruby -##ological -##mia -tel -bout -taxi -emmy -frost -rainbow -compounds -foundations -rainfall -assassination -nightmare -dominican -##win -achievements -deserve -orlando -intact -armenia -##nte -calgary -valentine -106 -marion -proclaimed -theodore -bells -courtyard -thigh -gonzalez -console -troop -minimal -monte -everyday -##ence -##if -supporter -terrorism -buck -openly -presbyterian -activists -carpet -##iers -rubbing -uprising -##yi -cute -conceived -legally -##cht -millennium -cello -velocity -ji -rescued -cardiff -1835 -rex -concentrate -senators -beard -rendered -glowing -battalions -scouts -competitors -sculptor -catalogue -arctic -ion -raja -bicycle -wow -glancing -lawn -##woman -gentleman -lighthouse -publish -predicted -calculated -##val -variants -##gne -strain -##ui -winston -deceased -##nus -touchdowns -brady -caleb -sinking -echoed -crush -hon -blessed -protagonist -hayes -endangered -magnitude -editors -##tine -estimate -responsibilities -##mel -backup -laying -consumed -sealed -zurich -lovers -frustrated -##eau -ahmed -kicking -mit -treasurer -1832 -biblical -refuse -terrified -pump -agrees -genuine -imprisonment -refuses -plymouth -##hen -lou -##nen -tara -trembling -antarctic -ton -learns -##tas -crap -crucial -faction -atop -##borough -wrap -lancaster -odds -hopkins -erik -lyon -##eon -bros -##ode -snap -locality -tips -empress -crowned -cal -acclaimed -chuckled -##ory -clara -sends -mild -towel -##fl -##day -##а -wishing -assuming -interviewed -##bal -##die -interactions -eden -cups -helena -##lf -indie -beck -##fire -batteries -filipino -wizard -parted -##lam -traces -##born -rows -idol -albany -delegates -##ees -##sar -discussions -##ex -notre -instructed -belgrade -highways -suggestion -lauren -possess -orientation -alexandria -abdul -beats -salary -reunion -ludwig -alright -wagner -intimate -pockets -slovenia -hugged -brighton -merchants -cruel -stole -trek -slopes -repairs -enrollment -politically -underlying -promotional -counting -boeing -##bb -isabella -naming -##и -keen -bacteria -listing -separately -belfast -ussr -450 -lithuanian -anybody -ribs -sphere -martinez -cock -embarrassed -proposals -fragments -nationals -##fs -##wski -premises -fin -1500 -alpine -matched -freely -bounded -jace -sleeve -##af -gaming -pier -populated -evident -##like -frances -flooded -##dle -frightened -pour -trainer -framed -visitor -challenging -pig -wickets -##fold -infected -email -##pes -arose -##aw -reward -ecuador -oblast -vale -ch -shuttle -##usa -bach -rankings -forbidden -cornwall -accordance -salem -consumers -bruno -fantastic -toes -machinery -resolved -julius -remembering -propaganda -iceland -bombardment -tide -contacts -wives -##rah -concerto -macdonald -albania -implement -daisy -tapped -sudan -helmet -angela -mistress -##lic -crop -sunk -finest -##craft -hostile -##ute -##tsu -boxer -fr -paths -adjusted -habit -ballot -supervision -soprano -##zen -bullets -wicked -sunset -regiments -disappear -lamp -performs -app -##gia -##oa -rabbit -digging -incidents -entries -##cion -dishes -##oi -introducing -##ati -##fied -freshman -slot -jill -tackles -baroque -backs -##iest -lone -sponsor -destiny -altogether -convert -##aro -consensus -shapes -demonstration -basically -feminist -auction -artifacts -##bing -strongest -twitter -halifax -2019 -allmusic -mighty -smallest -precise -alexandra -viola -##los -##ille -manuscripts -##illo -dancers -ari -managers -monuments -blades -barracks -springfield -maiden -consolidated -electron -##end -berry -airing -wheat -nobel -inclusion -blair -payments -geography -bee -cc -eleanor -react -##hurst -afc -manitoba -##yu -su -lineup -fitness -recreational -investments -airborne -disappointment -##dis -edmonton -viewing -##row -renovation -##cast -infant -bankruptcy -roses -aftermath -pavilion -##yer -carpenter -withdrawal -ladder -##hy -discussing -popped -reliable -agreements -rochester -##abad -curves -bombers -220 -rao -reverend -decreased -choosing -107 -stiff -consulting -naples -crawford -tracy -ka -ribbon -cops -##lee -crushed -deciding -unified -teenager -accepting -flagship -explorer -poles -sanchez -inspection -revived -skilled -induced -exchanged -flee -locals -tragedy -swallow -loading -hanna -demonstrate -##ela -salvador -flown -contestants -civilization -##ines -wanna -rhodes -fletcher -hector -knocking -considers -##ough -nash -mechanisms -sensed -mentally -walt -unclear -##eus -renovated -madame -##cks -crews -governmental -##hin -undertaken -monkey -##ben -##ato -fatal -armored -copa -caves -governance -grasp -perception -certification -froze -damp -tugged -wyoming -##rg -##ero -newman -##lor -nerves -curiosity -graph -115 -##ami -withdraw -tunnels -dull -meredith -moss -exhibits -neighbors -communicate -accuracy -explored -raiders -republicans -secular -kat -superman -penny -criticised -##tch -freed -update -conviction -wade -ham -likewise -delegation -gotta -doll -promises -technological -myth -nationality -resolve -convent -##mark -sharon -dig -sip -coordinator -entrepreneur -fold -##dine -capability -councillor -synonym -blown -swan -cursed -1815 -jonas -haired -sofa -canvas -keeper -rivalry -##hart -rapper -speedway -swords -postal -maxwell -estonia -potter -recurring -##nn -##ave -errors -##oni -cognitive -1834 -##² -claws -nadu -roberto -bce -wrestler -ellie -##ations -infinite -ink -##tia -presumably -finite -staircase -108 -noel -patricia -nacional -##cation -chill -eternal -tu -preventing -prussia -fossil -limbs -##logist -ernst -frog -perez -rene -##ace -pizza -prussian -##ios -##vy -molecules -regulatory -answering -opinions -sworn -lengths -supposedly -hypothesis -upward -habitats -seating -ancestors -drank -yield -hd -synthesis -researcher -modest -##var -mothers -peered -voluntary -homeland -##the -acclaim -##igan -static -valve -luxembourg -alto -carroll -fe -receptor -norton -ambulance -##tian -johnston -catholics -depicting -jointly -elephant -gloria -mentor -badge -ahmad -distinguish -remarked -councils -precisely -allison -advancing -detection -crowded -##10 -cooperative -ankle -mercedes -dagger -surrendered -pollution -commit -subway -jeffrey -lesson -sculptures -provider -##fication -membrane -timothy -rectangular -fiscal -heating -teammate -basket -particle -anonymous -deployment -##ple -missiles -courthouse -proportion -shoe -sec -##ller -complaints -forbes -blacks -abandon -remind -sizes -overwhelming -autobiography -natalie -##awa -risks -contestant -countryside -babies -scorer -invaded -enclosed -proceed -hurling -disorders -##cu -reflecting -continuously -cruiser -graduates -freeway -investigated -ore -deserved -maid -blocking -phillip -jorge -shakes -dove -mann -variables -lacked -burden -accompanying -que -consistently -organizing -provisional -complained -endless -##rm -tubes -juice -georges -krishna -mick -labels -thriller -##uch -laps -arcade -sage -snail -##table -shannon -fi -laurence -seoul -vacation -presenting -hire -churchill -surprisingly -prohibited -savannah -technically -##oli -170 -##lessly -testimony -suited -speeds -toys -romans -mlb -flowering -measurement -talented -kay -settings -charleston -expectations -shattered -achieving -triumph -ceremonies -portsmouth -lanes -mandatory -loser -stretching -cologne -realizes -seventy -cornell -careers -webb -##ulating -americas -budapest -ava -suspicion -##ison -yo -conrad -##hai -sterling -jessie -rector -##az -1831 -transform -organize -loans -christine -volcanic -warrant -slender -summers -subfamily -newer -danced -dynamics -rhine -proceeds -heinrich -gastropod -commands -sings -facilitate -easter -ra -positioned -responses -expense -fruits -yanked -imported -25th -velvet -vic -primitive -tribune -baldwin -neighbourhood -donna -rip -hay -pr -##uro -1814 -espn -welcomed -##aria -qualifier -glare -highland -timing -##cted -shells -eased -geometry -louder -exciting -slovakia -##sion -##iz -##lot -savings -prairie -##ques -marching -rafael -tonnes -##lled -curtain -preceding -shy -heal -greene -worthy -##pot -detachment -bury -sherman -##eck -reinforced -seeks -bottles -contracted -duchess -outfit -walsh -##sc -mickey -##ase -geoffrey -archer -squeeze -dawson -eliminate -invention -##enberg -neal -##eth -stance -dealer -coral -maple -retire -polo -simplified -##ht -1833 -hid -watts -backwards -jules -##oke -genesis -mt -frames -rebounds -burma -woodland -moist -santos -whispers -drained -subspecies -##aa -streaming -ulster -burnt -correspondence -maternal -gerard -denis -stealing -##load -genius -duchy -##oria -inaugurated -momentum -suits -placement -sovereign -clause -thames -##hara -confederation -reservation -sketch -yankees -lets -rotten -charm -hal -verses -ultra -commercially -dot -salon -citation -adopt -winnipeg -mist -allocated -cairo -##boy -jenkins -interference -objectives -##wind -1820 -portfolio -armoured -sectors -##eh -initiatives -##world -integrity -exercises -robe -tap -ab -gazed -##tones -distracted -rulers -111 -favorable -jerome -tended -cart -factories -##eri -diplomat -valued -gravel -charitable -##try -calvin -exploring -chang -shepherd -terrace -pdf -pupil -##ural -reflects -ups -##rch -governors -shelf -depths -##nberg -trailed -crest -tackle -##nian -##ats -hatred -##kai -clare -makers -ethiopia -longtime -detected -embedded -lacking -slapped -rely -thomson -anticipation -iso -morton -successive -agnes -screenwriter -straightened -philippe -playwright -haunted -licence -iris -intentions -sutton -112 -logical -correctly -##weight -branded -licked -tipped -silva -ricky -narrator -requests -##ents -greeted -supernatural -cow -##wald -lung -refusing -employer -strait -gaelic -liner -##piece -zoe -sabha -##mba -driveway -harvest -prints -bates -reluctantly -threshold -algebra -ira -wherever -coupled -240 -assumption -picks -##air -designers -raids -gentlemen -##ean -roller -blowing -leipzig -locks -screw -dressing -strand -##lings -scar -dwarf -depicts -##nu -nods -##mine -differ -boris -##eur -yuan -flip -##gie -mob -invested -questioning -applying -##ture -shout -##sel -gameplay -blamed -illustrations -bothered -weakness -rehabilitation -##of -##zes -envelope -rumors -miners -leicester -subtle -kerry -##ico -ferguson -##fu -premiership -ne -##cat -bengali -prof -catches -remnants -dana -##rily -shouting -presidents -baltic -ought -ghosts -dances -sailors -shirley -fancy -dominic -##bie -madonna -##rick -bark -buttons -gymnasium -ashes -liver -toby -oath -providence -doyle -evangelical -nixon -cement -carnegie -embarked -hatch -surroundings -guarantee -needing -pirate -essence -##bee -filter -crane -hammond -projected -immune -percy -twelfth -##ult -regent -doctoral -damon -mikhail -##ichi -lu -critically -elect -realised -abortion -acute -screening -mythology -steadily -##fc -frown -nottingham -kirk -wa -minneapolis -##rra -module -algeria -mc -nautical -encounters -surprising -statues -availability -shirts -pie -alma -brows -munster -mack -soup -crater -tornado -sanskrit -cedar -explosive -bordered -dixon -planets -stamp -exam -happily -##bble -carriers -kidnapped -##vis -accommodation -emigrated -##met -knockout -correspondent -violation -profits -peaks -lang -specimen -agenda -ancestry -pottery -spelling -equations -obtaining -ki -linking -1825 -debris -asylum -##20 -buddhism -teddy -##ants -gazette -##nger -##sse -dental -eligibility -utc -fathers -averaged -zimbabwe -francesco -coloured -hissed -translator -lynch -mandate -humanities -mackenzie -uniforms -lin -##iana -##gio -asset -mhz -fitting -samantha -genera -wei -rim -beloved -shark -riot -entities -expressions -indo -carmen -slipping -owing -abbot -neighbor -sidney -##av -rats -recommendations -encouraging -squadrons -anticipated -commanders -conquered -##oto -donations -diagnosed -##mond -divide -##iva -guessed -decoration -vernon -auditorium -revelation -conversations -##kers -##power -herzegovina -dash -alike -protested -lateral -herman -accredited -mg -##gent -freeman -mel -fiji -crow -crimson -##rine -livestock -##pped -humanitarian -bored -oz -whip -##lene -##ali -legitimate -alter -grinning -spelled -anxious -oriental -wesley -##nin -##hole -carnival -controller -detect -##ssa -bowed -educator -kosovo -macedonia -##sin -occupy -mastering -stephanie -janeiro -para -unaware -nurses -noon -135 -cam -hopefully -ranger -combine -sociology -polar -rica -##eer -neill -##sman -holocaust -##ip -doubled -lust -1828 -109 -decent -cooling -unveiled -##card -1829 -nsw -homer -chapman -meyer -##gin -dive -mae -reagan -expertise -##gled -darwin -brooke -sided -prosecution -investigating -comprised -petroleum -genres -reluctant -differently -trilogy -johns -vegetables -corpse -highlighted -lounge -pension -unsuccessfully -elegant -aided -ivory -beatles -amelia -cain -dubai -sunny -immigrant -babe -click -##nder -underwater -pepper -combining -mumbled -atlas -horns -accessed -ballad -physicians -homeless -gestured -rpm -freak -louisville -corporations -patriots -prizes -rational -warn -modes -decorative -overnight -din -troubled -phantom -##ort -monarch -sheer -##dorf -generals -guidelines -organs -addresses -##zon -enhance -curling -parishes -cord -##kie -linux -caesar -deutsche -bavaria -##bia -coleman -cyclone -##eria -bacon -petty -##yama -##old -hampton -diagnosis -1824 -throws -complexity -rita -disputed -##₃ -pablo -##sch -marketed -trafficking -##ulus -examine -plague -formats -##oh -vault -faithful -##bourne -webster -##ox -highlights -##ient -##ann -phones -vacuum -sandwich -modeling -##gated -bolivia -clergy -qualities -isabel -##nas -##ars -wears -screams -reunited -annoyed -bra -##ancy -##rate -differential -transmitter -tattoo -container -poker -##och -excessive -resides -cowboys -##tum -augustus -trash -providers -statute -retreated -balcony -reversed -void -storey -preceded -masses -leap -laughs -neighborhoods -wards -schemes -falcon -santo -battlefield -pad -ronnie -thread -lesbian -venus -##dian -beg -sandstone -daylight -punched -gwen -analog -stroked -wwe -acceptable -measurements -dec -toxic -##kel -adequate -surgical -economist -parameters -varsity -##sberg -quantity -ella -##chy -##rton -countess -generating -precision -diamonds -expressway -ga -##ı -1821 -uruguay -talents -galleries -expenses -scanned -colleague -outlets -ryder -lucien -##ila -paramount -##bon -syracuse -dim -fangs -gown -sweep -##sie -toyota -missionaries -websites -##nsis -sentences -adviser -val -trademark -spells -##plane -patience -starter -slim -##borg -toe -incredibly -shoots -elliot -nobility -##wyn -cowboy -endorsed -gardner -tendency -persuaded -organisms -emissions -kazakhstan -amused -boring -chips -themed -##hand -llc -constantinople -chasing -systematic -guatemala -borrowed -erin -carey -##hard -highlands -struggles -1810 -##ifying -##ced -wong -exceptions -develops -enlarged -kindergarten -castro -##ern -##rina -leigh -zombie -juvenile -##most -consul -##nar -sailor -hyde -clarence -intensive -pinned -nasty -useless -jung -clayton -stuffed -exceptional -ix -apostolic -230 -transactions -##dge -exempt -swinging -cove -religions -##ash -shields -dairy -bypass -190 -pursuing -bug -joyce -bombay -chassis -southampton -chat -interact -redesignated -##pen -nascar -pray -salmon -rigid -regained -malaysian -grim -publicity -constituted -capturing -toilet -delegate -purely -tray -drift -loosely -striker -weakened -trinidad -mitch -itv -defines -transmitted -ming -scarlet -nodding -fitzgerald -fu -narrowly -sp -tooth -standings -virtue -##₁ -##wara -##cting -chateau -gloves -lid -##nel -hurting -conservatory -##pel -sinclair -reopened -sympathy -nigerian -strode -advocated -optional -chronic -discharge -##rc -suck -compatible -laurel -stella -shi -fails -wage -dodge -128 -informal -sorts -levi -buddha -villagers -##aka -chronicles -heavier -summoned -gateway -3000 -eleventh -jewelry -translations -accordingly -seas -##ency -fiber -pyramid -cubic -dragging -##ista -caring -##ops -android -contacted -lunar -##dt -kai -lisbon -patted -1826 -sacramento -theft -madagascar -subtropical -disputes -ta -holidays -piper -willow -mare -cane -itunes -newfoundland -benny -companions -dong -raj -observe -roar -charming -plaque -tibetan -fossils -enacted -manning -bubble -tina -tanzania -##eda -##hir -funk -swamp -deputies -cloak -ufc -scenario -par -scratch -metals -anthem -guru -engaging -specially -##boat -dialects -nineteen -cecil -duet -disability -messenger -unofficial -##lies -defunct -eds -moonlight -drainage -surname -puzzle -honda -switching -conservatives -mammals -knox -broadcaster -sidewalk -cope -##ried -benson -princes -peterson -##sal -bedford -sharks -eli -wreck -alberto -gasp -archaeology -lgbt -teaches -securities -madness -compromise -waving -coordination -davidson -visions -leased -possibilities -eighty -jun -fernandez -enthusiasm -assassin -sponsorship -reviewer -kingdoms -estonian -laboratories -##fy -##nal -applies -verb -celebrations -##zzo -rowing -lightweight -sadness -submit -mvp -balanced -dude -##vas -explicitly -metric -magnificent -mound -brett -mohammad -mistakes -irregular -##hing -##ass -sanders -betrayed -shipped -surge -##enburg -reporters -termed -georg -pity -verbal -bulls -abbreviated -enabling -appealed -##are -##atic -sicily -sting -heel -sweetheart -bart -spacecraft -brutal -monarchy -##tter -aberdeen -cameo -diane -##ub -survivor -clyde -##aries -complaint -##makers -clarinet -delicious -chilean -karnataka -coordinates -1818 -panties -##rst -pretending -ar -dramatically -kiev -bella -tends -distances -113 -catalog -launching -instances -telecommunications -portable -lindsay -vatican -##eim -angles -aliens -marker -stint -screens -bolton -##rne -judy -wool -benedict -plasma -europa -spark -imaging -filmmaker -swiftly -##een -contributor -##nor -opted -stamps -apologize -financing -butter -gideon -sophisticated -alignment -avery -chemicals -yearly -speculation -prominence -professionally -##ils -immortal -institutional -inception -wrists -identifying -tribunal -derives -gains -##wo -papal -preference -linguistic -vince -operative -brewery -##ont -unemployment -boyd -##ured -##outs -albeit -prophet -1813 -bi -##rr -##face -##rad -quarterly -asteroid -cleaned -radius -temper -##llen -telugu -jerk -viscount -menu -##ote -glimpse -##aya -yacht -hawaiian -baden -##rl -laptop -readily -##gu -monetary -offshore -scots -watches -##yang -##arian -upgrade -needle -xbox -lea -encyclopedia -flank -fingertips -##pus -delight -teachings -confirm -roth -beaches -midway -winters -##iah -teasing -daytime -beverly -gambling -bonnie -##backs -regulated -clement -hermann -tricks -knot -##shing -##uring -##vre -detached -ecological -owed -specialty -byron -inventor -bats -stays -screened -unesco -midland -trim -affection -##ander -##rry -jess -thoroughly -feedback -##uma -chennai -strained -heartbeat -wrapping -overtime -pleaded -##sworth -mon -leisure -oclc -##tate -##ele -feathers -angelo -thirds -nuts -surveys -clever -gill -commentator -##dos -darren -rides -gibraltar -##nc -##mu -dissolution -dedication -shin -meals -saddle -elvis -reds -chaired -taller -appreciation -functioning -niece -favored -advocacy -robbie -criminals -suffolk -yugoslav -passport -constable -congressman -hastings -vera -##rov -consecrated -sparks -ecclesiastical -confined -##ovich -muller -floyd -nora -1822 -paved -1827 -cumberland -ned -saga -spiral -##flow -appreciated -yi -collaborative -treating -similarities -feminine -finishes -##ib -jade -import -##nse -##hot -champagne -mice -securing -celebrities -helsinki -attributes -##gos -cousins -phases -ache -lucia -gandhi -submission -vicar -spear -shine -tasmania -biting -detention -constitute -tighter -seasonal -##gus -terrestrial -matthews -##oka -effectiveness -parody -philharmonic -##onic -1816 -strangers -encoded -consortium -guaranteed -regards -shifts -tortured -collision -supervisor -inform -broader -insight -theaters -armour -emeritus -blink -incorporates -mapping -##50 -##ein -handball -flexible -##nta -substantially -generous -thief -##own -carr -loses -1793 -prose -ucla -romeo -generic -metallic -realization -damages -mk -commissioners -zach -default -##ther -helicopters -lengthy -stems -spa -partnered -spectators -rogue -indication -penalties -teresa -1801 -sen -##tric -dalton -##wich -irving -photographic -##vey -dell -deaf -peters -excluded -unsure -##vable -patterson -crawled -##zio -resided -whipped -latvia -slower -ecole -pipes -employers -maharashtra -comparable -va -textile -pageant -##gel -alphabet -binary -irrigation -chartered -choked -antoine -offs -waking -supplement -##wen -quantities -demolition -regain -locate -urdu -folks -alt -114 -##mc -scary -andreas -whites -##ava -classrooms -mw -aesthetic -publishes -valleys -guides -cubs -johannes -bryant -conventions -affecting -##itt -drain -awesome -isolation -prosecutor -ambitious -apology -captive -downs -atmospheric -lorenzo -aisle -beef -foul -##onia -kidding -composite -disturbed -illusion -natives -##ffer -emi -rockets -riverside -wartime -painters -adolf -melted -##ail -uncertainty -simulation -hawks -progressed -meantime -builder -spray -breach -unhappy -regina -russians -##urg -determining -##tation -tram -1806 -##quin -aging -##12 -1823 -garion -rented -mister -diaz -terminated -clip -1817 -depend -nervously -disco -owe -defenders -shiva -notorious -disbelief -shiny -worcester -##gation -##yr -trailing -undertook -islander -belarus -limitations -watershed -fuller -overlooking -utilized -raphael -1819 -synthetic -breakdown -klein -##nate -moaned -memoir -lamb -practicing -##erly -cellular -arrows -exotic -##graphy -witches -117 -charted -rey -hut -hierarchy -subdivision -freshwater -giuseppe -aloud -reyes -qatar -marty -sideways -utterly -sexually -jude -prayers -mccarthy -softball -blend -damien -##gging -##metric -wholly -erupted -lebanese -negro -revenues -tasted -comparative -teamed -transaction -labeled -maori -sovereignty -parkway -trauma -gran -malay -121 -advancement -descendant -2020 -buzz -salvation -inventory -symbolic -##making -antarctica -mps -##gas -##bro -mohammed -myanmar -holt -submarines -tones -##lman -locker -patriarch -bangkok -emerson -remarks -predators -kin -afghan -confession -norwich -rental -emerge -advantages -##zel -rca -##hold -shortened -storms -aidan -##matic -autonomy -compliance -##quet -dudley -atp -##osis -1803 -motto -documentation -summary -professors -spectacular -christina -archdiocese -flashing -innocence -remake -##dell -psychic -reef -scare -employ -rs -sticks -meg -gus -leans -##ude -accompany -bergen -tomas -##iko -doom -wages -pools -##nch -##bes -breasts -scholarly -alison -outline -brittany -breakthrough -willis -realistic -##cut -##boro -competitor -##stan -pike -picnic -icon -designing -commercials -washing -villain -skiing -micro -costumes -auburn -halted -executives -##hat -logistics -cycles -vowel -applicable -barrett -exclaimed -eurovision -eternity -ramon -##umi -##lls -modifications -sweeping -disgust -##uck -torch -aviv -ensuring -rude -dusty -sonic -donovan -outskirts -cu -pathway -##band -##gun -##lines -disciplines -acids -cadet -paired -##40 -sketches -##sive -marriages -##⁺ -folding -peers -slovak -implies -admired -##beck -1880s -leopold -instinct -attained -weston -megan -horace -##ination -dorsal -ingredients -evolutionary -##its -complications -deity -lethal -brushing -levy -deserted -institutes -posthumously -delivering -telescope -coronation -motivated -rapids -luc -flicked -pays -volcano -tanner -weighed -##nica -crowds -frankie -gifted -addressing -granddaughter -winding -##rna -constantine -gomez -##front -landscapes -rudolf -anthropology -slate -werewolf -##lio -astronomy -circa -rouge -dreaming -sack -knelt -drowned -naomi -prolific -tracked -freezing -herb -##dium -agony -randall -twisting -wendy -deposit -touches -vein -wheeler -##bbled -##bor -batted -retaining -tire -presently -compare -specification -daemon -nigel -##grave -merry -recommendation -czechoslovakia -sandra -ng -roma -##sts -lambert -inheritance -sheikh -winchester -cries -examining -##yle -comeback -cuisine -nave -##iv -ko -retrieve -tomatoes -barker -polished -defining -irene -lantern -personalities -begging -tract -swore -1809 -175 -##gic -omaha -brotherhood -##rley -haiti -##ots -exeter -##ete -##zia -steele -dumb -pearson -210 -surveyed -elisabeth -trends -##ef -fritz -##rf -premium -bugs -fraction -calmly -viking -##birds -tug -inserted -unusually -##ield -confronted -distress -crashing -brent -turks -resign -##olo -cambodia -gabe -sauce -##kal -evelyn -116 -extant -clusters -quarry -teenagers -luna -##lers -##ister -affiliation -drill -##ashi -panthers -scenic -libya -anita -strengthen -inscriptions -##cated -lace -sued -judith -riots -##uted -mint -##eta -preparations -midst -dub -challenger -##vich -mock -cf -displaced -wicket -breaths -enables -schmidt -analyst -##lum -ag -highlight -automotive -axe -josef -newark -sufficiently -resembles -50th -##pal -flushed -mum -traits -##ante -commodore -incomplete -warming -titular -ceremonial -ethical -118 -celebrating -eighteenth -cao -lima -medalist -mobility -strips -snakes -##city -miniature -zagreb -barton -escapes -umbrella -automated -doubted -differs -cooled -georgetown -dresden -cooked -fade -wyatt -rna -jacobs -carlton -abundant -stereo -boost -madras -inning -##hia -spur -ip -malayalam -begged -osaka -groan -escaping -charging -dose -vista -##aj -bud -papa -communists -advocates -edged -tri -##cent -resemble -peaking -necklace -fried -montenegro -saxony -goose -glances -stuttgart -curator -recruit -grocery -sympathetic -##tting -##fort -127 -lotus -randolph -ancestor -##rand -succeeding -jupiter -1798 -macedonian -##heads -hiking -1808 -handing -fischer -##itive -garbage -node -##pies -prone -singular -papua -inclined -attractions -italia -pouring -motioned -grandma -garnered -jacksonville -corp -ego -ringing -aluminum -##hausen -ordering -##foot -drawer -traders -synagogue -##play -##kawa -resistant -wandering -fragile -fiona -teased -var -hardcore -soaked -jubilee -decisive -exposition -mercer -poster -valencia -hale -kuwait -1811 -##ises -##wr -##eed -tavern -gamma -122 -johan -##uer -airways -amino -gil -##ury -vocational -domains -torres -##sp -generator -folklore -outcomes -##keeper -canberra -shooter -fl -beams -confrontation -##lling -##gram -feb -aligned -forestry -pipeline -jax -motorway -conception -decay -##tos -coffin -##cott -stalin -1805 -escorted -minded -##nam -sitcom -purchasing -twilight -veronica -additions -passive -tensions -straw -123 -frequencies -1804 -refugee -cultivation -##iate -christie -clary -bulletin -crept -disposal -##rich -##zong -processor -crescent -##rol -bmw -emphasized -whale -nazis -aurora -##eng -dwelling -hauled -sponsors -toledo -mega -ideology -theatres -tessa -cerambycidae -saves -turtle -cone -suspects -kara -rusty -yelling -greeks -mozart -shades -cocked -participant -##tro -shire -spit -freeze -necessity -##cos -inmates -nielsen -councillors -loaned -uncommon -omar -peasants -botanical -offspring -daniels -formations -jokes -1794 -pioneers -sigma -licensing -##sus -wheelchair -polite -1807 -liquor -pratt -trustee -##uta -forewings -balloon -##zz -kilometre -camping -explicit -casually -shawn -foolish -teammates -nm -hassan -carrie -judged -satisfy -vanessa -knives -selective -cnn -flowed -##lice -eclipse -stressed -eliza -mathematician -cease -cultivated -##roy -commissions -browns -##ania -destroyers -sheridan -meadow -##rius -minerals -##cial -downstream -clash -gram -memoirs -ventures -baha -seymour -archie -midlands -edith -fare -flynn -invite -canceled -tiles -stabbed -boulder -incorporate -amended -camden -facial -mollusk -unreleased -descriptions -yoga -grabs -550 -raises -ramp -shiver -##rose -coined -pioneering -tunes -qing -warwick -tops -119 -melanie -giles -##rous -wandered -##inal -annexed -nov -30th -unnamed -##ished -organizational -airplane -normandy -stoke -whistle -blessing -violations -chased -holders -shotgun -##ctic -outlet -reactor -##vik -tires -tearing -shores -fortified -mascot -constituencies -nc -columnist -productive -tibet -##rta -lineage -hooked -oct -tapes -judging -cody -##gger -hansen -kashmir -triggered -##eva -solved -cliffs -##tree -resisted -anatomy -protesters -transparent -implied -##iga -injection -mattress -excluding -##mbo -defenses -helpless -devotion -##elli -growl -liberals -weber -phenomena -atoms -plug -##iff -mortality -apprentice -howe -convincing -aaa -swimmer -barber -leone -promptly -sodium -def -nowadays -arise -##oning -gloucester -corrected -dignity -norm -erie -##ders -elders -evacuated -sylvia -compression -##yar -hartford -pose -backpack -reasoning -accepts -24th -wipe -millimetres -marcel -##oda -dodgers -albion -1790 -overwhelmed -aerospace -oaks -1795 -showcase -acknowledge -recovering -nolan -ashe -hurts -geology -fashioned -disappearance -farewell -swollen -shrug -marquis -wimbledon -124 -rue -1792 -commemorate -reduces -experiencing -inevitable -calcutta -intel -##court -murderer -sticking -fisheries -imagery -bloom -280 -brake -##inus -gustav -hesitation -memorable -po -viral -beans -accidents -tunisia -antenna -spilled -consort -treatments -aye -perimeter -##gard -donation -hostage -migrated -banker -addiction -apex -lil -trout -##ously -conscience -##nova -rams -sands -genome -passionate -troubles -##lets -##set -amid -##ibility -##ret -higgins -exceed -vikings -##vie -payne -##zan -muscular -##ste -defendant -sucking -##wal -ibrahim -fuselage -claudia -vfl -europeans -snails -interval -##garh -preparatory -statewide -tasked -lacrosse -viktor -##lation -angola -##hra -flint -implications -employs -teens -patrons -stall -weekends -barriers -scrambled -nucleus -tehran -jenna -parsons -lifelong -robots -displacement -5000 -##bles -precipitation -##gt -knuckles -clutched -1802 -marrying -ecology -marx -accusations -declare -scars -kolkata -mat -meadows -bermuda -skeleton -finalists -vintage -crawl -coordinate -affects -subjected -orchestral -mistaken -##tc -mirrors -dipped -relied -260 -arches -candle -##nick -incorporating -wildly -fond -basilica -owl -fringe -rituals -whispering -stirred -feud -tertiary -slick -goat -honorable -whereby -skip -ricardo -stripes -parachute -adjoining -submerged -synthesizer -##gren -intend -positively -ninety -phi -beaver -partition -fellows -alexis -prohibition -carlisle -bizarre -fraternity -##bre -doubts -icy -cbc -aquatic -sneak -sonny -combines -airports -crude -supervised -spatial -merge -alfonso -##bic -corrupt -scan -undergo -##ams -disabilities -colombian -comparing -dolphins -perkins -##lish -reprinted -unanimous -bounced -hairs -underworld -midwest -semester -bucket -paperback -miniseries -coventry -demise -##leigh -demonstrations -sensor -rotating -yan -##hler -arrange -soils -##idge -hyderabad -labs -##dr -brakes -grandchildren -##nde -negotiated -rover -ferrari -continuation -directorate -augusta -stevenson -counterpart -gore -##rda -nursery -rican -ave -collectively -broadly -pastoral -repertoire -asserted -discovering -nordic -styled -fiba -cunningham -harley -middlesex -survives -tumor -tempo -zack -aiming -lok -urgent -##rade -##nto -devils -##ement -contractor -turin -##wl -##ool -bliss -repaired -simmons -moan -astronomical -cr -negotiate -lyric -1890s -lara -bred -clad -angus -pbs -##ience -engineered -posed -##lk -hernandez -possessions -elbows -psychiatric -strokes -confluence -electorate -lifts -campuses -lava -alps -##ep -##ution -##date -physicist -woody -##page -##ographic -##itis -juliet -reformation -sparhawk -320 -complement -suppressed -jewel -##½ -floated -##kas -continuity -sadly -##ische -inability -melting -scanning -paula -flour -judaism -safer -vague -##lm -solving -curb -##stown -financially -gable -bees -expired -miserable -cassidy -dominion -1789 -cupped -145 -robbery -facto -amos -warden -resume -tallest -marvin -ing -pounded -usd -declaring -gasoline -##aux -darkened -270 -650 -sophomore -##mere -erection -gossip -televised -risen -dial -##eu -pillars -##link -passages -profound -##tina -arabian -ashton -silicon -nail -##ead -##lated -##wer -##hardt -fleming -firearms -ducked -circuits -blows -waterloo -titans -##lina -atom -fireplace -cheshire -financed -activation -algorithms -##zzi -constituent -catcher -cherokee -partnerships -sexuality -platoon -tragic -vivian -guarded -whiskey -meditation -poetic -##late -##nga -##ake -porto -listeners -dominance -kendra -mona -chandler -factions -22nd -salisbury -attitudes -derivative -##ido -##haus -intake -paced -javier -illustrator -barrels -bias -cockpit -burnett -dreamed -ensuing -##anda -receptors -someday -hawkins -mattered -##lal -slavic -1799 -jesuit -cameroon -wasted -tai -wax -lowering -victorious -freaking -outright -hancock -librarian -sensing -bald -calcium -myers -tablet -announcing -barack -shipyard -pharmaceutical -##uan -greenwich -flush -medley -patches -wolfgang -pt -speeches -acquiring -exams -nikolai -##gg -hayden -kannada -##type -reilly -##pt -waitress -abdomen -devastated -capped -pseudonym -pharmacy -fulfill -paraguay -1796 -clicked -##trom -archipelago -syndicated -##hman -lumber -orgasm -rejection -clifford -lorraine -advent -mafia -rodney -brock -##ght -##used -##elia -cassette -chamberlain -despair -mongolia -sensors -developmental -upstream -##eg -##alis -spanning -165 -trombone -basque -seeded -interred -renewable -rhys -leapt -revision -molecule -##ages -chord -vicious -nord -shivered -23rd -arlington -debts -corpus -sunrise -bays -blackburn -centimetres -##uded -shuddered -gm -strangely -gripping -cartoons -isabelle -orbital -##ppa -seals -proving -##lton -refusal -strengthened -bust -assisting -baghdad -batsman -portrayal -mara -pushes -spears -og -##cock -reside -nathaniel -brennan -1776 -confirmation -caucus -##worthy -markings -yemen -nobles -ku -lazy -viewer -catalan -encompasses -sawyer -##fall -sparked -substances -patents -braves -arranger -evacuation -sergio -persuade -dover -tolerance -penguin -cum -jockey -insufficient -townships -occupying -declining -plural -processed -projection -puppet -flanders -introduces -liability -##yon -gymnastics -antwerp -taipei -hobart -candles -jeep -wes -observers -126 -chaplain -bundle -glorious -##hine -hazel -flung -sol -excavations -dumped -stares -sh -bangalore -triangular -icelandic -intervals -expressing -turbine -##vers -songwriting -crafts -##igo -jasmine -ditch -rite -##ways -entertaining -comply -sorrow -wrestlers -basel -emirates -marian -rivera -helpful -##some -caution -downward -networking -##atory -##tered -darted -genocide -emergence -replies -specializing -spokesman -convenient -unlocked -fading -augustine -concentrations -resemblance -elijah -investigator -andhra -##uda -promotes -bean -##rrell -fleeing -wan -simone -announcer -##ame -##bby -lydia -weaver -132 -residency -modification -##fest -stretches -##ast -alternatively -nat -lowe -lacks -##ented -pam -tile -concealed -inferior -abdullah -residences -tissues -vengeance -##ided -moisture -peculiar -groove -zip -bologna -jennings -ninja -oversaw -zombies -pumping -batch -livingston -emerald -installations -1797 -peel -nitrogen -rama -##fying -##star -schooling -strands -responding -werner -##ost -lime -casa -accurately -targeting -##rod -underway -##uru -hemisphere -lester -##yard -occupies -2d -griffith -angrily -reorganized -##owing -courtney -deposited -##dd -##30 -estadio -##ifies -dunn -exiled -##ying -checks -##combe -##о -##fly -successes -unexpectedly -blu -assessed -##flower -##Ù‡ -observing -sacked -spiders -kn -##tail -mu -nodes -prosperity -audrey -divisional -155 -broncos -tangled -adjust -feeds -erosion -paolo -surf -directory -snatched -humid -admiralty -screwed -gt -reddish -##nese -modules -trench -lamps -bind -leah -bucks -competes -##nz -##form -transcription -##uc -isles -violently -clutching -pga -cyclist -inflation -flats -ragged -unnecessary -##hian -stubborn -coordinated -harriet -baba -disqualified -330 -insect -wolfe -##fies -reinforcements -rocked -duel -winked -embraced -bricks -##raj -hiatus -defeats -pending -brightly -jealousy -##xton -##hm -##uki -lena -gdp -colorful -##dley -stein -kidney -##shu -underwear -wanderers -##haw -##icus -guardians -m³ -roared -habits -##wise -permits -gp -uranium -punished -disguise -bundesliga -elise -dundee -erotic -partisan -pi -collectors -float -individually -rendering -behavioral -bucharest -ser -hare -valerie -corporal -nutrition -proportional -##isa -immense -##kis -pavement -##zie -##eld -sutherland -crouched -1775 -##lp -suzuki -trades -endurance -operas -crosby -prayed -priory -rory -socially -##urn -gujarat -##pu -walton -cube -pasha -privilege -lennon -floods -thorne -waterfall -nipple -scouting -approve -##lov -minorities -voter -dwight -extensions -assure -ballroom -slap -dripping -privileges -rejoined -confessed -demonstrating -patriotic -yell -investor -##uth -pagan -slumped -squares -##cle -##kins -confront -bert -embarrassment -##aid -aston -urging -sweater -starr -yuri -brains -williamson -commuter -mortar -structured -selfish -exports -##jon -cds -##him -unfinished -##rre -mortgage -destinations -##nagar -canoe -solitary -buchanan -delays -magistrate -fk -##pling -motivation -##lier -##vier -recruiting -assess -##mouth -malik -antique -1791 -pius -rahman -reich -tub -zhou -smashed -airs -galway -xii -conditioning -honduras -discharged -dexter -##pf -lionel -129 -debates -lemon -tiffany -volunteered -dom -dioxide -procession -devi -sic -tremendous -advertisements -colts -transferring -verdict -hanover -decommissioned -utter -relate -pac -racism -##top -beacon -limp -similarity -terra -occurrence -ant -##how -becky -capt -updates -armament -richie -pal -##graph -halloween -mayo -##ssen -##bone -cara -serena -fcc -dolls -obligations -##dling -violated -lafayette -jakarta -exploitation -##ime -infamous -iconic -##lah -##park -kitty -moody -reginald -dread -spill -crystals -olivier -modeled -bluff -equilibrium -separating -notices -ordnance -extinction -onset -cosmic -attachment -sammy -expose -privy -anchored -##bil -abbott -admits -bending -baritone -emmanuel -policeman -vaughan -winged -climax -dresses -denny -polytechnic -mohamed -burmese -authentic -nikki -genetics -grandparents -homestead -gaza -postponed -metacritic -una -##sby -##bat -unstable -dissertation -##rial -##cian -curls -obscure -uncovered -bronx -praying -disappearing -##hoe -prehistoric -coke -turret -mutations -nonprofit -pits -monaco -##ÙŠ -##usion -prominently -dispatched -podium -##mir -uci -##uation -133 -fortifications -birthplace -kendall -##lby -##oll -preacher -rack -goodman -##rman -persistent -##ott -countless -jaime -recorder -lexington -persecution -jumps -renewal -wagons -##11 -crushing -##holder -decorations -##lake -abundance -wrath -laundry -£1 -garde -##rp -jeanne -beetles -peasant -##sl -splitting -caste -sergei -##rer -##ema -scripts -##ively -rub -satellites -##vor -inscribed -verlag -scrapped -gale -packages -chick -potato -slogan -kathleen -arabs -##culture -counterparts -reminiscent -choral -##tead -rand -retains -bushes -dane -accomplish -courtesy -closes -##oth -slaughter -hague -krakow -lawson -tailed -elias -ginger -##ttes -canopy -betrayal -rebuilding -turf -##hof -frowning -allegiance -brigades -kicks -rebuild -polls -alias -nationalism -td -rowan -audition -bowie -fortunately -recognizes -harp -dillon -horrified -##oro -renault -##tics -ropes -##α -presumed -rewarded -infrared -wiping -accelerated -illustration -##rid -presses -practitioners -badminton -##iard -detained -##tera -recognizing -relates -misery -##sies -##tly -reproduction -piercing -potatoes -thornton -esther -manners -hbo -##aan -ours -bullshit -ernie -perennial -sensitivity -illuminated -rupert -##jin -##iss -##ear -rfc -nassau -##dock -staggered -socialism -##haven -appointments -nonsense -prestige -sharma -haul -##tical -solidarity -gps -##ook -##rata -igor -pedestrian -##uit -baxter -tenants -wires -medication -unlimited -guiding -impacts -diabetes -##rama -sasha -pas -clive -extraction -131 -continually -constraints -##bilities -sonata -hunted -sixteenth -chu -planting -quote -mayer -pretended -abs -spat -##hua -ceramic -##cci -curtains -pigs -pitching -##dad -latvian -sore -dayton -##sted -##qi -patrols -slice -playground -##nted -shone -stool -apparatus -inadequate -mates -treason -##ija -desires -##liga -##croft -somalia -laurent -mir -leonardo -oracle -grape -obliged -chevrolet -thirteenth -stunning -enthusiastic -##ede -accounted -concludes -currents -basil -##kovic -drought -##rica -mai -##aire -shove -posting -##shed -pilgrimage -humorous -packing -fry -pencil -wines -smells -144 -marilyn -aching -newest -clung -bon -neighbours -sanctioned -##pie -mug -##stock -drowning -##mma -hydraulic -##vil -hiring -reminder -lilly -investigators -##ncies -sour -##eous -compulsory -packet -##rion -##graphic -##elle -cannes -##inate -depressed -##rit -heroic -importantly -theresa -##tled -conway -saturn -marginal -rae -##xia -corresponds -royce -pact -jasper -explosives -packaging -aluminium -##ttered -denotes -rhythmic -spans -assignments -hereditary -outlined -originating -sundays -lad -reissued -greeting -beatrice -##dic -pillar -marcos -plots -handbook -alcoholic -judiciary -avant -slides -extract -masculine -blur -##eum -##force -homage -trembled -owens -hymn -trey -omega -signaling -socks -accumulated -reacted -attic -theo -lining -angie -distraction -primera -talbot -##key -1200 -ti -creativity -billed -##hey -deacon -eduardo -identifies -proposition -dizzy -gunner -hogan -##yam -##pping -##hol -ja -##chan -jensen -reconstructed -##berger -clearance -darius -##nier -abe -harlem -plea -dei -circled -emotionally -notation -fascist -neville -exceeded -upwards -viable -ducks -##fo -workforce -racer -limiting -shri -##lson -possesses -1600 -kerr -moths -devastating -laden -disturbing -locking -##cture -gal -fearing -accreditation -flavor -aide -1870s -mountainous -##baum -melt -##ures -motel -texture -servers -soda -##mb -herd -##nium -erect -puzzled -hum -peggy -examinations -gould -testified -geoff -ren -devised -sacks -##law -denial -posters -grunted -cesar -tutor -ec -gerry -offerings -byrne -falcons -combinations -ct -incoming -pardon -rocking -26th -avengers -flared -mankind -seller -uttar -loch -nadia -stroking -exposing -##hd -fertile -ancestral -instituted -##has -noises -prophecy -taxation -eminent -vivid -pol -##bol -dart -indirect -multimedia -notebook -upside -displaying -adrenaline -referenced -geometric -##iving -progression -##ddy -blunt -announce -##far -implementing -##lav -aggression -liaison -cooler -cares -headache -plantations -gorge -dots -impulse -thickness -ashamed -averaging -kathy -obligation -precursor -137 -fowler -symmetry -thee -225 -hears -##rai -undergoing -ads -butcher -bowler -##lip -cigarettes -subscription -goodness -##ically -browne -##hos -##tech -kyoto -donor -##erty -damaging -friction -drifting -expeditions -hardened -prostitution -152 -fauna -blankets -claw -tossing -snarled -butterflies -recruits -investigative -coated -healed -138 -communal -hai -xiii -academics -boone -psychologist -restless -lahore -stephens -mba -brendan -foreigners -printer -##pc -ached -explode -27th -deed -scratched -dared -##pole -cardiac -1780 -okinawa -proto -commando -compelled -oddly -electrons -##base -replica -thanksgiving -##rist -sheila -deliberate -stafford -tidal -representations -hercules -ou -##path -##iated -kidnapping -lenses -##tling -deficit -samoa -mouths -consuming -computational -maze -granting -smirk -razor -fixture -ideals -inviting -aiden -nominal -##vs -issuing -julio -pitt -ramsey -docks -##oss -exhaust -##owed -bavarian -draped -anterior -mating -ethiopian -explores -noticing -##nton -discarded -convenience -hoffman -endowment -beasts -cartridge -mormon -paternal -probe -sleeves -interfere -lump -deadline -##rail -jenks -bulldogs -scrap -alternating -justified -reproductive -nam -seize -descending -secretariat -kirby -coupe -grouped -smash -panther -sedan -tapping -##18 -lola -cheer -germanic -unfortunate -##eter -unrelated -##fan -subordinate -##sdale -suzanne -advertisement -##ility -horsepower -##lda -cautiously -discourse -luigi -##mans -##fields -noun -prevalent -mao -schneider -everett -surround -governorate -kira -##avia -westward -##take -misty -rails -sustainability -134 -unused -##rating -packs -toast -unwilling -regulate -thy -suffrage -nile -awe -assam -definitions -travelers -affordable -##rb -conferred -sells -undefeated -beneficial -torso -basal -repeating -remixes -##pass -bahrain -cables -fang -##itated -excavated -numbering -statutory -##rey -deluxe -##lian -forested -ramirez -derbyshire -zeus -slamming -transfers -astronomer -banana -lottery -berg -histories -bamboo -##uchi -resurrection -posterior -bowls -vaguely -##thi -thou -preserving -tensed -offence -##inas -meyrick -callum -ridden -watt -langdon -tying -lowland -snorted -daring -truman -##hale -##girl -aura -overly -filing -weighing -goa -infections -philanthropist -saunders -eponymous -##owski -latitude -perspectives -reviewing -mets -commandant -radial -##kha -flashlight -reliability -koch -vowels -amazed -ada -elaine -supper -##rth -##encies -predator -debated -soviets -cola -##boards -##nah -compartment -crooked -arbitrary -fourteenth -##ctive -havana -majors -steelers -clips -profitable -ambush -exited -packers -##tile -nude -cracks -fungi -##е -limb -trousers -josie -shelby -tens -frederic -##ος -definite -smoothly -constellation -insult -baton -discs -lingering -##nco -conclusions -lent -staging -becker -grandpa -shaky -##tron -einstein -obstacles -sk -adverse -elle -economically -##moto -mccartney -thor -dismissal -motions -readings -nostrils -treatise -##pace -squeezing -evidently -prolonged -1783 -venezuelan -je -marguerite -beirut -takeover -shareholders -##vent -denise -digit -airplay -norse -##bbling -imaginary -pills -hubert -blaze -vacated -eliminating -##ello -vine -mansfield -##tty -retrospective -barrow -borne -clutch -bail -forensic -weaving -##nett -##witz -desktop -citadel -promotions -worrying -dorset -ieee -subdivided -##iating -manned -expeditionary -pickup -synod -chuckle -185 -barney -##rz -##ffin -functionality -karachi -litigation -meanings -uc -lick -turbo -anders -##ffed -execute -curl -oppose -ankles -typhoon -##د -##ache -##asia -linguistics -compassion -pressures -grazing -perfection -##iting -immunity -monopoly -muddy -backgrounds -136 -namibia -francesca -monitors -attracting -stunt -tuition -##ии -vegetable -##mates -##quent -mgm -jen -complexes -forts -##ond -cellar -bites -seventeenth -royals -flemish -failures -mast -charities -##cular -peruvian -capitals -macmillan -ipswich -outward -frigate -postgraduate -folds -employing -##ouse -concurrently -fiery -##tai -contingent -nightmares -monumental -nicaragua -##kowski -lizard -mal -fielding -gig -reject -##pad -harding -##ipe -coastline -##cin -##nos -beethoven -humphrey -innovations -##tam -##nge -norris -doris -solicitor -huang -obey -141 -##lc -niagara -##tton -shelves -aug -bourbon -curry -nightclub -specifications -hilton -##ndo -centennial -dispersed -worm -neglected -briggs -sm -font -kuala -uneasy -plc -##nstein -##bound -##aking -##burgh -awaiting -pronunciation -##bbed -##quest -eh -optimal -zhu -raped -greens -presided -brenda -worries -##life -venetian -marxist -turnout -##lius -refined -braced -sins -grasped -sunderland -nickel -speculated -lowell -cyrillic -communism -fundraising -resembling -colonists -mutant -freddie -usc -##mos -gratitude -##run -mural -##lous -chemist -wi -reminds -28th -steals -tess -pietro -##ingen -promoter -ri -microphone -honoured -rai -sant -##qui -feather -##nson -burlington -kurdish -terrorists -deborah -sickness -##wed -##eet -hazard -irritated -desperation -veil -clarity -##rik -jewels -xv -##gged -##ows -##cup -berkshire -unfair -mysteries -orchid -winced -exhaustion -renovations -stranded -obe -infinity -##nies -adapt -redevelopment -thanked -registry -olga -domingo -noir -tudor -ole -##atus -commenting -behaviors -##ais -crisp -pauline -probable -stirling -wigan -##bian -paralympics -panting -surpassed -##rew -luca -barred -pony -famed -##sters -cassandra -waiter -carolyn -exported -##orted -andres -destructive -deeds -jonah -castles -vacancy -suv -##glass -1788 -orchard -yep -famine -belarusian -sprang -##forth -skinny -##mis -administrators -rotterdam -zambia -zhao -boiler -discoveries -##ride -##physics -lucius -disappointing -outreach -spoon -##frame -qualifications -unanimously -enjoys -regency -##iidae -stade -realism -veterinary -rodgers -dump -alain -chestnut -castile -censorship -rumble -gibbs -##itor -communion -reggae -inactivated -logs -loads -##houses -homosexual -##iano -ale -informs -##cas -phrases -plaster -linebacker -ambrose -kaiser -fascinated -850 -limerick -recruitment -forge -mastered -##nding -leinster -rooted -threaten -##strom -borneo -##hes -suggestions -scholarships -propeller -documentaries -patronage -coats -constructing -invest -neurons -comet -entirety -shouts -identities -annoying -unchanged -wary -##antly -##ogy -neat -oversight -##kos -phillies -replay -constance -##kka -incarnation -humble -skies -minus -##acy -smithsonian -##chel -guerrilla -jar -cadets -##plate -surplus -audit -##aru -cracking -joanna -louisa -pacing -##lights -intentionally -##iri -diner -nwa -imprint -australians -tong -unprecedented -bunker -naive -specialists -ark -nichols -railing -leaked -pedal -##uka -shrub -longing -roofs -v8 -captains -neural -tuned -##ntal -##jet -emission -medina -frantic -codex -definitive -sid -abolition -intensified -stocks -enrique -sustain -genoa -oxide -##written -clues -cha -##gers -tributaries -fragment -venom -##rity -##ente -##sca -muffled -vain -sire -laos -##ingly -##hana -hastily -snapping -surfaced -sentiment -motive -##oft -contests -approximate -mesa -luckily -dinosaur -exchanges -propelled -accord -bourne -relieve -tow -masks -offended -##ues -cynthia -##mmer -rains -bartender -zinc -reviewers -lois -##sai -legged -arrogant -rafe -rosie -comprise -handicap -blockade -inlet -lagoon -copied -drilling -shelley -petals -##inian -mandarin -obsolete -##inated -onward -arguably -productivity -cindy -praising -seldom -busch -discusses -raleigh -shortage -ranged -stanton -encouragement -firstly -conceded -overs -temporal -##uke -cbe -##bos -woo -certainty -pumps -##pton -stalked -##uli -lizzie -periodic -thieves -weaker -##night -gases -shoving -chooses -wc -##chemical -prompting -weights -##kill -robust -flanked -sticky -hu -tuberculosis -##eb -##eal -christchurch -resembled -wallet -reese -inappropriate -pictured -distract -fixing -fiddle -giggled -burger -heirs -hairy -mechanic -torque -apache -obsessed -chiefly -cheng -logging -##tag -extracted -meaningful -numb -##vsky -gloucestershire -reminding -##bay -unite -##lit -breeds -diminished -clown -glove -1860s -##Ù† -##ug -archibald -focal -freelance -sliced -depiction -##yk -organism -switches -sights -stray -crawling -##ril -lever -leningrad -interpretations -loops -anytime -reel -alicia -delighted -##ech -inhaled -xiv -suitcase -bernie -vega -licenses -northampton -exclusion -induction -monasteries -racecourse -homosexuality -##right -##sfield -##rky -dimitri -michele -alternatives -ions -commentators -genuinely -objected -pork -hospitality -fencing -stephan -warships -peripheral -wit -drunken -wrinkled -quentin -spends -departing -chung -numerical -spokesperson -##zone -johannesburg -caliber -killers -##udge -assumes -neatly -demographic -abigail -bloc -##vel -mounting -##lain -bentley -slightest -xu -recipients -##jk -merlin -##writer -seniors -prisons -blinking -hindwings -flickered -kappa -##hel -80s -strengthening -appealing -brewing -gypsy -mali -lashes -hulk -unpleasant -harassment -bio -treaties -predict -instrumentation -pulp -troupe -boiling -mantle -##ffe -ins -##vn -dividing -handles -verbs -##onal -coconut -senegal -340 -thorough -gum -momentarily -##sto -cocaine -panicked -destined -##turing -teatro -denying -weary -captained -mans -##hawks -##code -wakefield -bollywood -thankfully -##16 -cyril -##wu -amendments -##bahn -consultation -stud -reflections -kindness -1787 -internally -##ovo -tex -mosaic -distribute -paddy -seeming -143 -##hic -piers -##15 -##mura -##verse -popularly -winger -kang -sentinel -mccoy -##anza -covenant -##bag -verge -fireworks -suppress -thrilled -dominate -##jar -swansea -##60 -142 -reconciliation -##ndi -stiffened -cue -dorian -##uf -damascus -amor -ida -foremost -##aga -porsche -unseen -dir -##had -##azi -stony -lexi -melodies -##nko -angular -integer -podcast -ants -inherent -jaws -justify -persona -##olved -josephine -##nr -##ressed -customary -flashes -gala -cyrus -glaring -backyard -ariel -physiology -greenland -html -stir -avon -atletico -finch -methodology -ked -##lent -mas -catholicism -townsend -branding -quincy -fits -containers -1777 -ashore -aragon -##19 -forearm -poisoning -##sd -adopting -conquer -grinding -amnesty -keller -finances -evaluate -forged -lankan -instincts -##uto -guam -bosnian -photographed -workplace -desirable -protector -##dog -allocation -intently -encourages -willy -##sten -bodyguard -electro -brighter -##ν -bihar -##chev -lasts -opener -amphibious -sal -verde -arte -##cope -captivity -vocabulary -yields -##tted -agreeing -desmond -pioneered -##chus -strap -campaigned -railroads -##ович -emblem -##dre -stormed -501 -##ulous -marijuana -northumberland -##gn -##nath -bowen -landmarks -beaumont -##qua -danube -##bler -attorneys -th -ge -flyers -critique -villains -cass -mutation -acc -##0s -colombo -mckay -motif -sampling -concluding -syndicate -##rell -neon -stables -ds -warnings -clint -mourning -wilkinson -##tated -merrill -leopard -evenings -exhaled -emil -sonia -ezra -discrete -stove -farrell -fifteenth -prescribed -superhero -##rier -worms -helm -wren -##duction -##hc -expo -##rator -hq -unfamiliar -antony -prevents -acceleration -fiercely -mari -painfully -calculations -cheaper -ign -clifton -irvine -davenport -mozambique -##np -pierced -##evich -wonders -##wig -##cate -##iling -crusade -ware -##uel -enzymes -reasonably -mls -##coe -mater -ambition -bunny -eliot -kernel -##fin -asphalt -headmaster -torah -aden -lush -pins -waived -##care -##yas -joao -substrate -enforce -##grad -##ules -alvarez -selections -epidemic -tempted -##bit -bremen -translates -ensured -waterfront -29th -forrest -manny -malone -kramer -reigning -cookies -simpler -absorption -205 -engraved -##ffy -evaluated -1778 -haze -146 -comforting -crossover -##abe -thorn -##rift -##imo -##pop -suppression -fatigue -cutter -##tr -201 -wurttemberg -##orf -enforced -hovering -proprietary -gb -samurai -syllable -ascent -lacey -tick -lars -tractor -merchandise -rep -bouncing -defendants -##yre -huntington -##ground -##oko -standardized -##hor -##hima -assassinated -nu -predecessors -rainy -liar -assurance -lyrical -##uga -secondly -flattened -ios -parameter -undercover -##mity -bordeaux -punish -ridges -markers -exodus -inactive -hesitate -debbie -nyc -pledge -savoy -nagar -offset -organist -##tium -hesse -marin -converting -##iver -diagram -propulsion -pu -validity -reverted -supportive -##dc -ministries -clans -responds -proclamation -##inae -##ø -##rea -ein -pleading -patriot -sf -birch -islanders -strauss -hates -##dh -brandenburg -concession -rd -##ob -1900s -killings -textbook -antiquity -cinematography -wharf -embarrassing -setup -creed -farmland -inequality -centred -signatures -fallon -370 -##ingham -##uts -ceylon -gazing -directive -laurie -##tern -globally -##uated -##dent -allah -excavation -threads -##cross -148 -frantically -icc -utilize -determines -respiratory -thoughtful -receptions -##dicate -merging -chandra -seine -147 -builders -builds -diagnostic -dev -visibility -goddamn -analyses -dhaka -cho -proves -chancel -concurrent -curiously -canadians -pumped -restoring -1850s -turtles -jaguar -sinister -spinal -traction -declan -vows -1784 -glowed -capitalism -swirling -install -universidad -##lder -##oat -soloist -##genic -##oor -coincidence -beginnings -nissan -dip -resorts -caucasus -combustion -infectious -##eno -pigeon -serpent -##itating -conclude -masked -salad -jew -##gr -surreal -toni -##wc -harmonica -151 -##gins -##etic -##coat -fishermen -intending -bravery -##wave -klaus -titan -wembley -taiwanese -ransom -40th -incorrect -hussein -eyelids -jp -cooke -dramas -utilities -##etta -##print -eisenhower -principally -granada -lana -##rak -openings -concord -##bl -bethany -connie -morality -sega -##mons -##nard -earnings -##kara -##cine -wii -communes -##rel -coma -composing -softened -severed -grapes -##17 -nguyen -analyzed -warlord -hubbard -heavenly -behave -slovenian -##hit -##ony -hailed -filmmakers -trance -caldwell -skye -unrest -coward -likelihood -##aging -bern -sci -taliban -honolulu -propose -##wang -1700 -browser -imagining -cobra -contributes -dukes -instinctively -conan -violinist -##ores -accessories -gradual -##amp -quotes -sioux -##dating -undertake -intercepted -sparkling -compressed -139 -fungus -tombs -haley -imposing -rests -degradation -lincolnshire -retailers -wetlands -tulsa -distributor -dungeon -nun -greenhouse -convey -atlantis -aft -exits -oman -dresser -lyons -##sti -joking -eddy -judgement -omitted -digits -##cts -##game -juniors -##rae -cents -stricken -une -##ngo -wizards -weir -breton -nan -technician -fibers -liking -royalty -##cca -154 -persia -terribly -magician -##rable -##unt -vance -cafeteria -booker -camille -warmer -##static -consume -cavern -gaps -compass -contemporaries -foyer -soothing -graveyard -maj -plunged -blush -##wear -cascade -demonstrates -ordinance -##nov -boyle -##lana -rockefeller -shaken -banjo -izzy -##ense -breathless -vines -##32 -##eman -alterations -chromosome -dwellings -feudal -mole -153 -catalonia -relics -tenant -mandated -##fm -fridge -hats -honesty -patented -raul -heap -cruisers -accusing -enlightenment -infants -wherein -chatham -contractors -zen -affinity -hc -osborne -piston -156 -traps -maturity -##rana -lagos -##zal -peering -##nay -attendant -dealers -protocols -subset -prospects -biographical -##cre -artery -##zers -insignia -nuns -endured -##eration -recommend -schwartz -serbs -berger -cromwell -crossroads -##ctor -enduring -clasped -grounded -##bine -marseille -twitched -abel -choke -https -catalyst -moldova -italians -##tist -disastrous -wee -##oured -##nti -wwf -nope -##piration -##asa -expresses -thumbs -167 -##nza -coca -1781 -cheating -##ption -skipped -sensory -heidelberg -spies -satan -dangers -semifinal -202 -bohemia -whitish -confusing -shipbuilding -relies -surgeons -landings -ravi -baku -moor -suffix -alejandro -##yana -litre -upheld -##unk -rajasthan -##rek -coaster -insists -posture -scenarios -etienne -favoured -appoint -transgender -elephants -poked -greenwood -defences -fulfilled -militant -somali -1758 -chalk -potent -##ucci -migrants -wink -assistants -nos -restriction -activism -niger -##ario -colon -shaun -##sat -daphne -##erated -swam -congregations -reprise -considerations -magnet -playable -xvi -##Ñ€ -overthrow -tobias -knob -chavez -coding -##mers -propped -katrina -orient -newcomer -##suke -temperate -##pool -farmhouse -interrogation -##vd -committing -##vert -forthcoming -strawberry -joaquin -macau -ponds -shocking -siberia -##cellular -chant -contributors -##nant -##ologists -sped -absorb -hail -1782 -spared -##hore -barbados -karate -opus -originates -saul -##xie -evergreen -leaped -##rock -correlation -exaggerated -weekday -unification -bump -tracing -brig -afb -pathways -utilizing -##ners -mod -mb -disturbance -kneeling -##stad -##guchi -100th -pune -##thy -decreasing -168 -manipulation -miriam -academia -ecosystem -occupational -rbi -##lem -rift -##14 -rotary -stacked -incorporation -awakening -generators -guerrero -racist -##omy -cyber -derivatives -culminated -allie -annals -panzer -sainte -wikipedia -pops -zu -austro -##vate -algerian -politely -nicholson -mornings -educate -tastes -thrill -dartmouth -##gating -db -##jee -regan -differing -concentrating -choreography -divinity -##media -pledged -alexandre -routing -gregor -madeline -##idal -apocalypse -##hora -gunfire -culminating -elves -fined -liang -lam -programmed -tar -guessing -transparency -gabrielle -##gna -cancellation -flexibility -##lining -accession -shea -stronghold -nets -specializes -##rgan -abused -hasan -sgt -ling -exceeding -##â‚„ -admiration -supermarket -##ark -photographers -specialised -tilt -resonance -hmm -perfume -380 -sami -threatens -garland -botany -guarding -boiled -greet -puppy -russo -supplier -wilmington -vibrant -vijay -##bius -paralympic -grumbled -paige -faa -licking -margins -hurricanes -##gong -fest -grenade -ripping -##uz -counseling -weigh -##sian -needles -wiltshire -edison -costly -##not -fulton -tramway -redesigned -staffordshire -cache -gasping -watkins -sleepy -candidacy -##group -monkeys -timeline -throbbing -##bid -##sos -berth -uzbekistan -vanderbilt -bothering -overturned -ballots -gem -##iger -sunglasses -subscribers -hooker -compelling -ang -exceptionally -saloon -stab -##rdi -carla -terrifying -rom -##vision -coil -##oids -satisfying -vendors -31st -mackay -deities -overlooked -ambient -bahamas -felipe -olympia -whirled -botanist -advertised -tugging -##dden -disciples -morales -unionist -rites -foley -morse -motives -creepy -##â‚€ -soo -##sz -bargain -highness -frightening -turnpike -tory -reorganization -##cer -depict -biographer -##walk -unopposed -manifesto -##gles -institut -emile -accidental -kapoor -##dam -kilkenny -cortex -lively -##13 -romanesque -jain -shan -cannons -##ood -##ske -petrol -echoing -amalgamated -disappears -cautious -proposes -sanctions -trenton -##ر -flotilla -aus -contempt -tor -canary -cote -theirs -##hun -conceptual -deleted -fascinating -paso -blazing -elf -honourable -hutchinson -##eiro -##outh -##zin -surveyor -tee -amidst -wooded -reissue -intro -##ono -cobb -shelters -newsletter -hanson -brace -encoding -confiscated -dem -caravan -marino -scroll -melodic -cows -imam -##adi -##aneous -northward -searches -biodiversity -cora -310 -roaring -##bers -connell -theologian -halo -compose -pathetic -unmarried -dynamo -##oot -az -calculation -toulouse -deserves -humour -nr -forgiveness -tam -undergone -martyr -pamela -myths -whore -counselor -hicks -290 -heavens -battleship -electromagnetic -##bbs -stellar -establishments -presley -hopped -##chin -temptation -90s -wills -nas -##yuan -nhs -##nya -seminars -##yev -adaptations -gong -asher -lex -indicator -sikh -tobago -cites -goin -##yte -satirical -##gies -characterised -correspond -bubbles -lure -participates -##vid -eruption -skate -therapeutic -1785 -canals -wholesale -defaulted -sac -460 -petit -##zzled -virgil -leak -ravens -256 -portraying -##yx -ghetto -creators -dams -portray -vicente -##rington -fae -namesake -bounty -##arium -joachim -##ota -##iser -aforementioned -axle -snout -depended -dismantled -reuben -480 -##ibly -gallagher -##lau -##pd -earnest -##ieu -##iary -inflicted -objections -##llar -asa -gritted -##athy -jericho -##sea -##was -flick -underside -ceramics -undead -substituted -195 -eastward -undoubtedly -wheeled -chimney -##iche -guinness -cb -##ager -siding -##bell -traitor -baptiste -disguised -inauguration -149 -tipperary -choreographer -perched -warmed -stationary -eco -##ike -##ntes -bacterial -##aurus -flores -phosphate -##core -attacker -invaders -alvin -intersects -a1 -indirectly -immigrated -businessmen -cornelius -valves -narrated -pill -sober -ul -nationale -monastic -applicants -scenery -##jack -161 -motifs -constitutes -cpu -##osh -jurisdictions -sd -tuning -irritation -woven -##uddin -fertility -gao -##erie -antagonist -impatient -glacial -hides -boarded -denominations -interception -##jas -cookie -nicola -##tee -algebraic -marquess -bahn -parole -buyers -bait -turbines -paperwork -bestowed -natasha -renee -oceans -purchases -157 -vaccine -215 -##tock -fixtures -playhouse -integrate -jai -oswald -intellectuals -##cky -booked -nests -mortimer -##isi -obsession -sept -##gler -##sum -440 -scrutiny -simultaneous -squinted -##shin -collects -oven -shankar -penned -remarkably -##я -slips -luggage -spectral -1786 -collaborations -louie -consolidation -##ailed -##ivating -420 -hoover -blackpool -harness -ignition -vest -tails -belmont -mongol -skinner -##nae -visually -mage -derry -##tism -##unce -stevie -transitional -##rdy -redskins -drying -prep -prospective -##21 -annoyance -oversee -##loaded -fills -##books -##iki -announces -fda -scowled -respects -prasad -mystic -tucson -##vale -revue -springer -bankrupt -1772 -aristotle -salvatore -habsburg -##geny -dal -natal -nut -pod -chewing -darts -moroccan -walkover -rosario -lenin -punjabi -##ße -grossed -scattering -wired -invasive -hui -polynomial -corridors -wakes -gina -portrays -##cratic -arid -retreating -erich -irwin -sniper -##dha -linen -lindsey -maneuver -butch -shutting -socio -bounce -commemorative -postseason -jeremiah -pines -275 -mystical -beads -bp -abbas -furnace -bidding -consulted -assaulted -empirical -rubble -enclosure -sob -weakly -cancel -polly -yielded -##emann -curly -prediction -battered -70s -vhs -jacqueline -render -sails -barked -detailing -grayson -riga -sloane -raging -##yah -herbs -bravo -##athlon -alloy -giggle -imminent -suffers -assumptions -waltz -##itate -accomplishments -##ited -bathing -remixed -deception -prefix -##emia -deepest -##tier -##eis -balkan -frogs -##rong -slab -##pate -philosophers -peterborough -grains -imports -dickinson -rwanda -##atics -1774 -dirk -lan -tablets -##rove -clone -##rice -caretaker -hostilities -mclean -##gre -regimental -treasures -norms -impose -tsar -tango -diplomacy -variously -complain -192 -recognise -arrests -1779 -celestial -pulitzer -##dus -bing -libretto -##moor -adele -splash -##rite -expectation -lds -confronts -##izer -spontaneous -harmful -wedge -entrepreneurs -buyer -##ope -bilingual -translate -rugged -conner -circulated -uae -eaton -##gra -##zzle -lingered -lockheed -vishnu -reelection -alonso -##oom -joints -yankee -headline -cooperate -heinz -laureate -invading -##sford -echoes -scandinavian -##dham -hugging -vitamin -salute -micah -hind -trader -##sper -radioactive -##ndra -militants -poisoned -ratified -remark -campeonato -deprived -wander -prop -##dong -outlook -##tani -##rix -##eye -chiang -darcy -##oping -mandolin -spice -statesman -babylon -182 -walled -forgetting -afro -##cap -158 -giorgio -buffer -##polis -planetary -##gis -overlap -terminals -kinda -centenary -##bir -arising -manipulate -elm -ke -1770 -ak -##tad -chrysler -mapped -moose -pomeranian -quad -macarthur -assemblies -shoreline -recalls -stratford -##rted -noticeable -##evic -imp -##rita -##sque -accustomed -supplying -tents -disgusted -vogue -sipped -filters -khz -reno -selecting -luftwaffe -mcmahon -tyne -masterpiece -carriages -collided -dunes -exercised -flare -remembers -muzzle -##mobile -heck -##rson -burgess -lunged -middleton -boycott -bilateral -##sity -hazardous -lumpur -multiplayer -spotlight -jackets -goldman -liege -porcelain -rag -waterford -benz -attracts -hopeful -battling -ottomans -kensington -baked -hymns -cheyenne -lattice -levine -borrow -polymer -clashes -michaels -monitored -commitments -denounced -##25 -##von -cavity -##oney -hobby -akin -##holders -futures -intricate -cornish -patty -##oned -illegally -dolphin -##lag -barlow -yellowish -maddie -apologized -luton -plagued -##puram -nana -##rds -sway -fanny -Å‚odz -##rino -psi -suspicions -hanged -##eding -initiate -charlton -##por -nak -competent -235 -analytical -annex -wardrobe -reservations -##rma -sect -162 -fairfax -hedge -piled -buckingham -uneven -bauer -simplicity -snyder -interpret -accountability -donors -moderately -byrd -continents -##cite -##max -disciple -hr -jamaican -ping -nominees -##uss -mongolian -diver -attackers -eagerly -ideological -pillows -miracles -apartheid -revolver -sulfur -clinics -moran -163 -##enko -ile -katy -rhetoric -##icated -chronology -recycling -##hrer -elongated -mughal -pascal -profiles -vibration -databases -domination -##fare -##rant -matthias -digest -rehearsal -polling -weiss -initiation -reeves -clinging -flourished -impress -ngo -##hoff -##ume -buckley -symposium -rhythms -weed -emphasize -transforming -##taking -##gence -##yman -accountant -analyze -flicker -foil -priesthood -voluntarily -decreases -##80 -##hya -slater -sv -charting -mcgill -##lde -moreno -##iu -besieged -zur -robes -##phic -admitting -api -deported -turmoil -peyton -earthquakes -##ares -nationalists -beau -clair -brethren -interrupt -welch -curated -galerie -requesting -164 -##ested -impending -steward -viper -##vina -complaining -beautifully -brandy -foam -nl -1660 -##cake -alessandro -punches -laced -explanations -##lim -attribute -clit -reggie -discomfort -##cards -smoothed -whales -##cene -adler -countered -duffy -disciplinary -widening -recipe -reliance -conducts -goats -gradient -preaching -##shaw -matilda -quasi -striped -meridian -cannabis -cordoba -certificates -##agh -##tering -graffiti -hangs -pilgrims -repeats -##ych -revive -urine -etat -##hawk -fueled -belts -fuzzy -susceptible -##hang -mauritius -salle -sincere -beers -hooks -##cki -arbitration -entrusted -advise -sniffed -seminar -junk -donnell -processors -principality -strapped -celia -mendoza -everton -fortunes -prejudice -starving -reassigned -steamer -##lund -tuck -evenly -foreman -##ffen -dans -375 -envisioned -slit -##xy -baseman -liberia -rosemary -##weed -electrified -periodically -potassium -stride -contexts -sperm -slade -mariners -influx -bianca -subcommittee -##rane -spilling -icao -estuary -##nock -delivers -iphone -##ulata -isa -mira -bohemian -dessert -##sbury -welcoming -proudly -slowing -##chs -musee -ascension -russ -##vian -waits -##psy -africans -exploit -##morphic -gov -eccentric -crab -peck -##ull -entrances -formidable -marketplace -groom -bolted -metabolism -patton -robbins -courier -payload -endure -##ifier -andes -refrigerator -##pr -ornate -##uca -ruthless -illegitimate -masonry -strasbourg -bikes -adobe -##³ -apples -quintet -willingly -niche -bakery -corpses -energetic -##cliffe -##sser -##ards -177 -centimeters -centro -fuscous -cretaceous -rancho -##yde -andrei -telecom -tottenham -oasis -ordination -vulnerability -presiding -corey -cp -penguins -sims -##pis -malawi -piss -##48 -correction -##cked -##ffle -##ryn -countdown -detectives -psychiatrist -psychedelic -dinosaurs -blouse -##get -choi -vowed -##oz -randomly -##pol -49ers -scrub -blanche -bruins -dusseldorf -##using -unwanted -##ums -212 -dominique -elevations -headlights -om -laguna -##oga -1750 -famously -ignorance -shrewsbury -##aine -ajax -breuning -che -confederacy -greco -overhaul -##screen -paz -skirts -disagreement -cruelty -jagged -phoebe -shifter -hovered -viruses -##wes -mandy -##lined -##gc -landlord -squirrel -dashed -##ι -ornamental -gag -wally -grange -literal -spurs -undisclosed -proceeding -yin -##text -billie -orphan -spanned -humidity -indy -weighted -presentations -explosions -lucian -##tary -vaughn -hindus -##anga -##hell -psycho -171 -daytona -protects -efficiently -rematch -sly -tandem -##oya -rebranded -impaired -hee -metropolis -peach -godfrey -diaspora -ethnicity -prosperous -gleaming -dar -grossing -playback -##rden -stripe -pistols -##tain -births -labelled -##cating -172 -rudy -alba -##onne -aquarium -hostility -##gb -##tase -shudder -sumatra -hardest -lakers -consonant -creeping -demos -homicide -capsule -zeke -liberties -expulsion -pueblo -##comb -trait -transporting -##ddin -##neck -##yna -depart -gregg -mold -ledge -hangar -oldham -playboy -termination -analysts -gmbh -romero -##itic -insist -cradle -filthy -brightness -slash -shootout -deposed -bordering -##truct -isis -microwave -tumbled -sheltered -cathy -werewolves -messy -andersen -convex -clapped -clinched -satire -wasting -edo -vc -rufus -##jak -mont -##etti -poznan -##keeping -restructuring -transverse -##rland -azerbaijani -slovene -gestures -roommate -choking -shear -##quist -vanguard -oblivious -##hiro -disagreed -baptism -##lich -coliseum -##aceae -salvage -societe -cory -locke -relocation -relying -versailles -ahl -swelling -##elo -cheerful -##word -##edes -gin -sarajevo -obstacle -diverted -##nac -messed -thoroughbred -fluttered -utrecht -chewed -acquaintance -assassins -dispatch -mirza -##wart -nike -salzburg -swell -yen -##gee -idle -ligue -samson -##nds -##igh -playful -spawned -##cise -tease -##case -burgundy -##bot -stirring -skeptical -interceptions -marathi -##dies -bedrooms -aroused -pinch -##lik -preferences -tattoos -buster -digitally -projecting -rust -##ital -kitten -priorities -addison -pseudo -##guard -dusk -icons -sermon -##psis -##iba -bt -##lift -##xt -ju -truce -rink -##dah -##wy -defects -psychiatry -offences -calculate -glucose -##iful -##rized -##unda -francaise -##hari -richest -warwickshire -carly -1763 -purity -redemption -lending -##cious -muse -bruises -cerebral -aero -carving -##name -preface -terminology -invade -monty -##int -anarchist -blurred -##iled -rossi -treats -guts -shu -foothills -ballads -undertaking -premise -cecilia -affiliates -blasted -conditional -wilder -minors -drone -rudolph -buffy -swallowing -horton -attested -##hop -rutherford -howell -primetime -livery -penal -##bis -minimize -hydro -wrecked -wrought -palazzo -##gling -cans -vernacular -friedman -nobleman -shale -walnut -danielle -##ection -##tley -sears -##kumar -chords -lend -flipping -streamed -por -dracula -gallons -sacrifices -gamble -orphanage -##iman -mckenzie -##gible -boxers -daly -##balls -##ان -208 -##ific -##rative -##iq -exploited -slated -##uity -circling -hillary -pinched -goldberg -provost -campaigning -lim -piles -ironically -jong -mohan -successors -usaf -##tem -##ught -autobiographical -haute -preserves -##ending -acquitted -comparisons -203 -hydroelectric -gangs -cypriot -torpedoes -rushes -chrome -derive -bumps -instability -fiat -pets -##mbe -silas -dye -reckless -settler -##itation -info -heats -##writing -176 -canonical -maltese -fins -mushroom -stacy -aspen -avid -##kur -##loading -vickers -gaston -hillside -statutes -wilde -gail -kung -sabine -comfortably -motorcycles -##rgo -169 -pneumonia -fetch -##sonic -axel -faintly -parallels -##oop -mclaren -spouse -compton -interdisciplinary -miner -##eni -181 -clamped -##chal -##llah -separates -versa -##mler -scarborough -labrador -##lity -##osing -rutgers -hurdles -como -166 -burt -divers -##100 -wichita -cade -coincided -##erson -bruised -mla -##pper -vineyard -##ili -##brush -notch -mentioning -jase -hearted -kits -doe -##acle -pomerania -##ady -ronan -seizure -pavel -problematic -##zaki -domenico -##ulin -catering -penelope -dependence -parental -emilio -ministerial -atkinson -##bolic -clarkson -chargers -colby -grill -peeked -arises -summon -##aged -fools -##grapher -faculties -qaeda -##vial -garner -refurbished -##hwa -geelong -disasters -nudged -bs -shareholder -lori -algae -reinstated -rot -##ades -##nous -invites -stainless -183 -inclusive -##itude -diocesan -til -##icz -denomination -##xa -benton -floral -registers -##ider -##erman -##kell -absurd -brunei -guangzhou -hitter -retaliation -##uled -##eve -blanc -nh -consistency -contamination -##eres -##rner -dire -palermo -broadcasters -diaries -inspire -vols -brewer -tightening -ky -mixtape -hormone -##tok -stokes -##color -##dly -##ssi -pg -##ometer -##lington -sanitation -##tility -intercontinental -apps -##adt -¹⁄₂ -cylinders -economies -favourable -unison -croix -gertrude -odyssey -vanity -dangling -##logists -upgrades -dice -middleweight -practitioner -##ight -206 -henrik -parlor -orion -angered -lac -python -blurted -##rri -sensual -intends -swings -angled -##phs -husky -attain -peerage -precinct -textiles -cheltenham -shuffled -dai -confess -tasting -bhutan -##riation -tyrone -segregation -abrupt -ruiz -##rish -smirked -blackwell -confidential -browning -amounted -##put -vase -scarce -fabulous -raided -staple -guyana -unemployed -glider -shay -##tow -carmine -troll -intervene -squash -superstar -##uce -cylindrical -len -roadway -researched -handy -##rium -##jana -meta -lao -declares -##rring -##tadt -##elin -##kova -willem -shrubs -napoleonic -realms -skater -qi -volkswagen -##Å‚ -tad -hara -archaeologist -awkwardly -eerie -##kind -wiley -##heimer -##24 -titus -organizers -cfl -crusaders -lama -usb -vent -enraged -thankful -occupants -maximilian -##gaard -possessing -textbooks -##oran -collaborator -quaker -##ulo -avalanche -mono -silky -straits -isaiah -mustang -surged -resolutions -potomac -descend -cl -kilograms -plato -strains -saturdays -##olin -bernstein -##ype -holstein -ponytail -##watch -belize -conversely -heroine -perpetual -##ylus -charcoal -piedmont -glee -negotiating -backdrop -prologue -##jah -##mmy -pasadena -climbs -ramos -sunni -##holm -##tner -##tri -anand -deficiency -hertfordshire -stout -##avi -aperture -orioles -##irs -doncaster -intrigued -bombed -coating -otis -##mat -cocktail -##jit -##eto -amir -arousal -sar -##proof -##act -##ories -dixie -pots -##bow -whereabouts -159 -##fted -drains -bullying -cottages -scripture -coherent -fore -poe -appetite -##uration -sampled -##ators -##dp -derrick -rotor -jays -peacock -installment -##rro -advisors -##coming -rodeo -scotch -##mot -##db -##fen -##vant -ensued -rodrigo -dictatorship -martyrs -twenties -##н -towed -incidence -marta -rainforest -sai -scaled -##cles -oceanic -qualifiers -symphonic -mcbride -dislike -generalized -aubrey -colonization -##iation -##lion -##ssing -disliked -lublin -salesman -##ulates -spherical -whatsoever -sweating -avalon -contention -punt -severity -alderman -atari -##dina -##grant -##rop -scarf -seville -vertices -annexation -fairfield -fascination -inspiring -launches -palatinate -regretted -##rca -feral -##iom -elk -nap -olsen -reddy -yong -##leader -##iae -garment -transports -feng -gracie -outrage -viceroy -insides -##esis -breakup -grady -organizer -softer -grimaced -222 -murals -galicia -arranging -vectors -##rsten -bas -##sb -##cens -sloan -##eka -bitten -ara -fender -nausea -bumped -kris -banquet -comrades -detector -persisted -##llan -adjustment -endowed -cinemas -##shot -sellers -##uman -peek -epa -kindly -neglect -simpsons -talon -mausoleum -runaway -hangul -lookout -##cic -rewards -coughed -acquainted -chloride -##ald -quicker -accordion -neolithic -##qa -artemis -coefficient -lenny -pandora -tx -##xed -ecstasy -litter -segunda -chairperson -gemma -hiss -rumor -vow -nasal -antioch -compensate -patiently -transformers -##eded -judo -morrow -penis -posthumous -philips -bandits -husbands -denote -flaming -##any -##phones -langley -yorker -1760 -walters -##uo -##kle -gubernatorial -fatty -samsung -leroy -outlaw -##nine -unpublished -poole -jakob -##áµ¢ -##â‚™ -crete -distorted -superiority -##dhi -intercept -crust -mig -claus -crashes -positioning -188 -stallion -301 -frontal -armistice -##estinal -elton -aj -encompassing -camel -commemorated -malaria -woodward -calf -cigar -penetrate -##oso -willard -##rno -##uche -illustrate -amusing -convergence -noteworthy -##lma -##rva -journeys -realise -manfred -##sable -410 -##vocation -hearings -fiance -##posed -educators -provoked -adjusting -##cturing -modular -stockton -paterson -vlad -rejects -electors -selena -maureen -##tres -uber -##rce -swirled -##num -proportions -nanny -pawn -naturalist -parma -apostles -awoke -ethel -wen -##bey -monsoon -overview -##inating -mccain -rendition -risky -adorned -##ih -equestrian -germain -nj -conspicuous -confirming -##yoshi -shivering -##imeter -milestone -rumours -flinched -bounds -smacked -token -##bei -lectured -automobiles -##shore -impacted -##iable -nouns -nero -##leaf -ismail -prostitute -trams -##lace -bridget -sud -stimulus -impressions -reins -revolves -##oud -##gned -giro -honeymoon -##swell -criterion -##sms -##uil -libyan -prefers -##osition -211 -preview -sucks -accusation -bursts -metaphor -diffusion -tolerate -faye -betting -cinematographer -liturgical -specials -bitterly -humboldt -##ckle -flux -rattled -##itzer -archaeologists -odor -authorised -marshes -discretion -##ов -alarmed -archaic -inverse -##leton -explorers -##pine -drummond -tsunami -woodlands -##minate -##tland -booklet -insanity -owning -insert -crafted -calculus -##tore -receivers -##bt -stung -##eca -##nched -prevailing -travellers -eyeing -lila -graphs -##borne -178 -julien -##won -morale -adaptive -therapist -erica -cw -libertarian -bowman -pitches -vita -##ional -crook -##ads -##entation -caledonia -mutiny -##sible -1840s -automation -##ß -flock -##pia -ironic -pathology -##imus -remarried -##22 -joker -withstand -energies -##att -shropshire -hostages -madeleine -tentatively -conflicting -mateo -recipes -euros -ol -mercenaries -nico -##ndon -albuquerque -augmented -mythical -bel -freud -##child -cough -##lica -365 -freddy -lillian -genetically -nuremberg -calder -209 -bonn -outdoors -paste -suns -urgency -vin -restraint -tyson -##cera -##selle -barrage -bethlehem -kahn -##par -mounts -nippon -barony -happier -ryu -makeshift -sheldon -blushed -castillo -barking -listener -taped -bethel -fluent -headlines -pornography -rum -disclosure -sighing -mace -doubling -gunther -manly -##plex -rt -interventions -physiological -forwards -emerges -##tooth -##gny -compliment -rib -recession -visibly -barge -faults -connector -exquisite -prefect -##rlin -patio -##cured -elevators -brandt -italics -pena -173 -wasp -satin -ea -botswana -graceful -respectable -##jima -##rter -##oic -franciscan -generates -##dl -alfredo -disgusting -##olate -##iously -sherwood -warns -cod -promo -cheryl -sino -##Ø© -##escu -twitch -##zhi -brownish -thom -ortiz -##dron -densely -##beat -carmel -reinforce -##bana -187 -anastasia -downhill -vertex -contaminated -remembrance -harmonic -homework -##sol -fiancee -gears -olds -angelica -loft -ramsay -quiz -colliery -sevens -##cape -autism -##hil -walkway -##boats -ruben -abnormal -ounce -khmer -##bbe -zachary -bedside -morphology -punching -##olar -sparrow -convinces -##35 -hewitt -queer -remastered -rods -mabel -solemn -notified -lyricist -symmetric -##xide -174 -encore -passports -wildcats -##uni -baja -##pac -mildly -##ease -bleed -commodity -mounds -glossy -orchestras -##omo -damian -prelude -ambitions -##vet -awhile -remotely -##aud -asserts -imply -##iques -distinctly -modelling -remedy -##dded -windshield -dani -xiao -##endra -audible -powerplant -1300 -invalid -elemental -acquisitions -##hala -immaculate -libby -plata -smuggling -ventilation -denoted -minh -##morphism -430 -differed -dion -kelley -lore -mocking -sabbath -spikes -hygiene -drown -runoff -stylized -tally -liberated -aux -interpreter -righteous -aba -siren -reaper -pearce -millie -##cier -##yra -gaius -##iso -captures -##ttering -dorm -claudio -##sic -benches -knighted -blackness -##ored -discount -fumble -oxidation -routed -##Ï‚ -novak -perpendicular -spoiled -fracture -splits -##urt -pads -topology -##cats -axes -fortunate -offenders -protestants -esteem -221 -broadband -convened -frankly -hound -prototypes -isil -facilitated -keel -##sher -sahara -awaited -bubba -orb -prosecutors -186 -hem -520 -##xing -relaxing -remnant -romney -sorted -slalom -stefano -ulrich -##active -exemption -folder -pauses -foliage -hitchcock -epithet -204 -criticisms -##aca -ballistic -brody -hinduism -chaotic -youths -equals -##pala -pts -thicker -analogous -capitalist -improvised -overseeing -sinatra -ascended -beverage -##tl -straightforward -##kon -curran -##west -bois -325 -induce -surveying -emperors -sax -unpopular -##kk -cartoonist -fused -##mble -unto -##yuki -localities -##cko -##ln -darlington -slain -academie -lobbying -sediment -puzzles -##grass -defiance -dickens -manifest -tongues -alumnus -arbor -coincide -184 -appalachian -mustafa -examiner -cabaret -traumatic -yves -bracelet -draining -heroin -magnum -baths -odessa -consonants -mitsubishi -##gua -kellan -vaudeville -##fr -joked -null -straps -probation -##Å‚aw -ceded -interfaces -##pas -##zawa -blinding -viet -224 -rothschild -museo -640 -huddersfield -##vr -tactic -##storm -brackets -dazed -incorrectly -##vu -reg -glazed -fearful -manifold -benefited -irony -##sun -stumbling -##rte -willingness -balkans -mei -wraps -##aba -injected -##lea -gu -syed -harmless -##hammer -bray -takeoff -poppy -timor -cardboard -astronaut -purdue -weeping -southbound -cursing -stalls -diagonal -##neer -lamar -bryce -comte -weekdays -harrington -##uba -negatively -##see -lays -grouping -##cken -##henko -affirmed -halle -modernist -##lai -hodges -smelling -aristocratic -baptized -dismiss -justification -oilers -##now -coupling -qin -snack -healer -##qing -gardener -layla -battled -formulated -stephenson -gravitational -##gill -##jun -1768 -granny -coordinating -suites -##cd -##ioned -monarchs -##cote -##hips -sep -blended -apr -barrister -deposition -fia -mina -policemen -paranoid -##pressed -churchyard -covert -crumpled -creep -abandoning -tr -transmit -conceal -barr -understands -readiness -spire -##cology -##enia -##erry -610 -startling -unlock -vida -bowled -slots -##nat -##islav -spaced -trusting -admire -rig -##ink -slack -##70 -mv -207 -casualty -##wei -classmates -##odes -##rar -##rked -amherst -furnished -evolve -foundry -menace -mead -##lein -flu -wesleyan -##kled -monterey -webber -##vos -wil -##mith -##на -bartholomew -justices -restrained -##cke -amenities -191 -mediated -sewage -trenches -ml -mainz -##thus -1800s -##cula -##inski -caine -bonding -213 -converts -spheres -superseded -marianne -crypt -sweaty -ensign -historia -##br -spruce -##post -##ask -forks -thoughtfully -yukon -pamphlet -ames -##uter -karma -##yya -bryn -negotiation -sighs -incapable -##mbre -##ntial -actresses -taft -##mill -luce -prevailed -##amine -1773 -motionless -envoy -testify -investing -sculpted -instructors -provence -kali -cullen -horseback -##while -goodwin -##jos -gaa -norte -##ldon -modify -wavelength -abd -214 -skinned -sprinter -forecast -scheduling -marries -squared -tentative -##chman -boer -##isch -bolts -swap -fisherman -assyrian -impatiently -guthrie -martins -murdoch -194 -tanya -nicely -dolly -lacy -med -##45 -syn -decks -fashionable -millionaire -##ust -surfing -##ml -##ision -heaved -tammy -consulate -attendees -routinely -197 -fuse -saxophonist -backseat -malaya -##lord -scowl -tau -##ishly -193 -sighted -steaming -##rks -303 -911 -##holes -##hong -ching -##wife -bless -conserved -jurassic -stacey -unix -zion -chunk -rigorous -blaine -198 -peabody -slayer -dismay -brewers -nz -##jer -det -##glia -glover -postwar -int -penetration -sylvester -imitation -vertically -airlift -heiress -knoxville -viva -##uin -390 -macon -##rim -##fighter -##gonal -janice -##orescence -##wari -marius -belongings -leicestershire -196 -blanco -inverted -preseason -sanity -sobbing -##due -##elt -##dled -collingwood -regeneration -flickering -shortest -##mount -##osi -feminism -##lat -sherlock -cabinets -fumbled -northbound -precedent -snaps -##mme -researching -##akes -guillaume -insights -manipulated -vapor -neighbour -sap -gangster -frey -f1 -stalking -scarcely -callie -barnett -tendencies -audi -doomed -assessing -slung -panchayat -ambiguous -bartlett -##etto -distributing -violating -wolverhampton -##hetic -swami -histoire -##urus -liable -pounder -groin -hussain -larsen -popping -surprises -##atter -vie -curt -##station -mute -relocate -musicals -authorization -richter -##sef -immortality -tna -bombings -##press -deteriorated -yiddish -##acious -robbed -colchester -cs -pmid -ao -verified -balancing -apostle -swayed -recognizable -oxfordshire -retention -nottinghamshire -contender -judd -invitational -shrimp -uhf -##icient -cleaner -longitudinal -tanker -##mur -acronym -broker -koppen -sundance -suppliers -##gil -4000 -clipped -fuels -petite -##anne -landslide -helene -diversion -populous -landowners -auspices -melville -quantitative -##xes -ferries -nicky -##llus -doo -haunting -roche -carver -downed -unavailable -##pathy -approximation -hiroshima -##hue -garfield -valle -comparatively -keyboardist -traveler -##eit -congestion -calculating -subsidiaries -##bate -serb -modernization -fairies -deepened -ville -averages -##lore -inflammatory -tonga -##itch -coâ‚‚ -squads -##hea -gigantic -serum -enjoyment -retailer -verona -35th -cis -##phobic -magna -technicians -##vati -arithmetic -##sport -levin -##dation -amtrak -chow -sienna -##eyer -backstage -entrepreneurship -##otic -learnt -tao -##udy -worcestershire -formulation -baggage -hesitant -bali -sabotage -##kari -barren -enhancing -murmur -pl -freshly -putnam -syntax -aces -medicines -resentment -bandwidth -##sier -grins -chili -guido -##sei -framing -implying -gareth -lissa -genevieve -pertaining -admissions -geo -thorpe -proliferation -sato -bela -analyzing -parting -##gor -awakened -##isman -huddled -secrecy -##kling -hush -gentry -540 -dungeons -##ego -coasts -##utz -sacrificed -##chule -landowner -mutually -prevalence -programmer -adolescent -disrupted -seaside -gee -trusts -vamp -georgie -##nesian -##iol -schedules -sindh -##market -etched -hm -sparse -bey -beaux -scratching -gliding -unidentified -216 -collaborating -gems -jesuits -oro -accumulation -shaping -mbe -anal -##xin -231 -enthusiasts -newscast -##egan -janata -dewey -parkinson -179 -ankara -biennial -towering -dd -inconsistent -950 -##chet -thriving -terminate -cabins -furiously -eats -advocating -donkey -marley -muster -phyllis -leiden -##user -grassland -glittering -iucn -loneliness -217 -memorandum -armenians -##ddle -popularized -rhodesia -60s -lame -##illon -sans -bikini -header -orbits -##xx -##finger -##ulator -sharif -spines -biotechnology -strolled -naughty -yates -##wire -fremantle -milo -##mour -abducted -removes -##atin -humming -wonderland -##chrome -##ester -hume -pivotal -##rates -armand -grams -believers -elector -rte -apron -bis -scraped -##yria -endorsement -initials -##llation -eps -dotted -hints -buzzing -emigration -nearer -##tom -indicators -##ulu -coarse -neutron -protectorate -##uze -directional -exploits -pains -loire -1830s -proponents -guggenheim -rabbits -ritchie -305 -hectare -inputs -hutton -##raz -verify -##ako -boilers -longitude -##lev -skeletal -yer -emilia -citrus -compromised -##gau -pokemon -prescription -paragraph -eduard -cadillac -attire -categorized -kenyan -weddings -charley -##bourg -entertain -monmouth -##lles -nutrients -davey -mesh -incentive -practised -ecosystems -kemp -subdued -overheard -##rya -bodily -maxim -##nius -apprenticeship -ursula -##fight -lodged -rug -silesian -unconstitutional -patel -inspected -coyote -unbeaten -##hak -34th -disruption -convict -parcel -##cl -##nham -collier -implicated -mallory -##iac -##lab -susannah -winkler -##rber -shia -phelps -sediments -graphical -robotic -##sner -adulthood -mart -smoked -##isto -kathryn -clarified -##aran -divides -convictions -oppression -pausing -burying -##mt -federico -mathias -eileen -##tana -kite -hunched -##acies -189 -##atz -disadvantage -liza -kinetic -greedy -paradox -yokohama -dowager -trunks -ventured -##gement -gupta -vilnius -olaf -##thest -crimean -hopper -##ej -progressively -arturo -mouthed -arrondissement -##fusion -rubin -simulcast -oceania -##orum -##stra -##rred -busiest -intensely -navigator -cary -##vine -##hini -##bies -fife -rowe -rowland -posing -insurgents -shafts -lawsuits -activate -conor -inward -culturally -garlic -265 -##eering -eclectic -##hui -##kee -##nl -furrowed -vargas -meteorological -rendezvous -##aus -culinary -commencement -##dition -quota -##notes -mommy -salaries -overlapping -mule -##iology -##mology -sums -wentworth -##isk -##zione -mainline -subgroup -##illy -hack -plaintiff -verdi -bulb -differentiation -engagements -multinational -supplemented -bertrand -caller -regis -##naire -##sler -##arts -##imated -blossom -propagation -kilometer -viaduct -vineyards -##uate -beckett -optimization -golfer -songwriters -seminal -semitic -thud -volatile -evolving -ridley -##wley -trivial -distributions -scandinavia -jiang -##ject -wrestled -insistence -##dio -emphasizes -napkin -##ods -adjunct -rhyme -##ricted -##eti -hopeless -surrounds -tremble -32nd -smoky -##ntly -oils -medicinal -padded -steer -wilkes -219 -255 -concessions -hue -uniquely -blinded -landon -yahoo -##lane -hendrix -commemorating -dex -specify -chicks -##ggio -intercity -1400 -morley -##torm -highlighting -##oting -pang -oblique -stalled -##liner -flirting -newborn -1769 -bishopric -shaved -232 -currie -##ush -dharma -spartan -##ooped -favorites -smug -novella -sirens -abusive -creations -espana -##lage -paradigm -semiconductor -sheen -##rdo -##yen -##zak -nrl -renew -##pose -##tur -adjutant -marches -norma -##enity -ineffective -weimar -grunt -##gat -lordship -plotting -expenditure -infringement -lbs -refrain -av -mimi -mistakenly -postmaster -1771 -##bara -ras -motorsports -tito -199 -subjective -##zza -bully -stew -##kaya -prescott -1a -##raphic -##zam -bids -styling -paranormal -reeve -sneaking -exploding -katz -akbar -migrant -syllables -indefinitely -##ogical -destroys -replaces -applause -##phine -pest -##fide -218 -articulated -bertie -##thing -##cars -##ptic -courtroom -crowley -aesthetics -cummings -tehsil -hormones -titanic -dangerously -##ibe -stadion -jaenelle -auguste -ciudad -##chu -mysore -partisans -##sio -lucan -philipp -##aly -debating -henley -interiors -##rano -##tious -homecoming -beyonce -usher -henrietta -prepares -weeds -##oman -ely -plucked -##pire -##dable -luxurious -##aq -artifact -password -pasture -juno -maddy -minsk -##dder -##ologies -##rone -assessments -martian -royalist -1765 -examines -##mani -##rge -nino -223 -parry -scooped -relativity -##eli -##uting -##cao -congregational -noisy -traverse -##agawa -strikeouts -nickelodeon -obituary -transylvania -binds -depictions -polk -trolley -##yed -##lard -breeders -##under -dryly -hokkaido -1762 -strengths -stacks -bonaparte -connectivity -neared -prostitutes -stamped -anaheim -gutierrez -sinai -##zzling -bram -fresno -madhya -##86 -proton -##lena -##llum -##phon -reelected -wanda -##anus -##lb -ample -distinguishing -##yler -grasping -sermons -tomato -bland -stimulation -avenues -##eux -spreads -scarlett -fern -pentagon -assert -baird -chesapeake -ir -calmed -distortion -fatalities -##olis -correctional -pricing -##astic -##gina -prom -dammit -ying -collaborate -##chia -welterweight -33rd -pointer -substitution -bonded -umpire -communicating -multitude -paddle -##obe -federally -intimacy -##insky -betray -ssr -##lett -##lean -##lves -##therapy -airbus -##tery -functioned -ud -bearer -biomedical -netflix -##hire -##nca -condom -brink -ik -##nical -macy -##bet -flap -gma -experimented -jelly -lavender -##icles -##ulia -munro -##mian -##tial -rye -##rle -60th -gigs -hottest -rotated -predictions -fuji -bu -##erence -##omi -barangay -##fulness -##sas -clocks -##rwood -##liness -cereal -roe -wight -decker -uttered -babu -onion -xml -forcibly -##df -petra -sarcasm -hartley -peeled -storytelling -##42 -##xley -##ysis -##ffa -fibre -kiel -auditor -fig -harald -greenville -##berries -geographically -nell -quartz -##athic -cemeteries -##lr -crossings -nah -holloway -reptiles -chun -sichuan -snowy -660 -corrections -##ivo -zheng -ambassadors -blacksmith -fielded -fluids -hardcover -turnover -medications -melvin -academies -##erton -ro -roach -absorbing -spaniards -colton -##founded -outsider -espionage -kelsey -245 -edible -##ulf -dora -establishes -##sham -##tries -contracting -##tania -cinematic -costello -nesting -##uron -connolly -duff -##nology -mma -##mata -fergus -sexes -gi -optics -spectator -woodstock -banning -##hee -##fle -differentiate -outfielder -refinery -226 -312 -gerhard -horde -lair -drastically -##udi -landfall -##cheng -motorsport -odi -##achi -predominant -quay -skins -##ental -edna -harshly -complementary -murdering -##aves -wreckage -##90 -ono -outstretched -lennox -munitions -galen -reconcile -470 -scalp -bicycles -gillespie -questionable -rosenberg -guillermo -hostel -jarvis -kabul -volvo -opium -yd -##twined -abuses -decca -outpost -##cino -sensible -neutrality -##64 -ponce -anchorage -atkins -turrets -inadvertently -disagree -libre -vodka -reassuring -weighs -##yal -glide -jumper -ceilings -repertory -outs -stain -##bial -envy -##ucible -smashing -heightened -policing -hyun -mixes -lai -prima -##ples -celeste -##bina -lucrative -intervened -kc -manually -##rned -stature -staffed -bun -bastards -nairobi -priced -##auer -thatcher -##kia -tripped -comune -##ogan -##pled -brasil -incentives -emanuel -hereford -musica -##kim -benedictine -biennale -##lani -eureka -gardiner -rb -knocks -sha -##ael -##elled -##onate -efficacy -ventura -masonic -sanford -maize -leverage -##feit -capacities -santana -##aur -novelty -vanilla -##cter -##tour -benin -##oir -##rain -neptune -drafting -tallinn -##cable -humiliation -##boarding -schleswig -fabian -bernardo -liturgy -spectacle -sweeney -pont -routledge -##tment -cosmos -ut -hilt -sleek -universally -##eville -##gawa -typed -##dry -favors -allegheny -glaciers -##rly -recalling -aziz -##log -parasite -requiem -auf -##berto -##llin -illumination -##breaker -##issa -festivities -bows -govern -vibe -vp -333 -sprawled -larson -pilgrim -bwf -leaping -##rts -##ssel -alexei -greyhound -hoarse -##dler -##oration -seneca -##cule -gaping -##ulously -##pura -cinnamon -##gens -##rricular -craven -fantasies -houghton -engined -reigned -dictator -supervising -##oris -bogota -commentaries -unnatural -fingernails -spirituality -tighten -##tm -canadiens -protesting -intentional -cheers -sparta -##ytic -##iere -##zine -widen -belgarath -controllers -dodd -iaaf -navarre -##ication -defect -squire -steiner -whisky -##mins -560 -inevitably -tome -##gold -chew -##uid -##lid -elastic -##aby -streaked -alliances -jailed -regal -##ined -##phy -czechoslovak -narration -absently -##uld -bluegrass -guangdong -quran -criticizing -hose -hari -##liest -##owa -skier -streaks -deploy -##lom -raft -bose -dialed -huff -##eira -haifa -simplest -bursting -endings -ib -sultanate -##titled -franks -whitman -ensures -sven -##ggs -collaborators -forster -organising -ui -banished -napier -injustice -teller -layered -thump -##otti -roc -battleships -evidenced -fugitive -sadie -robotics -##roud -equatorial -geologist -##iza -yielding -##bron -##sr -internationale -mecca -##diment -sbs -skyline -toad -uploaded -reflective -undrafted -lal -leafs -bayern -##dai -lakshmi -shortlisted -##stick -##wicz -camouflage -donate -af -christi -lau -##acio -disclosed -nemesis -1761 -assemble -straining -northamptonshire -tal -##asi -bernardino -premature -heidi -42nd -coefficients -galactic -reproduce -buzzed -sensations -zionist -monsieur -myrtle -##eme -archery -strangled -musically -viewpoint -antiquities -bei -trailers -seahawks -cured -pee -preferring -tasmanian -lange -sul -##mail -##working -colder -overland -lucivar -massey -gatherings -haitian -##smith -disapproval -flaws -##cco -##enbach -1766 -npr -##icular -boroughs -creole -forums -techno -1755 -dent -abdominal -streetcar -##eson -##stream -procurement -gemini -predictable -##tya -acheron -christoph -feeder -fronts -vendor -bernhard -jammu -tumors -slang -##uber -goaltender -twists -curving -manson -vuelta -mer -peanut -confessions -pouch -unpredictable -allowance -theodor -vascular -##factory -bala -authenticity -metabolic -coughing -nanjing -##cea -pembroke -##bard -splendid -36th -ff -hourly -##ahu -elmer -handel -##ivate -awarding -thrusting -dl -experimentation -##hesion -##46 -caressed -entertained -steak -##rangle -biologist -orphans -baroness -oyster -stepfather -##dridge -mirage -reefs -speeding -##31 -barons -1764 -227 -inhabit -preached -repealed -##tral -honoring -boogie -captives -administer -johanna -##imate -gel -suspiciously -1767 -sobs -##dington -backbone -hayward -garry -##folding -##nesia -maxi -##oof -##ppe -ellison -galileo -##stand -crimea -frenzy -amour -bumper -matrices -natalia -baking -garth -palestinians -##grove -smack -conveyed -ensembles -gardening -##manship -##rup -##stituting -1640 -harvesting -topography -jing -shifters -dormitory -##carriage -##lston -ist -skulls -##stadt -dolores -jewellery -sarawak -##wai -##zier -fences -christy -confinement -tumbling -credibility -fir -stench -##bria -##plication -##nged -##sam -virtues -##belt -marjorie -pba -##eem -##made -celebrates -schooner -agitated -barley -fulfilling -anthropologist -##pro -restrict -novi -regulating -##nent -padres -##rani -##hesive -loyola -tabitha -milky -olson -proprietor -crambidae -guarantees -intercollegiate -ljubljana -hilda -##sko -ignorant -hooded -##lts -sardinia -##lidae -##vation -frontman -privileged -witchcraft -##gp -jammed -laude -poking -##than -bracket -amazement -yunnan -##erus -maharaja -linnaeus -264 -commissioning -milano -peacefully -##logies -akira -rani -regulator -##36 -grasses -##rance -luzon -crows -compiler -gretchen -seaman -edouard -tab -buccaneers -ellington -hamlets -whig -socialists -##anto -directorial -easton -mythological -##kr -##vary -rhineland -semantic -taut -dune -inventions -succeeds -##iter -replication -branched -##pired -jul -prosecuted -kangaroo -penetrated -##avian -middlesbrough -doses -bleak -madam -predatory -relentless -##vili -reluctance -##vir -hailey -crore -silvery -1759 -monstrous -swimmers -transmissions -hawthorn -informing -##eral -toilets -caracas -crouch -kb -##sett -295 -cartel -hadley -##aling -alexia -yvonne -##biology -cinderella -eton -superb -blizzard -stabbing -industrialist -maximus -##gm -##orus -groves -maud -clade -oversized -comedic -##bella -rosen -nomadic -fulham -montane -beverages -galaxies -redundant -swarm -##rot -##folia -##llis -buckinghamshire -fen -bearings -bahadur -##rom -gilles -phased -dynamite -faber -benoit -vip -##ount -##wd -booking -fractured -tailored -anya -spices -westwood -cairns -auditions -inflammation -steamed -##rocity -##acion -##urne -skyla -thereof -watford -torment -archdeacon -transforms -lulu -demeanor -fucked -serge -##sor -mckenna -minas -entertainer -##icide -caress -originate -residue -##sty -1740 -##ilised -##org -beech -##wana -subsidies -##ghton -emptied -gladstone -ru -firefighters -voodoo -##rcle -het -nightingale -tamara -edmond -ingredient -weaknesses -silhouette -285 -compatibility -withdrawing -hampson -##mona -anguish -giggling -##mber -bookstore -##jiang -southernmost -tilting -##vance -bai -economical -rf -briefcase -dreadful -hinted -projections -shattering -totaling -##rogate -analogue -indicted -periodical -fullback -##dman -haynes -##tenberg -##ffs -##ishment -1745 -thirst -stumble -penang -vigorous -##ddling -##kor -##lium -octave -##ove -##enstein -##inen -##ones -siberian -##uti -cbn -repeal -swaying -##vington -khalid -tanaka -unicorn -otago -plastered -lobe -riddle -##rella -perch -##ishing -croydon -filtered -graeme -tripoli -##ossa -crocodile -##chers -sufi -mined -##tung -inferno -lsu -##phi -swelled -utilizes -£2 -cale -periodicals -styx -hike -informally -coop -lund -##tidae -ala -hen -qui -transformations -disposed -sheath -chickens -##cade -fitzroy -sas -silesia -unacceptable -odisha -1650 -sabrina -pe -spokane -ratios -athena -massage -shen -dilemma -##drum -##riz -##hul -corona -doubtful -niall -##pha -##bino -fines -cite -acknowledging -bangor -ballard -bathurst -##resh -huron -mustered -alzheimer -garments -kinase -tyre -warship -##cp -flashback -pulmonary -braun -cheat -kamal -cyclists -constructions -grenades -ndp -traveller -excuses -stomped -signalling -trimmed -futsal -mosques -relevance -##wine -wta -##23 -##vah -##lter -hoc -##riding -optimistic -##´s -deco -sim -interacting -rejecting -moniker -waterways -##ieri -##oku -mayors -gdansk -outnumbered -pearls -##ended -##hampton -fairs -totals -dominating -262 -notions -stairway -compiling -pursed -commodities -grease -yeast -##jong -carthage -griffiths -residual -amc -contraction -laird -sapphire -##marine -##ivated -amalgamation -dissolve -inclination -lyle -packaged -altitudes -suez -canons -graded -lurched -narrowing -boasts -guise -wed -enrico -##ovsky -rower -scarred -bree -cub -iberian -protagonists -bargaining -proposing -trainers -voyages -vans -fishes -##aea -##ivist -##verance -encryption -artworks -kazan -sabre -cleopatra -hepburn -rotting -supremacy -mecklenburg -##brate -burrows -hazards -outgoing -flair -organizes -##ctions -scorpion -##usions -boo -234 -chevalier -dunedin -slapping -##34 -ineligible -pensions -##38 -##omic -manufactures -emails -bismarck -238 -weakening -blackish -ding -mcgee -quo -##rling -northernmost -xx -manpower -greed -sampson -clicking -##ange -##horpe -##inations -##roving -torre -##eptive -##moral -symbolism -38th -asshole -meritorious -outfits -splashed -biographies -sprung -astros -##tale -302 -737 -filly -raoul -nw -tokugawa -linden -clubhouse -##apa -tracts -romano -##pio -putin -tags -##note -chained -dickson -gunshot -moe -gunn -rashid -##tails -zipper -##bas -##nea -contrasted -##ply -##udes -plum -pharaoh -##pile -aw -comedies -ingrid -sandwiches -subdivisions -1100 -mariana -nokia -kamen -hz -delaney -veto -herring -##words -possessive -outlines -##roup -siemens -stairwell -rc -gallantry -messiah -palais -yells -233 -zeppelin -##dm -bolivar -##cede -smackdown -mckinley -##mora -##yt -muted -geologic -finely -unitary -avatar -hamas -maynard -rees -bog -contrasting -##rut -liv -chico -disposition -pixel -##erate -becca -dmitry -yeshiva -narratives -##lva -##ulton -mercenary -sharpe -tempered -navigate -stealth -amassed -keynes -##lini -untouched -##rrie -havoc -lithium -##fighting -abyss -graf -southward -wolverine -balloons -implements -ngos -transitions -##icum -ambushed -concacaf -dormant -economists -##dim -costing -csi -rana -universite -boulders -verity -##llon -collin -mellon -misses -cypress -fluorescent -lifeless -spence -##ulla -crewe -shepard -pak -revelations -##Ù… -jolly -gibbons -paw -##dro -##quel -freeing -##test -shack -fries -palatine -##51 -##hiko -accompaniment -cruising -recycled -##aver -erwin -sorting -synthesizers -dyke -realities -sg -strides -enslaved -wetland -##ghan -competence -gunpowder -grassy -maroon -reactors -objection -##oms -carlson -gearbox -macintosh -radios -shelton -##sho -clergyman -prakash -254 -mongols -trophies -oricon -228 -stimuli -twenty20 -cantonese -cortes -mirrored -##saurus -bhp -cristina -melancholy -##lating -enjoyable -nuevo -##wny -downfall -schumacher -##ind -banging -lausanne -rumbled -paramilitary -reflex -ax -amplitude -migratory -##gall -##ups -midi -barnard -lastly -sherry -##hp -##nall -keystone -##kra -carleton -slippery -##53 -coloring -foe -socket -otter -##rgos -mats -##tose -consultants -bafta -bison -topping -##km -490 -primal -abandonment -transplant -atoll -hideous -mort -pained -reproduced -tae -howling -##turn -unlawful -billionaire -hotter -poised -lansing -##chang -dinamo -retro -messing -nfc -domesday -##mina -blitz -timed -##athing -##kley -ascending -gesturing -##izations -signaled -tis -chinatown -mermaid -savanna -jameson -##aint -catalina -##pet -##hers -cochrane -cy -chatting -##kus -alerted -computation -mused -noelle -majestic -mohawk -campo -octagonal -##sant -##hend -241 -aspiring -##mart -comprehend -iona -paralyzed -shimmering -swindon -rhone -##eley -reputed -configurations -pitchfork -agitation -francais -gillian -lipstick -##ilo -outsiders -pontifical -resisting -bitterness -sewer -rockies -##edd -##ucher -misleading -1756 -exiting -galloway -##nging -risked -##heart -246 -commemoration -schultz -##rka -integrating -##rsa -poses -shrieked -##weiler -guineas -gladys -jerking -owls -goldsmith -nightly -penetrating -##unced -lia -##33 -ignited -betsy -##aring -##thorpe -follower -vigorously -##rave -coded -kiran -knit -zoology -tbilisi -##28 -##bered -repository -govt -deciduous -dino -growling -##bba -enhancement -unleashed -chanting -pussy -biochemistry -##eric -kettle -repression -toxicity -nrhp -##arth -##kko -##bush -ernesto -commended -outspoken -242 -mca -parchment -sms -kristen -##aton -bisexual -raked -glamour -navajo -a2 -conditioned -showcased -##hma -spacious -youthful -##esa -usl -appliances -junta -brest -layne -conglomerate -enchanted -chao -loosened -picasso -circulating -inspect -montevideo -##centric -##kti -piazza -spurred -##aith -bari -freedoms -poultry -stamford -lieu -##ect -indigo -sarcastic -bahia -stump -attach -dvds -frankenstein -lille -approx -scriptures -pollen -##script -nmi -overseen -##ivism -tides -proponent -newmarket -inherit -milling -##erland -centralized -##rou -distributors -credentials -drawers -abbreviation -##lco -##xon -downing -uncomfortably -ripe -##oes -erase -franchises -##ever -populace -##bery -##khar -decomposition -pleas -##tet -daryl -sabah -##stle -##wide -fearless -genie -lesions -annette -##ogist -oboe -appendix -nair -dripped -petitioned -maclean -mosquito -parrot -rpg -hampered -1648 -operatic -reservoirs -##tham -irrelevant -jolt -summarized -##fp -medallion -##taff -##− -clawed -harlow -narrower -goddard -marcia -bodied -fremont -suarez -altering -tempest -mussolini -porn -##isms -sweetly -oversees -walkers -solitude -grimly -shrines -hk -ich -supervisors -hostess -dietrich -legitimacy -brushes -expressive -##yp -dissipated -##rse -localized -systemic -##nikov -gettysburg -##js -##uaries -dialogues -muttering -251 -housekeeper -sicilian -discouraged -##frey -beamed -kaladin -halftime -kidnap -##amo -##llet -1754 -synonymous -depleted -instituto -insulin -reprised -##opsis -clashed -##ctric -interrupting -radcliffe -insisting -medici -1715 -ejected -playfully -turbulent -##47 -starvation -##rini -shipment -rebellious -petersen -verification -merits -##rified -cakes -##charged -1757 -milford -shortages -spying -fidelity -##aker -emitted -storylines -harvested -seismic -##iform -cheung -kilda -theoretically -barbie -lynx -##rgy -##tius -goblin -mata -poisonous -##nburg -reactive -residues -obedience -##евич -conjecture -##rac -401 -hating -sixties -kicker -moaning -motown -##bha -emancipation -neoclassical -##hering -consoles -ebert -professorship -##tures -sustaining -assaults -obeyed -affluent -incurred -tornadoes -##eber -##zow -emphasizing -highlanders -cheated -helmets -##ctus -internship -terence -bony -executions -legislators -berries -peninsular -tinged -##aco -1689 -amplifier -corvette -ribbons -lavish -pennant -##lander -worthless -##chfield -##forms -mariano -pyrenees -expenditures -##icides -chesterfield -mandir -tailor -39th -sergey -nestled -willed -aristocracy -devotees -goodnight -raaf -rumored -weaponry -remy -appropriations -harcourt -burr -riaa -##lence -limitation -unnoticed -guo -soaking -swamps -##tica -collapsing -tatiana -descriptive -brigham -psalm -##chment -maddox -##lization -patti -caliph -##aja -akron -injuring -serra -##ganj -basins -##sari -astonished -launcher -##church -hilary -wilkins -sewing -##sf -stinging -##fia -##ncia -underwood -startup -##ition -compilations -vibrations -embankment -jurist -##nity -bard -juventus -groundwater -kern -palaces -helium -boca -cramped -marissa -soto -##worm -jae -princely -##ggy -faso -bazaar -warmly -##voking -229 -pairing -##lite -##grate -##nets -wien -freaked -ulysses -rebirth -##alia -##rent -mummy -guzman -jimenez -stilled -##nitz -trajectory -tha -woken -archival -professions -##pts -##pta -hilly -shadowy -shrink -##bolt -norwood -glued -migrate -stereotypes -devoid -##pheus -625 -evacuate -horrors -infancy -gotham -knowles -optic -downloaded -sachs -kingsley -parramatta -darryl -mor -##onale -shady -commence -confesses -kan -##meter -##placed -marlborough -roundabout -regents -frigates -io -##imating -gothenburg -revoked -carvings -clockwise -convertible -intruder -##sche -banged -##ogo -vicky -bourgeois -##mony -dupont -footing -##gum -pd -##real -buckle -yun -penthouse -sane -720 -serviced -stakeholders -neumann -bb -##eers -comb -##gam -catchment -pinning -rallies -typing -##elles -forefront -freiburg -sweetie -giacomo -widowed -goodwill -worshipped -aspirations -midday -##vat -fishery -##trick -bournemouth -turk -243 -hearth -ethanol -guadalajara -murmurs -sl -##uge -afforded -scripted -##hta -wah -##jn -coroner -translucent -252 -memorials -puck -progresses -clumsy -##race -315 -candace -recounted -##27 -##slin -##uve -filtering -##mac -howl -strata -heron -leveled -##ays -dubious -##oja -##Ñ‚ -##wheel -citations -exhibiting -##laya -##mics -##pods -turkic -##lberg -injunction -##ennial -##mit -antibodies -##44 -organise -##rigues -cardiovascular -cushion -inverness -##zquez -dia -cocoa -sibling -##tman -##roid -expanse -feasible -tunisian -algiers -##relli -rus -bloomberg -dso -westphalia -bro -tacoma -281 -downloads -##ours -konrad -duran -##hdi -continuum -jett -compares -legislator -secession -##nable -##gues -##zuka -translating -reacher -##gley -##Å‚a -aleppo -##agi -tc -orchards -trapping -linguist -versatile -drumming -postage -calhoun -superiors -##mx -barefoot -leary -##cis -ignacio -alfa -kaplan -##rogen -bratislava -mori -##vot -disturb -haas -313 -cartridges -gilmore -radiated -salford -tunic -hades -##ulsive -archeological -delilah -magistrates -auditioned -brewster -charters -empowerment -blogs -cappella -dynasties -iroquois -whipping -##krishna -raceway -truths -myra -weaken -judah -mcgregor -##horse -mic -refueling -37th -burnley -bosses -markus -premio -query -##gga -dunbar -##economic -darkest -lyndon -sealing -commendation -reappeared -##mun -addicted -ezio -slaughtered -satisfactory -shuffle -##eves -##thic -##uj -fortification -warrington -##otto -resurrected -fargo -mane -##utable -##lei -##space -foreword -ox -##aris -##vern -abrams -hua -##mento -sakura -##alo -uv -sentimental -##skaya -midfield -##eses -sturdy -scrolls -macleod -##kyu -entropy -##lance -mitochondrial -cicero -excelled -thinner -convoys -perceive -##oslav -##urable -systematically -grind -burkina -287 -##tagram -ops -##aman -guantanamo -##cloth -##tite -forcefully -wavy -##jou -pointless -##linger -##tze -layton -portico -superficial -clerical -outlaws -##hism -burials -muir -##inn -creditors -hauling -rattle -##leg -calais -monde -archers -reclaimed -dwell -wexford -hellenic -falsely -remorse -##tek -dough -furnishings -##uttered -gabon -neurological -novice -##igraphy -contemplated -pulpit -nightstand -saratoga -##istan -documenting -pulsing -taluk -##firmed -busted -marital -##rien -disagreements -wasps -##yes -hodge -mcdonnell -mimic -fran -pendant -dhabi -musa -##nington -congratulations -argent -darrell -concussion -losers -regrets -thessaloniki -reversal -donaldson -hardwood -thence -achilles -ritter -##eran -demonic -jurgen -prophets -goethe -eki -classmate -buff -##cking -yank -irrational -##inging -perished -seductive -qur -sourced -##crat -##typic -mustard -ravine -barre -horizontally -characterization -phylogenetic -boise -##dit -##runner -##tower -brutally -intercourse -seduce -##bbing -fay -ferris -ogden -amar -nik -unarmed -##inator -evaluating -kyrgyzstan -sweetness -##lford -##oki -mccormick -meiji -notoriety -stimulate -disrupt -figuring -instructional -mcgrath -##zoo -groundbreaking -##lto -flinch -khorasan -agrarian -bengals -mixer -radiating -##sov -ingram -pitchers -nad -tariff -##cript -tata -##codes -##emi -##ungen -appellate -lehigh -##bled -##giri -brawl -duct -texans -##ciation -##ropolis -skipper -speculative -vomit -doctrines -stresses -253 -davy -graders -whitehead -jozef -timely -cumulative -haryana -paints -appropriately -boon -cactus -##ales -##pid -dow -legions -##pit -perceptions -1730 -picturesque -##yse -periphery -rune -wr -##aha -celtics -sentencing -whoa -##erin -confirms -variance -425 -moines -mathews -spade -rave -m1 -fronted -fx -blending -alleging -reared -##gl -237 -##paper -grassroots -eroded -##free -##physical -directs -ordeal -##sÅ‚aw -accelerate -hacker -rooftop -##inia -lev -buys -cebu -devote -##lce -specialising -##ulsion -choreographed -repetition -warehouses -##ryl -paisley -tuscany -analogy -sorcerer -hash -huts -shards -descends -exclude -nix -chaplin -gaga -ito -vane -##drich -causeway -misconduct -limo -orchestrated -glands -jana -##kot -u2 -##mple -##sons -branching -contrasts -scoop -longed -##virus -chattanooga -##75 -syrup -cornerstone -##tized -##mind -##iaceae -careless -precedence -frescoes -##uet -chilled -consult -modelled -snatch -peat -##thermal -caucasian -humane -relaxation -spins -temperance -##lbert -occupations -lambda -hybrids -moons -mp3 -##oese -247 -rolf -societal -yerevan -ness -##ssler -befriended -mechanized -nominate -trough -boasted -cues -seater -##hom -bends -##tangle -conductors -emptiness -##lmer -eurasian -adriatic -tian -##cie -anxiously -lark -propellers -chichester -jock -ev -2a -##holding -credible -recounts -tori -loyalist -abduction -##hoot -##redo -nepali -##mite -ventral -tempting -##ango -##crats -steered -##wice -javelin -dipping -laborers -prentice -looming -titanium -##ː -badges -emir -tensor -##ntation -egyptians -rash -denies -hawthorne -lombard -showers -wehrmacht -dietary -trojan -##reus -welles -executing -horseshoe -lifeboat -##lak -elsa -infirmary -nearing -roberta -boyer -mutter -trillion -joanne -##fine -##oked -sinks -vortex -uruguayan -clasp -sirius -##block -accelerator -prohibit -sunken -byu -chronological -diplomats -ochreous -510 -symmetrical -1644 -maia -##tology -salts -reigns -atrocities -##ия -hess -bared -issn -##vyn -cater -saturated -##cycle -##isse -sable -voyager -dyer -yusuf -##inge -fountains -wolff -##39 -##nni -engraving -rollins -atheist -ominous -##ault -herr -chariot -martina -strung -##fell -##farlane -horrific -sahib -gazes -saetan -erased -ptolemy -##olic -flushing -lauderdale -analytic -##ices -530 -navarro -beak -gorilla -herrera -broom -guadalupe -raiding -sykes -311 -bsc -deliveries -1720 -invasions -carmichael -tajikistan -thematic -ecumenical -sentiments -onstage -##rians -##brand -##sume -catastrophic -flanks -molten -##arns -waller -aimee -terminating -##icing -alternately -##oche -nehru -printers -outraged -##eving -empires -template -banners -repetitive -za -##oise -vegetarian -##tell -guiana -opt -cavendish -lucknow -synthesized -##hani -##mada -finalized -##ctable -fictitious -mayoral -unreliable -##enham -embracing -peppers -rbis -##chio -##neo -inhibition -slashed -togo -orderly -embroidered -safari -salty -236 -barron -benito -totaled -##dak -pubs -simulated -caden -devin -tolkien -momma -welding -sesame -##ept -gottingen -hardness -630 -shaman -temeraire -620 -adequately -pediatric -##kit -ck -assertion -radicals -composure -cadence -seafood -beaufort -lazarus -mani -warily -cunning -kurdistan -249 -cantata -##kir -ares -##41 -##clusive -nape -townland -geared -insulted -flutter -boating -violate -draper -dumping -malmo -##hh -##romatic -firearm -alta -bono -obscured -##clave -exceeds -panorama -unbelievable -##train -preschool -##essed -disconnected -installing -rescuing -secretaries -accessibility -##castle -##drive -##ifice -##film -bouts -slug -waterway -mindanao -##buro -##ratic -halves -##Ù„ -calming -liter -maternity -adorable -bragg -electrification -mcc -##dote -roxy -schizophrenia -##body -munoz -kaye -whaling -239 -mil -tingling -tolerant -##ago -unconventional -volcanoes -##finder -deportivo -##llie -robson -kaufman -neuroscience -wai -deportation -masovian -scraping -converse -##bh -hacking -bulge -##oun -administratively -yao -580 -amp -mammoth -booster -claremont -hooper -nomenclature -pursuits -mclaughlin -melinda -##sul -catfish -barclay -substrates -taxa -zee -originals -kimberly -packets -padma -##ality -borrowing -ostensibly -solvent -##bri -##genesis -##mist -lukas -shreveport -veracruz -##ÑŒ -##lou -##wives -cheney -tt -anatolia -hobbs -##zyn -cyclic -radiant -alistair -greenish -siena -dat -independents -##bation -conform -pieter -hyper -applicant -bradshaw -spores -telangana -vinci -inexpensive -nuclei -322 -jang -nme -soho -spd -##ign -cradled -receptionist -pow -##43 -##rika -fascism -##ifer -experimenting -##ading -##iec -##region -345 -jocelyn -maris -stair -nocturnal -toro -constabulary -elgin -##kker -msc -##giving -##schen -##rase -doherty -doping -sarcastically -batter -maneuvers -##cano -##apple -##gai -##git -intrinsic -##nst -##stor -1753 -showtime -cafes -gasps -lviv -ushered -##thed -fours -restart -astonishment -transmitting -flyer -shrugs -##sau -intriguing -cones -dictated -mushrooms -medial -##kovsky -##elman -escorting -gaped -##26 -godfather -##door -##sell -djs -recaptured -timetable -vila -1710 -3a -aerodrome -mortals -scientology -##orne -angelina -mag -convection -unpaid -insertion -intermittent -lego -##nated -endeavor -kota -pereira -##lz -304 -bwv -glamorgan -insults -agatha -fey -##cend -fleetwood -mahogany -protruding -steamship -zeta -##arty -mcguire -suspense -##sphere -advising -urges -##wala -hurriedly -meteor -gilded -inline -arroyo -stalker -##oge -excitedly -revered -##cure -earle -introductory -##break -##ilde -mutants -puff -pulses -reinforcement -##haling -curses -lizards -stalk -correlated -##fixed -fallout -macquarie -##unas -bearded -denton -heaving -802 -##ocation -winery -assign -dortmund -##lkirk -everest -invariant -charismatic -susie -##elling -bled -lesley -telegram -sumner -bk -##ogen -##к -wilcox -needy -colbert -duval -##iferous -##mbled -allotted -attends -imperative -##hita -replacements -hawker -##inda -insurgency -##zee -##eke -casts -##yla -680 -ives -transitioned -##pack -##powering -authoritative -baylor -flex -cringed -plaintiffs -woodrow -##skie -drastic -ape -aroma -unfolded -commotion -nt -preoccupied -theta -routines -lasers -privatization -wand -domino -ek -clenching -nsa -strategically -showered -bile -handkerchief -pere -storing -christophe -insulting -316 -nakamura -romani -asiatic -magdalena -palma -cruises -stripping -405 -konstantin -soaring -##berman -colloquially -forerunner -havilland -incarcerated -parasites -sincerity -##utus -disks -plank -saigon -##ining -corbin -homo -ornaments -powerhouse -##tlement -chong -fastened -feasibility -idf -morphological -usable -##nish -##zuki -aqueduct -jaguars -keepers -##flies -aleksandr -faust -assigns -ewing -bacterium -hurled -tricky -hungarians -integers -wallis -321 -yamaha -##isha -hushed -oblivion -aviator -evangelist -friars -##eller -monograph -ode -##nary -airplanes -labourers -charms -##nee -1661 -hagen -tnt -rudder -fiesta -transcript -dorothea -ska -inhibitor -maccabi -retorted -raining -encompassed -clauses -menacing -1642 -lineman -##gist -vamps -##ape -##dick -gloom -##rera -dealings -easing -seekers -##nut -##pment -helens -unmanned -##anu -##isson -basics -##amy -##ckman -adjustments -1688 -brutality -horne -##zell -sui -##55 -##mable -aggregator -##thal -rhino -##drick -##vira -counters -zoom -##01 -##rting -mn -montenegrin -packard -##unciation -##â™­ -##kki -reclaim -scholastic -thugs -pulsed -##icia -syriac -quan -saddam -banda -kobe -blaming -buddies -dissent -##lusion -##usia -corbett -jaya -delle -erratic -lexie -##hesis -435 -amiga -hermes -##pressing -##leen -chapels -gospels -jamal -##uating -compute -revolving -warp -##sso -##thes -armory -##eras -##gol -antrim -loki -##kow -##asian -##good -##zano -braid -handwriting -subdistrict -funky -pantheon -##iculate -concurrency -estimation -improper -juliana -##his -newcomers -johnstone -staten -communicated -##oco -##alle -sausage -stormy -##stered -##tters -superfamily -##grade -acidic -collateral -tabloid -##oped -##rza -bladder -austen -##ellant -mcgraw -##hay -hannibal -mein -aquino -lucifer -wo -badger -boar -cher -christensen -greenberg -interruption -##kken -jem -244 -mocked -bottoms -cambridgeshire -##lide -sprawling -##bbly -eastwood -ghent -synth -##buck -advisers -##bah -nominally -hapoel -qu -daggers -estranged -fabricated -towels -vinnie -wcw -misunderstanding -anglia -nothin -unmistakable -##dust -##lova -chilly -marquette -truss -##edge -##erine -reece -##lty -##chemist -##connected -272 -308 -41st -bash -raion -waterfalls -##ump -##main -labyrinth -queue -theorist -##istle -bharatiya -flexed -soundtracks -rooney -leftist -patrolling -wharton -plainly -alleviate -eastman -schuster -topographic -engages -immensely -unbearable -fairchild -1620 -dona -lurking -parisian -oliveira -ia -indictment -hahn -bangladeshi -##aster -vivo -##uming -##ential -antonia -expects -indoors -kildare -harlan -##logue -##ogenic -##sities -forgiven -##wat -childish -tavi -##mide -##orra -plausible -grimm -successively -scooted -##bola -##dget -##rith -spartans -emery -flatly -azure -epilogue -##wark -flourish -##iny -##tracted -##overs -##oshi -bestseller -distressed -receipt -spitting -hermit -topological -##cot -drilled -subunit -francs -##layer -eel -##fk -##itas -octopus -footprint -petitions -ufo -##say -##foil -interfering -leaking -palo -##metry -thistle -valiant -##pic -narayan -mcpherson -##fast -gonzales -##ym -##enne -dustin -novgorod -solos -##zman -doin -##raph -##patient -##meyer -soluble -ashland -cuffs -carole -pendleton -whistling -vassal -##river -deviation -revisited -constituents -rallied -rotate -loomed -##eil -##nting -amateurs -augsburg -auschwitz -crowns -skeletons -##cona -bonnet -257 -dummy -globalization -simeon -sleeper -mandal -differentiated -##crow -##mare -milne -bundled -exasperated -talmud -owes -segregated -##feng -##uary -dentist -piracy -props -##rang -devlin -##torium -malicious -paws -##laid -dependency -##ergy -##fers -##enna -258 -pistons -rourke -jed -grammatical -tres -maha -wig -512 -ghostly -jayne -##achal -##creen -##ilis -##lins -##rence -designate -##with -arrogance -cambodian -clones -showdown -throttle -twain -##ception -lobes -metz -nagoya -335 -braking -##furt -385 -roaming -##minster -amin -crippled -##37 -##llary -indifferent -hoffmann -idols -intimidating -1751 -261 -influenza -memo -onions -1748 -bandage -consciously -##landa -##rage -clandestine -observes -swiped -tangle -##ener -##jected -##trum -##bill -##lta -hugs -congresses -josiah -spirited -##dek -humanist -managerial -filmmaking -inmate -rhymes -debuting -grimsby -ur -##laze -duplicate -vigor -##tf -republished -bolshevik -refurbishment -antibiotics -martini -methane -newscasts -royale -horizons -levant -iain -visas -##ischen -paler -##around -manifestation -snuck -alf -chop -futile -pedestal -rehab -##kat -bmg -kerman -res -fairbanks -jarrett -abstraction -saharan -##zek -1746 -procedural -clearer -kincaid -sash -luciano -##ffey -crunch -helmut -##vara -revolutionaries -##tute -creamy -leach -##mmon -1747 -permitting -nes -plight -wendell -##lese -contra -ts -clancy -ipa -mach -staples -autopsy -disturbances -nueva -karin -pontiac -##uding -proxy -venerable -haunt -leto -bergman -expands -##helm -wal -##pipe -canning -celine -cords -obesity -##enary -intrusion -planner -##phate -reasoned -sequencing -307 -harrow -##chon -##dora -marred -mcintyre -repay -tarzan -darting -248 -harrisburg -margarita -repulsed -##hur -##lding -belinda -hamburger -novo -compliant -runways -bingham -registrar -skyscraper -ic -cuthbert -improvisation -livelihood -##corp -##elial -admiring -##dened -sporadic -believer -casablanca -popcorn -##29 -asha -shovel -##bek -##dice -coiled -tangible -##dez -casper -elsie -resin -tenderness -rectory -##ivision -avail -sonar -##mori -boutique -##dier -guerre -bathed -upbringing -vaulted -sandals -blessings -##naut -##utnant -1680 -306 -foxes -pia -corrosion -hesitantly -confederates -crystalline -footprints -shapiro -tirana -valentin -drones -45th -microscope -shipments -texted -inquisition -wry -guernsey -unauthorized -resigning -760 -ripple -schubert -stu -reassure -felony -##ardo -brittle -koreans -##havan -##ives -dun -implicit -tyres -##aldi -##lth -magnolia -##ehan -##puri -##poulos -aggressively -fei -gr -familiarity -##poo -indicative -##trust -fundamentally -jimmie -overrun -395 -anchors -moans -##opus -britannia -armagh -##ggle -purposely -seizing -##vao -bewildered -mundane -avoidance -cosmopolitan -geometridae -quartermaster -caf -415 -chatter -engulfed -gleam -purge -##icate -juliette -jurisprudence -guerra -revisions -##bn -casimir -brew -##jm -1749 -clapton -cloudy -conde -hermitage -278 -simulations -torches -vincenzo -matteo -##rill -hidalgo -booming -westbound -accomplishment -tentacles -unaffected -##sius -annabelle -flopped -sloping -##litz -dreamer -interceptor -vu -##loh -consecration -copying -messaging -breaker -climates -hospitalized -1752 -torino -afternoons -winfield -witnessing -##teacher -breakers -choirs -sawmill -coldly -##ege -sipping -haste -uninhabited -conical -bibliography -pamphlets -severn -edict -##oca -deux -illnesses -grips -##pl -rehearsals -sis -thinkers -tame -##keepers -1690 -acacia -reformer -##osed -##rys -shuffling -##iring -##shima -eastbound -ionic -rhea -flees -littered -##oum -rocker -vomiting -groaning -champ -overwhelmingly -civilizations -paces -sloop -adoptive -##tish -skaters -##vres -aiding -mango -##joy -nikola -shriek -##ignon -pharmaceuticals -##mg -tuna -calvert -gustavo -stocked -yearbook -##urai -##mana -computed -subsp -riff -hanoi -kelvin -hamid -moors -pastures -summons -jihad -nectar -##ctors -bayou -untitled -pleasing -vastly -republics -intellect -##η -##ulio -##tou -crumbling -stylistic -sb -##ÛŒ -consolation -frequented -hâ‚‚o -walden -widows -##iens -404 -##ignment -chunks -improves -288 -grit -recited -##dev -snarl -sociological -##arte -##gul -inquired -##held -bruise -clube -consultancy -homogeneous -hornets -multiplication -pasta -prick -savior -##grin -##kou -##phile -yoon -##gara -grimes -vanishing -cheering -reacting -bn -distillery -##quisite -##vity -coe -dockyard -massif -##jord -escorts -voss -##valent -byte -chopped -hawke -illusions -workings -floats -##koto -##vac -kv -annapolis -madden -##onus -alvaro -noctuidae -##cum -##scopic -avenge -steamboat -forte -illustrates -erika -##trip -570 -dew -nationalities -bran -manifested -thirsty -diversified -muscled -reborn -##standing -arson -##lessness -##dran -##logram -##boys -##kushima -##vious -willoughby -##phobia -286 -alsace -dashboard -yuki -##chai -granville -myspace -publicized -tricked -##gang -adjective -##ater -relic -reorganisation -enthusiastically -indications -saxe -##lassified -consolidate -iec -padua -helplessly -ramps -renaming -regulars -pedestrians -accents -convicts -inaccurate -lowers -mana -##pati -barrie -bjp -outta -someplace -berwick -flanking -invoked -marrow -sparsely -excerpts -clothed -rei -##ginal -wept -##straße -##vish -alexa -excel -##ptive -membranes -aquitaine -creeks -cutler -sheppard -implementations -ns -##dur -fragrance -budge -concordia -magnesium -marcelo -##antes -gladly -vibrating -##rral -##ggles -montrose -##omba -lew -seamus -1630 -cocky -##ament -##uen -bjorn -##rrick -fielder -fluttering -##lase -methyl -kimberley -mcdowell -reductions -barbed -##jic -##tonic -aeronautical -condensed -distracting -##promising -huffed -##cala -##sle -claudius -invincible -missy -pious -balthazar -ci -##lang -butte -combo -orson -##dication -myriad -1707 -silenced -##fed -##rh -coco -netball -yourselves -##oza -clarify -heller -peg -durban -etudes -offender -roast -blackmail -curvature -##woods -vile -309 -illicit -suriname -##linson -overture -1685 -bubbling -gymnast -tucking -##mming -##ouin -maldives -##bala -gurney -##dda -##eased -##oides -backside -pinto -jars -racehorse -tending -##rdial -baronetcy -wiener -duly -##rke -barbarian -cupping -flawed -##thesis -bertha -pleistocene -puddle -swearing -##nob -##tically -fleeting -prostate -amulet -educating -##mined -##iti -##tler -75th -jens -respondents -analytics -cavaliers -papacy -raju -##iente -##ulum -##tip -funnel -271 -disneyland -##lley -sociologist -##iam -2500 -faulkner -louvre -menon -##dson -276 -##ower -afterlife -mannheim -peptide -referees -comedians -meaningless -##anger -##laise -fabrics -hurley -renal -sleeps -##bour -##icle -breakout -kristin -roadside -animator -clover -disdain -unsafe -redesign -##urity -firth -barnsley -portage -reset -narrows -268 -commandos -expansive -speechless -tubular -##lux -essendon -eyelashes -smashwords -##yad -##bang -##claim -craved -sprinted -chet -somme -astor -wrocÅ‚aw -orton -266 -bane -##erving -##uing -mischief -##amps -##sund -scaling -terre -##xious -impairment -offenses -undermine -moi -soy -contiguous -arcadia -inuit -seam -##tops -macbeth -rebelled -##icative -##iot -590 -elaborated -frs -uniformed -##dberg -259 -powerless -priscilla -stimulated -980 -qc -arboretum -frustrating -trieste -bullock -##nified -enriched -glistening -intern -##adia -locus -nouvelle -ollie -ike -lash -starboard -ee -tapestry -headlined -hove -rigged -##vite -pollock -##yme -thrive -clustered -cas -roi -gleamed -olympiad -##lino -pressured -regimes -##hosis -##lick -ripley -##ophone -kickoff -gallon -rockwell -##arable -crusader -glue -revolutions -scrambling -1714 -grover -##jure -englishman -aztec -263 -contemplating -coven -ipad -preach -triumphant -tufts -##esian -rotational -##phus -328 -falkland -##brates -strewn -clarissa -rejoin -environmentally -glint -banded -drenched -moat -albanians -johor -rr -maestro -malley -nouveau -shaded -taxonomy -v6 -adhere -bunk -airfields -##ritan -1741 -encompass -remington -tran -##erative -amelie -mazda -friar -morals -passions -##zai -breadth -vis -##hae -argus -burnham -caressing -insider -rudd -##imov -##mini -##rso -italianate -murderous -textual -wainwright -armada -bam -weave -timer -##taken -##nh -fra -##crest -ardent -salazar -taps -tunis -##ntino -allegro -gland -philanthropic -##chester -implication -##optera -esq -judas -noticeably -wynn -##dara -inched -indexed -crises -villiers -bandit -royalties -patterned -cupboard -interspersed -accessory -isla -kendrick -entourage -stitches -##esthesia -headwaters -##ior -interlude -distraught -draught -1727 -##basket -biased -sy -transient -triad -subgenus -adapting -kidd -shortstop -##umatic -dimly -spiked -mcleod -reprint -nellie -pretoria -windmill -##cek -singled -##mps -273 -reunite -##orous -747 -bankers -outlying -##omp -##ports -##tream -apologies -cosmetics -patsy -##deh -##ocks -##yson -bender -nantes -serene -##nad -lucha -mmm -323 -##cius -##gli -cmll -coinage -nestor -juarez -##rook -smeared -sprayed -twitching -sterile -irina -embodied -juveniles -enveloped -miscellaneous -cancers -dq -gulped -luisa -crested -swat -donegal -ref -##anov -##acker -hearst -mercantile -##lika -doorbell -ua -vicki -##alla -##som -bilbao -psychologists -stryker -sw -horsemen -turkmenistan -wits -##national -anson -mathew -screenings -##umb -rihanna -##agne -##nessy -aisles -##iani -##osphere -hines -kenton -saskatoon -tasha -truncated -##champ -##itan -mildred -advises -fredrik -interpreting -inhibitors -##athi -spectroscopy -##hab -##kong -karim -panda -##oia -##nail -##vc -conqueror -kgb -leukemia -##dity -arrivals -cheered -pisa -phosphorus -shielded -##riated -mammal -unitarian -urgently -chopin -sanitary -##mission -spicy -drugged -hinges -##tort -tipping -trier -impoverished -westchester -##caster -267 -epoch -nonstop -##gman -##khov -aromatic -centrally -cerro -##tively -##vio -billions -modulation -sedimentary -283 -facilitating -outrageous -goldstein -##eak -##kt -ld -maitland -penultimate -pollard -##dance -fleets -spaceship -vertebrae -##nig -alcoholism -als -recital -##bham -##ference -##omics -m2 -##bm -trois -##tropical -##в -commemorates -##meric -marge -##raction -1643 -670 -cosmetic -ravaged -##ige -catastrophe -eng -##shida -albrecht -arterial -bellamy -decor -harmon -##rde -bulbs -synchronized -vito -easiest -shetland -shielding -wnba -##glers -##ssar -##riam -brianna -cumbria -##aceous -##rard -cores -thayer -##nsk -brood -hilltop -luminous -carts -keynote -larkin -logos -##cta -##ا -##mund -##quay -lilith -tinted -277 -wrestle -mobilization -##uses -sequential -siam -bloomfield -takahashi -274 -##ieving -presenters -ringo -blazed -witty -##oven -##ignant -devastation -haydn -harmed -newt -therese -##peed -gershwin -molina -rabbis -sudanese -001 -innate -restarted -##sack -##fus -slices -wb -##shah -enroll -hypothetical -hysterical -1743 -fabio -indefinite -warped -##hg -exchanging -525 -unsuitable -##sboro -gallo -1603 -bret -cobalt -homemade -##hunter -mx -operatives -##dhar -terraces -durable -latch -pens -whorls -##ctuated -##eaux -billing -ligament -succumbed -##gly -regulators -spawn -##brick -##stead -filmfare -rochelle -##nzo -1725 -circumstance -saber -supplements -##nsky -##tson -crowe -wellesley -carrot -##9th -##movable -primate -drury -sincerely -topical -##mad -##rao -callahan -kyiv -smarter -tits -undo -##yeh -announcements -anthologies -barrio -nebula -##islaus -##shaft -##tyn -bodyguards -2021 -assassinate -barns -emmett -scully -##mah -##yd -##eland -##tino -##itarian -demoted -gorman -lashed -prized -adventist -writ -##gui -alla -invertebrates -##ausen -1641 -amman -1742 -align -healy -redistribution -##gf -##rize -insulation -##drop -adherents -hezbollah -vitro -ferns -yanking -269 -php -registering -uppsala -cheerleading -confines -mischievous -tully -##ross -49th -docked -roam -stipulated -pumpkin -##bry -prompt -##ezer -blindly -shuddering -craftsmen -frail -scented -katharine -scramble -shaggy -sponge -helix -zaragoza -279 -##52 -43rd -backlash -fontaine -seizures -posse -cowan -nonfiction -telenovela -wwii -hammered -undone -##gpur -encircled -irs -##ivation -artefacts -oneself -searing -smallpox -##belle -##osaurus -shandong -breached -upland -blushing -rankin -infinitely -psyche -tolerated -docking -evicted -##col -unmarked -##lving -gnome -lettering -litres -musique -##oint -benevolent -##jal -blackened -##anna -mccall -racers -tingle -##ocene -##orestation -introductions -radically -292 -##hiff -##باد -1610 -1739 -munchen -plead -##nka -condo -scissors -##sight -##tens -apprehension -##cey -##yin -hallmark -watering -formulas -sequels -##llas -aggravated -bae -commencing -##building -enfield -prohibits -marne -vedic -civilized -euclidean -jagger -beforehand -blasts -dumont -##arney -##nem -740 -conversions -hierarchical -rios -simulator -##dya -##lellan -hedges -oleg -thrusts -shadowed -darby -maximize -1744 -gregorian -##nded -##routed -sham -unspecified -##hog -emory -factual -##smo -##tp -fooled -##rger -ortega -wellness -marlon -##oton -##urance -casket -keating -ley -enclave -##ayan -char -influencing -jia -##chenko -412 -ammonia -erebidae -incompatible -violins -cornered -##arat -grooves -astronauts -columbian -rampant -fabrication -kyushu -mahmud -vanish -##dern -mesopotamia -##lete -ict -##rgen -caspian -kenji -pitted -##vered -999 -grimace -roanoke -tchaikovsky -twinned -##analysis -##awan -xinjiang -arias -clemson -kazakh -sizable -1662 -##khand -##vard -plunge -tatum -vittorio -##nden -cholera -##dana -##oper -bracing -indifference -projectile -superliga -##chee -realises -upgrading -299 -porte -retribution -##vies -nk -stil -##resses -ama -bureaucracy -blackberry -bosch -testosterone -collapses -greer -##pathic -ioc -fifties -malls -##erved -bao -baskets -adolescents -siegfried -##osity -##tosis -mantra -detecting -existent -fledgling -##cchi -dissatisfied -gan -telecommunication -mingled -sobbed -6000 -controversies -outdated -taxis -##raus -fright -slams -##lham -##fect -##tten -detectors -fetal -tanned -##uw -fray -goth -olympian -skipping -mandates -scratches -sheng -unspoken -hyundai -tracey -hotspur -restrictive -##buch -americana -mundo -##bari -burroughs -diva -vulcan -##6th -distinctions -thumping -##ngen -mikey -sheds -fide -rescues -springsteen -vested -valuation -##ece -##ely -pinnacle -rake -sylvie -##edo -almond -quivering -##irus -alteration -faltered -##wad -51st -hydra -ticked -##kato -recommends -##dicated -antigua -arjun -stagecoach -wilfred -trickle -pronouns -##pon -aryan -nighttime -##anian -gall -pea -stitch -##hei -leung -milos -##dini -eritrea -nexus -starved -snowfall -kant -parasitic -cot -discus -hana -strikers -appleton -kitchens -##erina -##partisan -##itha -##vius -disclose -metis -##channel -1701 -tesla -##vera -fitch -1735 -blooded -##tila -decimal -##tang -##bai -cyclones -eun -bottled -peas -pensacola -basha -bolivian -crabs -boil -lanterns -partridge -roofed -1645 -necks -##phila -opined -patting -##kla -##lland -chuckles -volta -whereupon -##nche -devout -euroleague -suicidal -##dee -inherently -involuntary -knitting -nasser -##hide -puppets -colourful -courageous -southend -stills -miraculous -hodgson -richer -rochdale -ethernet -greta -uniting -prism -umm -##haya -##itical -##utation -deterioration -pointe -prowess -##ropriation -lids -scranton -billings -subcontinent -##koff -##scope -brute -kellogg -psalms -degraded -##vez -stanisÅ‚aw -##ructured -ferreira -pun -astonishing -gunnar -##yat -arya -prc -gottfried -##tight -excursion -##ographer -dina -##quil -##nare -huffington -illustrious -wilbur -gundam -verandah -##zard -naacp -##odle -constructive -fjord -kade -##naud -generosity -thrilling -baseline -cayman -frankish -plastics -accommodations -zoological -##fting -cedric -qb -motorized -##dome -##otted -squealed -tackled -canucks -budgets -situ -asthma -dail -gabled -grasslands -whimpered -writhing -judgments -##65 -minnie -pv -##carbon -bananas -grille -domes -monique -odin -maguire -markham -tierney -##estra -##chua -libel -poke -speedy -atrium -laval -notwithstanding -##edly -fai -kala -##sur -robb -##sma -listings -luz -supplementary -tianjin -##acing -enzo -jd -ric -scanner -croats -transcribed -##49 -arden -cv -##hair -##raphy -##lver -##uy -357 -seventies -staggering -alam -horticultural -hs -regression -timbers -blasting -##ounded -montagu -manipulating -##cit -catalytic -1550 -troopers -##meo -condemnation -fitzpatrick -##oire -##roved -inexperienced -1670 -castes -##lative -outing -314 -dubois -flicking -quarrel -ste -learners -1625 -iq -whistled -##class -282 -classify -tariffs -temperament -355 -folly -liszt -##yles -immersed -jordanian -ceasefire -apparel -extras -maru -fished -##bio -harta -stockport -assortment -craftsman -paralysis -transmitters -##cola -blindness -##wk -fatally -proficiency -solemnly -##orno -repairing -amore -groceries -ultraviolet -##chase -schoolhouse -##tua -resurgence -nailed -##otype -##× -ruse -saliva -diagrams -##tructing -albans -rann -thirties -1b -antennas -hilarious -cougars -paddington -stats -##eger -breakaway -ipod -reza -authorship -prohibiting -scoffed -##etz -##ttle -conscription -defected -trondheim -##fires -ivanov -keenan -##adan -##ciful -##fb -##slow -locating -##ials -##tford -cadiz -basalt -blankly -interned -rags -rattling -##tick -carpathian -reassured -sync -bum -guildford -iss -staunch -##onga -astronomers -sera -sofie -emergencies -susquehanna -##heard -duc -mastery -vh1 -williamsburg -bayer -buckled -craving -##khan -##rdes -bloomington -##write -alton -barbecue -##bians -justine -##hri -##ndt -delightful -smartphone -newtown -photon -retrieval -peugeot -hissing -##monium -##orough -flavors -lighted -relaunched -tainted -##games -##lysis -anarchy -microscopic -hopping -adept -evade -evie -##beau -inhibit -sinn -adjustable -hurst -intuition -wilton -cisco -44th -lawful -lowlands -stockings -thierry -##dalen -##hila -##nai -fates -prank -tb -maison -lobbied -provocative -1724 -4a -utopia -##qual -carbonate -gujarati -purcell -##rford -curtiss -##mei -overgrown -arenas -mediation -swallows -##rnik -respectful -turnbull -##hedron -##hope -alyssa -ozone -##Ê»i -ami -gestapo -johansson -snooker -canteen -cuff -declines -empathy -stigma -##ags -##iner -##raine -taxpayers -gui -volga -##wright -##copic -lifespan -overcame -tattooed -enactment -giggles -##ador -##camp -barrington -bribe -obligatory -orbiting -peng -##enas -elusive -sucker -##vating -cong -hardship -empowered -anticipating -estrada -cryptic -greasy -detainees -planck -sudbury -plaid -dod -marriott -kayla -##ears -##vb -##zd -mortally -##hein -cognition -radha -319 -liechtenstein -meade -richly -argyle -harpsichord -liberalism -trumpets -lauded -tyrant -salsa -tiled -lear -promoters -reused -slicing -trident -##chuk -##gami -##lka -cantor -checkpoint -##points -gaul -leger -mammalian -##tov -##aar -##schaft -doha -frenchman -nirvana -##vino -delgado -headlining -##eron -##iography -jug -tko -1649 -naga -intersections -##jia -benfica -nawab -##suka -ashford -gulp -##deck -##vill -##rug -brentford -frazier -pleasures -dunne -potsdam -shenzhen -dentistry -##tec -flanagan -##dorff -##hear -chorale -dinah -prem -quezon -##rogated -relinquished -sutra -terri -##pani -flaps -##rissa -poly -##rnet -homme -aback -##eki -linger -womb -##kson -##lewood -doorstep -orthodoxy -threaded -westfield -##rval -dioceses -fridays -subsided -##gata -loyalists -##biotic -##ettes -letterman -lunatic -prelate -tenderly -invariably -souza -thug -winslow -##otide -furlongs -gogh -jeopardy -##runa -pegasus -##umble -humiliated -standalone -tagged -##roller -freshmen -klan -##bright -attaining -initiating -transatlantic -logged -viz -##uance -1723 -combatants -intervening -stephane -chieftain -despised -grazed -317 -cdc -galveston -godzilla -macro -simulate -##planes -parades -##esses -960 -##ductive -##unes -equator -overdose -##cans -##hosh -##lifting -joshi -epstein -sonora -treacherous -aquatics -manchu -responsive -##sation -supervisory -##christ -##llins -##ibar -##balance -##uso -kimball -karlsruhe -mab -##emy -ignores -phonetic -reuters -spaghetti -820 -almighty -danzig -rumbling -tombstone -designations -lured -outset -##felt -supermarkets -##wt -grupo -kei -kraft -susanna -##blood -comprehension -genealogy -##aghan -##verted -redding -##ythe -1722 -bowing -##pore -##roi -lest -sharpened -fulbright -valkyrie -sikhs -##unds -swans -bouquet -merritt -##tage -##venting -commuted -redhead -clerks -leasing -cesare -dea -hazy -##vances -fledged -greenfield -servicemen -##gical -armando -blackout -dt -sagged -downloadable -intra -potion -pods -##4th -##mism -xp -attendants -gambia -stale -##ntine -plump -asteroids -rediscovered -buds -flea -hive -##neas -1737 -classifications -debuts -##eles -olympus -scala -##eurs -##gno -##mute -hummed -sigismund -visuals -wiggled -await -pilasters -clench -sulfate -##ances -bellevue -enigma -trainee -snort -##sw -clouded -denim -##rank -##rder -churning -hartman -lodges -riches -sima -##missible -accountable -socrates -regulates -mueller -##cr -1702 -avoids -solids -himalayas -nutrient -pup -##jevic -squat -fades -nec -##lates -##pina -##rona -##ου -privateer -tequila -##gative -##mpton -apt -hornet -immortals -##dou -asturias -cleansing -dario -##rries -##anta -etymology -servicing -zhejiang -##venor -##nx -horned -erasmus -rayon -relocating -£10 -##bags -escalated -promenade -stubble -2010s -artisans -axial -liquids -mora -sho -yoo -##tsky -bundles -oldies -##nally -notification -bastion -##ths -sparkle -##lved -1728 -leash -pathogen -highs -##hmi -immature -880 -gonzaga -ignatius -mansions -monterrey -sweets -bryson -##loe -polled -regatta -brightest -pei -rosy -squid -hatfield -payroll -addict -meath -cornerback -heaviest -lodging -##mage -capcom -rippled -##sily -barnet -mayhem -ymca -snuggled -rousseau -##cute -blanchard -284 -fragmented -leighton -chromosomes -risking -##md -##strel -##utter -corinne -coyotes -cynical -hiroshi -yeomanry -##ractive -ebook -grading -mandela -plume -agustin -magdalene -##rkin -bea -femme -trafford -##coll -##lun -##tance -52nd -fourier -upton -##mental -camilla -gust -iihf -islamabad -longevity -##kala -feldman -netting -##rization -endeavour -foraging -mfa -orr -##open -greyish -contradiction -graz -##ruff -handicapped -marlene -tweed -oaxaca -spp -campos -miocene -pri -configured -cooks -pluto -cozy -pornographic -##entes -70th -fairness -glided -jonny -lynne -rounding -sired -##emon -##nist -remade -uncover -##mack -complied -lei -newsweek -##jured -##parts -##enting -##pg -293 -finer -guerrillas -athenian -deng -disused -stepmother -accuse -gingerly -seduction -521 -confronting -##walker -##going -gora -nostalgia -sabres -virginity -wrenched -##minated -syndication -wielding -eyre -##56 -##gnon -##igny -behaved -taxpayer -sweeps -##growth -childless -gallant -##ywood -amplified -geraldine -scrape -##ffi -babylonian -fresco -##rdan -##kney -##position -1718 -restricting -tack -fukuoka -osborn -selector -partnering -##dlow -318 -gnu -kia -tak -whitley -gables -##54 -##mania -mri -softness -immersion -##bots -##evsky -1713 -chilling -insignificant -pcs -##uis -elites -lina -purported -supplemental -teaming -##americana -##dding -##inton -proficient -rouen -##nage -##rret -niccolo -selects -##bread -fluffy -1621 -gruff -knotted -mukherjee -polgara -thrash -nicholls -secluded -smoothing -thru -corsica -loaf -whitaker -inquiries -##rrier -##kam -indochina -289 -marlins -myles -peking -##tea -extracts -pastry -superhuman -connacht -vogel -##ditional -##het -##udged -##lash -gloss -quarries -refit -teaser -##alic -##gaon -20s -materialized -sling -camped -pickering -tung -tracker -pursuant -##cide -cranes -soc -##cini -##typical -##viere -anhalt -overboard -workout -chores -fares -orphaned -stains -##logie -fenton -surpassing -joyah -triggers -##itte -grandmaster -##lass -##lists -clapping -fraudulent -ledger -nagasaki -##cor -##nosis -##tsa -eucalyptus -tun -##icio -##rney -##tara -dax -heroism -ina -wrexham -onboard -unsigned -##dates -moshe -galley -winnie -droplets -exiles -praises -watered -noodles -##aia -fein -adi -leland -multicultural -stink -bingo -comets -erskine -modernized -canned -constraint -domestically -chemotherapy -featherweight -stifled -##mum -darkly -irresistible -refreshing -hasty -isolate -##oys -kitchener -planners -##wehr -cages -yarn -implant -toulon -elects -childbirth -yue -##lind -##lone -cn -rightful -sportsman -junctions -remodeled -specifies -##rgh -291 -##oons -complimented -##urgent -lister -ot -##logic -bequeathed -cheekbones -fontana -gabby -##dial -amadeus -corrugated -maverick -resented -triangles -##hered -##usly -nazareth -tyrol -1675 -assent -poorer -sectional -aegean -##cous -296 -nylon -ghanaian -##egorical -##weig -cushions -forbid -fusiliers -obstruction -somerville -##scia -dime -earrings -elliptical -leyte -oder -polymers -timmy -atm -midtown -piloted -settles -continual -externally -mayfield -##uh -enrichment -henson -keane -persians -1733 -benji -braden -pep -324 -##efe -contenders -pepsi -valet -##isches -298 -##asse -##earing -goofy -stroll -##amen -authoritarian -occurrences -adversary -ahmedabad -tangent -toppled -dorchester -1672 -modernism -marxism -islamist -charlemagne -exponential -racks -unicode -brunette -mbc -pic -skirmish -##bund -##lad -##powered -##yst -hoisted -messina -shatter -##ctum -jedi -vantage -##music -##neil -clemens -mahmoud -corrupted -authentication -lowry -nils -##washed -omnibus -wounding -jillian -##itors -##opped -serialized -narcotics -handheld -##arm -##plicity -intersecting -stimulating -##onis -crate -fellowships -hemingway -casinos -climatic -fordham -copeland -drip -beatty -leaflets -robber -brothel -madeira -##hedral -sphinx -ultrasound -##vana -valor -forbade -leonid -villas -##aldo -duane -marquez -##cytes -disadvantaged -forearms -kawasaki -reacts -consular -lax -uncles -uphold -##hopper -concepcion -dorsey -lass -##izan -arching -passageway -1708 -researches -tia -internationals -##graphs -##opers -distinguishes -javanese -divert -##uven -plotted -##listic -##rwin -##erik -##tify -affirmative -signifies -validation -##bson -kari -felicity -georgina -zulu -##eros -##rained -##rath -overcoming -##dot -argyll -##rbin -1734 -chiba -ratification -windy -earls -parapet -##marks -hunan -pristine -astrid -punta -##gart -brodie -##kota -##oder -malaga -minerva -rouse -##phonic -bellowed -pagoda -portals -reclamation -##gur -##odies -##⁄₄ -parentheses -quoting -allergic -palette -showcases -benefactor -heartland -nonlinear -##tness -bladed -cheerfully -scans -##ety -##hone -1666 -girlfriends -pedersen -hiram -sous -##liche -##nator -1683 -##nery -##orio -##umen -bobo -primaries -smiley -##cb -unearthed -uniformly -fis -metadata -1635 -ind -##oted -recoil -##titles -##tura -##ια -406 -hilbert -jamestown -mcmillan -tulane -seychelles -##frid -antics -coli -fated -stucco -##grants -1654 -bulky -accolades -arrays -caledonian -carnage -optimism -puebla -##tative -##cave -enforcing -rotherham -seo -dunlop -aeronautics -chimed -incline -zoning -archduke -hellenistic -##oses -##sions -candi -thong -##ople -magnate -rustic -##rsk -projective -slant -##offs -danes -hollis -vocalists -##ammed -congenital -contend -gesellschaft -##ocating -##pressive -douglass -quieter -##cm -##kshi -howled -salim -spontaneously -townsville -buena -southport -##bold -kato -1638 -faerie -stiffly -##vus -##rled -297 -flawless -realising -taboo -##7th -bytes -straightening -356 -jena -##hid -##rmin -cartwright -berber -bertram -soloists -411 -noses -417 -coping -fission -hardin -inca -##cen -1717 -mobilized -vhf -##raf -biscuits -curate -##85 -##anial -331 -gaunt -neighbourhoods -1540 -##abas -blanca -bypassed -sockets -behold -coincidentally -##bane -nara -shave -splinter -terrific -##arion -##erian -commonplace -juris -redwood -waistband -boxed -caitlin -fingerprints -jennie -naturalized -##ired -balfour -craters -jody -bungalow -hugely -quilt -glitter -pigeons -undertaker -bulging -constrained -goo -##sil -##akh -assimilation -reworked -##person -persuasion -##pants -felicia -##cliff -##ulent -1732 -explodes -##dun -##inium -##zic -lyman -vulture -hog -overlook -begs -northwards -ow -spoil -##urer -fatima -favorably -accumulate -sargent -sorority -corresponded -dispersal -kochi -toned -##imi -##lita -internacional -newfound -##agger -##lynn -##rigue -booths -peanuts -##eborg -medicare -muriel -nur -##uram -crates -millennia -pajamas -worsened -##breakers -jimi -vanuatu -yawned -##udeau -carousel -##hony -hurdle -##ccus -##mounted -##pod -rv -##eche -airship -ambiguity -compulsion -recapture -##claiming -arthritis -##osomal -1667 -asserting -ngc -sniffing -dade -discontent -glendale -ported -##amina -defamation -rammed -##scent -fling -livingstone -##fleet -875 -##ppy -apocalyptic -comrade -lcd -##lowe -cessna -eine -persecuted -subsistence -demi -hoop -reliefs -710 -coptic -progressing -stemmed -perpetrators -1665 -priestess -##nio -dobson -ebony -rooster -itf -tortricidae -##bbon -##jian -cleanup -##jean -##øy -1721 -eighties -taxonomic -holiness -##hearted -##spar -antilles -showcasing -stabilized -##nb -gia -mascara -michelangelo -dawned -##uria -##vinsky -extinguished -fitz -grotesque -£100 -##fera -##loid -##mous -barges -neue -throbbed -cipher -johnnie -##a1 -##mpt -outburst -##swick -spearheaded -administrations -c1 -heartbreak -pixels -pleasantly -##enay -lombardy -plush -##nsed -bobbie -##hly -reapers -tremor -xiang -minogue -substantive -hitch -barak -##wyl -kwan -##encia -910 -obscene -elegance -indus -surfer -bribery -conserve -##hyllum -##masters -horatio -##fat -apes -rebound -psychotic -##pour -iteration -##mium -##vani -botanic -horribly -antiques -dispose -paxton -##hli -##wg -timeless -1704 -disregard -engraver -hounds -##bau -##version -looted -uno -facilitates -groans -masjid -rutland -antibody -disqualification -decatur -footballers -quake -slacks -48th -rein -scribe -stabilize -commits -exemplary -tho -##hort -##chison -pantry -traversed -##hiti -disrepair -identifiable -vibrated -baccalaureate -##nnis -csa -interviewing -##iensis -##raße -greaves -wealthiest -343 -classed -jogged -£5 -##58 -##atal -illuminating -knicks -respecting -##uno -scrubbed -##iji -##dles -kruger -moods -growls -raider -silvia -chefs -kam -vr -cree -percival -##terol -gunter -counterattack -defiant -henan -ze -##rasia -##riety -equivalence -submissions -##fra -##thor -bautista -mechanically -##heater -cornice -herbal -templar -##mering -outputs -ruining -ligand -renumbered -extravagant -mika -blockbuster -eta -insurrection -##ilia -darkening -ferocious -pianos -strife -kinship -##aer -melee -##anor -##iste -##may -##oue -decidedly -weep -##jad -##missive -##ppel -354 -puget -unease -##gnant -1629 -hammering -kassel -ob -wessex -##lga -bromwich -egan -paranoia -utilization -##atable -##idad -contradictory -provoke -##ols -##ouring -##tangled -knesset -##very -##lette -plumbing -##sden -##¹ -greensboro -occult -sniff -338 -zev -beaming -gamer -haggard -mahal -##olt -##pins -mendes -utmost -briefing -gunnery -##gut -##pher -##zh -##rok -1679 -khalifa -sonya -##boot -principals -urbana -wiring -##liffe -##minating -##rrado -dahl -nyu -skepticism -np -townspeople -ithaca -lobster -somethin -##fur -##arina -##−1 -freighter -zimmerman -biceps -contractual -##herton -amend -hurrying -subconscious -##anal -336 -meng -clermont -spawning -##eia -##lub -dignitaries -impetus -snacks -spotting -twigs -##bilis -##cz -##ouk -libertadores -nic -skylar -##aina -##firm -gustave -asean -##anum -dieter -legislatures -flirt -bromley -trolls -umar -##bbies -##tyle -blah -parc -bridgeport -crank -negligence -##nction -46th -constantin -molded -bandages -seriousness -00pm -siegel -carpets -compartments -upbeat -statehood -##dner -##edging -marko -730 -platt -##hane -paving -##iy -1738 -abbess -impatience -limousine -nbl -##talk -441 -lucille -mojo -nightfall -robbers -##nais -karel -brisk -calves -replicate -ascribed -telescopes -##olf -intimidated -##reen -ballast -specialization -##sit -aerodynamic -caliphate -rainer -visionary -##arded -epsilon -##aday -##onte -aggregation -auditory -boosted -reunification -kathmandu -loco -robyn -402 -acknowledges -appointing -humanoid -newell -redeveloped -restraints -##tained -barbarians -chopper -1609 -italiana -##lez -##lho -investigates -wrestlemania -##anies -##bib -690 -##falls -creaked -dragoons -gravely -minions -stupidity -volley -##harat -##week -musik -##eries -##uously -fungal -massimo -semantics -malvern -##ahl -##pee -discourage -embryo -imperialism -1910s -profoundly -##ddled -jiangsu -sparkled -stat -##holz -sweatshirt -tobin -##iction -sneered -##cheon -##oit -brit -causal -smyth -##neuve -diffuse -perrin -silvio -##ipes -##recht -detonated -iqbal -selma -##nism -##zumi -roasted -##riders -tay -##ados -##mament -##mut -##rud -840 -completes -nipples -cfa -flavour -hirsch -##laus -calderon -sneakers -moravian -##ksha -1622 -rq -294 -##imeters -bodo -##isance -##pre -##ronia -anatomical -excerpt -##lke -dh -kunst -##tablished -##scoe -biomass -panted -unharmed -gael -housemates -montpellier -##59 -coa -rodents -tonic -hickory -singleton -##taro -451 -1719 -aldo -breaststroke -dempsey -och -rocco -##cuit -merton -dissemination -midsummer -serials -##idi -haji -polynomials -##rdon -gs -enoch -prematurely -shutter -taunton -£3 -##grating -##inates -archangel -harassed -##asco -326 -archway -dazzling -##ecin -1736 -sumo -wat -##kovich -1086 -honneur -##ently -##nostic -##ttal -##idon -1605 -403 -1716 -blogger -rents -##gnan -hires -##ikh -##dant -howie -##rons -handler -retracted -shocks -1632 -arun -duluth -kepler -trumpeter -##lary -peeking -seasoned -trooper -##mara -laszlo -##iciencies -##rti -heterosexual -##inatory -##ssion -indira -jogging -##inga -##lism -beit -dissatisfaction -malice -##ately -nedra -peeling -##rgeon -47th -stadiums -475 -vertigo -##ains -iced -restroom -##plify -##tub -illustrating -pear -##chner -##sibility -inorganic -rappers -receipts -watery -##kura -lucinda -##oulos -reintroduced -##8th -##tched -gracefully -saxons -nutritional -wastewater -rained -favourites -bedrock -fisted -hallways -likeness -upscale -##lateral -1580 -blinds -prequel -##pps -##tama -deter -humiliating -restraining -tn -vents -1659 -laundering -recess -rosary -tractors -coulter -federer -##ifiers -##plin -persistence -##quitable -geschichte -pendulum -quakers -##beam -bassett -pictorial -buffet -koln -##sitor -drills -reciprocal -shooters -##57 -##cton -##tees -converge -pip -dmitri -donnelly -yamamoto -aqua -azores -demographics -hypnotic -spitfire -suspend -wryly -roderick -##rran -sebastien -##asurable -mavericks -##fles -##200 -himalayan -prodigy -##iance -transvaal -demonstrators -handcuffs -dodged -mcnamara -sublime -1726 -crazed -##efined -##till -ivo -pondered -reconciled -shrill -sava -##duk -bal -cad -heresy -jaipur -goran -##nished -341 -lux -shelly -whitehall -##hre -israelis -peacekeeping -##wled -1703 -demetrius -ousted -##arians -##zos -beale -anwar -backstroke -raged -shrinking -cremated -##yck -benign -towing -wadi -darmstadt -landfill -parana -soothe -colleen -sidewalks -mayfair -tumble -hepatitis -ferrer -superstructure -##gingly -##urse -##wee -anthropological -translators -##mies -closeness -hooves -##pw -mondays -##roll -##vita -landscaping -##urized -purification -sock -thorns -thwarted -jalan -tiberius -##taka -saline -##rito -confidently -khyber -sculptors -##ij -brahms -hammersmith -inspectors -battista -fivb -fragmentation -hackney -##uls -arresting -exercising -antoinette -bedfordshire -##zily -dyed -##hema -1656 -racetrack -variability -##tique -1655 -austrians -deteriorating -madman -theorists -aix -lehman -weathered -1731 -decreed -eruptions -1729 -flaw -quinlan -sorbonne -flutes -nunez -1711 -adored -downwards -fable -rasped -1712 -moritz -mouthful -renegade -shivers -stunts -dysfunction -restrain -translit -327 -pancakes -##avio -##cision -##tray -351 -vial -##lden -bain -##maid -##oxide -chihuahua -malacca -vimes -##rba -##rnier -1664 -donnie -plaques -##ually -337 -bangs -floppy -huntsville -loretta -nikolay -##otte -eater -handgun -ubiquitous -##hett -eras -zodiac -1634 -##omorphic -1820s -##zog -cochran -##bula -##lithic -warring -##rada -dalai -excused -blazers -mcconnell -reeling -bot -este -##abi -geese -hoax -taxon -##bla -guitarists -##icon -condemning -hunts -inversion -moffat -taekwondo -##lvis -1624 -stammered -##rest -##rzy -sousa -fundraiser -marylebone -navigable -uptown -cabbage -daniela -salman -shitty -whimper -##kian -##utive -programmers -protections -rm -##rmi -##rued -forceful -##enes -fuss -##tao -##wash -brat -oppressive -reykjavik -spartak -ticking -##inkles -##kiewicz -adolph -horst -maui -protege -straighten -cpc -landau -concourse -clements -resultant -##ando -imaginative -joo -reactivated -##rem -##ffled -##uising -consultative -##guide -flop -kaitlyn -mergers -parenting -somber -##vron -supervise -vidhan -##imum -courtship -exemplified -harmonies -medallist -refining -##rrow -##ка -amara -##hum -780 -goalscorer -sited -overshadowed -rohan -displeasure -secretive -multiplied -osman -##orth -engravings -padre -##kali -##veda -miniatures -mis -##yala -clap -pali -rook -##cana -1692 -57th -antennae -astro -oskar -1628 -bulldog -crotch -hackett -yucatan -##sure -amplifiers -brno -ferrara -migrating -##gree -thanking -turing -##eza -mccann -ting -andersson -onslaught -gaines -ganga -incense -standardization -##mation -sentai -scuba -stuffing -turquoise -waivers -alloys -##vitt -regaining -vaults -##clops -##gizing -digger -furry -memorabilia -probing -##iad -payton -rec -deutschland -filippo -opaque -seamen -zenith -afrikaans -##filtration -disciplined -inspirational -##merie -banco -confuse -grafton -tod -##dgets -championed -simi -anomaly -biplane -##ceptive -electrode -##para -1697 -cleavage -crossbow -swirl -informant -##lars -##osta -afi -bonfire -spec -##oux -lakeside -slump -##culus -##lais -##qvist -##rrigan -1016 -facades -borg -inwardly -cervical -xl -pointedly -050 -stabilization -##odon -chests -1699 -hacked -ctv -orthogonal -suzy -##lastic -gaulle -jacobite -rearview -##cam -##erted -ashby -##drik -##igate -##mise -##zbek -affectionately -canine -disperse -latham -##istles -##ivar -spielberg -##orin -##idium -ezekiel -cid -##sg -durga -middletown -##cina -customized -frontiers -harden -##etano -##zzy -1604 -bolsheviks -##66 -coloration -yoko -##bedo -briefs -slabs -debra -liquidation -plumage -##oin -blossoms -dementia -subsidy -1611 -proctor -relational -jerseys -parochial -ter -##ici -esa -peshawar -cavalier -loren -cpi -idiots -shamrock -1646 -dutton -malabar -mustache -##endez -##ocytes -referencing -terminates -marche -yarmouth -##sop -acton -mated -seton -subtly -baptised -beige -extremes -jolted -kristina -telecast -##actic -safeguard -waldo -##baldi -##bular -endeavors -sloppy -subterranean -##ensburg -##itung -delicately -pigment -tq -##scu -1626 -##ound -collisions -coveted -herds -##personal -##meister -##nberger -chopra -##ricting -abnormalities -defective -galician -lucie -##dilly -alligator -likened -##genase -burundi -clears -complexion -derelict -deafening -diablo -fingered -champaign -dogg -enlist -isotope -labeling -mrna -##erre -brilliance -marvelous -##ayo -1652 -crawley -ether -footed -dwellers -deserts -hamish -rubs -warlock -skimmed -##lizer -870 -buick -embark -heraldic -irregularities -##ajan -kiara -##kulam -##ieg -antigen -kowalski -##lge -oakley -visitation -##mbit -vt -##suit -1570 -murderers -##miento -##rites -chimneys -##sling -condemn -custer -exchequer -havre -##ghi -fluctuations -##rations -dfb -hendricks -vaccines -##tarian -nietzsche -biking -juicy -##duced -brooding -scrolling -selangor -##ragan -352 -annum -boomed -seminole -sugarcane -##dna -departmental -dismissing -innsbruck -arteries -ashok -batavia -daze -kun -overtook -##rga -##tlan -beheaded -gaddafi -holm -electronically -faulty -galilee -fractures -kobayashi -##lized -gunmen -magma -aramaic -mala -eastenders -inference -messengers -bf -##qu -407 -bathrooms -##vere -1658 -flashbacks -ideally -misunderstood -##jali -##weather -mendez -##grounds -505 -uncanny -##iii -1709 -friendships -##nbc -sacrament -accommodated -reiterated -logistical -pebbles -thumped -##escence -administering -decrees -drafts -##flight -##cased -##tula -futuristic -picket -intimidation -winthrop -##fahan -interfered -339 -afar -francoise -morally -uta -cochin -croft -dwarfs -##bruck -##dents -##nami -biker -##hner -##meral -nano -##isen -##ometric -##pres -##ан -brightened -meek -parcels -securely -gunners -##jhl -##zko -agile -hysteria -##lten -##rcus -bukit -champs -chevy -cuckoo -leith -sadler -theologians -welded -##section -1663 -jj -plurality -xander -##rooms -##formed -shredded -temps -intimately -pau -tormented -##lok -##stellar -1618 -charred -ems -essen -##mmel -alarms -spraying -ascot -blooms -twinkle -##abia -##apes -internment -obsidian -##chaft -snoop -##dav -##ooping -malibu -##tension -quiver -##itia -hays -mcintosh -travers -walsall -##ffie -1623 -beverley -schwarz -plunging -structurally -m3 -rosenthal -vikram -##tsk -770 -ghz -##onda -##tiv -chalmers -groningen -pew -reckon -unicef -##rvis -55th -##gni -1651 -sulawesi -avila -cai -metaphysical -screwing -turbulence -##mberg -augusto -samba -56th -baffled -momentary -toxin -##urian -##wani -aachen -condoms -dali -steppe -##3d -##app -##oed -##year -adolescence -dauphin -electrically -inaccessible -microscopy -nikita -##ega -atv -##cel -##enter -##oles -##oteric -##Ñ‹ -accountants -punishments -wrongly -bribes -adventurous -clinch -flinders -southland -##hem -##kata -gough -##ciency -lads -soared -##×” -undergoes -deformation -outlawed -rubbish -##arus -##mussen -##nidae -##rzburg -arcs -##ingdon -##tituted -1695 -wheelbase -wheeling -bombardier -campground -zebra -##lices -##oj -##bain -lullaby -##ecure -donetsk -wylie -grenada -##arding -##ης -squinting -eireann -opposes -##andra -maximal -runes -##broken -##cuting -##iface -##ror -##rosis -additive -britney -adultery -triggering -##drome -detrimental -aarhus -containment -jc -swapped -vichy -##ioms -madly -##oric -##rag -brant -##ckey -##trix -1560 -1612 -broughton -rustling -##stems -##uder -asbestos -mentoring -##nivorous -finley -leaps -##isan -apical -pry -slits -substitutes -##dict -intuitive -fantasia -insistent -unreasonable -##igen -##vna -domed -hannover -margot -ponder -##zziness -impromptu -jian -lc -rampage -stemming -##eft -andrey -gerais -whichever -amnesia -appropriated -anzac -clicks -modifying -ultimatum -cambrian -maids -verve -yellowstone -##mbs -conservatoire -##scribe -adherence -dinners -spectra -imperfect -mysteriously -sidekick -tatar -tuba -##aks -##ifolia -distrust -##athan -##zle -c2 -ronin -zac -##pse -celaena -instrumentalist -scents -skopje -##mbling -comical -compensated -vidal -condor -intersect -jingle -wavelengths -##urrent -mcqueen -##izzly -carp -weasel -422 -kanye -militias -postdoctoral -eugen -gunslinger -##É› -faux -hospice -##for -appalled -derivation -dwarves -##elis -dilapidated -##folk -astoria -philology -##lwyn -##otho -##saka -inducing -philanthropy -##bf -##itative -geek -markedly -sql -##yce -bessie -indices -rn -##flict -495 -frowns -resolving -weightlifting -tugs -cleric -contentious -1653 -mania -rms -##miya -##reate -##ruck -##tucket -bien -eels -marek -##ayton -##cence -discreet -unofficially -##ife -leaks -##bber -1705 -332 -dung -compressor -hillsborough -pandit -shillings -distal -##skin -381 -##tat -##you -nosed -##nir -mangrove -undeveloped -##idia -textures -##inho -##500 -##rise -ae -irritating -nay -amazingly -bancroft -apologetic -compassionate -kata -symphonies -##lovic -airspace -##lch -930 -gifford -precautions -fulfillment -sevilla -vulgar -martinique -##urities -looting -piccolo -tidy -##dermott -quadrant -armchair -incomes -mathematicians -stampede -nilsson -##inking -##scan -foo -quarterfinal -##ostal -shang -shouldered -squirrels -##owe -344 -vinegar -##bner -##rchy -##systems -delaying -##trics -ars -dwyer -rhapsody -sponsoring -##gration -bipolar -cinder -starters -##olio -##urst -421 -signage -##nty -aground -figurative -mons -acquaintances -duets -erroneously -soyuz -elliptic -recreated -##cultural -##quette -##ssed -##tma -##zcz -moderator -scares -##itaire -##stones -##udence -juniper -sighting -##just -##nsen -britten -calabria -ry -bop -cramer -forsyth -stillness -##л -airmen -gathers -unfit -##umber -##upt -taunting -##rip -seeker -streamlined -##bution -holster -schumann -tread -vox -##gano -##onzo -strive -dil -reforming -covent -newbury -predicting -##orro -decorate -tre -##puted -andover -ie -asahi -dept -dunkirk -gills -##tori -buren -huskies -##stis -##stov -abstracts -bets -loosen -##opa -1682 -yearning -##glio -##sir -berman -effortlessly -enamel -napoli -persist -##peration -##uez -attache -elisa -b1 -invitations -##kic -accelerating -reindeer -boardwalk -clutches -nelly -polka -starbucks -##kei -adamant -huey -lough -unbroken -adventurer -embroidery -inspecting -stanza -##ducted -naia -taluka -##pone -##roids -chases -deprivation -florian -##jing -##ppet -earthly -##lib -##ssee -colossal -foreigner -vet -freaks -patrice -rosewood -triassic -upstate -##pkins -dominates -ata -chants -ks -vo -##400 -##bley -##raya -##rmed -555 -agra -infiltrate -##ailing -##ilation -##tzer -##uppe -##werk -binoculars -enthusiast -fujian -squeak -##avs -abolitionist -almeida -boredom -hampstead -marsden -rations -##ands -inflated -334 -bonuses -rosalie -patna -##rco -329 -detachments -penitentiary -54th -flourishing -woolf -##dion -##etched -papyrus -##lster -##nsor -##toy -bobbed -dismounted -endelle -inhuman -motorola -tbs -wince -wreath -##ticus -hideout -inspections -sanjay -disgrace -infused -pudding -stalks -##urbed -arsenic -leases -##hyl -##rrard -collarbone -##waite -##wil -dowry -##bant -##edance -genealogical -nitrate -salamanca -scandals -thyroid -necessitated -##! -##" -### -##$ -##% -##& -##' -##( -##) -##* -##+ -##, -##- -##. -##/ -##: -##; -##< -##= -##> -##? -##@ -##[ -##\ -##] -##^ -##_ -##` -##{ -##| -##} -##~ -##¡ -##¢ -##£ -##¤ -##Â¥ -##¦ -##§ -##¨ -##© -##ª -##« -##¬ -##® -##± -##´ -##µ -##¶ -##· -##º -##» -##¼ -##¾ -##¿ -##æ -##ð -##÷ -##þ -##Ä‘ -##ħ -##Å‹ -##Å“ -##Æ’ -##ɐ -##É‘ -##É’ -##É” -##É• -##É™ -##É¡ -##É£ -##ɨ -##ɪ -##É« -##ɬ -##ɯ -##ɲ -##É´ -##ɹ -##ɾ -##Ê€ -##ʁ -##Ê‚ -##ʃ -##ʉ -##ÊŠ -##Ê‹ -##ÊŒ -##ÊŽ -##ʐ -##Ê‘ -##Ê’ -##Ê” -##ʰ -##ʲ -##ʳ -##Ê· -##ʸ -##Ê» -##ʼ -##ʾ -##Ê¿ -##ˈ -##Ë¡ -##Ë¢ -##Ë£ -##ˤ -##β -##γ -##δ -##ε -##ζ -##θ -##κ -##λ -##μ -##ξ -##ο -##Ï€ -##ρ -##σ -##Ï„ -##Ï… -##φ -##χ -##ψ -##ω -##б -##г -##д -##ж -##з -##м -##п -##с -##у -##Ñ„ -##Ñ… -##ц -##ч -##ш -##щ -##ÑŠ -##э -##ÑŽ -##Ñ’ -##Ñ” -##Ñ– -##ј -##Ñ™ -##Ñš -##Ñ› -##ӏ -##Õ¡ -##Õ¢ -##Õ£ -##Õ¤ -##Õ¥ -##Õ© -##Õ« -##Õ¬ -##Õ¯ -##Õ° -##Õ´ -##Õµ -##Õ¶ -##Õ¸ -##Õº -##Õ½ -##Õ¾ -##Õ¿ -##Ö€ -##Ö‚ -##Ö„ -##Ö¾ -##א -##ב -##×’ -##ד -##ו -##×– -##×— -##ט -##×™ -##ך -##×› -##ל -##ם -##מ -##ן -##× -##ס -##×¢ -##×£ -##פ -##×¥ -##צ -##×§ -##ר -##ש -##ת -##ØŒ -##Ø¡ -##ب -##ت -##Ø« -##ج -##Ø­ -##Ø® -##ذ -##ز -##س -##Ø´ -##ص -##ض -##Ø· -##ظ -##ع -##غ -##Ù€ -##ف -##Ù‚ -##Ùƒ -##Ùˆ -##Ù‰ -##Ù¹ -##Ù¾ -##Ú† -##Ú© -##Ú¯ -##Úº -##Ú¾ -##ہ -##Û’ -##अ -##आ -##उ -##ए -##क -##ख -##ग -##च -##ज -##ट -##ड -##ण -##त -##थ -##द -##ध -##न -##प -##ब -##भ -##म -##य -##र -##ल -##व -##श -##ष -##स -##ह -##ा -##ि -##ी -##ो -##। -##॥ -##ং -##অ -##আ -##ই -##উ -##এ -##ও -##ক -##খ -##গ -##চ -##ছ -##জ -##ট -##ড -##ণ -##ত -##থ -##দ -##ধ -##ন -##প -##ব -##ভ -##ম -##য -##র -##ল -##শ -##ষ -##স -##হ -##া -##ি -##à§€ -##ে -##க -##ச -##ட -##த -##ந -##ன -##ப -##à®® -##ய -##à®° -##ல -##ள -##வ -##ா -##ி -##ு -##ே -##ை -##ನ -##ರ -##ಾ -##à¶š -##ය -##à¶» -##à¶½ -##à·€ -##ා -##ก -##ง -##ต -##ท -##น -##พ -##ม -##ย -##ร -##ล -##ว -##ส -##อ -##า -##เ -##་ -##། -##ག -##ང -##ད -##ན -##པ -##བ -##མ -##འ-##ར -##ལ -##ས -##မ -##ა -##ბ -##გ -##დ -##ე -##ვ -##თ -##ი -##კ -##ლ -##მ -##ნ -##ო -##რ-##ს -##ტ -##უ -##á„€ -##á„‚ -##ᄃ -##á„… -##ᄆ -##ᄇ -##ᄉ -##ᄊ -##á„‹ -##ᄌ -##ᄎ -##ᄏ -##ᄐ -##á„‘ -##á„’ -##á…¡ -##á…¢ -##á…¥ -##á…¦ -##á…§ -##á…© -##á…ª -##á…­ -##á…® -##á…¯ -##á…² -##á…³ -##á…´ -##á…µ -##ᆨ -##ᆫ -##ᆯ -##ᆷ -##ᆸ -##ᆼ -##á´¬ -##á´® -##á´° -##á´µ -##á´º -##áµ€ -##ᵃ -##ᵇ -##ᵈ -##ᵉ -##ᵍ -##ᵏ -##ᵐ -##áµ’ -##áµ– -##áµ— -##ᵘ -##áµ£ -##ᵤ -##áµ¥ -##á¶œ -##á¶ -##‐ -##‑ -##‒ -##– -##— -##― -##‖ -##‘ -##’ -##‚ -##“ -##” -##„ -##†-##‡ -##• -##… -##‰ -##′ -##″ -##› -##‿ -##⁄ -##⁰ -##ⁱ -##⁴ -##⁵ -##⁶ -##⁷ -##⁸ -##⁹ -##⁻ -##ⁿ -##â‚… -##₆ -##₇ -##₈ -##₉ -##₊ -##₍ -##₎ -##ₐ -##â‚‘ -##â‚’ -##â‚“ -##â‚• -##â‚– -##â‚— -##ₘ -##ₚ -##â‚› -##ₜ -##₤ -##â‚© -##€ -##₱ -##₹ -##â„“ -##â„– -##ℝ -##â„¢ -##â…“ -##â…” -##← -##↑ -##→ -##↓ -##↔ -##↦ -##⇄ -##⇌ -##⇒ -##∂ -##∅ -##∆ -##∇ -##∈ -##∗ -##∘ -##√ -##∞ -##∧ -##∨ -##∩ -##∪ -##≈ -##≡ -##≤ -##≥ -##⊂ -##⊆ -##⊕ -##⊗ -##â‹… -##─ -##│ -##â– -##â–ª -##● -##★ -##☆ -##☉ -##â™ -##♣ -##♥ -##♦ -##♯ -##⟨ -##⟩ -##â±¼ -##⺩ -##⺼ -##â½¥ -##、 -##。 -##〈 -##〉 -##《 -##》 -##「 -##」 -##『 -##』 -##〜 -##あ -##い -##う -##え -##お -##か -##き -##く -##け -##こ -##さ -##し -##す -##せ -##そ -##た -##ち -##っ -##つ -##て -##と -##な -##に -##ぬ -##ね -##の -##は -##ひ -##ふ -##へ -##ほ -##ま -##み -##ã‚€ -##め -##ã‚‚ -##ã‚„ -##ゆ -##よ -##ら -##り -##ã‚‹ -##れ -##ろ -##ã‚’ -##ã‚“ -##ã‚¡ -##ã‚¢ -##ã‚£ -##イ -##ウ -##ã‚§ -##エ -##オ -##ã‚« -##ã‚­ -##ク -##ケ -##コ -##サ -##ã‚· -##ス -##ã‚» -##ã‚¿ -##チ -##ッ -##ツ -##テ -##ト -##ナ -##ニ -##ノ -##ハ -##ヒ -##フ -##ヘ -##ホ -##マ -##ミ -##ム-##メ -##モ -##ャ -##ュ -##ョ -##ラ -##リ -##ル -##レ -##ロ -##ワ -##ン -##・ -##ー -##一 -##三 -##上 -##下 -##不 -##世 -##中 -##主 -##ä¹… -##之 -##也 -##事 -##二 -##五 -##井 -##京 -##人 -##亻 -##仁 -##介 -##代 -##ä»® -##伊 -##会 -##佐 -##侍 -##保 -##ä¿¡ -##健 -##å…ƒ -##å…‰ -##å…« -##å…¬ -##内 -##出 -##分 -##前 -##劉 -##力 -##åŠ -##勝 -##北 -##区 -##十 -##千 -##南 -##博 -##原 -##口 -##古 -##史 -##司 -##合 -##吉 -##同 -##名 -##å’Œ -##å›— -##å›› -##国 -##國 -##土 -##地 -##坂 -##城 -##å ‚ -##å ´ -##士 -##夏 -##外 -##大 -##天 -##太 -##夫 -##奈 -##女 -##子 -##å­¦ -##宀 -##宇 -##安 -##å®— -##定 -##宣 -##å®® -##å®¶ -##宿 -##寺 -##å°‡ -##小 -##å°š -##å±± -##岡 -##å³¶ -##å´Ž -##川 -##å·ž -##å·¿ -##帝 -##å¹³ -##å¹´ -##幸 -##广 -##弘 -##å¼µ -##å½³ -##後 -##御 -##å¾· -##心 -##å¿„ -##å¿— -##å¿ -##æ„› -##成 -##我 -##戦 -##戸 -##手 -##扌 -##政 -##æ–‡ -##æ–° -##æ–¹ -##æ—¥ -##明 -##星 -##春 -##昭 -##智 -##曲 -##書 -##月 -##有 -##朝 -##木 -##本 -##李 -##村 -##東 -##松 -##æž— -##森 -##楊 -##樹 -##æ©‹ -##æ­Œ -##æ­¢ -##æ­£ -##æ­¦ -##比 -##氏 -##æ°‘ -##æ°´ -##æ°µ -##æ°· -##æ°¸ -##江 -##æ²¢ -##æ²³ -##æ²» -##法 -##æµ· -##清 -##æ¼¢ -##瀬 -##火 -##版 -##犬 -##王 -##生 -##ç”° -##ç”· -##ç–’ -##発 -##白 -##çš„ -##皇 -##ç›® -##相 -##省 -##真 -##石 -##示 -##社 -##神 -##福 -##禾 -##ç§€ -##ç§‹ -##空 -##ç«‹ -##ç« -##竹 -##ç³¹ -##美 -##義 -##耳 -##良 -##艹 -##花 -##英 -##華 -##葉 -##è—¤ -##行 -##è¡— -##西 -##見 -##訁 -##語 -##è°· -##貝 -##è²´ -##車 -##軍 -##è¾¶ -##道 -##郎 -##郡 -##部 -##都 -##里 -##野 -##金 -##鈴 -##镇 -##é•· -##é–€ -##é–“ -##阝 -##阿 -##陳 -##陽 -##雄 -##青 -##面 -##風 -##食 -##香 -##馬 -##高 -##龍 -##龸 -##fi -##fl -##! -##( -##) -##, -##- -##. -##/ -##: -##? -##~ \ No newline at end of file diff --git a/Samples/ICD10/ICD10.Api/icd10-schema.yaml b/Samples/ICD10/ICD10.Api/icd10-schema.yaml deleted file mode 100644 index 8d938d2f..00000000 --- a/Samples/ICD10/ICD10.Api/icd10-schema.yaml +++ /dev/null @@ -1,359 +0,0 @@ -name: icd10 -tables: -- name: icd10_chapter - columns: - - name: Id - type: Text - - name: ChapterNumber - type: Text - - name: Title - type: Text - - name: CodeRangeStart - type: Text - - name: CodeRangeEnd - type: Text - - name: LastUpdated - type: Text - defaultValue: (now()) - - name: VersionId - type: BigInt - defaultValue: 1 - indexes: - - name: idx_icd10_chapter_number - columns: - - ChapterNumber - primaryKey: - name: PK_icd10_chapter - columns: - - Id - -- name: icd10_block - columns: - - name: Id - type: Text - - name: ChapterId - type: Text - - name: BlockCode - type: Text - - name: Title - type: Text - - name: CodeRangeStart - type: Text - - name: CodeRangeEnd - type: Text - - name: LastUpdated - type: Text - defaultValue: (now()) - - name: VersionId - type: BigInt - defaultValue: 1 - indexes: - - name: idx_icd10_block_code - columns: - - BlockCode - - name: idx_icd10_block_chapter - columns: - - ChapterId - foreignKeys: - - name: FK_icd10_block_ChapterId - columns: - - ChapterId - referencedTable: icd10_chapter - referencedColumns: - - Id - primaryKey: - name: PK_icd10_block - columns: - - Id - -- name: icd10_category - columns: - - name: Id - type: Text - - name: BlockId - type: Text - - name: CategoryCode - type: Text - - name: Title - type: Text - - name: LastUpdated - type: Text - defaultValue: (now()) - - name: VersionId - type: BigInt - defaultValue: 1 - indexes: - - name: idx_icd10_category_code - columns: - - CategoryCode - - name: idx_icd10_category_block - columns: - - BlockId - foreignKeys: - - name: FK_icd10_category_BlockId - columns: - - BlockId - referencedTable: icd10_block - referencedColumns: - - Id - primaryKey: - name: PK_icd10_category - columns: - - Id - -- name: icd10_code - columns: - - name: Id - type: Text - - name: CategoryId - type: Text - - name: Code - type: Text - - name: ShortDescription - type: Text - - name: LongDescription - type: Text - - name: InclusionTerms - type: Text - - name: ExclusionTerms - type: Text - - name: CodeAlso - type: Text - - name: CodeFirst - type: Text - - name: Synonyms - type: Text - - name: Billable - type: BigInt - defaultValue: 1 - - name: EffectiveFrom - type: Text - - name: EffectiveTo - type: Text - - name: Edition - type: Text - - name: LastUpdated - type: Text - defaultValue: (now()) - - name: VersionId - type: BigInt - defaultValue: 1 - indexes: - - name: idx_icd10_code_code - columns: - - Code - - name: idx_icd10_code_category - columns: - - CategoryId - - name: idx_icd10_code_billable - columns: - - Billable - - name: idx_icd10_code_edition - columns: - - Edition - primaryKey: - name: PK_icd10_code - columns: - - Id - foreignKeys: - - name: FK_icd10_code_CategoryId - columns: - - CategoryId - referencedTable: icd10_category - referencedColumns: - - Id - -- name: icd10_code_embedding - columns: - - name: Id - type: Text - - name: CodeId - type: Text - - name: Embedding - type: Text - - name: EmbeddingModel - type: Text - defaultValue: "'MedEmbed-Small-v0.1'" - - name: LastUpdated - type: Text - defaultValue: (now()) - indexes: - - name: idx_icd10_embedding_code - columns: - - CodeId - isUnique: true - - name: idx_icd10_embedding_model - columns: - - EmbeddingModel - primaryKey: - name: PK_icd10_code_embedding - columns: - - Id - foreignKeys: - - name: FK_icd10_code_embedding_CodeId - columns: - - CodeId - referencedTable: icd10_code - referencedColumns: - - Id - -- name: achi_block - columns: - - name: Id - type: Text - - name: BlockNumber - type: Text - - name: Title - type: Text - - name: CodeRangeStart - type: Text - - name: CodeRangeEnd - type: Text - - name: LastUpdated - type: Text - defaultValue: (now()) - - name: VersionId - type: BigInt - defaultValue: 1 - indexes: - - name: idx_achi_block_number - columns: - - BlockNumber - primaryKey: - name: PK_achi_block - columns: - - Id - -- name: achi_code - columns: - - name: Id - type: Text - - name: BlockId - type: Text - - name: Code - type: Text - - name: ShortDescription - type: Text - - name: LongDescription - type: Text - - name: Billable - type: BigInt - defaultValue: 1 - - name: EffectiveFrom - type: Text - - name: EffectiveTo - type: Text - - name: Edition - type: Text - - name: LastUpdated - type: Text - defaultValue: (now()) - - name: VersionId - type: BigInt - defaultValue: 1 - indexes: - - name: idx_achi_code_code - columns: - - Code - - name: idx_achi_code_block - columns: - - BlockId - - name: idx_achi_code_edition - columns: - - Edition - primaryKey: - name: PK_achi_code - columns: - - Id - foreignKeys: - - name: FK_achi_code_BlockId - columns: - - BlockId - referencedTable: achi_block - referencedColumns: - - Id - -- name: achi_code_embedding - columns: - - name: Id - type: Text - - name: CodeId - type: Text - - name: Embedding - type: Text - - name: EmbeddingModel - type: Text - defaultValue: "'MedEmbed-Large-v1'" - - name: LastUpdated - type: Text - defaultValue: (now()) - indexes: - - name: idx_achi_embedding_code - columns: - - CodeId - primaryKey: - name: PK_achi_code_embedding - columns: - - Id - foreignKeys: - - name: FK_achi_code_embedding_CodeId - columns: - - CodeId - referencedTable: achi_code - referencedColumns: - - Id - -- name: coding_standard - columns: - - name: Id - type: Text - - name: StandardNumber - type: Text - - name: Title - type: Text - - name: Content - type: Text - - name: ApplicableCodes - type: Text - - name: Edition - type: BigInt - defaultValue: 13 - - name: LastUpdated - type: Text - defaultValue: (now()) - - name: VersionId - type: BigInt - defaultValue: 1 - indexes: - - name: idx_coding_standard_number - columns: - - StandardNumber - primaryKey: - name: PK_coding_standard - columns: - - Id - -- name: user_search_history - columns: - - name: Id - type: Text - - name: UserId - type: Text - - name: Query - type: Text - - name: SelectedCode - type: Text - - name: Timestamp - type: Text - defaultValue: (now()) - indexes: - - name: idx_search_history_user - columns: - - UserId - - name: idx_search_history_timestamp - columns: - - Timestamp - primaryKey: - name: PK_user_search_history - columns: - - Id diff --git a/Samples/ICD10/ICD10.Cli.Tests/CliE2ETests.cs b/Samples/ICD10/ICD10.Cli.Tests/CliE2ETests.cs deleted file mode 100644 index 9b2f6232..00000000 --- a/Samples/ICD10/ICD10.Cli.Tests/CliE2ETests.cs +++ /dev/null @@ -1,1170 +0,0 @@ -namespace ICD10.Cli.Tests; - -/// -/// E2E tests for ICD-10-CM CLI - REAL database, mock API only. -/// Uses Spectre.Console.Testing to drive the CLI through TestConsole. -/// -public sealed class CliE2ETests : IClassFixture -{ - readonly CliTestFixture _fixture; - - public CliE2ETests(CliTestFixture fixture) => _fixture = fixture; - - [Fact] - public async Task Help_DisplaysAllCommands() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("help"); - console.Input.PushTextWithEnter("quit"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("search", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("find", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("lookup", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("browse", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("stats", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("history", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("clear", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("quit", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task HelpShortcut_DisplaysHelp() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("h"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("search", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Shortcuts", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task QuestionMarkHelp_DisplaysHelp() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("?"); - console.Input.PushTextWithEnter("quit"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("search", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Quit_ExitsGracefully() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("quit"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("Goodbye", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task QuitShortcut_ExitsGracefully() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("Goodbye", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Exit_ExitsGracefully() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("exit"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("Goodbye", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Stats_DisplaysApiStatus() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("stats"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - // Should show API health status - Assert.Contains("API", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Status", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task History_ShowsCommandHistory() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("help"); - console.Input.PushTextWithEnter("stats"); - console.Input.PushTextWithEnter("history"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("help", output); - Assert.Contains("stats", output); - } - - [Fact] - public async Task History_EmptyWhenNoCommands() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("history"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // First command is "history" so history will show that - // But the message "No history yet" won't appear since history itself gets added first - Assert.Contains("history", console.Output); - } - - [Fact] - public async Task Find_SearchesByText() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("find chest"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - // Should show codes containing "chest" (first results are D48.111, D57.01, etc.) - Assert.Contains("chest", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task FindShortcut_SearchesByText() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("f pneumonia"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - // Should show codes containing "pneumonia" (first results start with A codes) - Assert.Contains("pneumonia", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Find_ReturnsEmptyForNoMatch() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("find zzznomatchzzz"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("No codes found", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Find_RequiresArgument() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("find"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("Usage", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_FindsExactCode() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("lookup R07.9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("R07.9", output); - Assert.Contains("Chest pain", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task LookupShortcut_FindsCode() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l E11.9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("E11.9", output); - Assert.Contains("diabetes", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_HandlesNoDot() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("lookup R079"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("R07.9", console.Output); - } - - [Fact] - public async Task Lookup_HandlesLowercase() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("lookup r07.9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("R07.9", console.Output); - } - - [Fact] - public async Task Lookup_RequiresArgument() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("lookup"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("Usage", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsMultipleMatches() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("lookup R07"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("R07.9", output); - Assert.Contains("R07.89", output); - } - - [Fact] - public async Task Browse_ShowsChapterOverview() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("browse"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("A-B", output); - Assert.Contains("Infectious", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Browse_FiltersByLetter() - { - // Browse R0 to get codes starting with R0 (R alone matches too many descriptions) - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("browse R0"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - // Should show R0x codes - Assert.Contains("R0", output); - } - - [Fact] - public async Task BrowseShortcut_FiltersByLetter() - { - // Browse J1 to get codes starting with J1 (J alone matches too many descriptions) - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("b J1"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - // Should show J1x codes - Assert.Contains("J1", output); - } - - [Fact] - public async Task Search_ShowsResultsOrFallsBack() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("search chest pain"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - - await cli.RunAsync(); - - var output = console.Output; - // Should show search results or fall back to text search - Assert.Contains("chest", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Search_RequiresArgument() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("search"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - Assert.Contains("Usage", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Search_ShowsHeartRelatedResults() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("search heart attack"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - - await cli.RunAsync(); - - var output = console.Output; - // Should show search results - either RAG or fallback to text search - Assert.Contains("Results", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Search_ShortcutWorks() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("s diabetes"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - - await cli.RunAsync(); - - // Should show diabetes-related results - Assert.Contains("diabetes", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task UnknownCommand_TreatedAsSearch() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("chest pain symptoms"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - - await cli.RunAsync(); - - // Should treat as search and find chest-related codes - Assert.Contains("chest", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Clear_ClearsScreenAndShowsHeader() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("clear"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // After clear, should show header again (FigletText renders as ASCII art) - Assert.Contains( - "Medical Diagnosis Code Explorer", - console.Output, - StringComparison.OrdinalIgnoreCase - ); - } - - [Fact] - public async Task EmptyInput_IsIgnored() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter(""); - console.Input.PushTextWithEnter(""); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // Should just continue and eventually quit - Assert.Contains("Goodbye", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task HeaderDisplaysOnStartup() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - // FigletText renders as ASCII art, so check for panel content instead - Assert.Contains( - "Medical Diagnosis Code Explorer", - output, - StringComparison.OrdinalIgnoreCase - ); - Assert.Contains("help", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ApiStatusDisplaysOnStartup() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // API health status shown on startup - Assert.Contains("API", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task BillableIndicator_ShownInCodeList() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("find chest"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // Should show billable indicator - Assert.Contains("billable", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_CodeNotFound_ShowsErrorMessage() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("lookup ZZZ99.99"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // Should show "not found" message instead of crashing - Assert.Contains("not found", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Json_CodeNotFound_ShowsErrorMessage() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("json ZZZ99.99"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // Should show "not found" message instead of crashing - Assert.Contains("not found", console.Output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Search_NoResults_HandlesGracefully() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("search xyznonexistent123"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // Should gracefully handle search and exit without crashing - var output = console.Output; - Assert.Contains("Goodbye", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Browse_InvalidLetter_ShowsEmpty() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("browse 9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - // Should handle invalid letter gracefully - Assert.Contains("Goodbye", console.Output, StringComparison.OrdinalIgnoreCase); - } - - // ========================================================================= - // CRITICAL: Lookup tests for ICD-10-CM codes (returned by RAG search) - // These tests ensure codes from search can actually be looked up - // ========================================================================= - - [Fact] - public async Task Lookup_FindsIcd10CmCode_I10() - { - // I10 (Essential hypertension) is in icd10_code (used by RAG search) - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l I10"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("I10", output); - Assert.Contains("hypertension", output, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_FindsIcd10CmCode_I2111_HeartAttack() - { - // I21.11 (ST elevation MI) is in icd10_code - critical for "heart attack" search - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l I21.11"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("I21.11", output); - Assert.Contains("myocardial infarction", output, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_FindsIcd10CmCode_M545_BackPain() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l M54.5"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("M54.5", output); - Assert.Contains("back pain", output, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_FindsIcd10CmCode_G43909_Migraine() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l G43.909"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("G43.909", output); - Assert.Contains("migraine", output, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsFullCodeDetails_AllFields() - { - // Verify lookup shows ALL required information for R07.9 - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l R07.9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // MUST show the code - Assert.Contains("R07.9", output); - - // MUST show short description - Assert.Contains("Chest pain", output, StringComparison.OrdinalIgnoreCase); - - // MUST show billable status - Assert.Contains("Billable", output, StringComparison.OrdinalIgnoreCase); - - // MUST show chapter info (ICD-10-CM uses numeric chapter numbers) - Assert.Contains("18", output); - Assert.Contains("Symptoms", output, StringComparison.OrdinalIgnoreCase); - - // MUST show block info - Assert.Contains("R00-R09", output); - - // MUST show category info - Assert.Contains("R07", output); - Assert.Contains("Pain in throat and chest", output, StringComparison.OrdinalIgnoreCase); - - // MUST NOT say not found - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsChapterBlockCategoryHierarchy() - { - // Verify I10 (hypertension) shows full hierarchy - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l I10"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // Code and description - Assert.Contains("I10", output); - Assert.Contains("hypertension", output, StringComparison.OrdinalIgnoreCase); - - // Chapter 9 - Circulatory system (ICD-10-CM uses numeric chapter numbers) - Assert.Contains("Chapter", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("circulatory", output, StringComparison.OrdinalIgnoreCase); - - // Block I10-I1A (ICD-10-CM 2025 hypertensive diseases block) - Assert.Contains("I10-I1A", output); - Assert.Contains("Hypertensive", output, StringComparison.OrdinalIgnoreCase); - - // Category I10 - "Essential (primary) hypertension" - Assert.Contains( - "Essential (primary) hypertension", - output, - StringComparison.OrdinalIgnoreCase - ); - - // Synonym (actual database synonym) - Assert.Contains("benign", output, StringComparison.OrdinalIgnoreCase); - - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsSynonyms_WhenPresent() - { - // Verify E11.9 (Type 2 diabetes) shows synonyms - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l E11.9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - Assert.Contains("E11.9", output); - Assert.Contains("Type 2 diabetes", output, StringComparison.OrdinalIgnoreCase); - // Must show synonyms (actual synonyms from CDC ICD-10-CM data) - Assert.Contains("Diabetes", output, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsLongDescription() - { - // Verify G43.909 shows long description - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l G43.909"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - Assert.Contains("G43.909", output); - Assert.Contains("Migraine", output, StringComparison.OrdinalIgnoreCase); - // Long description has more detail - Assert.Contains("without status migrainosus", output, StringComparison.OrdinalIgnoreCase); - // Synonyms (actual database synonyms) - Assert.Contains("Hemicrania", output, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsEditionInfo() - { - // Verify edition/version info is shown - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l I21.11"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - Assert.Contains("I21.11", output); - Assert.Contains("STEMI", output, StringComparison.OrdinalIgnoreCase); - // Must show edition - Assert.Contains("2025", output); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_AllSeededCodes_Succeed() - { - // Verify ALL seeded ICD-10-CM codes can be looked up - var codesToTest = new[] - { - ("R07.9", "chest pain"), - ("R06.02", "shortness of breath"), - ("I21.11", "myocardial infarction"), - ("J18.9", "pneumonia"), - ("E11.9", "diabetes"), - ("I10", "hypertension"), - ("M54.5", "back pain"), - }; - - foreach (var (code, expectedText) in codesToTest) - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter($"l {code}"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.True( - output.Contains(code, StringComparison.Ordinal), - $"Lookup for {code} should show the code in output" - ); - Assert.True( - output.Contains(expectedText, StringComparison.OrdinalIgnoreCase), - $"Lookup for {code} should show '{expectedText}' in output" - ); - Assert.False( - output.Contains("not found", StringComparison.OrdinalIgnoreCase), - $"Lookup for {code} should NOT show 'not found'" - ); - } - } - - [Fact] - public async Task Json_FindsIcd10CmCode() - { - // JSON command should also find ICD-10-CM codes - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("json I10"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("I10", output); - Assert.Contains("hypertension", output, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsChapterInfo() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l I10"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("I10", output); - Assert.Contains("Chapter", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("9", output); // ICD-10-CM uses numeric chapter numbers - Assert.Contains("circulatory", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsCategoryInfo() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l E11.9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("E11.9", output); - Assert.Contains("Category", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("E11", output); - } - - [Fact] - public async Task Lookup_ShowsSynonymsWhenPresent() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l I10"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("I10", output); - Assert.Contains("Synonyms", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("high blood pressure", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Lookup_ShowsMultipleSynonyms() - { - // M54.50 has synonyms including lumbago - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l M54.50"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - Assert.Contains("M54.50", output); - Assert.Contains("Synonym", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("lumbago", output, StringComparison.OrdinalIgnoreCase); - } - - // ========================================================================= - // COMPREHENSIVE E2E TEST: LOOKUP COMMAND DISPLAYS ALL DETAILS - // This test PROVES the `l` command shows EVERY SINGLE FIELD - // ========================================================================= - - [Fact] - public async Task LookupCommand_E2E_DisplaysAllCodeDetails_ChapterBlockCategorySynonymsEdition() - { - // ARRANGE: Use I10 (hypertension) - has full hierarchy and synonyms - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l I10"); - console.Input.PushTextWithEnter("q"); - - // ACT: Run the CLI - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // ASSERT: Code is displayed - Assert.Contains("I10", output); - - // ASSERT: Short description is displayed - Assert.Contains("hypertension", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: CHAPTER is displayed (Chapter 9 - Circulatory) - Assert.Contains("Chapter", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("9", output); // ICD-10-CM uses numeric chapter numbers - Assert.Contains("circulatory", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: BLOCK is displayed (I10-I1A - Hypertensive diseases, ICD-10-CM 2025) - Assert.Contains("Block", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("I10-I1A", output); - Assert.Contains("Hypertensive", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: CATEGORY is displayed (I10 - Essential (primary) hypertension) - Assert.Contains("Category", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains( - "Essential (primary) hypertension", - output, - StringComparison.OrdinalIgnoreCase - ); - - // ASSERT: SYNONYMS are displayed (actual database synonyms) - Assert.Contains("Synonym", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("benign", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: EDITION is displayed - Assert.Contains("Edition", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("2025", output); - - // ASSERT: Billable status is displayed - Assert.Contains("Billable", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: NOT showing "not found" - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task LookupCommand_E2E_R074_DisplaysAllCodeDetails() - { - // ARRANGE: Use R07.9 (chest pain) - has full hierarchy and synonyms - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l R07.9"); - console.Input.PushTextWithEnter("q"); - - // ACT - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // ASSERT: Code - Assert.Contains("R07.9", output); - - // ASSERT: Description - Assert.Contains("Chest pain", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: CHAPTER 18 - Symptoms (ICD-10-CM uses numeric chapter numbers) - Assert.Contains("Chapter", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("18", output); - Assert.Contains("Symptoms", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: BLOCK R00-R09 - Assert.Contains("Block", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("R00-R09", output); - - // ASSERT: CATEGORY R07 - Pain in throat and chest - Assert.Contains("Category", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("R07", output); - Assert.Contains("Pain in throat and chest", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: EDITION - Assert.Contains("Edition", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("2025", output); - - // ASSERT: Billable - Assert.Contains("Billable", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: NOT "not found" - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task LookupCommand_E2E_E119_Diabetes_DisplaysAllCodeDetails() - { - // ARRANGE: Use E11.9 (Type 2 diabetes) - has synonyms - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l E11.9"); - console.Input.PushTextWithEnter("q"); - - // ACT - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // ASSERT: Code - Assert.Contains("E11.9", output); - - // ASSERT: Description - Assert.Contains("Type 2 diabetes", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: CHAPTER 4 - Endocrine - Assert.Contains("Chapter", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("4", output); - - // ASSERT: BLOCK E08-E13 - Diabetes mellitus (ICD-10-CM 2025) - Assert.Contains("Block", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("E08-E13", output); - Assert.Contains("Diabetes mellitus", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: CATEGORY E11 - Type 2 diabetes mellitus - Assert.Contains("Category", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("E11", output); - Assert.Contains("Type 2 diabetes mellitus", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: SYNONYMS section exists (actual synonyms depend on database content) - Assert.Contains("Synonym", output, StringComparison.OrdinalIgnoreCase); - - // ASSERT: EDITION - Assert.Contains("Edition", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("2025", output); - - // ASSERT: NOT "not found" - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - } -} - -/// -/// E2E tests that verify the CLI works with ACTUAL ICD-10-CM 2025 data. -/// Tests real codes from the production database. -/// -public sealed class RealDataE2ETests : IClassFixture -{ - private readonly CliTestFixture _fixture; - - /// - /// Creates tests using the shared test fixture. - /// - public RealDataE2ETests(CliTestFixture fixture) => _fixture = fixture; - - [Fact] - public async Task Lookup_H53481_DisplaysAllDetails() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l H53.481"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // MUST find the code - Assert.Contains("H53.481", output); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - - // MUST show description - Assert.Contains("visual field", output, StringComparison.OrdinalIgnoreCase); - - // MUST show chapter - Assert.Contains("Chapter", output, StringComparison.OrdinalIgnoreCase); - - // MUST show block - Assert.Contains("Block", output, StringComparison.OrdinalIgnoreCase); - - // MUST show category - Assert.Contains("Category", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("H53", output); - } - - [Fact] - public async Task Lookup_Q531_DisplaysAllDetails() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l Q53.1"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // MUST find the code - Assert.Contains("Q53.1", output); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - - // MUST show description - Assert.Contains("testicle", output, StringComparison.OrdinalIgnoreCase); - - // MUST show chapter 17 - Assert.Contains("Chapter", output, StringComparison.OrdinalIgnoreCase); - - // MUST show category - Assert.Contains("Category", output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Q53", output); - } - - [Fact] - public async Task Lookup_E119_DiabetesCode_DisplaysAllDetails() - { - var console = new TestConsole(); - console.Profile.Capabilities.Interactive = true; - console.Input.PushTextWithEnter("l E11.9"); - console.Input.PushTextWithEnter("q"); - - using var cli = new Icd10Cli(_fixture.ApiUrl, console, _fixture.HttpClient); - await cli.RunAsync(); - - var output = console.Output; - - // MUST find the code - Assert.Contains("E11.9", output); - Assert.DoesNotContain("not found", output, StringComparison.OrdinalIgnoreCase); - - // MUST show diabetes-related description - Assert.Contains("diabetes", output, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/Samples/ICD10/ICD10.Cli.Tests/CliTestFixture.cs b/Samples/ICD10/ICD10.Cli.Tests/CliTestFixture.cs deleted file mode 100644 index 36f53c42..00000000 --- a/Samples/ICD10/ICD10.Cli.Tests/CliTestFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ICD10.Api.Tests; - -namespace ICD10.Cli.Tests; - -/// -/// Test fixture that spins up a real API with seeded test data. -/// CLI tests run against this real API. -/// -public sealed class CliTestFixture : IDisposable -{ - private readonly ICD10ApiFactory _factory; - private readonly HttpClient _httpClient; - - /// - /// Gets the API base URL. - /// - public string ApiUrl { get; } - - /// - /// Gets the HTTP client configured for the test API. - /// - public HttpClient HttpClient => _httpClient; - - /// - /// Creates a new test fixture with a real API server. - /// - public CliTestFixture() - { - _factory = new ICD10ApiFactory(); - _httpClient = _factory.CreateClient(); - ApiUrl = _httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "http://localhost"; - } - - /// - public void Dispose() - { - _httpClient.Dispose(); - _factory.Dispose(); - } -} diff --git a/Samples/ICD10/ICD10.Cli.Tests/GlobalUsings.cs b/Samples/ICD10/ICD10.Cli.Tests/GlobalUsings.cs deleted file mode 100644 index f8cb8b56..00000000 --- a/Samples/ICD10/ICD10.Cli.Tests/GlobalUsings.cs +++ /dev/null @@ -1,5 +0,0 @@ -global using System; -global using System.Net.Http; -global using System.Threading.Tasks; -global using Spectre.Console.Testing; -global using Xunit; diff --git a/Samples/ICD10/ICD10.Cli.Tests/ICD10.Cli.Tests.csproj b/Samples/ICD10/ICD10.Cli.Tests/ICD10.Cli.Tests.csproj deleted file mode 100644 index c80aaec4..00000000 --- a/Samples/ICD10/ICD10.Cli.Tests/ICD10.Cli.Tests.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - Library - true - ICD10.Cli.Tests - CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859;CA1849;CA2234;CA1812;CA2007;CA2000;xUnit1030;CA1056 - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/Samples/ICD10/ICD10.Cli/GlobalUsings.cs b/Samples/ICD10/ICD10.Cli/GlobalUsings.cs deleted file mode 100644 index a26ca3ac..00000000 --- a/Samples/ICD10/ICD10.Cli/GlobalUsings.cs +++ /dev/null @@ -1,42 +0,0 @@ -global using ApiErrorResponseError = Outcome.HttpError.ErrorResponseError; -global using ApiExceptionError = Outcome.HttpError.ExceptionError; -global using ChaptersErrorResponse = Outcome.Result< - System.Collections.Immutable.ImmutableArray, - Outcome.HttpError ->.Error, Outcome.HttpError>; -global using CodeErrorResponse = Outcome.Result>.Error< - Icd10Code, - Outcome.HttpError ->; -global using CodesErrorResponse = Outcome.Result< - System.Collections.Immutable.ImmutableArray, - Outcome.HttpError ->.Error, Outcome.HttpError>; -global using HealthErrorResponse = Outcome.Result< - HealthResponse, - Outcome.HttpError ->.Error>; -global using OkChapters = Outcome.Result< - System.Collections.Immutable.ImmutableArray, - Outcome.HttpError ->.Ok, Outcome.HttpError>; -global using OkCode = Outcome.Result>.Ok< - Icd10Code, - Outcome.HttpError ->; -global using OkCodes = Outcome.Result< - System.Collections.Immutable.ImmutableArray, - Outcome.HttpError ->.Ok, Outcome.HttpError>; -global using OkHealth = Outcome.Result>.Ok< - HealthResponse, - Outcome.HttpError ->; -global using OkSearch = Outcome.Result>.Ok< - SearchResponse, - Outcome.HttpError ->; -global using SearchErrorResponse = Outcome.Result< - SearchResponse, - Outcome.HttpError ->.Error>; diff --git a/Samples/ICD10/ICD10.Cli/ICD10.Cli.csproj b/Samples/ICD10/ICD10.Cli/ICD10.Cli.csproj deleted file mode 100644 index bba3acaf..00000000 --- a/Samples/ICD10/ICD10.Cli/ICD10.Cli.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - Exe - net10.0 - enable - enable - CA1515;CA1822;CA1812;CA1307;CA1305;CA2007 - - - - - - - - - - - - diff --git a/Samples/ICD10/ICD10.Cli/Program.cs b/Samples/ICD10/ICD10.Cli/Program.cs deleted file mode 100644 index c0641d1e..00000000 --- a/Samples/ICD10/ICD10.Cli/Program.cs +++ /dev/null @@ -1,979 +0,0 @@ -using System.Collections.Immutable; -using System.Text.Json; -using RestClient.Net; -using Spectre.Console; -using Spectre.Console.Rendering; -using Urls; - -var apiUrl = args.Length > 0 ? args[0] : FindApiUrl(); - -using var app = new Icd10Cli(apiUrl, AnsiConsole.Console); -await app.RunAsync(); -return 0; - -static string FindApiUrl() -{ - var envUrl = Environment.GetEnvironmentVariable("ICD10_API_URL"); - return envUrl ?? "http://localhost:5558"; -} - -/// -/// ICD-10-AM code from API. -/// -internal sealed record Icd10Code( - string Id, - string Code, - string ShortDescription, - string LongDescription, - long Billable, - string? CategoryCode, - string? CategoryTitle, - string? BlockCode, - string? BlockTitle, - string? ChapterNumber, - string? ChapterTitle, - string? InclusionTerms, - string? ExclusionTerms, - string? CodeAlso, - string? CodeFirst, - string? Synonyms, - string? Edition -); - -/// -/// RAG search result from API. -/// -internal sealed record SearchResult( - string Code, - string Description, - string LongDescription, - double Confidence, - string? CodeType, - string? Chapter, - string? ChapterTitle, - string? Category -); - -/// -/// API search response. -/// -internal sealed record SearchResponse( - ImmutableArray Results, - string Query, - string Model -); - -/// -/// Chapter from API. -/// -internal sealed record Chapter( - string Id, - string ChapterNumber, - string Title, - string CodeRangeStart, - string CodeRangeEnd -); - -/// -/// Health response from API. -/// -internal sealed record HealthResponse(string Status, string Service); - -/// -/// Search request to API. -/// -internal sealed record SearchRequest(string Query, int? Limit, bool IncludeAchi, string? Format); - -/// -/// Error response from API. -/// -internal sealed record ErrorResponse(string? Detail); - -/// -/// Beautiful TUI for ICD-10-AM code lookup via API. -/// -sealed class Icd10Cli : IDisposable -{ - static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - }; - - readonly HttpClient _httpClient; - readonly HttpClient? _ownedHttpClient; - readonly List _history = []; - readonly string _apiUrl; - readonly IAnsiConsole _console; - - /// - /// Creates CLI with API URL. - /// - public Icd10Cli(string apiUrl, IAnsiConsole console) - : this(apiUrl, console, null) { } - - /// - /// Creates CLI with API URL and optional HTTP client for testing. - /// - public Icd10Cli(string apiUrl, IAnsiConsole console, HttpClient? httpClient) - { - _apiUrl = apiUrl.TrimEnd('/'); - _console = console; - _ownedHttpClient = httpClient is null ? new HttpClient() : null; - _httpClient = httpClient ?? _ownedHttpClient!; - } - - /// - /// Runs the interactive CLI loop. - /// - public async Task RunAsync() - { - if (!_console.Profile.Capabilities.Interactive) - { - _console.MarkupLine("[red]Error:[/] This CLI requires an interactive terminal."); - _console.MarkupLine("[dim]Run from a terminal, not VS Code's debug console.[/]"); - return; - } - - RenderHeader(); - await RenderStatsAsync().ConfigureAwait(false); - - while (true) - { - _console.WriteLine(); - _console.MarkupLine("[dim]Enter symptoms, condition, or code (h=help, q=quit):[/]"); - var input = _console.Prompt(new TextPrompt("[cyan]>[/]").AllowEmpty()); - - if (string.IsNullOrWhiteSpace(input)) - continue; - - _history.Add(input); - var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - var cmd = parts[0].ToLowerInvariant(); - var arg = parts.Length > 1 ? parts[1] : ""; - - switch (cmd) - { - case "q": - case "quit": - case "exit": - RenderGoodbye(); - return; - - case "?": - case "h": - case "help": - RenderHelp(); - break; - - case "s": - case "search": - if (string.IsNullOrWhiteSpace(arg)) - _console.MarkupLine("[yellow]Usage:[/] search "); - else - await SearchAsync(arg).ConfigureAwait(false); - break; - - case "l": - case "lookup": - if (string.IsNullOrWhiteSpace(arg)) - _console.MarkupLine("[yellow]Usage:[/] lookup "); - else - await LookupAsync(arg).ConfigureAwait(false); - break; - - case "f": - case "find": - if (string.IsNullOrWhiteSpace(arg)) - _console.MarkupLine("[yellow]Usage:[/] find "); - else - await FindAsync(arg).ConfigureAwait(false); - break; - - case "b": - case "browse": - await BrowseAsync(arg).ConfigureAwait(false); - break; - - case "j": - case "json": - if (string.IsNullOrWhiteSpace(arg)) - _console.MarkupLine("[yellow]Usage:[/] json "); - else - await ShowJsonAsync(arg).ConfigureAwait(false); - break; - - case "stats": - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10-AM Statistics[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.WriteLine(); - await RenderStatsAsync().ConfigureAwait(false); - break; - - case "history": - RenderHistory(); - break; - - case "clear": - _console.Clear(); - RenderHeader(); - break; - - default: - await SearchAsync(input).ConfigureAwait(false); - break; - } - } - } - - void RenderHeader() - { - var header = new FigletText("ICD-10-AM").Centered().Color(Color.Cyan1); - - _console.Write(header); - - var panel = new Panel( - "[grey]Medical Diagnosis Code Explorer[/]\n" - + $"[dim]API: {_apiUrl.EscapeMarkup()}[/]\n" - + "[dim]Type [cyan]help[/] for commands, or just start typing to search[/]" - ) - .Border(BoxBorder.Rounded) - .BorderColor(Color.Grey) - .Padding(1, 0); - - _console.Write(panel); - } - - void RenderHelp() - { - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10-AM Help[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.WriteLine(); - - var table = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Grey) - .AddColumn(new TableColumn("[cyan]Command[/]").Width(20)) - .AddColumn(new TableColumn("[cyan]Description[/]")); - - table.AddRow("[green]search[/] [dim][/]", "Semantic RAG search (AI-powered)"); - table.AddRow("[green]find[/] [dim][/]", "Text search in descriptions"); - table.AddRow("[green]lookup[/] [dim][/]", "Direct code lookup (e.g., R07.9)"); - table.AddRow("[green]json[/] [dim][/]", "Show raw JSON for a code"); - table.AddRow("[green]browse[/]", "Browse chapters"); - table.AddRow("[green]stats[/]", "Show API status"); - table.AddRow("[green]history[/]", "Show command history"); - table.AddRow("[green]clear[/]", "Clear screen"); - table.AddRow("[green]help[/]", "Show this help"); - table.AddRow("[green]quit[/]", "Exit the application"); - table.AddEmptyRow(); - table.AddRow("[dim][/]", "[dim]Treated as search query[/]"); - - _console.Write(table); - - _console.MarkupLine( - "\n[dim]Shortcuts: s=search, f=find, l=lookup, j=json, b=browse, h=help, q=quit[/]" - ); - } - - async Task RenderStatsAsync() - { - var result = await _httpClient - .GetAsync( - url: $"{_apiUrl}/health".ToAbsoluteUrl(), - deserializeSuccess: DeserializeHealth, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - switch (result) - { - case OkHealth(var health): - var grid = new Grid().AddColumn().AddColumn(); - grid.AddRow( - new Panel($"[bold green]✓[/]\n[dim]API Status[/]") - .Border(BoxBorder.Rounded) - .BorderColor(Color.Green), - new Panel($"[bold cyan]{_apiUrl.EscapeMarkup()}[/]\n[dim]Endpoint[/]") - .Border(BoxBorder.Rounded) - .BorderColor(Color.Cyan1) - ); - _console.Write(grid); - break; - - case HealthErrorResponse(ApiErrorResponseError _): - case HealthErrorResponse(ApiExceptionError _): - _console.MarkupLine($"[red]API unavailable[/]"); - _console.MarkupLine( - $"[yellow]Make sure the API is running at {_apiUrl.EscapeMarkup()}[/]" - ); - break; - } - } - - void RenderHistory() - { - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10-AM Command History[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.WriteLine(); - - if (_history.Count == 0) - { - _console.MarkupLine("[dim]No history yet.[/]"); - return; - } - - var table = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Grey) - .AddColumn(new TableColumn("[dim]#[/]").RightAligned()) - .AddColumn("[cyan]Command[/]"); - - for (var i = 0; i < _history.Count; i++) - { - table.AddRow($"[dim]{i + 1}[/]", _history[i].EscapeMarkup()); - } - - _console.Write(table); - } - - async Task SearchAsync(string query) - { - var results = await _console - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("cyan")) - .StartAsync( - "Searching via AI...", - async _ => - { - var requestBody = new SearchRequest(query, 15, false, null); - var json = JsonSerializer.Serialize(requestBody, JsonOptions); - var content = new StringContent( - json, - System.Text.Encoding.UTF8, - "application/json" - ); - - var result = await _httpClient - .PostAsync( - $"{_apiUrl}/api/search".ToAbsoluteUrl(), - content, - DeserializeSearchResponse, - DeserializeError - ) - .ConfigureAwait(false); - - return result switch - { - OkSearch(var response) => response.Results, - SearchErrorResponse(ApiErrorResponseError _) => [], - SearchErrorResponse(ApiExceptionError _) => [], - }; - } - ) - .ConfigureAwait(false); - - if (results.Length == 0) - { - _console.MarkupLine("[yellow]No results. Falling back to text search...[/]"); - await FindAsync(query).ConfigureAwait(false); - return; - } - - RenderSearchResults(query, results); - } - - void RenderSearchResults(string query, ImmutableArray results) - { - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10-AM AI Search Results[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.MarkupLine($"[dim]Query:[/] [cyan]{query.EscapeMarkup()}[/]\n"); - - if (results.Length == 0) - { - _console.MarkupLine("[yellow]No results found.[/]"); - return; - } - - var table = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Cyan1) - .AddColumn(new TableColumn("[cyan]Score[/]").Width(7).RightAligned()) - .AddColumn(new TableColumn("[cyan]Code[/]").Width(10)) - .AddColumn(new TableColumn("[cyan]Ch[/]").Width(4)) - .AddColumn(new TableColumn("[cyan]Category[/]").Width(6)) - .AddColumn(new TableColumn("[cyan]Description[/]")); - - foreach (var r in results) - { - var scoreColor = r.Confidence switch - { - > 0.8 => "green", - > 0.6 => "yellow", - _ => "dim", - }; - - table.AddRow( - $"[{scoreColor}]{r.Confidence:P0}[/]", - $"[bold]{r.Code.EscapeMarkup()}[/]", - $"[magenta]{(r.Chapter ?? "").EscapeMarkup()}[/]", - $"[yellow]{(r.Category ?? "").EscapeMarkup()}[/]", - Truncate(r.Description, 50).EscapeMarkup() - ); - } - - _console.Write(table); - _console.MarkupLine( - $"[dim]{results.Length} results (Ch=Chapter) - type [cyan]l [/] for details[/]" - ); - } - - async Task FindAsync(string text) - { - var codes = await _console - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("cyan")) - .StartAsync( - "Searching...", - async _ => - { - var result = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes?q={Uri.EscapeDataString(text)}&limit=20".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCodes, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - return result switch - { - OkCodes(var c) => c, - CodesErrorResponse(ApiErrorResponseError _) => [], - CodesErrorResponse(ApiExceptionError _) => [], - }; - } - ) - .ConfigureAwait(false); - - RenderCodeList($"Text search: {text}", codes); - } - - async Task LookupAsync(string code) - { - var normalized = code.ToUpperInvariant().Replace(".", ""); - if (normalized.Length > 3) - { - normalized = normalized[..3] + "." + normalized[3..]; - } - - var result = await _console - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("cyan")) - .StartAsync( - "Looking up...", - async _ => - { - // Try ICD-10-CM first (matches RAG search results) - var r = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes/{Uri.EscapeDataString(normalized)}".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCode, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - if (r is OkCode(var cmCode)) - { - return cmCode; - } - - // Fall back to ICD-10-AM - var amResult = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes/{Uri.EscapeDataString(normalized)}".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCode, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - return amResult switch - { - OkCode(var c) => c, - CodeErrorResponse(ApiErrorResponseError _) => null, - CodeErrorResponse(ApiExceptionError _) => null, - }; - } - ) - .ConfigureAwait(false); - - if (result is not null) - { - RenderCodeDetail(result); - return; - } - - // Exact match not found - try searching for codes starting with input - var codes = await _console - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("cyan")) - .StartAsync( - "Searching for matching codes...", - async _ => - { - // Try ICD-10-CM first - var r = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes?q={Uri.EscapeDataString(normalized)}&limit=20".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCodes, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - if (r is OkCodes(var cmCodes) && cmCodes.Length > 0) - { - return cmCodes - .Where(x => - x.Code.StartsWith(normalized, StringComparison.OrdinalIgnoreCase) - ) - .ToImmutableArray(); - } - - // Fall back to ICD-10-AM - var amResult = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes?q={Uri.EscapeDataString(normalized)}&limit=20".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCodes, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - return amResult switch - { - OkCodes(var c) => - [ - .. c.Where(x => - x.Code.StartsWith(normalized, StringComparison.OrdinalIgnoreCase) - ), - ], - CodesErrorResponse(ApiErrorResponseError _) => [], - CodesErrorResponse(ApiExceptionError _) => [], - }; - } - ) - .ConfigureAwait(false); - - if (codes.Length > 0) - { - RenderCodeList($"Codes matching {code}", codes); - } - else - { - _console.MarkupLine($"[yellow]Code not found:[/] {code.EscapeMarkup()}"); - } - } - - async Task BrowseAsync(string letterFilter) - { - if (!string.IsNullOrWhiteSpace(letterFilter)) - { - // Filter codes by starting letter - var letter = letterFilter.Trim().ToUpperInvariant(); - var codes = await _console - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("cyan")) - .StartAsync( - $"Loading codes starting with {letter}...", - async _ => - { - var result = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes?q={Uri.EscapeDataString(letter)}&limit=50".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCodes, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - return result switch - { - OkCodes(var c) => c.Where(code => - code.Code.StartsWith(letter, StringComparison.OrdinalIgnoreCase) - ) - .ToImmutableArray(), - CodesErrorResponse(ApiErrorResponseError _) => [], - CodesErrorResponse(ApiExceptionError _) => [], - }; - } - ) - .ConfigureAwait(false); - - RenderCodeList($"Codes starting with {letter}", codes); - return; - } - - var chapters = await _console - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("cyan")) - .StartAsync( - "Loading chapters...", - async _ => - { - var result = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/chapters".ToAbsoluteUrl(), - deserializeSuccess: DeserializeChapters, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - return result switch - { - OkChapters(var c) => c, - ChaptersErrorResponse(ApiErrorResponseError _) => [], - ChaptersErrorResponse(ApiExceptionError _) => [], - }; - } - ) - .ConfigureAwait(false); - - RenderChapters(chapters); - } - - void RenderChapters(ImmutableArray chapters) - { - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10-AM Chapters[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.WriteLine(); - - if (chapters.Length == 0) - { - _console.MarkupLine("[yellow]No chapters found.[/]"); - return; - } - - var table = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Grey) - .AddColumn(new TableColumn("[cyan]#[/]").Width(5)) - .AddColumn(new TableColumn("[cyan]Range[/]").Width(8)) - .AddColumn(new TableColumn("[cyan]Title[/]")); - - foreach (var chapter in chapters) - { - var rangeStart = - chapter.CodeRangeStart.Length > 0 ? chapter.CodeRangeStart[0].ToString() : ""; - var rangeEnd = - chapter.CodeRangeEnd.Length > 0 ? chapter.CodeRangeEnd[0].ToString() : ""; - var range = rangeStart == rangeEnd ? rangeStart : $"{rangeStart}-{rangeEnd}"; - - table.AddRow( - $"[bold]{chapter.ChapterNumber.EscapeMarkup()}[/]", - $"[green]{range}[/]", - chapter.Title.EscapeMarkup() - ); - } - - _console.Write(table); - _console.MarkupLine( - $"\n[dim]{chapters.Length} chapters - type [cyan]b [/] to browse codes[/]" - ); - } - - void RenderCodeList(string title, ImmutableArray codes) - { - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10-AM Results[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.MarkupLine($"[dim]{title.EscapeMarkup()}[/]\n"); - - if (codes.Length == 0) - { - _console.MarkupLine("[yellow]No codes found.[/]"); - return; - } - - var table = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Grey) - .AddColumn(new TableColumn("[cyan]Code[/]").Width(10)) - .AddColumn(new TableColumn("[cyan]Description[/]")) - .AddColumn(new TableColumn("[cyan]$[/]").Width(3).Centered()); - - foreach (var code in codes) - { - table.AddRow( - $"[bold]{code.Code.EscapeMarkup()}[/]", - Truncate(code.ShortDescription, 55).EscapeMarkup(), - code.Billable == 1 ? "[green]✓[/]" : "[dim]-[/]" - ); - } - - _console.Write(table); - _console.MarkupLine( - $"[dim]{codes.Length} codes ([green]✓[/] = billable) - type [cyan]l [/] for details[/]" - ); - } - - void RenderCodeDetail(Icd10Code code) - { - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10-AM Code Detail[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.WriteLine(); - - var rows = new List - { - new Markup($"[bold cyan]{code.Code.EscapeMarkup()}[/]"), - new Rule().RuleStyle("grey"), - new Markup($"[bold]{code.ShortDescription.EscapeMarkup()}[/]"), - new Text(""), - new Markup($"[dim]{code.LongDescription.EscapeMarkup()}[/]"), - }; - - if (!string.IsNullOrWhiteSpace(code.ChapterNumber)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[magenta]Chapter:[/]")); - rows.Add( - new Markup( - $"[dim]{code.ChapterNumber.EscapeMarkup()} - {(code.ChapterTitle ?? "").EscapeMarkup()}[/]" - ) - ); - } - - if (!string.IsNullOrWhiteSpace(code.BlockCode)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[green]Block:[/]")); - rows.Add( - new Markup( - $"[dim]{code.BlockCode.EscapeMarkup()} - {(code.BlockTitle ?? "").EscapeMarkup()}[/]" - ) - ); - } - - if (!string.IsNullOrWhiteSpace(code.CategoryCode)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[yellow]Category:[/]")); - rows.Add( - new Markup( - $"[dim]{code.CategoryCode.EscapeMarkup()} - {(code.CategoryTitle ?? "").EscapeMarkup()}[/]" - ) - ); - } - - if (!string.IsNullOrWhiteSpace(code.InclusionTerms)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[blue]Includes:[/]")); - rows.Add(new Markup($"[dim]{code.InclusionTerms.EscapeMarkup()}[/]")); - } - - if (!string.IsNullOrWhiteSpace(code.ExclusionTerms)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[red]Excludes:[/]")); - rows.Add(new Markup($"[dim]{code.ExclusionTerms.EscapeMarkup()}[/]")); - } - - if (!string.IsNullOrWhiteSpace(code.CodeAlso)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[olive]Code Also:[/]")); - rows.Add(new Markup($"[dim]{code.CodeAlso.EscapeMarkup()}[/]")); - } - - if (!string.IsNullOrWhiteSpace(code.CodeFirst)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[orange3]Code First:[/]")); - rows.Add(new Markup($"[dim]{code.CodeFirst.EscapeMarkup()}[/]")); - } - - if (!string.IsNullOrWhiteSpace(code.Synonyms)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[aqua]Synonyms:[/]")); - rows.Add(new Markup($"[dim]{code.Synonyms.EscapeMarkup()}[/]")); - } - - if (!string.IsNullOrWhiteSpace(code.Edition)) - { - rows.Add(new Text("")); - rows.Add(new Markup("[silver]Edition:[/]")); - rows.Add(new Markup($"[dim]{code.Edition.EscapeMarkup()}[/]")); - } - - rows.Add(new Text("")); - rows.Add( - new Markup( - code.Billable == 1 - ? "[green]✓ Billable[/]" - : "[yellow]Not directly billable (category code)[/]" - ) - ); - - if (!string.IsNullOrWhiteSpace(code.Edition)) - { - rows.Add(new Markup($"[dim]Edition: {code.Edition.EscapeMarkup()}[/]")); - } - - var panel = new Panel(new Rows(rows)) - .Border(BoxBorder.Rounded) - .BorderColor(Color.Cyan1) - .Header("[cyan] Code Detail [/]") - .Padding(1, 1); - - _console.Write(panel); - } - - async Task ShowJsonAsync(string code) - { - var normalized = code.ToUpperInvariant().Replace(".", ""); - if (normalized.Length > 3) - { - normalized = normalized[..3] + "." + normalized[3..]; - } - - // Try ICD-10-CM first (matches RAG search results) - var result = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes/{Uri.EscapeDataString(normalized)}".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCode, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - - if (result is not OkCode) - { - // Fall back to ICD-10-AM - result = await _httpClient - .GetAsync( - url: $"{_apiUrl}/api/icd10/codes/{Uri.EscapeDataString(normalized)}".ToAbsoluteUrl(), - deserializeSuccess: DeserializeCode, - deserializeError: DeserializeError - ) - .ConfigureAwait(false); - } - - switch (result) - { - case OkCode(var c): - var json = JsonSerializer.Serialize(c, JsonOptions); - _console.Clear(); - _console.MarkupLine("[bold cyan]ICD-10 JSON[/]"); - _console.Write(new Rule().RuleStyle("grey")); - _console.MarkupLine($"[dim]Code:[/] [cyan]{c.Code.EscapeMarkup()}[/]\n"); - _console.Write(new Panel(json).Border(BoxBorder.Rounded).BorderColor(Color.Grey)); - break; - - case CodeErrorResponse(ApiErrorResponseError _): - case CodeErrorResponse(ApiExceptionError _): - _console.MarkupLine($"[yellow]Code not found:[/] {code.EscapeMarkup()}"); - break; - } - } - - void RenderGoodbye() - { - _console.WriteLine(); - _console.Write(new Rule("[cyan]Goodbye![/]").RuleStyle("grey")); - } - - static string Truncate(string text, int maxLength) => - text.Length <= maxLength ? text : text[..(maxLength - 3)] + "..."; - - // Deserialization helpers - static async Task DeserializeHealth( - HttpResponseMessage r, - CancellationToken ct - ) => - await JsonSerializer - .DeserializeAsync( - await r.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), - JsonOptions, - ct - ) - .ConfigureAwait(false); - - static async Task DeserializeSearchResponse( - HttpResponseMessage r, - CancellationToken ct - ) => - await JsonSerializer - .DeserializeAsync( - await r.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), - JsonOptions, - ct - ) - .ConfigureAwait(false); - - static async Task> DeserializeCodes( - HttpResponseMessage r, - CancellationToken ct - ) - { - var codes = await JsonSerializer - .DeserializeAsync( - await r.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), - JsonOptions, - ct - ) - .ConfigureAwait(false); - return codes is null ? [] : [.. codes]; - } - - static async Task DeserializeCode(HttpResponseMessage r, CancellationToken ct) => - await JsonSerializer - .DeserializeAsync( - await r.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), - JsonOptions, - ct - ) - .ConfigureAwait(false); - - static async Task> DeserializeChapters( - HttpResponseMessage r, - CancellationToken ct - ) - { - var chapters = await JsonSerializer - .DeserializeAsync( - await r.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), - JsonOptions, - ct - ) - .ConfigureAwait(false); - return chapters is null ? [] : [.. chapters]; - } - - static async Task DeserializeError(HttpResponseMessage r, CancellationToken ct) - { - try - { - var stream = await r.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - return stream.Length == 0 - ? null - : await JsonSerializer - .DeserializeAsync(stream, JsonOptions, ct) - .ConfigureAwait(false); - } - catch (JsonException) - { - return null; - } - } - - public void Dispose() => _ownedHttpClient?.Dispose(); -} diff --git a/Samples/ICD10/README.md b/Samples/ICD10/README.md deleted file mode 100644 index 1f8e9def..00000000 --- a/Samples/ICD10/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# ICD-10 Microservice - -Universal RAG semantic search for ICD-10 diagnosis and procedure codes. Supports **any ICD-10 variant** including: - -| Variant | Country | Edition Format | Example | -|---------|---------|----------------|---------| -| ICD-10-CM | United States | Year-based | "2025" | -| ICD-10-AM | Australia | Edition number | "13" | -| ICD-10-GM | Germany | Year-based | "2024" | -| ICD-10-CA | Canada | Year-based | "2024" | - -The service is **country-agnostic** - the `Edition` column is stored as text to accommodate different versioning schemes across variants. - -## Quick Start - -```bash -# First time: create database and import codes -./scripts/CreateDb/import.sh - -# Run the service -./scripts/run.sh -``` - -## Scripts - -``` -scripts/ -├── run.sh # Run the API and embedding service -├── Dependencies/ # Docker services -│ ├── start.sh # Start embedding service container -│ └── stop.sh # Stop embedding service container -└── CreateDb/ # First-time database setup - ├── import.sh # Migrate + import + embeddings - ├── import_icd10cm.py # Import codes (US CM data source) - ├── generate_embeddings.py - ├── generate_sample_data.py - └── requirements.txt -``` - -| Script/Folder | Purpose | -|---------------|---------| -| `run.sh` | Run the API and dependencies | -| `Dependencies/` | Start/stop Docker services (embedding service) | -| `CreateDb/` | One-time setup: migrate schema, import codes, generate embeddings | - -## Test It - -```bash -# Health check -curl http://localhost:5558/health - -# RAG semantic search -curl -X POST http://localhost:5558/api/search \ - -H "Content-Type: application/json" \ - -d '{"Query": "chest pain with shortness of breath", "Limit": 10}' - -# Direct code lookup -curl http://localhost:5558/api/icd10/codes/R07.4 -``` - -## Run E2E Tests - -```bash -cd ICD10.Api.Tests -dotnet test -``` - -## API Endpoints - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/health` | Health check | -| POST | `/api/search` | RAG semantic search | -| GET | `/api/icd10/codes/{code}` | Direct code lookup | -| GET | `/api/icd10/codes` | List codes (paginated) | -| GET | `/api/icd10/chapters` | List chapters | -| GET | `/api/achi/blocks` | ACHI procedure blocks | -| GET | `/api/achi/codes/{code}` | ACHI procedure lookup | - -## Architecture - -```mermaid -flowchart LR - subgraph Clients["Clients"] - CLI["ICD10.Cli
(C# / RestClient.Net)"] - Dashboard["Dashboard
(React)"] - end - - Clients --> |HTTP| API["ICD10.Api
(C# / .NET)
:5558"] - - API --> |cosine similarity| DB[(SQLite
icd10.db)] - - API --> |POST /embed| Embedding - subgraph Embedding["Docker Container :8000"] - PyAPI["FastAPI + sentence-transformers
MedEmbed-small"] - end -``` - -**Single Database**: All clients (CLI, Dashboard) access data through the API. The API owns the database. No client accesses the database directly. - -## Database Schema - -The unified schema supports any ICD-10 variant: - -- **icd10_chapter** - Top-level classification chapters -- **icd10_block** - Code range blocks within chapters -- **icd10_category** - Three-character categories -- **icd10_code** - Full diagnosis codes with `Edition` and `Synonyms` -- **icd10_code_embedding** - Pre-computed embeddings for RAG search -- **achi_block** - ACHI procedure blocks -- **achi_code** - ACHI procedure codes with `Edition` -- **achi_code_embedding** - ACHI embeddings - -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `DbPath` | Path to SQLite database | `icd10.db` | -| `EmbeddingService:BaseUrl` | Embedding service URL | `http://localhost:8000` | - -## Troubleshooting - -### "Embedding service unavailable" -Start the Docker container: -```bash -./scripts/Dependencies/start.sh -``` - -### "No embeddings found" -Run `CreateDb/import.sh` - RAG search requires pre-computed embeddings. - -### "Database not found" -Run `CreateDb/import.sh` first. diff --git a/Samples/ICD10/SPEC.md b/Samples/ICD10/SPEC.md deleted file mode 100644 index 7f67d9e8..00000000 --- a/Samples/ICD10/SPEC.md +++ /dev/null @@ -1,457 +0,0 @@ -# ICD-10 Microservice Specification - -## Overview - -The ICD-10 microservice provides clinical coders with RAG (Retrieval-Augmented Generation) search capabilities and standard lookup functionality for ICD-10 diagnosis codes. - -### Country-Agnostic Design - -**IMPORTANT**: This service uses a UNIFIED schema that supports ANY ICD-10 variant: - -| Variant | Country | Edition Format | Data Source | -|---------|---------|----------------|-------------| -| **ICD-10-CM** | USA | Year (e.g., "2025") | CMS.gov (FREE) | -| **ICD-10-AM** | Australia | Edition number (e.g., "13") | IHACPA (Licensed) | -| **ICD-10-GM** | Germany | Year (e.g., "2025") | BfArM | -| **ICD-10-CA** | Canada | Year (e.g., "2025") | CIHI | - -The `Edition` column (Text type) stores the edition identifier in whatever format the source standard uses. - -### What is ICD-10? - -**ICD-10** (International Statistical Classification of Diseases and Related Health Problems, Tenth Revision) is used worldwide to classify diseases, injuries, and related health problems in healthcare settings. - -Each country may have its own clinical modification: -- **ICD-10-CM**: US Clinical Modification (FREE from CMS.gov) -- **ICD-10-AM**: Australian Modification (Licensed from IHACPA) -- **ICD-10-PCS**: US Procedure Coding System (FREE from CMS.gov) -- **ACHI**: Australian Classification of Health Interventions (Licensed from IHACPA) - -### Data Sources - -**US (FREE & Public Domain)**: -- [CMS.gov ICD-10 Codes](https://www.cms.gov/medicare/coding-billing/icd-10-codes) -- [CDC ICD-10-CM Files](https://www.cdc.gov/nchs/icd/icd-10-cm/files.html) - -**Australia (Licensed)**: -- [IHACPA ICD-10-AM/ACHI/ACS](https://www.ihacpa.gov.au/health-care/classification/icd-10-amachiacs) - -To import US ICD-10-CM codes (FREE): -```bash -python scripts/import_icd10cm.py --db-path icd10.db -``` - -This downloads directly from CMS.gov and creates a complete database with 74,000+ codes. - -## Features - -### 1. RAG Search (Primary Feature) -Semantic search using medical embeddings to find relevant ICD-10 codes from natural language clinical descriptions. - -**Use Case**: A clinical coder enters "patient presented with chest pain and shortness of breath" and receives ranked ICD-10 code suggestions with confidence scores. - -### 2. Standard Lookup -Direct code lookup and hierarchical browsing with both simple JSON and FHIR-compliant response formats. - -## Embedding Model Selection - -### Recommended: MedEmbed-Large-v1 - -**Repository**: [abhinand5/MedEmbed](https://github.com/abhinand5/MedEmbed) - -**Why MedEmbed?** -- Purpose-built for medical/clinical information retrieval -- Fine-tuned on clinical notes and PubMed Central literature -- Outperforms general-purpose models on medical benchmarks -- Open source with permissive licensing -- Available in multiple sizes (Small/Base/Large) - -**Model Variants**: -| Model | Dimensions | Use Case | -|-------|------------|----------| -| MedEmbed-Small-v1 | 384 | Edge devices, resource-constrained | -| MedEmbed-Base-v1 | 768 | Balanced performance | -| MedEmbed-Large-v1 | 1024 | Maximum accuracy (recommended) | - -**Alternatives Considered**: -- PubMedBERT Embeddings - Good but less specialized for retrieval -- MedEIR - Newer, supports 8192 tokens but less mature -- BGE/E5 fine-tuned - Requires custom fine-tuning effort - -## Architecture - -### System Context - -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Dashboard.Web │────▶│ icd10.Api │────▶│ PostgreSQL │ -│ (Clinical UI) │ │ (This Service) │ │ (Vector DB) │ -└─────────────────┘ └──────────────────┘ └─────────────────┘ - │ - ▼ - ┌──────────────────┐ - │ Gatekeeper.Api │ - │ (Auth + RLS) │ - └──────────────────┘ -``` - -### Request Flow - -1. User authenticates via Gatekeeper (WebAuthn/JWT) -2. icd10.Api receives request with JWT token -3. API impersonates user for PostgreSQL connection (RLS enforcement) -4. Query executes with row-level security -5. Results returned (JSON or FHIR format) - -## Database Schema - -### Entity Relationship Diagram - -```mermaid -erDiagram - icd10_chapter { - uuid Id PK - text ChapterNumber - text Title - text CodeRangeStart - text CodeRangeEnd - text LastUpdated - int VersionId - } - - icd10_block { - uuid Id PK - uuid ChapterId FK - text BlockCode - text Title - text CodeRangeStart - text CodeRangeEnd - text LastUpdated - int VersionId - } - - icd10_category { - uuid Id PK - uuid BlockId FK - text CategoryCode - text Title - text LastUpdated - int VersionId - } - - icd10_code { - uuid Id PK - uuid CategoryId FK - text Code - text ShortDescription - text LongDescription - text InclusionTerms - text ExclusionTerms - text CodeAlso - text CodeFirst - text Synonyms - boolean Billable - text EffectiveFrom - text EffectiveTo - text Edition - text LastUpdated - int VersionId - } - - icd10_code_embedding { - uuid Id PK - uuid CodeId FK - vector Embedding - text EmbeddingModel - text LastUpdated - } - - achi_block { - uuid Id PK - text BlockNumber - text Title - text CodeRangeStart - text CodeRangeEnd - text LastUpdated - int VersionId - } - - achi_code { - uuid Id PK - uuid BlockId FK - text Code - text ShortDescription - text LongDescription - boolean Billable - text EffectiveFrom - text EffectiveTo - text Edition - text LastUpdated - int VersionId - } - - achi_code_embedding { - uuid Id PK - uuid CodeId FK - vector Embedding - text EmbeddingModel - text LastUpdated - } - - coding_standard { - uuid Id PK - text StandardNumber - text Title - text Content - text ApplicableCodes - text Edition - text LastUpdated - int VersionId - } - - user_search_history { - uuid Id PK - uuid UserId FK - text Query - text SelectedCode - text Timestamp - } - - icd10_chapter ||--o{ icd10_block : contains - icd10_block ||--o{ icd10_category : contains - icd10_category ||--o{ icd10_code : contains - icd10_code ||--|| icd10_code_embedding : has - achi_block ||--o{ achi_code : contains - achi_code ||--|| achi_code_embedding : has -``` - -### Row Level Security (RLS) - -The API impersonates the authenticated user when connecting to PostgreSQL. RLS policies ensure: - -- Users can only access codes relevant to their organization -- Search history is private per user -- Audit trails are maintained - -```sql --- Example RLS policy (conceptual - actual implementation via DataProvider) --- Users see their own search history only -CREATE POLICY user_search_history_policy ON user_search_history - USING (UserId = current_setting('app.current_user_id')::uuid); -``` - -## API Endpoints - -### RAG Search - -``` -POST /api/search -Content-Type: application/json -Authorization: Bearer - -{ - "query": "chest pain with shortness of breath", - "limit": 10, - "includeAchi": true, - "format": "json" | "fhir" -} -``` - -**Response (JSON format)**: -```json -{ - "results": [ - { - "code": "R07.4", - "description": "Chest pain, unspecified", - "confidence": 0.92, - "category": "Symptoms and signs involving the circulatory and respiratory systems", - "chapter": "XVIII" - }, - { - "code": "R06.0", - "description": "Dyspnoea", - "confidence": 0.87, - "category": "Symptoms and signs involving the circulatory and respiratory systems", - "chapter": "XVIII" - } - ], - "query": "chest pain with shortness of breath", - "model": "MedEmbed-Large-v1" -} -``` - -**Response (FHIR format)**: -```json -{ - "resourceType": "Bundle", - "type": "searchset", - "total": 2, - "entry": [ - { - "resource": { - "resourceType": "CodeSystem", - "url": "http://hl7.org/fhir/sid/icd-10-am", - "concept": { - "code": "R07.4", - "display": "Chest pain, unspecified" - } - }, - "search": { - "score": 0.92 - } - } - ] -} -``` - -### Direct Lookup - -``` -GET /api/codes/{code} -Authorization: Bearer -Accept: application/json | application/fhir+json -``` - -### Hierarchical Browse - -``` -GET /api/chapters -GET /api/chapters/{chapterId}/blocks -GET /api/blocks/{blockId}/categories -GET /api/categories/{categoryId}/codes -``` - -### ACHI Procedures - -``` -GET /api/achi/search?q={query} -GET /api/achi/codes/{code} -``` - -## Configuration - -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `DATABASE_URL` | PostgreSQL connection string | Required | -| `GATEKEEPER_URL` | Gatekeeper API base URL | `http://localhost:5002` | -| `EMBEDDING_MODEL` | MedEmbed model variant | `MedEmbed-Large-v1` | -| `VECTOR_DIMENSIONS` | Embedding dimensions | `1024` | -| `JWT_SIGNING_KEY` | Base64 JWT signing key | Required in production | - -### PostgreSQL Extensions - -Required extensions: -- `pgvector` - Vector similarity search -- `pg_trgm` - Trigram text search (fallback) - -## Import Pipeline - -### Step 1: Import ICD-10 Codes - -Location: `scripts/import_icd10cm.py` (US) or country-specific import script - -Downloads ICD-10 data from the appropriate source and populates the database. - -```bash -cd Samples/ICD10 -pip install click requests -python scripts/import_icd10cm.py --db-path icd10.db -``` - -**Result**: 74,260+ ICD-10 codes imported into SQLite. - -### Step 2: Generate Embeddings (REQUIRED FOR RAG SEARCH) - -Location: `scripts/generate_embeddings.py` - -**CRITICAL**: RAG semantic search will NOT work until embeddings are generated! - -```bash -cd Samples/ICD10 -pip install sentence-transformers torch click - -# Generate embeddings for all codes (takes ~30-60 minutes) -python scripts/generate_embeddings.py --db-path icd10.db - -# Or generate in batches for large datasets -python scripts/generate_embeddings.py --db-path icd10.db --batch-size 500 -``` - -**What it does**: -1. Loads MedEmbed-Small-v0.1 model (384 dimensions) -2. For each ICD-10 code, generates embedding from: `{Code} {ShortDescription} {LongDescription}` -3. Stores embedding as JSON array in `icd10_code_embedding` table -4. Links embedding to code via `CodeId` foreign key - -**Embedding Text Format**: -``` -{Code} | {ShortDescription} | {LongDescription} -``` -Example: `R07.4 | Chest pain, unspecified | Pain in chest, unspecified cause` - -**Storage Format**: JSON array of 384 floats in `Embedding` column. - -### Runtime Architecture - -```mermaid -flowchart LR - Client([User]) --> |POST /api/search| API["icd10.Api
(C# / .NET)"] - API --> |POST /embed| Container - subgraph Container["Docker Container"] - PyAPI["FastAPI
(Python)"] - PyAPI --> ST["sentence-transformers"] - ST --> Model["MedEmbed-small"] - end - Container --> |vector| API - API --> |cosine similarity| DB[(PostgreSQL)] - API --> |ranked results| Client -``` - -### Setup (One-Time) - -**Python scripts for data preparation:** -- `import_icd10cm.py` - Import 74,260+ ICD-10 codes (US source from CMS.gov) -- `generate_embeddings.py` - Generate embeddings for all codes - -### Runtime - -**C# API** calls **Docker Embedding Service** for query encoding: -- icd10.Api receives search request -- Calls `POST /embed` on Docker container (port 8000) -- Container runs FastAPI + sentence-transformers + MedEmbed -- Returns embedding vector -- C# computes cosine similarity against stored embeddings -- Returns ranked results - -## Testing Strategy - -- **Integration tests**: Full API tests against real PostgreSQL with pgvector -- **No mocking**: Real database, real embeddings -- **Coverage target**: 100% coverage, Stryker score 70%+ - -## Security Considerations - -1. **Authentication**: All endpoints require valid JWT from Gatekeeper -2. **Authorization**: RLS policies enforce data access at database level -3. **User Impersonation**: API sets PostgreSQL session variables for RLS -4. **Audit Logging**: All searches logged with user context -5. **No PII in codes**: ICD codes themselves are not patient data - -## Future Enhancements - -- [ ] Code suggestion based on clinical notes (NER integration) -- [ ] Coding standards (ACS) search and retrieval -- [ ] Multi-edition support (11th, 12th, 13th editions) -- [ ] Offline-first sync for mobile coders -- [ ] Integration with Clinical.Api for condition coding - -## References - -- [IHACPA ICD-10-AM/ACHI/ACS](https://www.ihacpa.gov.au/health-care/classification/icd-10-amachiacs) -- [ICD-10-AM Thirteenth Edition](https://www.ihacpa.gov.au/resources/icd-10-amachiacs-thirteenth-edition) -- [MedEmbed GitHub](https://github.com/abhinand5/MedEmbed) -- [pgvector Documentation](https://github.com/pgvector/pgvector) -- [FHIR CodeSystem Resource](https://build.fhir.org/codesystem.html) diff --git a/Samples/ICD10/embedding-service/Dockerfile b/Samples/ICD10/embedding-service/Dockerfile deleted file mode 100644 index 887a342e..00000000 --- a/Samples/ICD10/embedding-service/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# MedEmbed Embedding Service -# Uses MedEmbed-Small-v0.1 for reasonable container size (~500MB model) -# For production, consider MedEmbed-Large-v0.1 (1024 dims, ~1.3GB) - -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - && rm -rf /var/lib/apt/lists/* - -# Install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Download model at build time for faster startup -RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('abhinand/MedEmbed-small-v0.1')" - -# Copy application code -COPY main.py . - -# Expose port -EXPOSE 8000 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 - -# Run the service -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Samples/ICD10/embedding-service/docker-compose.yml b/Samples/ICD10/embedding-service/docker-compose.yml deleted file mode 100644 index 01341440..00000000 --- a/Samples/ICD10/embedding-service/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: '3.8' - -services: - embedding-service: - build: - context: . - dockerfile: Dockerfile - ports: - - "8000:8000" - environment: - - PYTHONUNBUFFERED=1 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - deploy: - resources: - limits: - memory: 2G - reservations: - memory: 1G diff --git a/Samples/ICD10/embedding-service/main.py b/Samples/ICD10/embedding-service/main.py deleted file mode 100644 index 27b8641f..00000000 --- a/Samples/ICD10/embedding-service/main.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -MedEmbed Embedding Service - -FastAPI service that generates medical embeddings using MedEmbed-Small-v0.1. -Designed for ICD-10-AM RAG search functionality. - -Model: abhinand5/MedEmbed-small-v0.1 (384 dimensions) -For higher accuracy, use MedEmbed-large-v0.1 (1024 dimensions) -""" - -import logging -from contextlib import asynccontextmanager -from typing import Any, Optional - -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from sentence_transformers import SentenceTransformer - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -MODEL_NAME = "abhinand/MedEmbed-small-v0.1" -MODEL_DIMENSIONS = 384 - -model: Optional[SentenceTransformer] = None - - -class EmbedRequest(BaseModel): - """Request to generate embedding for text.""" - - text: str - - -class EmbedBatchRequest(BaseModel): - """Request to generate embeddings for multiple texts.""" - - texts: list[str] - - -class EmbedResponse(BaseModel): - """Response containing the embedding vector.""" - - embedding: list[float] - model: str - dimensions: int - - -class EmbedBatchResponse(BaseModel): - """Response containing multiple embedding vectors.""" - - embeddings: list[list[float]] - model: str - dimensions: int - count: int - - -class HealthResponse(BaseModel): - """Health check response.""" - - status: str - model: str - dimensions: int - - -@asynccontextmanager -async def lifespan(app: FastAPI) -> Any: - """Load model on startup.""" - global model - logger.info(f"Loading model: {MODEL_NAME}") - model = SentenceTransformer(MODEL_NAME) - logger.info(f"Model loaded successfully. Dimensions: {MODEL_DIMENSIONS}") - yield - logger.info("Shutting down embedding service") - - -app = FastAPI( - title="MedEmbed Embedding Service", - description="Medical embedding service for ICD-10-AM RAG search", - version="1.0.0", - lifespan=lifespan, -) - - -@app.get("/health", response_model=HealthResponse) -async def health_check() -> HealthResponse: - """Health check endpoint.""" - if model is None: - raise HTTPException(status_code=503, detail="Model not loaded") - return HealthResponse(status="healthy", model=MODEL_NAME, dimensions=MODEL_DIMENSIONS) - - -@app.post("/embed", response_model=EmbedResponse) -async def generate_embedding(request: EmbedRequest) -> EmbedResponse: - """Generate embedding for a single text.""" - if model is None: - raise HTTPException(status_code=503, detail="Model not loaded") - - if not request.text.strip(): - raise HTTPException(status_code=400, detail="Text cannot be empty") - - embedding = model.encode(request.text, normalize_embeddings=True) - return EmbedResponse( - embedding=embedding.tolist(), - model=MODEL_NAME, - dimensions=MODEL_DIMENSIONS, - ) - - -@app.post("/embed/batch", response_model=EmbedBatchResponse) -async def generate_embeddings_batch(request: EmbedBatchRequest) -> EmbedBatchResponse: - """Generate embeddings for multiple texts.""" - if model is None: - raise HTTPException(status_code=503, detail="Model not loaded") - - if not request.texts: - raise HTTPException(status_code=400, detail="Texts list cannot be empty") - - if len(request.texts) > 100: - raise HTTPException(status_code=400, detail="Maximum 100 texts per batch") - - embeddings = model.encode(request.texts, normalize_embeddings=True) - return EmbedBatchResponse( - embeddings=[e.tolist() for e in embeddings], - model=MODEL_NAME, - dimensions=MODEL_DIMENSIONS, - count=len(request.texts), - ) - - -@app.get("/") -async def root() -> dict[str, str]: - """Root endpoint with service info.""" - return { - "service": "MedEmbed Embedding Service", - "model": MODEL_NAME, - "dimensions": str(MODEL_DIMENSIONS), - "endpoints": "/embed, /embed/batch, /health", - } diff --git a/Samples/ICD10/embedding-service/requirements.txt b/Samples/ICD10/embedding-service/requirements.txt deleted file mode 100644 index f6a1d21f..00000000 --- a/Samples/ICD10/embedding-service/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi>=0.100.0 -uvicorn[standard]>=0.23.0 -sentence-transformers>=2.2.2 -torch>=2.0.0 -pydantic>=2.0.0 -numpy>=1.24.0 diff --git a/Samples/ICD10/scripts/CreateDb/generate_embeddings.py b/Samples/ICD10/scripts/CreateDb/generate_embeddings.py deleted file mode 100644 index 8b116ba1..00000000 --- a/Samples/ICD10/scripts/CreateDb/generate_embeddings.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate embeddings for ICD-10 codes using MedEmbed model. - -This script populates the icd10_code_embedding table with vector embeddings -for semantic RAG search. Works with ANY ICD-10 variant (CM, AM, GM, etc.). -MUST be run after importing codes. - -Usage: - python generate_embeddings.py --db-path ../ICD10.Api/icd10.db - python generate_embeddings.py --db-path ../ICD10.Api/icd10.db --batch-size 500 -""" - -import json -import sqlite3 -import uuid -from datetime import datetime - -import click - -# Model configuration -EMBEDDING_MODEL = "abhinand/MedEmbed-small-v0.1" -EMBEDDING_DIMENSIONS = 384 - - -def get_codes_without_embeddings(conn: sqlite3.Connection, limit: int = 0) -> list: - """Get all codes that don't have embeddings yet, including hierarchy.""" - cursor = conn.cursor() - query = """ - SELECT c.Id, c.Code, c.ShortDescription, c.LongDescription, - c.InclusionTerms, c.CodeAlso, c.CodeFirst, c.Synonyms, - cat.CategoryCode, cat.Title AS CategoryTitle, - b.BlockCode, b.Title AS BlockTitle, - ch.ChapterNumber, ch.Title AS ChapterTitle - FROM icd10_code c - LEFT JOIN icd10_code_embedding e ON c.Id = e.CodeId - LEFT JOIN icd10_category cat ON c.CategoryId = cat.Id - LEFT JOIN icd10_block b ON cat.BlockId = b.Id - LEFT JOIN icd10_chapter ch ON b.ChapterId = ch.Id - WHERE e.Id IS NULL - """ - if limit > 0: - query += f" LIMIT {limit}" - - cursor.execute(query) - return cursor.fetchall() - - -def create_embedding_text( - code: str, - short_desc: str, - long_desc: str, - inclusion_terms: str, - code_also: str, - code_first: str, - synonyms: str, - category_code: str, - category_title: str, - block_code: str, - block_title: str, - chapter_number: str, - chapter_title: str, -) -> str: - """Create embedding text from code fields + hierarchy. Excludes exclusion terms.""" - parts = [] - - # Hierarchy context first - if chapter_title: - parts.append(f"Chapter {chapter_number}: {chapter_title}") - - if block_title: - parts.append(f"Block {block_code}: {block_title}") - - if category_title: - parts.append(f"Category {category_code}: {category_title}") - - # Main code info - parts.append(f"{code} {short_desc}") - - if long_desc and long_desc != short_desc: - parts.append(long_desc) - - if synonyms: - parts.append(f"Also known as: {synonyms}") - - if inclusion_terms: - parts.append(f"Includes: {inclusion_terms}") - - # Note: Exclusion terms deliberately NOT included - they describe what this code is NOT - - if code_also: - parts.append(f"Code also: {code_also}") - - if code_first: - parts.append(f"Code first: {code_first}") - - return " | ".join(parts) - - -def insert_embedding( - conn: sqlite3.Connection, - code_id: str, - embedding: list[float], - model_name: str -) -> None: - """Insert embedding into database.""" - cursor = conn.cursor() - embedding_json = json.dumps(embedding) - embedding_id = str(uuid.uuid4()) - timestamp = datetime.utcnow().isoformat() - - cursor.execute( - """ - INSERT INTO icd10_code_embedding (Id, CodeId, Embedding, EmbeddingModel, LastUpdated) - VALUES (?, ?, ?, ?, ?) - """, - (embedding_id, code_id, embedding_json, model_name, timestamp) - ) - - -@click.command() -@click.option("--db-path", required=True, help="Path to SQLite database") -@click.option("--batch-size", default=100, help="Batch size for processing") -@click.option("--limit", default=0, help="Limit number of codes to process (0 = all)") -def main(db_path: str, batch_size: int, limit: int): - """Generate embeddings for ICD-10 codes (any variant: CM, AM, GM, etc.).""" - - print(f"Loading MedEmbed model: {EMBEDDING_MODEL}") - print("This may take a minute on first run (downloads ~100MB model)...") - - from sentence_transformers import SentenceTransformer - model = SentenceTransformer(EMBEDDING_MODEL) - print(f"Model loaded! Embedding dimensions: {EMBEDDING_DIMENSIONS}") - - conn = sqlite3.connect(db_path) - - # Get codes needing embeddings - codes = get_codes_without_embeddings(conn, limit) - total = len(codes) - - if total == 0: - print("All codes already have embeddings!") - return - - print(f"Generating embeddings for {total} codes...") - print(f"Batch size: {batch_size}") - print("-" * 60) - - processed = 0 - for i in range(0, total, batch_size): - batch = codes[i:i + batch_size] - - # Create texts for batch - include hierarchy + all relevant fields (excluding exclusions) - texts = [ - create_embedding_text( - code, short_desc, long_desc, incl, code_also, code_first, synonyms, - cat_code or "", cat_title or "", block_code or "", block_title or "", - chap_num or "", chap_title or "" - ) - for _, code, short_desc, long_desc, incl, code_also, code_first, synonyms, - cat_code, cat_title, block_code, block_title, chap_num, chap_title in batch - ] - - # Generate embeddings in batch - embeddings = model.encode(texts, show_progress_bar=False) - - # Insert into database - for j, (code_id, code, *_) in enumerate(batch): - embedding_list = embeddings[j].tolist() - insert_embedding(conn, code_id, embedding_list, EMBEDDING_MODEL) - - conn.commit() - processed += len(batch) - - pct = (processed / total) * 100 - print(f"Progress: {processed}/{total} ({pct:.1f}%) - Last code: {batch[-1][1]}") - - print("-" * 60) - print(f"DONE! Generated {processed} embeddings.") - - # Verify - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM icd10_code_embedding") - total_embeddings = cursor.fetchone()[0] - print(f"Total embeddings in database: {total_embeddings}") - - conn.close() - - -if __name__ == "__main__": - main() diff --git a/Samples/ICD10/scripts/CreateDb/generate_sample_data.py b/Samples/ICD10/scripts/CreateDb/generate_sample_data.py deleted file mode 100644 index 5b45b8ae..00000000 --- a/Samples/ICD10/scripts/CreateDb/generate_sample_data.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate Sample ICD-10-AM Data for Testing - -Creates a sample dataset for testing the ICD-10-AM microservice. -For production use, obtain licensed data from IHACPA: -https://www.ihacpa.gov.au/resources/icd-10-amachiacs-thirteenth-edition - -Note: ICD-10-AM is copyrighted by IHACPA and the World Health Organization. -This sample data is for testing purposes only and does not represent -the complete ICD-10-AM classification. -""" - -import json -import sqlite3 -import uuid -from datetime import datetime -from pathlib import Path - - -def generate_uuid() -> str: - return str(uuid.uuid4()) - - -def get_timestamp() -> str: - return datetime.utcnow().isoformat() + "Z" - - -# Sample ICD-10-AM Chapters (subset for testing) -CHAPTERS = [ - ("I", "Certain infectious and parasitic diseases", "A00", "B99"), - ("II", "Neoplasms", "C00", "D48"), - ("IV", "Endocrine, nutritional and metabolic diseases", "E00", "E90"), - ("V", "Mental and behavioural disorders", "F00", "F99"), - ("VI", "Diseases of the nervous system", "G00", "G99"), - ("IX", "Diseases of the circulatory system", "I00", "I99"), - ("X", "Diseases of the respiratory system", "J00", "J99"), - ("XI", "Diseases of the digestive system", "K00", "K93"), - ("XIII", "Diseases of the musculoskeletal system and connective tissue", "M00", "M99"), - ("XIV", "Diseases of the genitourinary system", "N00", "N99"), - ( - "XVIII", - "Symptoms, signs and abnormal clinical and laboratory findings", - "R00", - "R99", - ), - ( - "XIX", - "Injury, poisoning and certain other consequences of external causes", - "S00", - "T98", - ), -] - -# Sample ICD-10-AM Codes (common diagnoses for testing) -SAMPLE_CODES = [ - # Infectious diseases - ("A00.0", "Cholera due to Vibrio cholerae 01, biovar cholerae", "I", "A00-A09"), - ("A00.1", "Cholera due to Vibrio cholerae 01, biovar eltor", "I", "A00-A09"), - ("A09.0", "Other and unspecified gastroenteritis and colitis of infectious origin", "I", "A00-A09"), - # Diabetes - ("E10.9", "Type 1 diabetes mellitus without complications", "IV", "E10-E14"), - ("E11.9", "Type 2 diabetes mellitus without complications", "IV", "E10-E14"), - ("E11.65", "Type 2 diabetes mellitus with hyperglycaemia", "IV", "E10-E14"), - # Mental health - ("F32.0", "Mild depressive episode", "V", "F30-F39"), - ("F32.1", "Moderate depressive episode", "V", "F30-F39"), - ("F41.0", "Panic disorder [episodic paroxysmal anxiety]", "V", "F40-F48"), - ("F41.1", "Generalised anxiety disorder", "V", "F40-F48"), - # Circulatory - ("I10", "Essential (primary) hypertension", "IX", "I10-I15"), - ("I20.0", "Unstable angina", "IX", "I20-I25"), - ("I21.0", "Acute transmural myocardial infarction of anterior wall", "IX", "I20-I25"), - ("I21.4", "Acute subendocardial myocardial infarction", "IX", "I20-I25"), - ("I25.10", "Atherosclerotic heart disease", "IX", "I20-I25"), - ("I48.0", "Paroxysmal atrial fibrillation", "IX", "I44-I49"), - ("I50.0", "Congestive heart failure", "IX", "I50"), - # Respiratory - ("J06.9", "Acute upper respiratory infection, unspecified", "X", "J00-J06"), - ("J18.9", "Pneumonia, unspecified", "X", "J12-J18"), - ("J44.1", "Chronic obstructive pulmonary disease with acute exacerbation", "X", "J44"), - ("J45.0", "Predominantly allergic asthma", "X", "J45-J46"), - # Digestive - ("K21.0", "Gastro-oesophageal reflux disease with oesophagitis", "XI", "K20-K31"), - ("K29.7", "Gastritis, unspecified", "XI", "K20-K31"), - ("K80.20", "Calculus of gallbladder without cholecystitis", "XI", "K80-K87"), - # Musculoskeletal - ("M54.5", "Low back pain", "XIII", "M54"), - ("M79.3", "Panniculitis, unspecified", "XIII", "M79"), - # Symptoms - ("R07.4", "Chest pain, unspecified", "XVIII", "R00-R09"), - ("R06.0", "Dyspnoea", "XVIII", "R00-R09"), - ("R10.4", "Other and unspecified abdominal pain", "XVIII", "R10-R19"), - ("R50.9", "Fever, unspecified", "XVIII", "R50-R69"), - ("R51", "Headache", "XVIII", "R50-R69"), - # Injuries - ("S72.00", "Fracture of neck of femur, closed", "XIX", "S70-S79"), - ("S82.0", "Fracture of patella", "XIX", "S80-S89"), -] - -# Sample ACHI Procedures -SAMPLE_ACHI = [ - ("38497-00", "Coronary angiography", "1820", "Procedures on heart"), - ("38500-00", "Percutaneous transluminal coronary angioplasty", "1820", "Procedures on heart"), - ("38503-00", "Percutaneous insertion of coronary artery stent", "1820", "Procedures on heart"), - ("90661-00", "Appendicectomy", "0926", "Procedures on appendix"), - ("30571-00", "Cholecystectomy", "0965", "Procedures on gallbladder and biliary tract"), - ("30575-00", "Laparoscopic cholecystectomy", "0965", "Procedures on gallbladder and biliary tract"), - ("48900-00", "Total hip replacement", "1489", "Procedures on hip joint"), - ("49318-00", "Total knee replacement", "1518", "Procedures on knee joint"), - ("41764-00", "Insertion of permanent pacemaker", "0668", "Procedures on heart"), - ("35503-00", "Colonoscopy", "0905", "Procedures on intestine"), - ("30473-00", "Upper gastrointestinal endoscopy", "0874", "Procedures on oesophagus"), - ("45564-00", "Cataract extraction with lens implantation", "0195", "Procedures on lens"), -] - - -def create_sample_database(db_path: str) -> None: - """Create a sample SQLite database with ICD-10-AM data.""" - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Create tables - cursor.executescript(""" - CREATE TABLE IF NOT EXISTS icd10_chapter ( - Id TEXT PRIMARY KEY, - ChapterNumber TEXT UNIQUE, - Title TEXT, - CodeRangeStart TEXT, - CodeRangeEnd TEXT, - LastUpdated TEXT, - VersionId INTEGER DEFAULT 1 - ); - - CREATE TABLE IF NOT EXISTS icd10_block ( - Id TEXT PRIMARY KEY, - ChapterId TEXT, - BlockCode TEXT UNIQUE, - Title TEXT, - CodeRangeStart TEXT, - CodeRangeEnd TEXT, - LastUpdated TEXT, - VersionId INTEGER DEFAULT 1, - FOREIGN KEY (ChapterId) REFERENCES icd10_chapter(Id) - ); - - CREATE TABLE IF NOT EXISTS icd10_category ( - Id TEXT PRIMARY KEY, - BlockId TEXT, - CategoryCode TEXT UNIQUE, - Title TEXT, - LastUpdated TEXT, - VersionId INTEGER DEFAULT 1, - FOREIGN KEY (BlockId) REFERENCES icd10_block(Id) - ); - - CREATE TABLE IF NOT EXISTS icd10_code ( - Id TEXT PRIMARY KEY, - CategoryId TEXT, - Code TEXT UNIQUE, - ShortDescription TEXT, - LongDescription TEXT, - InclusionTerms TEXT, - ExclusionTerms TEXT, - CodeAlso TEXT, - CodeFirst TEXT, - Synonyms TEXT, - Billable INTEGER DEFAULT 1, - EffectiveFrom TEXT, - EffectiveTo TEXT, - Edition TEXT, - LastUpdated TEXT, - VersionId INTEGER DEFAULT 1, - FOREIGN KEY (CategoryId) REFERENCES icd10_category(Id) - ); - - CREATE TABLE IF NOT EXISTS icd10_code_embedding ( - Id TEXT PRIMARY KEY, - CodeId TEXT UNIQUE, - Embedding TEXT, - EmbeddingModel TEXT DEFAULT 'MedEmbed-Small-v0.1', - LastUpdated TEXT, - FOREIGN KEY (CodeId) REFERENCES icd10_code(Id) - ); - - CREATE TABLE IF NOT EXISTS achi_block ( - Id TEXT PRIMARY KEY, - BlockNumber TEXT UNIQUE, - Title TEXT, - CodeRangeStart TEXT, - CodeRangeEnd TEXT, - LastUpdated TEXT, - VersionId INTEGER DEFAULT 1 - ); - - CREATE TABLE IF NOT EXISTS achi_code ( - Id TEXT PRIMARY KEY, - BlockId TEXT, - Code TEXT UNIQUE, - ShortDescription TEXT, - LongDescription TEXT, - Billable INTEGER DEFAULT 1, - EffectiveFrom TEXT, - EffectiveTo TEXT, - Edition TEXT, - LastUpdated TEXT, - VersionId INTEGER DEFAULT 1, - FOREIGN KEY (BlockId) REFERENCES achi_block(Id) - ); - - CREATE TABLE IF NOT EXISTS achi_code_embedding ( - Id TEXT PRIMARY KEY, - CodeId TEXT UNIQUE, - Embedding TEXT, - EmbeddingModel TEXT DEFAULT 'MedEmbed-Small-v0.1', - LastUpdated TEXT, - FOREIGN KEY (CodeId) REFERENCES achi_code(Id) - ); - - CREATE TABLE IF NOT EXISTS user_search_history ( - Id TEXT PRIMARY KEY, - UserId TEXT, - Query TEXT, - SelectedCode TEXT, - Timestamp TEXT - ); - """) - - # Insert chapters - chapter_ids = {} - for chapter_num, title, start, end in CHAPTERS: - chapter_id = generate_uuid() - chapter_ids[chapter_num] = chapter_id - cursor.execute( - """ - INSERT INTO icd10_chapter (Id, ChapterNumber, Title, CodeRangeStart, CodeRangeEnd, LastUpdated) - VALUES (?, ?, ?, ?, ?, ?) - """, - (chapter_id, chapter_num, title, start, end, get_timestamp()), - ) - - # Insert codes with blocks and categories - block_ids = {} - category_ids = {} - - for code, description, chapter_num, block_code in SAMPLE_CODES: - chapter_id = chapter_ids.get(chapter_num) - if not chapter_id: - continue - - # Create block if not exists - if block_code not in block_ids: - block_id = generate_uuid() - block_ids[block_code] = block_id - cursor.execute( - """ - INSERT OR IGNORE INTO icd10_block (Id, ChapterId, BlockCode, Title, CodeRangeStart, CodeRangeEnd, LastUpdated) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (block_id, chapter_id, block_code, f"Block {block_code}", block_code.split("-")[0], block_code.split("-")[-1] if "-" in block_code else block_code, get_timestamp()), - ) - - # Create category (3-char code) - category_code = code[:3] - if category_code not in category_ids: - category_id = generate_uuid() - category_ids[category_code] = category_id - cursor.execute( - """ - INSERT OR IGNORE INTO icd10_category (Id, BlockId, CategoryCode, Title, LastUpdated) - VALUES (?, ?, ?, ?, ?) - """, - (category_id, block_ids[block_code], category_code, f"Category {category_code}", get_timestamp()), - ) - - # Insert code - code_id = generate_uuid() - cursor.execute( - """ - INSERT INTO icd10_code (Id, CategoryId, Code, ShortDescription, LongDescription, InclusionTerms, ExclusionTerms, CodeAlso, CodeFirst, Synonyms, Billable, EffectiveFrom, EffectiveTo, Edition, LastUpdated) - VALUES (?, ?, ?, ?, ?, '', '', '', '', '', 1, '2025-07-01', '', '13', ?) - """, - (code_id, category_ids[category_code], code, description, description, get_timestamp()), - ) - - # Insert ACHI data - achi_block_ids = {} - for code, description, block_num, block_title in SAMPLE_ACHI: - if block_num not in achi_block_ids: - block_id = generate_uuid() - achi_block_ids[block_num] = block_id - cursor.execute( - """ - INSERT OR IGNORE INTO achi_block (Id, BlockNumber, Title, CodeRangeStart, CodeRangeEnd, LastUpdated) - VALUES (?, ?, ?, ?, ?, ?) - """, - (block_id, block_num, block_title, code, code, get_timestamp()), - ) - - code_id = generate_uuid() - cursor.execute( - """ - INSERT INTO achi_code (Id, BlockId, Code, ShortDescription, LongDescription, Billable, EffectiveFrom, EffectiveTo, Edition, LastUpdated) - VALUES (?, ?, ?, ?, ?, 1, '2025-07-01', '', '13', ?) - """, - (code_id, achi_block_ids[block_num], code, description, description, get_timestamp()), - ) - - conn.commit() - conn.close() - print(f"Sample database created: {db_path}") - print(f" - {len(CHAPTERS)} chapters") - print(f" - {len(SAMPLE_CODES)} ICD-10-AM codes") - print(f" - {len(SAMPLE_ACHI)} ACHI procedures") - - -if __name__ == "__main__": - import sys - - output_path = sys.argv[1] if len(sys.argv) > 1 else "icd10_sample.db" - create_sample_database(output_path) diff --git a/Samples/ICD10/scripts/CreateDb/import.sh b/Samples/ICD10/scripts/CreateDb/import.sh deleted file mode 100755 index 387a95b8..00000000 --- a/Samples/ICD10/scripts/CreateDb/import.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -# Create and populate the ICD-10 database -# Usage: ./import.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" -REPO_ROOT="$(dirname "$(dirname "$PROJECT_DIR")")" -API_DIR="${PROJECT_DIR}/ICD10.Api" -DB_PATH="${API_DIR}/icd10.db" -SCHEMA_PATH="${API_DIR}/icd10-schema.yaml" -MIGRATION_CLI="${REPO_ROOT}/Migration/Migration.Cli" -VENV_DIR="${PROJECT_DIR}/.venv" - -echo "=== ICD-10 Database Setup ===" -echo "Database: $DB_PATH" -echo "" - -# 1. Delete existing database -echo "=== Step 1: Removing existing database ===" -if [ -f "$DB_PATH" ]; then - rm "$DB_PATH" - echo "Deleted: $DB_PATH" -else - echo "No existing database found" -fi - -# 2. Migrate database schema -echo "=== Step 2: Migrating database schema ===" -dotnet run --project "$MIGRATION_CLI" -- \ - --schema "$SCHEMA_PATH" \ - --output "$DB_PATH" \ - --provider sqlite - -# 3. Set up Python virtual environment and install dependencies -echo "" -echo "=== Step 3: Setting up Python environment ===" -if [ ! -d "$VENV_DIR" ]; then - echo "Creating virtual environment..." - python3 -m venv "$VENV_DIR" -fi -"$VENV_DIR/bin/pip" install -r "$SCRIPT_DIR/requirements.txt" - -# 4. Import ICD-10 codes -echo "" -echo "=== Step 4: Importing ICD-10 codes ===" -"$VENV_DIR/bin/python" "$SCRIPT_DIR/import_icd10cm.py" --db-path "$DB_PATH" - -# 5. Generate embeddings -echo "" -echo "=== Step 5: Generating embeddings ===" -echo "This takes 30-60 minutes for all 74,260 codes" -"$VENV_DIR/bin/python" "$SCRIPT_DIR/generate_embeddings.py" --db-path "$DB_PATH" - -echo "" -echo "=== Database setup complete ===" -echo "Database: $DB_PATH" diff --git a/Samples/ICD10/scripts/CreateDb/import_icd10cm.py b/Samples/ICD10/scripts/CreateDb/import_icd10cm.py deleted file mode 100644 index 2168a304..00000000 --- a/Samples/ICD10/scripts/CreateDb/import_icd10cm.py +++ /dev/null @@ -1,572 +0,0 @@ -#!/usr/bin/env python3 -""" -ICD-10-CM Import Script (US Clinical Modification) - -Imports ICD-10-CM diagnosis codes from CDC XML files (FREE, Public Domain). -Includes full clinical details: inclusions, exclusions, code first, code also, SYNONYMS. -Also imports the FULL HIERARCHY: chapters, blocks, and categories. - -Source: https://ftp.cdc.gov/pub/Health_Statistics/NCHS/Publications/ICD10CM/ - -Usage: - python import_icd10cm.py --db-path ./icd10cm.db -""" - -import logging -import sqlite3 -import uuid -import zipfile -import xml.etree.ElementTree as ET -from collections import defaultdict -from dataclasses import dataclass -from datetime import datetime -from io import BytesIO -from typing import Generator, Union - -import click -import requests - -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") -logger = logging.getLogger(__name__) - -CDC_XML_URL = "https://ftp.cdc.gov/pub/Health_Statistics/NCHS/Publications/ICD10CM/2025/icd10cm-table-index-2025.zip" -TABULAR_XML = "icd-10-cm-tabular-2025.xml" -INDEX_XML = "icd-10-cm-index-2025.xml" - -# ACHI data source - Australian Government (simplified sample for testing) -# Real ACHI data requires license from IHPA -ACHI_SAMPLE_BLOCKS = [ - ("achi-blk-1", "1820", "Coronary artery procedures", "38400-00", "38599-00"), - ("achi-blk-2", "1821", "Heart valve procedures", "38600-00", "38699-00"), - ("achi-blk-3", "1822", "Cardiac pacemaker procedures", "38700-00", "38799-00"), -] - -ACHI_SAMPLE_CODES = [ - ("achi-code-1", "achi-blk-1", "38497-00", "Coronary angiography", "Coronary angiography with contrast"), - ("achi-code-2", "achi-blk-1", "38500-00", "Coronary artery bypass", "Coronary artery bypass graft, single vessel"), - ("achi-code-3", "achi-blk-1", "38503-00", "Coronary artery bypass, multiple", "Coronary artery bypass graft, multiple vessels"), - ("achi-code-4", "achi-blk-2", "38600-00", "Aortic valve replacement", "Aortic valve replacement, open"), - ("achi-code-5", "achi-blk-2", "38612-00", "Mitral valve repair", "Mitral valve repair, open"), - ("achi-code-6", "achi-blk-3", "38700-00", "Pacemaker insertion", "Insertion of permanent pacemaker"), -] - - -@dataclass -class Chapter: - id: str - chapter_number: str - title: str - code_range_start: str - code_range_end: str - - -@dataclass -class Block: - id: str - chapter_id: str - block_code: str - title: str - code_range_start: str - code_range_end: str - - -@dataclass -class Category: - id: str - block_id: str - category_code: str - title: str - - -@dataclass -class Code: - id: str - category_id: str - code: str - short_description: str - long_description: str - inclusion_terms: str - exclusion_terms: str - code_also: str - code_first: str - synonyms: str - billable: bool - - -def generate_uuid() -> str: - return str(uuid.uuid4()) - - -def get_timestamp() -> str: - return datetime.utcnow().isoformat() + "Z" - - -def extract_notes(element: ET.Element, tag: str) -> list[str]: - """Extract all note texts from a specific child element.""" - notes = [] - child = element.find(tag) - if child is not None: - for note in child.findall("note"): - if note.text: - notes.append(note.text.strip()) - return notes - - -def extract_all_notes(element: ET.Element, tags: list[str]) -> str: - """Extract and combine notes from multiple tags.""" - all_notes = [] - for tag in tags: - all_notes.extend(extract_notes(element, tag)) - return "; ".join(all_notes) if all_notes else "" - - -def parse_index_for_synonyms(index_root: ET.Element) -> dict[str, list[str]]: - """Parse the Index XML to build code -> synonyms mapping.""" - logger.info("Parsing Index XML for synonyms...") - synonyms: dict[str, list[str]] = defaultdict(list) - - def process_term(term: ET.Element, prefix: str = "") -> None: - """Recursively process term elements.""" - title_elem = term.find("title") - code_elem = term.find("code") - - if title_elem is not None and title_elem.text: - title = title_elem.text.strip() - # Include any nemod (non-essential modifier) text - nemod = term.find(".//nemod") - if nemod is not None and nemod.text: - title = f"{title} {nemod.text.strip()}" - - full_title = f"{prefix} {title}".strip() if prefix else title - - if code_elem is not None and code_elem.text: - code = code_elem.text.strip().upper() - if full_title and len(full_title) > 2: - synonyms[code].append(full_title) - - # Process nested terms - for child_term in term.findall("term"): - child_title = "" - child_title_elem = term.find("title") - if child_title_elem is not None and child_title_elem.text: - child_title = child_title_elem.text.strip() - process_term(child_term, child_title) - - # Process all mainTerm elements - for letter in index_root.findall(".//letter"): - for main_term in letter.findall("mainTerm"): - process_term(main_term) - - logger.info(f"Found synonyms for {len(synonyms)} codes") - return dict(synonyms) - - -def roman_to_int(roman: str) -> int: - """Convert Roman numeral to integer.""" - values = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} - result = 0 - prev = 0 - for char in reversed(roman.upper()): - curr = values.get(char, 0) - if curr < prev: - result -= curr - else: - result += curr - prev = curr - return result - - -def parse_chapter_range(desc: str) -> tuple[str, str]: - """Extract code range from chapter description like '(A00-B99)'.""" - import re - match = re.search(r'\(([A-Z]\d+)-([A-Z]\d+)\)', desc) - if match: - return match.group(1), match.group(2) - return "", "" - - -def download_and_parse() -> tuple[list[Chapter], list[Block], list[Category], list[Code], dict[str, list[str]]]: - """Download CDC XML and parse all codes plus synonyms.""" - logger.info("Downloading ICD-10-CM XML from CDC...") - response = requests.get(CDC_XML_URL, timeout=120) - if response.status_code != 200: - raise Exception(f"CDC download failed: HTTP {response.status_code}") - logger.info("Downloaded CDC ICD-10-CM 2025 XML files") - - chapters = [] - blocks = [] - categories = [] - codes = [] - synonyms = {} - - # Track mappings for linking - chapter_map = {} # chapter_number -> chapter_id - block_map = {} # block_code -> block_id - category_map = {} # category_code -> category_id - - with zipfile.ZipFile(BytesIO(response.content)) as zf: - # Parse Tabular XML for codes - logger.info(f"Extracting {TABULAR_XML}...") - with zf.open(TABULAR_XML) as f: - tabular_tree = ET.parse(f) - - tabular_root = tabular_tree.getroot() - - for chapter_elem in tabular_root.findall(".//chapter"): - chapter_name_elem = chapter_elem.find("name") - chapter_desc_elem = chapter_elem.find("desc") - - if chapter_name_elem is None or chapter_name_elem.text is None: - continue - - chapter_number = chapter_name_elem.text.strip() - chapter_title = chapter_desc_elem.text.strip() if chapter_desc_elem is not None and chapter_desc_elem.text else "" - - # Parse code range from chapter description or sectionIndex - code_range_start, code_range_end = parse_chapter_range(chapter_title) - - # If not in title, try to get from first/last section - if not code_range_start: - sections = chapter_elem.findall(".//sectionRef") - if sections: - first_id = sections[0].get("id", "") - last_id = sections[-1].get("id", "") - if "-" in first_id: - code_range_start = first_id.split("-")[0] - if "-" in last_id: - code_range_end = last_id.split("-")[1] - - chapter_id = generate_uuid() - chapter_map[chapter_number] = chapter_id - - chapters.append(Chapter( - id=chapter_id, - chapter_number=chapter_number, - title=chapter_title, - code_range_start=code_range_start, - code_range_end=code_range_end, - )) - - logger.info(f"Processing Chapter {chapter_number}: {chapter_title[:50]}...") - - # Process sections (blocks) - for section_elem in chapter_elem.findall(".//section"): - section_id_attr = section_elem.get("id", "") - section_desc_elem = section_elem.find("desc") - - if not section_id_attr: - continue - - block_code = section_id_attr # e.g., "A00-A09" - block_title = section_desc_elem.text.strip() if section_desc_elem is not None and section_desc_elem.text else "" - - # Parse code range from block code - if "-" in block_code: - parts = block_code.split("-") - block_start, block_end = parts[0], parts[1] - else: - block_start, block_end = block_code, block_code - - block_id = generate_uuid() - block_map[block_code] = block_id - - blocks.append(Block( - id=block_id, - chapter_id=chapter_id, - block_code=block_code, - title=block_title, - code_range_start=block_start, - code_range_end=block_end, - )) - - # Process diag elements (categories and codes) - for diag in section_elem.findall("diag"): - parsed = list(parse_diag_with_hierarchy(diag, block_id, category_map)) - for item in parsed: - if isinstance(item, Category): - categories.append(item) - elif isinstance(item, Code): - codes.append(item) - - # Parse Index XML for synonyms - logger.info(f"Extracting {INDEX_XML}...") - with zf.open(INDEX_XML) as f: - index_tree = ET.parse(f) - - synonyms = parse_index_for_synonyms(index_tree.getroot()) - - return chapters, blocks, categories, codes, synonyms - - -def parse_diag_with_hierarchy( - diag: ET.Element, - block_id: str, - category_map: dict[str, str], - parent_category_id: str = "", - parent_includes: str = "", - parent_excludes: str = "", - depth: int = 0 -) -> Generator[Union[Category, Code], None, None]: - """Recursively parse a diag element, creating categories and codes.""" - name_elem = diag.find("name") - desc_elem = diag.find("desc") - - if name_elem is None or name_elem.text is None: - return - - code = name_elem.text.strip() - desc = desc_elem.text.strip() if desc_elem is not None and desc_elem.text else "" - - # Extract clinical details - inclusion = extract_all_notes(diag, ["inclusionTerm", "includes"]) - exclusion = extract_all_notes(diag, ["excludes1", "excludes2"]) - code_also = extract_all_notes(diag, ["codeAlso", "useAdditionalCode"]) - code_first = extract_all_notes(diag, ["codeFirst"]) - - # Inherit parent includes/excludes if this code has none - if not inclusion and parent_includes: - inclusion = parent_includes - if not exclusion and parent_excludes: - exclusion = parent_excludes - - # Check if this is a category (3-char code) or a full code - child_diags = [d for d in diag.findall("diag") if d.find("name") is not None] - is_category = len(code) == 3 and len(child_diags) > 0 - - current_category_id = parent_category_id - - if is_category: - # This is a category (e.g., A00, B01) - category_id = generate_uuid() - category_map[code] = category_id - current_category_id = category_id - - yield Category( - id=category_id, - block_id=block_id, - category_code=code, - title=desc, - ) - - # If no children, this is a billable code - if len(child_diags) == 0: - # Determine category_id - for 3-char codes without children, they are their own category - if len(code) == 3: - category_id = generate_uuid() - category_map[code] = category_id - current_category_id = category_id - - yield Category( - id=category_id, - block_id=block_id, - category_code=code, - title=desc, - ) - elif not current_category_id and len(code) >= 3: - # Try to find category from code prefix - cat_code = code[:3] - current_category_id = category_map.get(cat_code, "") - - yield Code( - id=generate_uuid(), - category_id=current_category_id, - code=code, - short_description=desc[:100] if desc else "", - long_description=desc, - inclusion_terms=inclusion, - exclusion_terms=exclusion, - code_also=code_also, - code_first=code_first, - synonyms="", # Will be filled later - billable=True, - ) - else: - # Has children - if it's not a 3-char category, still create a code entry (non-billable) - if not is_category: - if not current_category_id and len(code) >= 3: - cat_code = code[:3] - current_category_id = category_map.get(cat_code, "") - - yield Code( - id=generate_uuid(), - category_id=current_category_id, - code=code, - short_description=desc[:100] if desc else "", - long_description=desc, - inclusion_terms=inclusion, - exclusion_terms=exclusion, - code_also=code_also, - code_first=code_first, - synonyms="", - billable=False, - ) - - # Recursively process child diag elements - for child_diag in child_diags: - yield from parse_diag_with_hierarchy( - child_diag, block_id, category_map, current_category_id, inclusion, exclusion, depth + 1 - ) - - -class SQLiteImporter: - """Imports into fresh Migration-created schema (import.sh deletes DB first).""" - - def __init__(self, db_path: str): - self.conn = sqlite3.connect(db_path) - - def import_chapters(self, chapters: list[Chapter]): - """Import chapters into icd10_chapter table.""" - logger.info(f"Importing {len(chapters)} chapters into icd10_chapter") - - for c in chapters: - self.conn.execute( - """INSERT INTO icd10_chapter - (Id, ChapterNumber, Title, CodeRangeStart, CodeRangeEnd, LastUpdated, VersionId) - VALUES (?,?,?,?,?,?,?)""", - (c.id, c.chapter_number, c.title, c.code_range_start, c.code_range_end, get_timestamp(), 1), - ) - self.conn.commit() - - def import_blocks(self, blocks: list[Block]): - """Import blocks into icd10_block table.""" - logger.info(f"Importing {len(blocks)} blocks into icd10_block") - - for b in blocks: - self.conn.execute( - """INSERT INTO icd10_block - (Id, ChapterId, BlockCode, Title, CodeRangeStart, CodeRangeEnd, LastUpdated, VersionId) - VALUES (?,?,?,?,?,?,?,?)""", - (b.id, b.chapter_id, b.block_code, b.title, b.code_range_start, b.code_range_end, get_timestamp(), 1), - ) - self.conn.commit() - - def import_categories(self, categories: list[Category]): - """Import categories into icd10_category table.""" - logger.info(f"Importing {len(categories)} categories into icd10_category") - self.conn.execute("DELETE FROM icd10_category") - - for c in categories: - self.conn.execute( - """INSERT INTO icd10_category - (Id, BlockId, CategoryCode, Title, LastUpdated, VersionId) - VALUES (?,?,?,?,?,?)""", - (c.id, c.block_id, c.category_code, c.title, get_timestamp(), 1), - ) - self.conn.commit() - - def import_codes(self, codes: list[Code], synonyms: dict[str, list[str]]): - """Import codes into icd10_code table with synonyms.""" - logger.info(f"Importing {len(codes)} codes into icd10_code") - - for c in codes: - # Get synonyms for this code - code_synonyms = synonyms.get(c.code.upper(), []) - # Deduplicate and remove the description itself - unique_synonyms = list(set( - s for s in code_synonyms - if s.lower() != c.short_description.lower() - and s.lower() != c.long_description.lower() - )) - synonyms_str = "; ".join(unique_synonyms[:20]) # Limit to 20 synonyms - - self.conn.execute( - """INSERT INTO icd10_code - (Id, CategoryId, Code, ShortDescription, LongDescription, - InclusionTerms, ExclusionTerms, CodeAlso, CodeFirst, Synonyms, - Billable, EffectiveFrom, EffectiveTo, Edition, LastUpdated, VersionId) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - c.id, - c.category_id, - c.code, - c.short_description, - c.long_description, - c.inclusion_terms, - c.exclusion_terms, - c.code_also, - c.code_first, - synonyms_str, - 1 if c.billable else 0, - "2024-10-01", - "", - "2025", - get_timestamp(), - 1, - ), - ) - self.conn.commit() - - def import_achi_sample_data(self): - """Import sample ACHI data for testing.""" - logger.info("Importing sample ACHI data for testing...") - - # Import blocks - for block_id, block_num, title, start, end in ACHI_SAMPLE_BLOCKS: - self.conn.execute( - """INSERT INTO achi_block - (Id, BlockNumber, Title, CodeRangeStart, CodeRangeEnd, LastUpdated, VersionId) - VALUES (?,?,?,?,?,?,?)""", - (block_id, block_num, title, start, end, get_timestamp(), 1), - ) - - # Import codes - for code_id, block_id, code, short_desc, long_desc in ACHI_SAMPLE_CODES: - self.conn.execute( - """INSERT INTO achi_code - (Id, BlockId, Code, ShortDescription, LongDescription, Billable, - EffectiveFrom, EffectiveTo, Edition, LastUpdated, VersionId) - VALUES (?,?,?,?,?,?,?,?,?,?,?)""", - (code_id, block_id, code, short_desc, long_desc, 1, "2024-07-01", "", "13", get_timestamp(), 1), - ) - - self.conn.commit() - logger.info(f"Imported {len(ACHI_SAMPLE_BLOCKS)} ACHI blocks and {len(ACHI_SAMPLE_CODES)} ACHI codes") - - def close(self): - self.conn.close() - - -@click.command() -@click.option("--db-path", default="icd10cm.db") -def main(db_path: str): - logger.info("=" * 60) - logger.info("ICD-10-CM Import (CDC XML - Full Hierarchy + Synonyms)") - logger.info("=" * 60) - - chapters, blocks, categories, codes, synonyms = download_and_parse() - - logger.info(f"Total: {len(chapters)} chapters parsed") - logger.info(f"Total: {len(blocks)} blocks parsed") - logger.info(f"Total: {len(categories)} categories parsed") - logger.info(f"Total: {len(codes)} codes parsed from Tabular XML") - logger.info(f"Total: {len(synonyms)} codes have synonyms from Index XML") - - # Stats - with_inclusions = sum(1 for c in codes if c.inclusion_terms) - with_exclusions = sum(1 for c in codes if c.exclusion_terms) - with_code_also = sum(1 for c in codes if c.code_also) - with_code_first = sum(1 for c in codes if c.code_first) - billable = sum(1 for c in codes if c.billable) - codes_with_synonyms = sum(1 for c in codes if c.code.upper() in synonyms) - - logger.info(f" With inclusions: {with_inclusions}") - logger.info(f" With exclusions: {with_exclusions}") - logger.info(f" With code also: {with_code_also}") - logger.info(f" With code first: {with_code_first}") - logger.info(f" With synonyms: {codes_with_synonyms}") - logger.info(f" Billable codes: {billable}") - - importer = SQLiteImporter(db_path) - try: - importer.import_chapters(chapters) - importer.import_blocks(blocks) - importer.import_categories(categories) - importer.import_codes(codes, synonyms) - importer.import_achi_sample_data() - logger.info(f"SUCCESS! Full ICD-10-CM hierarchy imported to {db_path}") - finally: - importer.close() - - -if __name__ == "__main__": - main() diff --git a/Samples/ICD10/scripts/CreateDb/import_postgres.py b/Samples/ICD10/scripts/CreateDb/import_postgres.py deleted file mode 100644 index d697ba25..00000000 --- a/Samples/ICD10/scripts/CreateDb/import_postgres.py +++ /dev/null @@ -1,608 +0,0 @@ -#!/usr/bin/env python3 -""" -ICD-10-CM Import Script for PostgreSQL (Docker) - -Imports ICD-10-CM diagnosis codes from CDC XML files into PostgreSQL. -""" - -import json -import logging -import os -import uuid -import zipfile -import xml.etree.ElementTree as ET -from collections import defaultdict -from dataclasses import dataclass -from datetime import datetime -from io import BytesIO -from typing import Generator, Union - -import click -import psycopg2 -import requests - -EMBEDDING_SERVICE_URL = os.environ.get("EMBEDDING_SERVICE_URL", "http://localhost:8000") - -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") -logger = logging.getLogger(__name__) - -CDC_XML_URL = "https://ftp.cdc.gov/pub/Health_Statistics/NCHS/Publications/ICD10CM/2025/icd10cm-table-index-2025.zip" -TABULAR_XML = "icd-10-cm-tabular-2025.xml" -INDEX_XML = "icd-10-cm-index-2025.xml" - -ACHI_SAMPLE_BLOCKS = [ - ("achi-blk-1", "1820", "Coronary artery procedures", "38400-00", "38599-00"), - ("achi-blk-2", "1821", "Heart valve procedures", "38600-00", "38699-00"), - ("achi-blk-3", "1822", "Cardiac pacemaker procedures", "38700-00", "38799-00"), -] - -ACHI_SAMPLE_CODES = [ - ("achi-code-1", "achi-blk-1", "38497-00", "Coronary angiography", "Coronary angiography with contrast"), - ("achi-code-2", "achi-blk-1", "38500-00", "Coronary artery bypass", "Coronary artery bypass graft, single vessel"), - ("achi-code-3", "achi-blk-1", "38503-00", "Coronary artery bypass, multiple", "Coronary artery bypass graft, multiple vessels"), - ("achi-code-4", "achi-blk-2", "38600-00", "Aortic valve replacement", "Aortic valve replacement, open"), - ("achi-code-5", "achi-blk-2", "38612-00", "Mitral valve repair", "Mitral valve repair, open"), - ("achi-code-6", "achi-blk-3", "38700-00", "Pacemaker insertion", "Insertion of permanent pacemaker"), -] - - -@dataclass -class Chapter: - id: str - chapter_number: str - title: str - code_range_start: str - code_range_end: str - - -@dataclass -class Block: - id: str - chapter_id: str - block_code: str - title: str - code_range_start: str - code_range_end: str - - -@dataclass -class Category: - id: str - block_id: str - category_code: str - title: str - - -@dataclass -class Code: - id: str - category_id: str - code: str - short_description: str - long_description: str - inclusion_terms: str - exclusion_terms: str - code_also: str - code_first: str - synonyms: str - billable: bool - - -def generate_uuid() -> str: - return str(uuid.uuid4()) - - -def get_timestamp() -> str: - return datetime.utcnow().isoformat() + "Z" - - -def extract_notes(element: ET.Element, tag: str) -> list[str]: - notes = [] - child = element.find(tag) - if child is not None: - for note in child.findall("note"): - if note.text: - notes.append(note.text.strip()) - return notes - - -def extract_all_notes(element: ET.Element, tags: list[str]) -> str: - all_notes = [] - for tag in tags: - all_notes.extend(extract_notes(element, tag)) - return "; ".join(all_notes) if all_notes else "" - - -def parse_index_for_synonyms(index_root: ET.Element) -> dict[str, list[str]]: - logger.info("Parsing Index XML for synonyms...") - synonyms: dict[str, list[str]] = defaultdict(list) - - def process_term(term: ET.Element, prefix: str = "") -> None: - title_elem = term.find("title") - code_elem = term.find("code") - - if title_elem is not None and title_elem.text: - title = title_elem.text.strip() - nemod = term.find(".//nemod") - if nemod is not None and nemod.text: - title = f"{title} {nemod.text.strip()}" - - full_title = f"{prefix} {title}".strip() if prefix else title - - if code_elem is not None and code_elem.text: - code = code_elem.text.strip().upper() - if full_title and len(full_title) > 2: - synonyms[code].append(full_title) - - for child_term in term.findall("term"): - child_title = "" - child_title_elem = term.find("title") - if child_title_elem is not None and child_title_elem.text: - child_title = child_title_elem.text.strip() - process_term(child_term, child_title) - - for letter in index_root.findall(".//letter"): - for main_term in letter.findall("mainTerm"): - process_term(main_term) - - logger.info(f"Found synonyms for {len(synonyms)} codes") - return dict(synonyms) - - -def parse_chapter_range(desc: str) -> tuple[str, str]: - import re - match = re.search(r'\(([A-Z]\d+)-([A-Z]\d+)\)', desc) - if match: - return match.group(1), match.group(2) - return "", "" - - -def download_and_parse() -> tuple[list[Chapter], list[Block], list[Category], list[Code], dict[str, list[str]]]: - logger.info("Downloading ICD-10-CM XML from CDC...") - response = requests.get(CDC_XML_URL, timeout=120) - if response.status_code != 200: - raise Exception(f"CDC download failed: HTTP {response.status_code}") - logger.info("Downloaded CDC ICD-10-CM 2025 XML files") - - chapters = [] - blocks = [] - categories = [] - codes = [] - synonyms = {} - chapter_map = {} - block_map = {} - category_map = {} - - with zipfile.ZipFile(BytesIO(response.content)) as zf: - logger.info(f"Extracting {TABULAR_XML}...") - with zf.open(TABULAR_XML) as f: - tabular_tree = ET.parse(f) - - tabular_root = tabular_tree.getroot() - - for chapter_elem in tabular_root.findall(".//chapter"): - chapter_name_elem = chapter_elem.find("name") - chapter_desc_elem = chapter_elem.find("desc") - - if chapter_name_elem is None or chapter_name_elem.text is None: - continue - - chapter_number = chapter_name_elem.text.strip() - chapter_title = chapter_desc_elem.text.strip() if chapter_desc_elem is not None and chapter_desc_elem.text else "" - code_range_start, code_range_end = parse_chapter_range(chapter_title) - - if not code_range_start: - sections = chapter_elem.findall(".//sectionRef") - if sections: - first_id = sections[0].get("id", "") - last_id = sections[-1].get("id", "") - if "-" in first_id: - code_range_start = first_id.split("-")[0] - if "-" in last_id: - code_range_end = last_id.split("-")[1] - - chapter_id = generate_uuid() - chapter_map[chapter_number] = chapter_id - - chapters.append(Chapter( - id=chapter_id, - chapter_number=chapter_number, - title=chapter_title, - code_range_start=code_range_start, - code_range_end=code_range_end, - )) - - logger.info(f"Processing Chapter {chapter_number}: {chapter_title[:50]}...") - - for section_elem in chapter_elem.findall(".//section"): - section_id_attr = section_elem.get("id", "") - section_desc_elem = section_elem.find("desc") - - if not section_id_attr: - continue - - block_code = section_id_attr - block_title = section_desc_elem.text.strip() if section_desc_elem is not None and section_desc_elem.text else "" - - if "-" in block_code: - parts = block_code.split("-") - block_start, block_end = parts[0], parts[1] - else: - block_start, block_end = block_code, block_code - - block_id = generate_uuid() - block_map[block_code] = block_id - - blocks.append(Block( - id=block_id, - chapter_id=chapter_id, - block_code=block_code, - title=block_title, - code_range_start=block_start, - code_range_end=block_end, - )) - - for diag in section_elem.findall("diag"): - parsed = list(parse_diag_with_hierarchy(diag, block_id, category_map)) - for item in parsed: - if isinstance(item, Category): - categories.append(item) - elif isinstance(item, Code): - codes.append(item) - - logger.info(f"Extracting {INDEX_XML}...") - with zf.open(INDEX_XML) as f: - index_tree = ET.parse(f) - - synonyms = parse_index_for_synonyms(index_tree.getroot()) - - return chapters, blocks, categories, codes, synonyms - - -def parse_diag_with_hierarchy( - diag: ET.Element, - block_id: str, - category_map: dict[str, str], - parent_category_id: str = "", - parent_includes: str = "", - parent_excludes: str = "", - depth: int = 0 -) -> Generator[Union[Category, Code], None, None]: - name_elem = diag.find("name") - desc_elem = diag.find("desc") - - if name_elem is None or name_elem.text is None: - return - - code = name_elem.text.strip() - desc = desc_elem.text.strip() if desc_elem is not None and desc_elem.text else "" - - inclusion = extract_all_notes(diag, ["inclusionTerm", "includes"]) - exclusion = extract_all_notes(diag, ["excludes1", "excludes2"]) - code_also = extract_all_notes(diag, ["codeAlso", "useAdditionalCode"]) - code_first = extract_all_notes(diag, ["codeFirst"]) - - if not inclusion and parent_includes: - inclusion = parent_includes - if not exclusion and parent_excludes: - exclusion = parent_excludes - - child_diags = [d for d in diag.findall("diag") if d.find("name") is not None] - is_category = len(code) == 3 and len(child_diags) > 0 - - current_category_id = parent_category_id - - if is_category: - category_id = generate_uuid() - category_map[code] = category_id - current_category_id = category_id - - yield Category( - id=category_id, - block_id=block_id, - category_code=code, - title=desc, - ) - - if len(child_diags) == 0: - if len(code) == 3: - category_id = generate_uuid() - category_map[code] = category_id - current_category_id = category_id - - yield Category( - id=category_id, - block_id=block_id, - category_code=code, - title=desc, - ) - elif not current_category_id and len(code) >= 3: - cat_code = code[:3] - current_category_id = category_map.get(cat_code, "") - - yield Code( - id=generate_uuid(), - category_id=current_category_id, - code=code, - short_description=desc[:100] if desc else "", - long_description=desc, - inclusion_terms=inclusion, - exclusion_terms=exclusion, - code_also=code_also, - code_first=code_first, - synonyms="", - billable=True, - ) - else: - if not is_category: - if not current_category_id and len(code) >= 3: - cat_code = code[:3] - current_category_id = category_map.get(cat_code, "") - - yield Code( - id=generate_uuid(), - category_id=current_category_id, - code=code, - short_description=desc[:100] if desc else "", - long_description=desc, - inclusion_terms=inclusion, - exclusion_terms=exclusion, - code_also=code_also, - code_first=code_first, - synonyms="", - billable=False, - ) - - for child_diag in child_diags: - yield from parse_diag_with_hierarchy( - child_diag, block_id, category_map, current_category_id, inclusion, exclusion, depth + 1 - ) - - -def normalize_connection_string(conn_str: str) -> str: - """Convert ASP.NET style connection string to psycopg2 format (lowercase keys).""" - # psycopg2 requires lowercase keys: host, database, user, password, port - key_map = { - "host": "host", - "server": "host", - "database": "dbname", - "initial catalog": "dbname", - "user": "user", - "username": "user", - "user id": "user", - "password": "password", - "port": "port", - } - - parts = [] - for part in conn_str.split(";"): - if "=" not in part: - continue - key, value = part.split("=", 1) - key_lower = key.strip().lower() - if key_lower in key_map: - parts.append(f"{key_map[key_lower]}={value.strip()}") - - return " ".join(parts) - - -class PostgresImporter: - """Imports ICD-10 data into PostgreSQL.""" - - def __init__(self, connection_string: str): - normalized = normalize_connection_string(connection_string) - logger.info(f"Connecting to PostgreSQL...") - self.conn = psycopg2.connect(normalized) - self.conn.autocommit = False - - def import_chapters(self, chapters: list[Chapter]): - logger.info(f"Importing {len(chapters)} chapters") - cur = self.conn.cursor() - for c in chapters: - cur.execute( - """INSERT INTO icd10_chapter - (id, chapternumber, title, coderangestart, coderangeend, lastupdated, versionid) - VALUES (%s,%s,%s,%s,%s,%s,%s) - ON CONFLICT (id) DO NOTHING""", - (c.id, c.chapter_number, c.title, c.code_range_start, c.code_range_end, get_timestamp(), 1), - ) - self.conn.commit() - - def import_blocks(self, blocks: list[Block]): - logger.info(f"Importing {len(blocks)} blocks") - cur = self.conn.cursor() - for b in blocks: - cur.execute( - """INSERT INTO icd10_block - (id, chapterid, blockcode, title, coderangestart, coderangeend, lastupdated, versionid) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s) - ON CONFLICT (id) DO NOTHING""", - (b.id, b.chapter_id, b.block_code, b.title, b.code_range_start, b.code_range_end, get_timestamp(), 1), - ) - self.conn.commit() - - def import_categories(self, categories: list[Category]): - logger.info(f"Importing {len(categories)} categories") - cur = self.conn.cursor() - for c in categories: - cur.execute( - """INSERT INTO icd10_category - (id, blockid, categorycode, title, lastupdated, versionid) - VALUES (%s,%s,%s,%s,%s,%s) - ON CONFLICT (id) DO NOTHING""", - (c.id, c.block_id, c.category_code, c.title, get_timestamp(), 1), - ) - self.conn.commit() - - def import_codes(self, codes: list[Code], synonyms: dict[str, list[str]]): - logger.info(f"Importing {len(codes)} codes") - cur = self.conn.cursor() - for c in codes: - code_synonyms = synonyms.get(c.code.upper(), []) - unique_synonyms = list(set( - s for s in code_synonyms - if s.lower() != c.short_description.lower() - and s.lower() != c.long_description.lower() - )) - synonyms_str = "; ".join(unique_synonyms[:20]) - - cur.execute( - """INSERT INTO icd10_code - (id, categoryid, code, shortdescription, longdescription, - inclusionterms, exclusionterms, codealso, codefirst, synonyms, - billable, effectivefrom, effectiveto, edition, lastupdated, versionid) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) - ON CONFLICT (id) DO NOTHING""", - ( - c.id, - c.category_id if c.category_id else None, - c.code, - c.short_description, - c.long_description, - c.inclusion_terms, - c.exclusion_terms, - c.code_also, - c.code_first, - synonyms_str, - 1 if c.billable else 0, - "2024-10-01", - "", - "2025", - get_timestamp(), - 1, - ), - ) - self.conn.commit() - - def import_achi_sample_data(self): - logger.info("Importing sample ACHI data...") - cur = self.conn.cursor() - - for block_id, block_num, title, start, end in ACHI_SAMPLE_BLOCKS: - cur.execute( - """INSERT INTO achi_block - (id, blocknumber, title, coderangestart, coderangeend, lastupdated, versionid) - VALUES (%s,%s,%s,%s,%s,%s,%s) - ON CONFLICT (id) DO NOTHING""", - (block_id, block_num, title, start, end, get_timestamp(), 1), - ) - - for code_id, block_id, code, short_desc, long_desc in ACHI_SAMPLE_CODES: - cur.execute( - """INSERT INTO achi_code - (id, blockid, code, shortdescription, longdescription, billable, - effectivefrom, effectiveto, edition, lastupdated, versionid) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) - ON CONFLICT (id) DO NOTHING""", - (code_id, block_id, code, short_desc, long_desc, 1, "2024-07-01", "", "13", get_timestamp(), 1), - ) - - self.conn.commit() - logger.info(f"Imported {len(ACHI_SAMPLE_BLOCKS)} ACHI blocks and {len(ACHI_SAMPLE_CODES)} ACHI codes") - - def generate_embeddings(self, batch_size: int = 50): - """Generate embeddings for all ICD-10 codes using the embedding service.""" - logger.info("Generating embeddings for ICD-10 codes...") - cur = self.conn.cursor() - - # Get all codes that don't have embeddings yet - cur.execute(""" - SELECT c.id, c.code, c.shortdescription, c.longdescription - FROM icd10_code c - LEFT JOIN icd10_code_embedding e ON c.id = e.codeid - WHERE e.id IS NULL - """) - codes = cur.fetchall() - logger.info(f"Found {len(codes)} codes needing embeddings") - - if not codes: - logger.info("All codes already have embeddings") - return - - # Process in batches - total_generated = 0 - for i in range(0, len(codes), batch_size): - batch = codes[i:i + batch_size] - - # Create text for embedding (combine code + descriptions) - texts = [] - for code_id, code, short_desc, long_desc in batch: - text = f"{code}: {short_desc}" - if long_desc and long_desc != short_desc: - text += f" - {long_desc}" - texts.append(text) - - # Call embedding service - try: - response = requests.post( - f"{EMBEDDING_SERVICE_URL}/embed/batch", - json={"texts": texts}, - timeout=60, - ) - if response.status_code != 200: - logger.error(f"Embedding service error: {response.status_code}") - continue - - result = response.json() - embeddings = result.get("embeddings", []) - - # Store embeddings (skip if already exists for this code) - for j, (code_id, code, _, _) in enumerate(batch): - if j < len(embeddings): - embedding_json = json.dumps(embeddings[j]) - cur.execute( - """INSERT INTO icd10_code_embedding - (id, codeid, embedding, embeddingmodel, lastupdated) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (codeid) DO NOTHING""", - (generate_uuid(), code_id, embedding_json, "MedEmbed-Small-v0.1", get_timestamp()), - ) - total_generated += 1 - - self.conn.commit() - logger.info(f"Generated embeddings: {total_generated}/{len(codes)}") - - except requests.exceptions.RequestException as e: - logger.error(f"Failed to call embedding service: {e}") - continue - - logger.info(f"Finished generating {total_generated} embeddings") - - def close(self): - self.conn.close() - - -@click.command() -@click.option("--connection-string", envvar="DATABASE_URL", required=True, help="PostgreSQL connection string") -@click.option("--embeddings-only", is_flag=True, default=False, help="Only generate missing embeddings, skip data import") -def main(connection_string: str, embeddings_only: bool): - logger.info("=" * 60) - logger.info("ICD-10-CM Import for PostgreSQL") - logger.info("=" * 60) - - importer = PostgresImporter(connection_string) - try: - if not embeddings_only: - chapters, blocks, categories, codes, synonyms = download_and_parse() - - logger.info(f"Total: {len(chapters)} chapters") - logger.info(f"Total: {len(blocks)} blocks") - logger.info(f"Total: {len(categories)} categories") - logger.info(f"Total: {len(codes)} codes") - - importer.import_chapters(chapters) - importer.import_blocks(blocks) - importer.import_categories(categories) - importer.import_codes(codes, synonyms) - importer.import_achi_sample_data() - logger.info("ICD-10-CM codes imported to PostgreSQL") - - logger.info("Generating embeddings for AI search...") - importer.generate_embeddings() - logger.info("SUCCESS! Embedding generation complete") - finally: - importer.close() - - -if __name__ == "__main__": - main() diff --git a/Samples/ICD10/scripts/CreateDb/requirements.txt b/Samples/ICD10/scripts/CreateDb/requirements.txt deleted file mode 100644 index 26552b6b..00000000 --- a/Samples/ICD10/scripts/CreateDb/requirements.txt +++ /dev/null @@ -1,30 +0,0 @@ -# ICD-10-AM Import Script Dependencies - -# Database -psycopg2-binary>=2.9.9 -pgvector>=0.2.4 - -# Data Processing -pandas>=2.1.0 -openpyxl>=3.1.2 -xlrd>=2.0.1 - -# Embeddings -torch>=2.1.0 -transformers>=4.36.0 -sentence-transformers>=2.2.2 - -# HTTP/Downloads -requests>=2.31.0 -tqdm>=4.66.0 - -# XML/HTML Parsing (for IHACPA data) -lxml>=4.9.3 -beautifulsoup4>=4.12.2 - -# CLI -click>=8.1.7 -python-dotenv>=1.0.0 - -# Typing -typing-extensions>=4.8.0 diff --git a/Samples/ICD10/scripts/Dependencies/start.sh b/Samples/ICD10/scripts/Dependencies/start.sh deleted file mode 100755 index 4ea0c1d9..00000000 --- a/Samples/ICD10/scripts/Dependencies/start.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -# Start Docker dependencies (embedding service) -# Usage: ./start.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" -EMBEDDING_SERVICE_DIR="${PROJECT_DIR}/embedding-service" - -echo "=== Starting Dependencies ===" - -# Start embedding service (used for generating embeddings) -echo "Starting embedding service..." -cd "$EMBEDDING_SERVICE_DIR" -docker compose up -d - -echo "" -echo "Waiting for embedding service to be healthy..." -sleep 5 - -# Check health -if curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo "Embedding service is running at http://localhost:8000" -else - echo "Embedding service starting... (may take a minute to load model)" - echo "Check status with: docker compose -f $EMBEDDING_SERVICE_DIR/docker-compose.yml logs -f" -fi - -echo "" -echo "=== Dependencies started ===" diff --git a/Samples/ICD10/scripts/Dependencies/stop.sh b/Samples/ICD10/scripts/Dependencies/stop.sh deleted file mode 100755 index c6311081..00000000 --- a/Samples/ICD10/scripts/Dependencies/stop.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# Stop Docker dependencies -# Usage: ./stop.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" -EMBEDDING_SERVICE_DIR="${PROJECT_DIR}/embedding-service" - -echo "=== Stopping Dependencies ===" - -cd "$EMBEDDING_SERVICE_DIR" -docker compose down - -echo "=== Dependencies stopped ===" diff --git a/Samples/ICD10/scripts/run.sh b/Samples/ICD10/scripts/run.sh deleted file mode 100755 index 84df34e8..00000000 --- a/Samples/ICD10/scripts/run.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# Run the ICD-10 API and dependencies -# Usage: ./run.sh [port] - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -API_DIR="${PROJECT_DIR}/ICD10.Api" -DB_PATH="${PROJECT_DIR}/ICD10.Api/icd10.db" -PORT="${1:-5558}" - -echo "=== Starting ICD-10 Service ===" - -# Check database exists -if [ ! -f "$DB_PATH" ]; then - echo "ERROR: Database not found at $DB_PATH" - echo "Run CreateDb/import.sh first" - exit 1 -fi - -# Start embedding service (required for RAG search) -echo "Starting embedding service..." -"$SCRIPT_DIR/Dependencies/start.sh" - -echo "" -echo "=== Starting API ===" -echo "URL: http://localhost:$PORT" -echo "" - -cd "$API_DIR" -DbPath="$DB_PATH" dotnet run --urls "http://localhost:$PORT" diff --git a/Samples/Scheduling/Scheduling.Api.Tests/AppointmentEndpointTests.cs b/Samples/Scheduling/Scheduling.Api.Tests/AppointmentEndpointTests.cs deleted file mode 100644 index bcc8daed..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/AppointmentEndpointTests.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Scheduling.Api.Tests; - -/// -/// E2E tests for Appointment FHIR endpoints - REAL database, NO mocks. -/// Each test creates its own isolated factory and database. -/// -public sealed class AppointmentEndpointTests -{ - private static readonly string AuthToken = TestTokenHelper.GenerateSchedulerToken(); - - private static HttpClient CreateAuthenticatedClient(SchedulingApiFactory factory) - { - var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - AuthToken - ); - return client; - } - - private static async Task CreateTestPractitionerAsync(HttpClient client) - { - var request = new - { - Identifier = $"NPI-{Guid.NewGuid():N}", - NameFamily = "TestDoctor", - NameGiven = "Appointment", - Specialty = "General Practice", - }; - - var response = await client.PostAsJsonAsync("/Practitioner", request); - var created = await response.Content.ReadFromJsonAsync(); - return created.GetProperty("Id").GetString()!; - } - - [Fact] - public async Task GetUpcomingAppointments_ReturnsEmptyList_WhenNoAppointments() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/Appointment"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[]", content); - } - - [Fact] - public async Task CreateAppointment_ReturnsCreated_WithValidData() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var request = new - { - ServiceCategory = "General Practice", - ServiceType = "Consultation", - ReasonCode = "Annual checkup", - Priority = "routine", - Description = "Annual wellness visit", - Start = "2025-06-15T09:00:00Z", - End = "2025-06-15T09:30:00Z", - PatientReference = "Patient/patient-123", - PractitionerReference = $"Practitioner/{practitionerId}", - Comment = "Please bring insurance card", - }; - - var response = await client.PostAsJsonAsync("/Appointment", request); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var appointment = await response.Content.ReadFromJsonAsync(); - Assert.Equal("booked", appointment.GetProperty("Status").GetString()); - Assert.Equal("routine", appointment.GetProperty("Priority").GetString()); - Assert.Equal(30, appointment.GetProperty("MinutesDuration").GetInt32()); - Assert.NotNull(appointment.GetProperty("Id").GetString()); - } - - [Fact] - public async Task CreateAppointment_CalculatesDuration() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var request = new - { - ServiceCategory = "Specialty", - ServiceType = "Extended Consultation", - Priority = "routine", - Start = "2025-06-15T10:00:00Z", - End = "2025-06-15T11:00:00Z", - PatientReference = "Patient/patient-456", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - var response = await client.PostAsJsonAsync("/Appointment", request); - var appointment = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(60, appointment.GetProperty("MinutesDuration").GetInt32()); - } - - [Fact] - public async Task CreateAppointment_WithAllPriorities() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var priorities = new[] { "routine", "urgent", "asap", "stat" }; - - foreach (var priority in priorities) - { - var practitionerId = await CreateTestPractitionerAsync(client); - var request = new - { - ServiceCategory = "Test", - ServiceType = "Priority Test", - Priority = priority, - Start = "2025-06-15T14:00:00Z", - End = "2025-06-15T14:30:00Z", - PatientReference = "Patient/patient-priority", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - var response = await client.PostAsJsonAsync("/Appointment", request); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var appointment = await response.Content.ReadFromJsonAsync(); - Assert.Equal(priority, appointment.GetProperty("Priority").GetString()); - } - } - - [Fact] - public async Task GetAppointmentById_ReturnsAppointment_WhenExists() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var createRequest = new - { - ServiceCategory = "Test", - ServiceType = "GetById Test", - Priority = "routine", - Start = "2025-06-16T09:00:00Z", - End = "2025-06-16T09:30:00Z", - PatientReference = "Patient/patient-getbyid", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - var createResponse = await client.PostAsJsonAsync("/Appointment", createRequest); - var created = await createResponse.Content.ReadFromJsonAsync(); - var appointmentId = created.GetProperty("Id").GetString(); - - var response = await client.GetAsync($"/Appointment/{appointmentId}"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var appointment = await response.Content.ReadFromJsonAsync(); - Assert.Equal("booked", appointment.GetProperty("Status").GetString()); - } - - [Fact] - public async Task GetAppointmentById_ReturnsNotFound_WhenNotExists() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/Appointment/nonexistent-id-12345"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task UpdateAppointmentStatus_UpdatesStatus() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var createRequest = new - { - ServiceCategory = "Test", - ServiceType = "Status Update Test", - Priority = "routine", - Start = "2025-06-17T10:00:00Z", - End = "2025-06-17T10:30:00Z", - PatientReference = "Patient/patient-status", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - var createResponse = await client.PostAsJsonAsync("/Appointment", createRequest); - var created = await createResponse.Content.ReadFromJsonAsync(); - var appointmentId = created.GetProperty("Id").GetString(); - - var response = await client.PatchAsync( - $"/Appointment/{appointmentId}/status?status=arrived", - null - ); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - Assert.Equal("arrived", result.GetProperty("status").GetString()); - } - - [Fact] - public async Task UpdateAppointmentStatus_ReturnsNotFound_WhenNotExists() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.PatchAsync( - "/Appointment/nonexistent-id/status?status=cancelled", - null - ); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task GetAppointmentsByPatient_ReturnsPatientAppointments() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var patientId = Guid.NewGuid().ToString(); - var request = new - { - ServiceCategory = "Test", - ServiceType = "Patient Query Test", - Priority = "routine", - Start = "2025-06-18T11:00:00Z", - End = "2025-06-18T11:30:00Z", - PatientReference = $"Patient/{patientId}", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - await client.PostAsJsonAsync("/Appointment", request); - - var response = await client.GetAsync($"/Patient/{patientId}/Appointment"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var appointments = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(appointments); - Assert.True(appointments.Length >= 1); - } - - [Fact] - public async Task GetAppointmentsByPractitioner_ReturnsPractitionerAppointments() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var request = new - { - ServiceCategory = "Test", - ServiceType = "Practitioner Query Test", - Priority = "routine", - Start = "2025-06-19T14:00:00Z", - End = "2025-06-19T14:30:00Z", - PatientReference = "Patient/patient-doc-query", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - await client.PostAsJsonAsync("/Appointment", request); - - var response = await client.GetAsync($"/Practitioner/{practitionerId}/Appointment"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var appointments = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(appointments); - Assert.True(appointments.Length >= 1); - } - - [Fact] - public async Task CreateAppointment_SetsCreatedTimestamp() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var request = new - { - ServiceCategory = "Test", - ServiceType = "Timestamp Test", - Priority = "routine", - Start = "2025-06-20T15:00:00Z", - End = "2025-06-20T15:30:00Z", - PatientReference = "Patient/patient-timestamp", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - var response = await client.PostAsJsonAsync("/Appointment", request); - var appointment = await response.Content.ReadFromJsonAsync(); - - var created = appointment.GetProperty("Created").GetString(); - Assert.NotNull(created); - Assert.Matches(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", created); - } - - [Fact] - public async Task CreateAppointment_WithComment() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var request = new - { - ServiceCategory = "Test", - ServiceType = "Comment Test", - Priority = "routine", - Start = "2025-06-21T09:00:00Z", - End = "2025-06-21T09:30:00Z", - PatientReference = "Patient/patient-comment", - PractitionerReference = $"Practitioner/{practitionerId}", - Comment = "Patient has mobility issues, needs wheelchair accessible room", - }; - - var response = await client.PostAsJsonAsync("/Appointment", request); - var appointment = await response.Content.ReadFromJsonAsync(); - - Assert.Equal( - "Patient has mobility issues, needs wheelchair accessible room", - appointment.GetProperty("Comment").GetString() - ); - } - - [Fact] - public async Task CreateAppointment_GeneratesUniqueIds() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerId = await CreateTestPractitionerAsync(client); - var request = new - { - ServiceCategory = "Test", - ServiceType = "Unique ID Test", - Priority = "routine", - Start = "2025-06-22T10:00:00Z", - End = "2025-06-22T10:30:00Z", - PatientReference = "Patient/patient-unique", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - - var response1 = await client.PostAsJsonAsync("/Appointment", request); - var response2 = await client.PostAsJsonAsync("/Appointment", request); - - var appointment1 = await response1.Content.ReadFromJsonAsync(); - var appointment2 = await response2.Content.ReadFromJsonAsync(); - - Assert.NotEqual( - appointment1.GetProperty("Id").GetString(), - appointment2.GetProperty("Id").GetString() - ); - } -} diff --git a/Samples/Scheduling/Scheduling.Api.Tests/AuthorizationTests.cs b/Samples/Scheduling/Scheduling.Api.Tests/AuthorizationTests.cs deleted file mode 100644 index 2070cb77..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/AuthorizationTests.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; - -namespace Scheduling.Api.Tests; - -/// -/// Authorization tests for Scheduling.Api endpoints. -/// Tests that endpoints require proper authentication and permissions. -/// -public sealed class AuthorizationTests : IClassFixture -{ - private readonly HttpClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// Shared factory instance. - public AuthorizationTests(SchedulingApiFactory factory) => _client = factory.CreateClient(); - - // === PRACTITIONER ENDPOINTS === - - [Fact] - public async Task GetPractitioners_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/Practitioner"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPractitioners_WithInvalidToken_ReturnsUnauthorized() - { - using var request = new HttpRequestMessage(HttpMethod.Get, "/Practitioner"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); - - var response = await _client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPractitioners_WithExpiredToken_ReturnsUnauthorized() - { - using var request = new HttpRequestMessage(HttpMethod.Get, "/Practitioner"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", - TestTokenHelper.GenerateExpiredToken() - ); - - var response = await _client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPractitionerById_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/Practitioner/test-id"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task CreatePractitioner_WithoutToken_ReturnsUnauthorized() - { - var practitioner = new - { - Identifier = "PRACT-001", - NameFamily = "Smith", - NameGiven = "John", - Specialty = "General Practice", - }; - - var response = await _client.PostAsJsonAsync("/Practitioner", practitioner); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task UpdatePractitioner_WithoutToken_ReturnsUnauthorized() - { - var practitioner = new - { - Identifier = "PRACT-001", - NameFamily = "Smith", - NameGiven = "John", - Active = true, - }; - - var response = await _client.PutAsJsonAsync("/Practitioner/test-id", practitioner); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SearchPractitioners_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/Practitioner/_search?specialty=Cardiology"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - // === APPOINTMENT ENDPOINTS === - - [Fact] - public async Task GetAppointments_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/Appointment"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetAppointmentById_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/Appointment/test-id"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task CreateAppointment_WithoutToken_ReturnsUnauthorized() - { - // Note: Must match CreateAppointmentRequest record structure exactly - // JSON deserialization happens before endpoint filters in Minimal APIs - var appointment = new - { - ServiceCategory = "general", - ServiceType = "checkup", - ReasonCode = "routine", - Priority = "routine", - Description = "Test appointment", - Start = "2024-01-15T10:00:00Z", - End = "2024-01-15T11:00:00Z", - PatientReference = "Patient/test-patient", - PractitionerReference = "Practitioner/test-practitioner", - Comment = "test", - }; - - var response = await _client.PostAsJsonAsync("/Appointment", appointment); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task UpdateAppointment_WithoutToken_ReturnsUnauthorized() - { - // Note: Must match UpdateAppointmentRequest record structure exactly - var appointment = new - { - ServiceCategory = "general", - ServiceType = "checkup", - ReasonCode = "routine", - Priority = "routine", - Description = "Test appointment", - Start = "2024-01-15T10:00:00Z", - End = "2024-01-15T11:00:00Z", - PatientReference = "Patient/test-patient", - PractitionerReference = "Practitioner/test-practitioner", - Comment = "test", - Status = "booked", - }; - - var response = await _client.PutAsJsonAsync("/Appointment/test-id", appointment); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task PatchAppointmentStatus_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.PatchAsync( - "/Appointment/test-id/status?status=cancelled", - null - ); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPatientAppointments_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/Patient/test-patient/Appointment"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetPractitionerAppointments_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/Practitioner/test-practitioner/Appointment"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - // === SYNC ENDPOINTS === - - [Fact] - public async Task SyncChanges_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/changes"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncOrigin_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/origin"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncStatus_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/status"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncRecords_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.GetAsync("/sync/records"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task SyncRetry_WithoutToken_ReturnsUnauthorized() - { - var response = await _client.PostAsync("/sync/records/test-id/retry", null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - // === TOKEN VALIDATION TESTS === - - [Fact] - public async Task GetAppointments_WithValidToken_SucceedsInDevMode() - { - // In dev mode (default signing key is all zeros), Gatekeeper permission checks - // are bypassed to allow E2E testing without requiring Gatekeeper setup. - // Valid tokens pass through after local JWT validation. - using var request = new HttpRequestMessage(HttpMethod.Get, "/Appointment"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", - TestTokenHelper.GenerateNoRoleToken() - ); - - var response = await _client.SendAsync(request); - - // In dev mode, valid tokens succeed without permission checks - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } -} diff --git a/Samples/Scheduling/Scheduling.Api.Tests/DashboardIntegrationTests.cs b/Samples/Scheduling/Scheduling.Api.Tests/DashboardIntegrationTests.cs deleted file mode 100644 index 0d69b568..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/DashboardIntegrationTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Net.Http.Headers; - -namespace Scheduling.Api.Tests; - -/// -/// Tests that verify the Dashboard can actually connect to Scheduling API. -/// These tests MUST FAIL if CORS is not configured for Dashboard origin. -/// -public sealed class DashboardIntegrationTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly string _authToken = TestTokenHelper.GenerateSchedulerToken(); - - /// - /// The actual URL where Dashboard runs (for CORS origin testing). - /// - private const string DashboardOrigin = "http://localhost:5173"; - - /// - /// Initializes a new instance of the class. - /// - /// Shared factory instance. - public DashboardIntegrationTests(SchedulingApiFactory factory) - { - _client = factory.CreateClient(); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - _authToken - ); - } - - #region CORS Tests - - [Fact] - public async Task SchedulingApi_Returns_CorsHeaders_ForDashboardOrigin() - { - // The Dashboard runs on localhost:5173 and makes fetch() calls to Scheduling API. - // Browser enforces CORS - without proper headers, the request is blocked. - // - // This test verifies Scheduling API returns Access-Control-Allow-Origin header - // for the Dashboard's origin. - - var request = new HttpRequestMessage(HttpMethod.Get, "/Practitioner"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authToken); - - var response = await _client.SendAsync(request); - - // API should return CORS header allowing Dashboard origin - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Scheduling API must return Access-Control-Allow-Origin header for Dashboard to work" - ); - - var allowedOrigin = response - .Headers.GetValues("Access-Control-Allow-Origin") - .FirstOrDefault(); - Assert.True( - allowedOrigin == DashboardOrigin || allowedOrigin == "*", - $"Access-Control-Allow-Origin must be '{DashboardOrigin}' or '*', but was '{allowedOrigin}'" - ); - } - - [Fact] - public async Task SchedulingApi_Handles_PreflightRequest_ForDashboardOrigin() - { - // Before making actual requests, browsers send OPTIONS preflight request. - // API must respond with correct CORS headers. - - var request = new HttpRequestMessage(HttpMethod.Options, "/Practitioner"); - request.Headers.Add("Origin", DashboardOrigin); - request.Headers.Add("Access-Control-Request-Method", "GET"); - request.Headers.Add("Access-Control-Request-Headers", "Accept"); - - var response = await _client.SendAsync(request); - - // Preflight should succeed (200 or 204) - Assert.True( - response.IsSuccessStatusCode, - $"Preflight OPTIONS request failed with {response.StatusCode}" - ); - - // Must have CORS headers - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Preflight response must include Access-Control-Allow-Origin" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Methods"), - "Preflight response must include Access-Control-Allow-Methods" - ); - } - - #endregion -} diff --git a/Samples/Scheduling/Scheduling.Api.Tests/GlobalUsings.cs b/Samples/Scheduling/Scheduling.Api.Tests/GlobalUsings.cs deleted file mode 100644 index f68c2477..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Samples.Authorization; -global using Xunit; diff --git a/Samples/Scheduling/Scheduling.Api.Tests/PractitionerEndpointTests.cs b/Samples/Scheduling/Scheduling.Api.Tests/PractitionerEndpointTests.cs deleted file mode 100644 index e313b1e5..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/PractitionerEndpointTests.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Scheduling.Api.Tests; - -/// -/// E2E tests for Practitioner FHIR endpoints - REAL database, NO mocks. -/// Uses shared factory for all tests - starts once, runs all tests, shuts down. -/// -public sealed class PractitionerEndpointTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly string _authToken = TestTokenHelper.GenerateSchedulerToken(); - - /// - /// Initializes a new instance of the class. - /// - /// Shared factory instance. - public PractitionerEndpointTests(SchedulingApiFactory factory) - { - _client = factory.CreateClient(); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - _authToken - ); - } - - #region CORS Tests - Dashboard Integration - - [Fact] - public async Task GetPractitioners_WithDashboardOrigin_ReturnsCorsHeaders() - { - var request = new HttpRequestMessage(HttpMethod.Get, "/Practitioner"); - request.Headers.Add("Origin", "http://localhost:5173"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authToken); - - var response = await _client.SendAsync(request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Missing Access-Control-Allow-Origin header - Dashboard cannot fetch from Scheduling API!" - ); - - var allowOrigin = response - .Headers.GetValues("Access-Control-Allow-Origin") - .FirstOrDefault(); - Assert.True( - allowOrigin == "http://localhost:5173" || allowOrigin == "*", - $"Access-Control-Allow-Origin must allow Dashboard origin. Got: {allowOrigin}" - ); - } - - [Fact] - public async Task PreflightRequest_WithDashboardOrigin_ReturnsCorrectCorsHeaders() - { - var request = new HttpRequestMessage(HttpMethod.Options, "/Practitioner"); - request.Headers.Add("Origin", "http://localhost:5173"); - request.Headers.Add("Access-Control-Request-Method", "GET"); - request.Headers.Add("Access-Control-Request-Headers", "Content-Type"); - - var response = await _client.SendAsync(request); - - Assert.True( - response.StatusCode == HttpStatusCode.OK - || response.StatusCode == HttpStatusCode.NoContent, - $"Preflight request failed with {response.StatusCode}" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Missing Access-Control-Allow-Origin on preflight - Dashboard cannot make requests!" - ); - - Assert.True( - response.Headers.Contains("Access-Control-Allow-Methods"), - "Missing Access-Control-Allow-Methods on preflight" - ); - } - - [Fact] - public async Task GetAppointments_WithDashboardOrigin_ReturnsCorsHeaders() - { - var request = new HttpRequestMessage(HttpMethod.Get, "/Appointment"); - request.Headers.Add("Origin", "http://localhost:5173"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authToken); - - var response = await _client.SendAsync(request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True( - response.Headers.Contains("Access-Control-Allow-Origin"), - "Missing Access-Control-Allow-Origin on /Appointment - Dashboard cannot fetch appointments!" - ); - } - - #endregion - - [Fact] - public async Task GetAllPractitioners_ReturnsOk() - { - var response = await _client.GetAsync("/Practitioner"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task CreatePractitioner_ReturnsCreated_WithValidData() - { - var request = new - { - Identifier = "NPI-12345", - NameFamily = "Smith", - NameGiven = "John", - Qualification = "MD", - Specialty = "Cardiology", - TelecomEmail = "dr.smith@hospital.com", - TelecomPhone = "555-1234", - }; - - var response = await _client.PostAsJsonAsync("/Practitioner", request); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var practitioner = await response.Content.ReadFromJsonAsync(); - Assert.Equal("Smith", practitioner.GetProperty("NameFamily").GetString()); - Assert.Equal("John", practitioner.GetProperty("NameGiven").GetString()); - Assert.Equal("Cardiology", practitioner.GetProperty("Specialty").GetString()); - Assert.NotNull(practitioner.GetProperty("Id").GetString()); - } - - [Fact] - public async Task GetPractitionerById_ReturnsPractitioner_WhenExists() - { - var createRequest = new - { - Identifier = "NPI-GetById", - NameFamily = "Johnson", - NameGiven = "Jane", - Specialty = "Pediatrics", - }; - - var createResponse = await _client.PostAsJsonAsync("/Practitioner", createRequest); - var created = await createResponse.Content.ReadFromJsonAsync(); - var practitionerId = created.GetProperty("Id").GetString(); - - var response = await _client.GetAsync($"/Practitioner/{practitionerId}"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var practitioner = await response.Content.ReadFromJsonAsync(); - Assert.Equal("Johnson", practitioner.GetProperty("NameFamily").GetString()); - Assert.Equal("Jane", practitioner.GetProperty("NameGiven").GetString()); - } - - [Fact] - public async Task GetPractitionerById_ReturnsNotFound_WhenNotExists() - { - var response = await _client.GetAsync("/Practitioner/nonexistent-id-12345"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task SearchPractitionersBySpecialty_FindsPractitioners() - { - var request = new - { - Identifier = "NPI-Search", - NameFamily = "Williams", - NameGiven = "Robert", - Specialty = "Orthopedics", - }; - - await _client.PostAsJsonAsync("/Practitioner", request); - - var response = await _client.GetAsync("/Practitioner/_search?specialty=Orthopedics"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var practitioners = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(practitioners); - Assert.Contains( - practitioners, - p => p.GetProperty("Specialty").GetString() == "Orthopedics" - ); - } - - [Fact] - public async Task SearchPractitioners_WithoutSpecialty_ReturnsAll() - { - var request = new - { - Identifier = "NPI-All", - NameFamily = "Brown", - NameGiven = "Sarah", - Specialty = "Dermatology", - }; - - await _client.PostAsJsonAsync("/Practitioner", request); - - var response = await _client.GetAsync("/Practitioner/_search"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var practitioners = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(practitioners); - Assert.True(practitioners.Length >= 1); - } - - [Fact] - public async Task CreatePractitioner_SetsActiveToTrue() - { - var request = new - { - Identifier = "NPI-Active", - NameFamily = "Davis", - NameGiven = "Michael", - }; - - var response = await _client.PostAsJsonAsync("/Practitioner", request); - var practitioner = await response.Content.ReadFromJsonAsync(); - - Assert.True(practitioner.GetProperty("Active").GetBoolean()); - } - - [Fact] - public async Task CreatePractitioner_GeneratesUniqueIds() - { - var request = new - { - Identifier = "NPI-UniqueId", - NameFamily = "Wilson", - NameGiven = "Emily", - }; - - var response1 = await _client.PostAsJsonAsync("/Practitioner", request); - var response2 = await _client.PostAsJsonAsync("/Practitioner", request); - - var practitioner1 = await response1.Content.ReadFromJsonAsync(); - var practitioner2 = await response2.Content.ReadFromJsonAsync(); - - Assert.NotEqual( - practitioner1.GetProperty("Id").GetString(), - practitioner2.GetProperty("Id").GetString() - ); - } - - [Fact] - public async Task CreatePractitioner_WithQualification() - { - var request = new - { - Identifier = "NPI-Qual", - NameFamily = "Taylor", - NameGiven = "Chris", - Qualification = "MD, PhD, FACC", - }; - - var response = await _client.PostAsJsonAsync("/Practitioner", request); - var practitioner = await response.Content.ReadFromJsonAsync(); - - Assert.Equal("MD, PhD, FACC", practitioner.GetProperty("Qualification").GetString()); - } - - [Fact] - public async Task CreatePractitioner_WithContactInfo() - { - var request = new - { - Identifier = "NPI-Contact", - NameFamily = "Anderson", - NameGiven = "Lisa", - TelecomEmail = "lisa.anderson@clinic.com", - TelecomPhone = "555-9876", - }; - - var response = await _client.PostAsJsonAsync("/Practitioner", request); - var practitioner = await response.Content.ReadFromJsonAsync(); - - Assert.Equal( - "lisa.anderson@clinic.com", - practitioner.GetProperty("TelecomEmail").GetString() - ); - Assert.Equal("555-9876", practitioner.GetProperty("TelecomPhone").GetString()); - } - - [Fact] - public async Task GetAllPractitioners_ReturnsPractitioners_WhenExist() - { - var request1 = new - { - Identifier = "NPI-All1", - NameFamily = "Garcia", - NameGiven = "Maria", - Specialty = "Neurology", - }; - var request2 = new - { - Identifier = "NPI-All2", - NameFamily = "Martinez", - NameGiven = "Carlos", - Specialty = "Psychiatry", - }; - - await _client.PostAsJsonAsync("/Practitioner", request1); - await _client.PostAsJsonAsync("/Practitioner", request2); - - var response = await _client.GetAsync("/Practitioner"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var practitioners = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(practitioners); - Assert.True(practitioners.Length >= 2); - } -} diff --git a/Samples/Scheduling/Scheduling.Api.Tests/Scheduling.Api.Tests.csproj b/Samples/Scheduling/Scheduling.Api.Tests/Scheduling.Api.Tests.csproj deleted file mode 100644 index acd0accf..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/Scheduling.Api.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - Library - true - Scheduling.Api.Tests - CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859;CA1849;CA2234;CA1812;CA2007;CA2000;xUnit1030 - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/Samples/Scheduling/Scheduling.Api.Tests/SchedulingApiFactory.cs b/Samples/Scheduling/Scheduling.Api.Tests/SchedulingApiFactory.cs deleted file mode 100644 index 71ed797e..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/SchedulingApiFactory.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Npgsql; - -namespace Scheduling.Api.Tests; - -/// -/// WebApplicationFactory for Scheduling.Api e2e testing. -/// Creates an isolated PostgreSQL test database per factory instance. -/// -public sealed class SchedulingApiFactory : WebApplicationFactory -{ - private readonly string _dbName; - private readonly string _connectionString; - - private static readonly string BaseConnectionString = - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Database=postgres;Username=postgres;Password=changeme"; - - /// - /// Creates a new instance with an isolated PostgreSQL test database. - /// - public SchedulingApiFactory() - { - _dbName = $"test_scheduling_{Guid.NewGuid():N}"; - - using (var adminConn = new NpgsqlConnection(BaseConnectionString)) - { - adminConn.Open(); - using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {_dbName}"; - createCmd.ExecuteNonQuery(); - } - - _connectionString = BaseConnectionString.Replace( - "Database=postgres", - $"Database={_dbName}" - ); - } - - /// - /// Gets the connection string for direct access in tests if needed. - /// - public string ConnectionString => _connectionString; - - /// - protected override void ConfigureWebHost(IWebHostBuilder builder) => - builder.UseSetting("ConnectionStrings:Postgres", _connectionString); - - /// - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing) - { - try - { - using var adminConn = new NpgsqlConnection(BaseConnectionString); - adminConn.Open(); - - using var terminateCmd = adminConn.CreateCommand(); - terminateCmd.CommandText = - $"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{_dbName}'"; - terminateCmd.ExecuteNonQuery(); - - using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {_dbName}"; - dropCmd.ExecuteNonQuery(); - } - catch - { - // Ignore cleanup errors - } - } - } -} diff --git a/Samples/Scheduling/Scheduling.Api.Tests/SchedulingSyncTests.cs b/Samples/Scheduling/Scheduling.Api.Tests/SchedulingSyncTests.cs deleted file mode 100644 index 49e5b7f4..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/SchedulingSyncTests.cs +++ /dev/null @@ -1,365 +0,0 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Scheduling.Api.Tests; - -/// -/// Sync tests for Scheduling domain. -/// Uses shared SchedulingApiFactory with PostgreSQL - NO mocks. -/// -public sealed class SchedulingSyncTests : IClassFixture -{ - private readonly HttpClient _schedulingClient; - private static readonly string AuthToken = TestTokenHelper.GenerateSchedulerToken(); - - /// - /// Creates test instance with Scheduling API running against PostgreSQL. - /// - /// Shared factory instance. - public SchedulingSyncTests(SchedulingApiFactory factory) - { - _schedulingClient = factory.CreateClient(); - _schedulingClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - AuthToken - ); - } - - /// - /// Creating a practitioner in Scheduling.Api creates a sync log entry. - /// - [Fact] - public async Task CreatePractitionerInScheduling_GeneratesSyncLogEntry() - { - var practitionerRequest = new - { - Identifier = "NPI-SYNC-FULL", - NameFamily = "SyncDoctor", - NameGiven = "Full", - Specialty = "General Practice", - }; - - await _schedulingClient.PostAsJsonAsync("/Practitioner", practitionerRequest); - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains( - changes, - c => c.GetProperty("TableName").GetString() == "fhir_practitioner" - ); - } - - /// - /// Sync log contains practitioner data with proper payload. - /// - [Fact] - public async Task SchedulingSyncLog_ContainsPractitionerPayload() - { - var practitionerRequest = new - { - Identifier = "NPI-DATA-SYNC", - NameFamily = "DataDoctor", - NameGiven = "Sync", - Specialty = "Cardiology", - }; - - await _schedulingClient.PostAsJsonAsync("/Practitioner", practitionerRequest); - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - - var practitionerChange = changes.FirstOrDefault(c => - c.GetProperty("TableName").GetString() == "fhir_practitioner" - ); - - Assert.True(practitionerChange.ValueKind != JsonValueKind.Undefined); - Assert.True(practitionerChange.TryGetProperty("Payload", out _)); - } - - /// - /// Scheduling domain has a unique origin ID. - /// - [Fact] - public async Task SchedulingDomain_HasUniqueOriginId() - { - var response = await _schedulingClient.GetAsync("/sync/origin"); - var origin = await response.Content.ReadFromJsonAsync(); - var originId = origin.GetProperty("originId").GetString(); - - Assert.NotNull(originId); - Assert.NotEmpty(originId); - Assert.Matches(@"^[0-9a-fA-F-]{36}$", originId); - } - - /// - /// Sync log versions increment correctly across multiple changes. - /// - [Fact] - public async Task SyncLogVersions_IncrementCorrectly() - { - for (var i = 0; i < 5; i++) - { - var request = new - { - Identifier = $"NPI-VERSION-{i}", - NameFamily = $"VersionDoc{i}", - NameGiven = "Test", - }; - await _schedulingClient.PostAsJsonAsync("/Practitioner", request); - } - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.True(changes.Length >= 5); - - long previousVersion = 0; - foreach (var change in changes) - { - var currentVersion = change.GetProperty("Version").GetInt64(); - Assert.True(currentVersion > previousVersion); - previousVersion = currentVersion; - } - } - - /// - /// Sync log fromVersion parameter filters correctly. - /// - [Fact] - public async Task SyncLogFromVersion_FiltersCorrectly() - { - var request1 = new - { - Identifier = "NPI-FIRST", - NameFamily = "FirstDoc", - NameGiven = "Test", - }; - await _schedulingClient.PostAsJsonAsync("/Practitioner", request1); - - var initialResponse = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var initialChanges = await initialResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(initialChanges); - Assert.True(initialChanges.Length > 0); - var lastVersion = initialChanges.Max(c => c.GetProperty("Version").GetInt64()); - - var request2 = new - { - Identifier = "NPI-SECOND", - NameFamily = "SecondDoc", - NameGiven = "Test", - }; - await _schedulingClient.PostAsJsonAsync("/Practitioner", request2); - - var filteredResponse = await _schedulingClient.GetAsync( - $"/sync/changes?fromVersion={lastVersion}" - ); - var filteredChanges = await filteredResponse.Content.ReadFromJsonAsync(); - - Assert.NotNull(filteredChanges); - Assert.All( - filteredChanges, - c => Assert.True(c.GetProperty("Version").GetInt64() > lastVersion) - ); - } - - /// - /// Creating an appointment in Scheduling creates a sync log entry. - /// - [Fact] - public async Task CreateAppointment_InScheduling_GeneratesSyncLogEntry() - { - var practitionerRequest = new - { - Identifier = $"NPI-APPT-{Guid.NewGuid():N}", - NameFamily = "AppointmentDoc", - NameGiven = "Test", - }; - var practitionerResponse = await _schedulingClient.PostAsJsonAsync( - "/Practitioner", - practitionerRequest - ); - var practitioner = await practitionerResponse.Content.ReadFromJsonAsync(); - var practitionerId = practitioner.GetProperty("Id").GetString(); - - var appointmentRequest = new - { - ServiceCategory = "Test", - ServiceType = "Sync Test", - Priority = "routine", - Start = "2025-08-01T10:00:00Z", - End = "2025-08-01T10:30:00Z", - PatientReference = "Patient/test-patient", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - var appointmentResponse = await _schedulingClient.PostAsJsonAsync( - "/Appointment", - appointmentRequest - ); - Assert.True(appointmentResponse.IsSuccessStatusCode); - - var changesResponse = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await changesResponse.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains(changes, c => c.GetProperty("TableName").GetString() == "fhir_appointment"); - } - - /// - /// Sync log limit parameter correctly restricts result count. - /// - [Fact] - public async Task SyncLogLimit_RestrictsResultCount() - { - for (var i = 0; i < 5; i++) - { - var request = new - { - Identifier = $"NPI-LIMIT-{Guid.NewGuid():N}", - NameFamily = $"LimitDoc{i}", - NameGiven = "Test", - }; - await _schedulingClient.PostAsJsonAsync("/Practitioner", request); - } - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0&limit=3"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Equal(3, changes.Length); - } - - /// - /// Sync changes include INSERT operation type. - /// - [Fact] - public async Task SyncChanges_IncludeOperationType() - { - var request = new - { - Identifier = $"NPI-OP-{Guid.NewGuid():N}", - NameFamily = "OperationDoc", - NameGiven = "Test", - }; - await _schedulingClient.PostAsJsonAsync("/Practitioner", request); - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.All(changes, c => Assert.True(c.TryGetProperty("Operation", out _))); - - var practitionerChange = changes.First(c => - c.GetProperty("TableName").GetString() == "fhir_practitioner" - ); - // Operation is serialized as integer (0=Insert, 1=Update, 2=Delete) - Assert.Equal(0, practitionerChange.GetProperty("Operation").GetInt32()); - } - - /// - /// Sync changes include timestamp. - /// - [Fact] - public async Task SyncChanges_IncludeTimestamp() - { - var request = new - { - Identifier = $"NPI-TS-{Guid.NewGuid():N}", - NameFamily = "TimestampDoc", - NameGiven = "Test", - }; - await _schedulingClient.PostAsJsonAsync("/Practitioner", request); - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.All(changes, c => Assert.True(c.TryGetProperty("Timestamp", out _))); - } - - /// - /// Sync data contains expected practitioner fields. - /// - [Fact] - public async Task SyncData_ContainsExpectedPractitionerFields() - { - var request = new - { - Identifier = "NPI-FIELDS-123", - NameFamily = "FieldsDoctor", - NameGiven = "John", - Specialty = "Neurology", - Qualification = "MD, PhD", - TelecomEmail = "doctor@fields.com", - TelecomPhone = "555-DOCS", - }; - await _schedulingClient.PostAsJsonAsync("/Practitioner", request); - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - var practitionerChange = changes.First(c => - c.GetProperty("TableName").GetString() == "fhir_practitioner" - && c.GetProperty("Payload").GetString()!.Contains("NPI-FIELDS-123") - ); - - var payloadStr = practitionerChange.GetProperty("Payload").GetString(); - Assert.NotNull(payloadStr); - - var payload = JsonSerializer.Deserialize(payloadStr); - Assert.Equal("NPI-FIELDS-123", payload.GetProperty("identifier").GetString()); - Assert.Equal("FieldsDoctor", payload.GetProperty("namefamily").GetString()); - Assert.Equal("John", payload.GetProperty("namegiven").GetString()); - Assert.Equal("Neurology", payload.GetProperty("specialty").GetString()); - } - - /// - /// Multiple resource types are tracked in sync log (Practitioner and Appointment). - /// - [Fact] - public async Task MultipleResourceTypes_TrackedInSchedulingSyncLog() - { - var practitionerRequest = new - { - Identifier = $"NPI-MULTI-{Guid.NewGuid():N}", - NameFamily = "MultiDoc", - NameGiven = "Test", - }; - var practitionerResponse = await _schedulingClient.PostAsJsonAsync( - "/Practitioner", - practitionerRequest - ); - var practitioner = await practitionerResponse.Content.ReadFromJsonAsync(); - var practitionerId = practitioner.GetProperty("Id").GetString(); - - var appointmentRequest = new - { - ServiceCategory = "Test", - ServiceType = "Multi Test", - Priority = "routine", - Start = "2025-09-01T10:00:00Z", - End = "2025-09-01T10:30:00Z", - PatientReference = "Patient/multi-patient", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - await _schedulingClient.PostAsJsonAsync("/Appointment", appointmentRequest); - - var response = await _schedulingClient.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - - var tableNames = changes - .Select(c => c.GetProperty("TableName").GetString()) - .Distinct() - .ToList(); - Assert.Contains("fhir_practitioner", tableNames); - Assert.Contains("fhir_appointment", tableNames); - } -} diff --git a/Samples/Scheduling/Scheduling.Api.Tests/SyncEndpointTests.cs b/Samples/Scheduling/Scheduling.Api.Tests/SyncEndpointTests.cs deleted file mode 100644 index da5fad71..00000000 --- a/Samples/Scheduling/Scheduling.Api.Tests/SyncEndpointTests.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; - -namespace Scheduling.Api.Tests; - -/// -/// E2E tests for Sync endpoints - REAL database, NO mocks. -/// Tests sync log generation and origin tracking. -/// Each test creates its own isolated factory and database. -/// -public sealed class SyncEndpointTests -{ - private static readonly string AuthToken = TestTokenHelper.GenerateSchedulerToken(); - - private static HttpClient CreateAuthenticatedClient(SchedulingApiFactory factory) - { - var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - AuthToken - ); - return client; - } - - [Fact] - public async Task GetSyncOrigin_ReturnsOriginId() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/sync/origin"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - var originId = result.GetProperty("originId").GetString(); - Assert.NotNull(originId); - Assert.NotEmpty(originId); - } - - [Fact] - public async Task GetSyncChanges_ReturnsEmptyList_WhenNoChanges() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/sync/changes?fromVersion=999999"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[]", content); - } - - [Fact] - public async Task GetSyncChanges_ReturnChanges_AfterPractitionerCreated() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerRequest = new - { - Identifier = "NPI-Sync", - NameFamily = "SyncTest", - NameGiven = "Doctor", - Specialty = "Internal Medicine", - }; - - await client.PostAsJsonAsync("/Practitioner", practitionerRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var changes = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(changes); - Assert.True(changes.Length > 0); - } - - [Fact] - public async Task GetSyncChanges_RespectsLimitParameter() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - for (var i = 0; i < 5; i++) - { - var practitionerRequest = new - { - Identifier = $"NPI-Limit{i}", - NameFamily = $"LimitTest{i}", - NameGiven = "Doctor", - }; - await client.PostAsJsonAsync("/Practitioner", practitionerRequest); - } - - var response = await client.GetAsync("/sync/changes?fromVersion=0&limit=2"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var changes = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(changes); - Assert.True(changes.Length <= 2); - } - - [Fact] - public async Task GetSyncChanges_TracksPractitionerChanges() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerRequest = new - { - Identifier = "NPI-TrackPrac", - NameFamily = "TrackTest", - NameGiven = "Doctor", - }; - await client.PostAsJsonAsync("/Practitioner", practitionerRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains( - changes, - c => c.GetProperty("TableName").GetString() == "fhir_practitioner" - ); - } - - [Fact] - public async Task GetSyncChanges_TracksAppointmentChanges() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerRequest = new - { - Identifier = "NPI-TrackAppt", - NameFamily = "TrackApptTest", - NameGiven = "Doctor", - }; - var pracResponse = await client.PostAsJsonAsync("/Practitioner", practitionerRequest); - var practitioner = await pracResponse.Content.ReadFromJsonAsync(); - var practitionerId = practitioner.GetProperty("Id").GetString(); - - var appointmentRequest = new - { - ServiceCategory = "Test", - ServiceType = "Sync Track Test", - Priority = "routine", - Start = "2025-07-01T09:00:00Z", - End = "2025-07-01T09:30:00Z", - PatientReference = "Patient/patient-sync", - PractitionerReference = $"Practitioner/{practitionerId}", - }; - await client.PostAsJsonAsync("/Appointment", appointmentRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains(changes, c => c.GetProperty("TableName").GetString() == "fhir_appointment"); - } - - [Fact] - public async Task GetSyncChanges_ContainsOperation() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - var practitionerRequest = new - { - Identifier = "NPI-Op", - NameFamily = "OperationTest", - NameGiven = "Doctor", - }; - await client.PostAsJsonAsync("/Practitioner", practitionerRequest); - - var response = await client.GetAsync("/sync/changes?fromVersion=0"); - var changes = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(changes); - Assert.Contains( - changes, - c => - { - // Operation is serialized as integer (0=Insert, 1=Update, 2=Delete) - var opValue = c.GetProperty("Operation").GetInt32(); - return opValue >= 0 && opValue <= 2; - } - ); - } - - // ========== SYNC DASHBOARD ENDPOINT TESTS ========== - // These tests verify the endpoints required by the Sync Dashboard UI. - - /// - /// Tests GET /sync/status endpoint - returns service sync health status. - /// REQUIRED BY: Sync Dashboard service status cards. - /// - [Fact] - public async Task GetSyncStatus_ReturnsServiceStatus() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/sync/status"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - - // Should return service health info - Assert.True(result.TryGetProperty("service", out var service)); - Assert.Equal("Scheduling.Api", service.GetString()); - - Assert.True(result.TryGetProperty("status", out var status)); - var statusValue = status.GetString(); - Assert.True( - statusValue == "healthy" || statusValue == "degraded" || statusValue == "unhealthy", - $"Status should be healthy, degraded, or unhealthy but was '{statusValue}'" - ); - - Assert.True(result.TryGetProperty("lastSyncTime", out _)); - Assert.True(result.TryGetProperty("pendingCount", out _)); - Assert.True(result.TryGetProperty("failedCount", out _)); - } - - /// - /// Tests GET /sync/records endpoint - returns paginated sync records. - /// REQUIRED BY: Sync Dashboard sync records table. - /// - [Fact] - public async Task GetSyncRecords_ReturnsPaginatedRecords() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - // Create some data to generate sync records - var practitionerRequest = new - { - Identifier = "NPI-SyncRec", - NameFamily = "SyncRecordTest", - NameGiven = "Doctor", - }; - await client.PostAsJsonAsync("/Practitioner", practitionerRequest); - - var response = await client.GetAsync("/sync/records"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - - // Should return paginated response - Assert.True(result.TryGetProperty("records", out var records)); - Assert.True(records.GetArrayLength() > 0); - - Assert.True(result.TryGetProperty("total", out _)); - Assert.True(result.TryGetProperty("page", out _)); - Assert.True(result.TryGetProperty("pageSize", out _)); - } - - /// - /// Tests GET /sync/records with status filter. - /// REQUIRED BY: Sync Dashboard status filter dropdown. - /// - [Fact] - public async Task GetSyncRecords_FiltersByStatus() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - var response = await client.GetAsync("/sync/records?status=pending"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - - // All returned records should have pending status - var records = result.GetProperty("records"); - foreach (var record in records.EnumerateArray()) - { - Assert.Equal("pending", record.GetProperty("status").GetString()); - } - } - - /// - /// Tests POST /sync/records/{id}/retry endpoint - retries failed sync. - /// REQUIRED BY: Sync Dashboard retry button. - /// - [Fact] - public async Task PostSyncRetry_AcceptsRetryRequest() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - // Test that the endpoint exists and accepts the request - var response = await client.PostAsync("/sync/records/test-record-id/retry", null); - - // Should return 200 OK, 404 Not Found (if record doesn't exist), or 202 Accepted - Assert.True( - response.StatusCode == HttpStatusCode.OK - || response.StatusCode == HttpStatusCode.NotFound - || response.StatusCode == HttpStatusCode.Accepted, - $"Expected OK, NotFound, or Accepted but got {response.StatusCode}" - ); - } - - /// - /// Tests that sync records include required fields for dashboard display. - /// REQUIRED BY: Sync Dashboard table columns. - /// - [Fact] - public async Task GetSyncRecords_ContainsRequiredFields() - { - using var factory = new SchedulingApiFactory(); - var client = CreateAuthenticatedClient(factory); - - // Create data to generate sync records - var practitionerRequest = new - { - Identifier = "NPI-Fields", - NameFamily = "FieldTest", - NameGiven = "Doctor", - }; - await client.PostAsJsonAsync("/Practitioner", practitionerRequest); - - var response = await client.GetAsync("/sync/records"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var result = await response.Content.ReadFromJsonAsync(); - var records = result.GetProperty("records"); - Assert.True(records.GetArrayLength() > 0); - - var firstRecord = records[0]; - - // Required fields for Sync Dashboard UI - Assert.True(firstRecord.TryGetProperty("id", out _), "Missing 'id' field"); - Assert.True(firstRecord.TryGetProperty("entityType", out _), "Missing 'entityType' field"); - Assert.True(firstRecord.TryGetProperty("entityId", out _), "Missing 'entityId' field"); - Assert.True(firstRecord.TryGetProperty("status", out _), "Missing 'status' field"); - Assert.True( - firstRecord.TryGetProperty("lastAttempt", out _), - "Missing 'lastAttempt' field" - ); - } -} diff --git a/Samples/Scheduling/Scheduling.Api/DataProvider.json b/Samples/Scheduling/Scheduling.Api/DataProvider.json deleted file mode 100644 index 611251e6..00000000 --- a/Samples/Scheduling/Scheduling.Api/DataProvider.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "queries": [ - { - "name": "GetUpcomingAppointments", - "sqlFile": "Queries/GetUpcomingAppointments.generated.sql" - }, - { - "name": "GetAppointmentById", - "sqlFile": "Queries/GetAppointmentById.generated.sql" - }, - { - "name": "GetAppointmentsByPatient", - "sqlFile": "Queries/GetAppointmentsByPatient.generated.sql" - }, - { - "name": "GetAppointmentsByPractitioner", - "sqlFile": "Queries/GetAppointmentsByPractitioner.generated.sql" - }, - { - "name": "GetAllPractitioners", - "sqlFile": "Queries/GetAllPractitioners.generated.sql" - }, - { - "name": "GetPractitionerById", - "sqlFile": "Queries/GetPractitionerById.generated.sql" - }, - { - "name": "SearchPractitionersBySpecialty", - "sqlFile": "Queries/SearchPractitionersBySpecialty.generated.sql" - }, - { - "name": "GetAvailableSlots", - "sqlFile": "Queries/GetAvailableSlots.generated.sql" - }, - { - "name": "GetAppointmentsByStatus", - "sqlFile": "Queries/GetAppointmentsByStatus.generated.sql" - }, - { - "name": "CheckSchedulingConflicts", - "sqlFile": "Queries/CheckSchedulingConflicts.generated.sql" - }, - { - "name": "GetProviderAvailability", - "sqlFile": "Queries/GetProviderAvailability.generated.sql" - }, - { - "name": "GetProviderDailySchedule", - "sqlFile": "Queries/GetProviderDailySchedule.generated.sql" - } - ], - "tables": [ - { - "schema": "main", - "name": "fhir_Practitioner", - "generateInsert": true, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - }, - { - "schema": "main", - "name": "fhir_Appointment", - "generateInsert": true, - "generateUpdate": false, - "generateDelete": false, - "excludeColumns": ["Id"], - "primaryKeyColumns": ["Id"] - } - ], - "connectionString": "Data Source=scheduling.db" -} diff --git a/Samples/Scheduling/Scheduling.Api/DatabaseSetup.cs b/Samples/Scheduling/Scheduling.Api/DatabaseSetup.cs deleted file mode 100644 index 3c02dd6a..00000000 --- a/Samples/Scheduling/Scheduling.Api/DatabaseSetup.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Migration; -using Migration.Postgres; -using InitError = Outcome.Result.Error; -using InitOk = Outcome.Result.Ok; -using InitResult = Outcome.Result; - -namespace Scheduling.Api; - -/// -/// Database initialization for Scheduling.Api using Migration tool. -/// All tables follow FHIR R4 resource structure with fhir_ prefix. -/// See: https://build.fhir.org/resourcelist.html -/// -internal static class DatabaseSetup -{ - /// - /// Creates the database schema and sync infrastructure using Migration. - /// Tables conform to FHIR R4 resources. - /// - public static InitResult Initialize(NpgsqlConnection connection, ILogger logger) - { - // Create sync infrastructure - var schemaResult = PostgresSyncSchema.CreateSchema(connection); - if (schemaResult is BoolSyncError err) - { - var msg = SyncHelpers.ToMessage(err.Value); - logger.Log(LogLevel.Error, "Failed to create sync schema: {Error}", msg); - return new InitError($"Failed to create sync schema: {msg}"); - } - - _ = PostgresSyncSchema.SetOriginId(connection, Guid.NewGuid().ToString()); - - // Use Migration tool to create schema from YAML (source of truth) - try - { - var yamlPath = Path.Combine(AppContext.BaseDirectory, "scheduling-schema.yaml"); - var schema = SchemaYamlSerializer.FromYamlFile(yamlPath); - - foreach (var table in schema.Tables) - { - var ddl = PostgresDdlGenerator.Generate(new CreateTableOperation(table)); - using var cmd = connection.CreateCommand(); - cmd.CommandText = ddl; - cmd.ExecuteNonQuery(); - logger.Log(LogLevel.Debug, "Created table {TableName}", table.Name); - } - - logger.Log(LogLevel.Information, "Created Scheduling database schema from YAML"); - } - catch (Exception ex) - { - logger.Log(LogLevel.Error, ex, "Failed to create Scheduling database schema"); - return new InitError($"Failed to create Scheduling database schema: {ex.Message}"); - } - - // Create sync triggers for FHIR resources - _ = PostgresTriggerGenerator.CreateTriggers(connection, "fhir_practitioner", logger); - _ = PostgresTriggerGenerator.CreateTriggers(connection, "fhir_appointment", logger); - _ = PostgresTriggerGenerator.CreateTriggers(connection, "fhir_schedule", logger); - _ = PostgresTriggerGenerator.CreateTriggers(connection, "fhir_slot", logger); - - logger.Log( - LogLevel.Information, - "Scheduling.Api database initialized with FHIR tables and sync triggers" - ); - return new InitOk(true); - } -} diff --git a/Samples/Scheduling/Scheduling.Api/FileLoggerProvider.cs b/Samples/Scheduling/Scheduling.Api/FileLoggerProvider.cs deleted file mode 100644 index 2434cab3..00000000 --- a/Samples/Scheduling/Scheduling.Api/FileLoggerProvider.cs +++ /dev/null @@ -1,109 +0,0 @@ -namespace Scheduling.Api; - -/// -/// Extension methods for adding file logging. -/// -public static class FileLoggingExtensions -{ - /// - /// Adds file logging to the logging builder. - /// - public static ILoggingBuilder AddFileLogging(this ILoggingBuilder builder, string path) - { - // CA2000: DI container takes ownership and disposes when application shuts down -#pragma warning disable CA2000 - builder.Services.AddSingleton(new FileLoggerProvider(path)); -#pragma warning restore CA2000 - return builder; - } -} - -/// -/// Simple file logger provider for writing logs to disk. -/// -public sealed class FileLoggerProvider : ILoggerProvider -{ - private readonly string _path; - private readonly object _lock = new(); - - /// - /// Initializes a new instance of FileLoggerProvider. - /// - public FileLoggerProvider(string path) - { - _path = path; - } - - /// - /// Creates a logger for the specified category. - /// - public ILogger CreateLogger(string categoryName) => new FileLogger(_path, categoryName, _lock); - - /// - /// Disposes the provider. - /// - public void Dispose() - { - // Nothing to dispose - singleton managed by DI container - } -} - -/// -/// Simple file logger that appends log entries to a file. -/// -public sealed class FileLogger : ILogger -{ - private readonly string _path; - private readonly string _category; - private readonly object _lock; - - /// - /// Initializes a new instance of FileLogger. - /// - public FileLogger(string path, string category, object lockObj) - { - _path = path; - _category = category; - _lock = lockObj; - } - - /// - /// Begins a logical operation scope. - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - /// Checks if the given log level is enabled. - /// - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - - /// - /// Writes a log entry to the file. - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter - ) - { - if (!IsEnabled(logLevel)) - { - return; - } - - var message = formatter(state, exception); - var line = $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {_category}: {message}"; - if (exception != null) - { - line += Environment.NewLine + exception; - } - - lock (_lock) - { - File.AppendAllText(_path, line + Environment.NewLine); - } - } -} diff --git a/Samples/Scheduling/Scheduling.Api/GlobalSuppressions.cs b/Samples/Scheduling/Scheduling.Api/GlobalSuppressions.cs deleted file mode 100644 index 99269c73..00000000 --- a/Samples/Scheduling/Scheduling.Api/GlobalSuppressions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -[assembly: SuppressMessage( - "Performance", - "CA1812:Avoid uninstantiated internal classes", - Scope = "type", - Target = "~T:Scheduling.Api.Schedule", - Justification = "Model for future endpoints" -)] -[assembly: SuppressMessage( - "Performance", - "CA1812:Avoid uninstantiated internal classes", - Scope = "type", - Target = "~T:Scheduling.Api.Slot", - Justification = "Model for future endpoints" -)] -[assembly: SuppressMessage( - "Performance", - "CA1812:Avoid uninstantiated internal classes", - Scope = "type", - Target = "~T:Scheduling.Api.SyncedPatient", - Justification = "Model for future endpoints" -)] -[assembly: SuppressMessage( - "Performance", - "CA1812:Avoid uninstantiated internal classes", - Scope = "type", - Target = "~T:Scheduling.Api.CreatePractitionerRequest", - Justification = "Used by minimal API model binding" -)] -[assembly: SuppressMessage( - "Performance", - "CA1812:Avoid uninstantiated internal classes", - Scope = "type", - Target = "~T:Scheduling.Api.CreateAppointmentRequest", - Justification = "Used by minimal API model binding" -)] -[assembly: SuppressMessage( - "Usage", - "CA2100:Review SQL queries for security vulnerabilities", - Justification = "Schema file is trusted" -)] -[assembly: SuppressMessage( - "Design", - "CA1031:Do not catch general exception types", - Justification = "Sample code" -)] -[assembly: SuppressMessage( - "Reliability", - "CA2007:Consider calling ConfigureAwait on the awaited task", - Justification = "ASP.NET Core application" -)] -[assembly: SuppressMessage( - "Performance", - "CA1826:Do not use Enumerable methods on indexable collections", - Justification = "Sample code" -)] -[assembly: SuppressMessage( - "Naming", - "CA1711:Identifiers should not have incorrect suffix", - Justification = "RS1035 analyzer issue" -)] -[assembly: SuppressMessage( - "Design", - "CA1050:Declare types in namespaces", - Scope = "namespaceanddescendants", - Target = "~N", - Justification = "RS1035 analyzer issue" -)] -[assembly: SuppressMessage( - "Reliability", - "RS1035", - Justification = "Sample code - not an analyzer" -)] -[assembly: SuppressMessage("Reliability", "EPC12", Justification = "Sample code")] diff --git a/Samples/Scheduling/Scheduling.Api/GlobalUsings.cs b/Samples/Scheduling/Scheduling.Api/GlobalUsings.cs deleted file mode 100644 index 3a661bb0..00000000 --- a/Samples/Scheduling/Scheduling.Api/GlobalUsings.cs +++ /dev/null @@ -1,116 +0,0 @@ -global using System; -global using Generated; -global using Microsoft.Extensions.Logging; -global using Npgsql; -global using Outcome; -global using Selecta; -global using Sync; -global using Sync.Postgres; -// Sync result type aliases -global using BoolSyncError = Outcome.Result.Error; -global using GetAllPractitionersError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetAllPractitioners query result type aliases -global using GetAllPractitionersOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetAppointmentByIdError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error, Selecta.SqlError>; -// GetAppointmentById query result type aliases -global using GetAppointmentByIdOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetAppointmentsByPatientError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetAppointmentsByPatient query result type aliases -global using GetAppointmentsByPatientOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using GetAppointmentsByPractitionerError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetAppointmentsByPractitioner query result type aliases -global using GetAppointmentsByPractitionerOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using GetPractitionerByIdError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetPractitionerById query result type aliases -global using GetPractitionerByIdOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok, Selecta.SqlError>; -global using GetUpcomingAppointmentsError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// GetUpcomingAppointments query result type aliases -global using GetUpcomingAppointmentsOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using InsertError = Outcome.Result.Error; -// Insert result type aliases -global using InsertOk = Outcome.Result.Ok; -global using SearchPractitionersError = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Error< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -// SearchPractitionersBySpecialty query result type aliases -global using SearchPractitionersOk = Outcome.Result< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->.Ok< - System.Collections.Immutable.ImmutableList, - Selecta.SqlError ->; -global using StringSyncError = Outcome.Result.Error; -global using StringSyncOk = Outcome.Result.Ok; -global using SyncLogListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; diff --git a/Samples/Scheduling/Scheduling.Api/Program.cs b/Samples/Scheduling/Scheduling.Api/Program.cs deleted file mode 100644 index 0b405b71..00000000 --- a/Samples/Scheduling/Scheduling.Api/Program.cs +++ /dev/null @@ -1,821 +0,0 @@ -#pragma warning disable IDE0037 // Use inferred member name - prefer explicit for clarity in API responses - -using System.Collections.Immutable; -using System.Globalization; -using Microsoft.AspNetCore.Http.Json; -using Samples.Authorization; -using Scheduling.Api; -using InitError = Outcome.Result.Error; - -var builder = WebApplication.CreateBuilder(args); - -// File logging - use LOG_PATH env var or default to /tmp in containers -var logPath = - Environment.GetEnvironmentVariable("LOG_PATH") - ?? ( - Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true" - ? "/tmp/scheduling.log" - : Path.Combine(AppContext.BaseDirectory, "scheduling.log") - ); -builder.Logging.AddFileLogging(logPath); - -// Configure JSON to use PascalCase property names -builder.Services.Configure(options => -{ - options.SerializerOptions.PropertyNamingPolicy = null; -}); - -// Add CORS for dashboard - allow any origin for testing -builder.Services.AddCors(options => -{ - options.AddPolicy( - "Dashboard", - policy => - { - policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); - } - ); -}); - -var connectionString = - builder.Configuration.GetConnectionString("Postgres") - ?? throw new InvalidOperationException("PostgreSQL connection string 'Postgres' is required"); - -// Register a FACTORY that creates new connections - NOT a singleton connection -builder.Services.AddSingleton(() => -{ - var conn = new NpgsqlConnection(connectionString); - conn.Open(); - return conn; -}); - -// Gatekeeper configuration for authorization -var gatekeeperUrl = builder.Configuration["Gatekeeper:BaseUrl"] ?? "http://localhost:5002"; -var signingKeyBase64 = builder.Configuration["Jwt:SigningKey"]; -var signingKey = string.IsNullOrEmpty(signingKeyBase64) - ? ImmutableArray.Create(new byte[32]) // Default empty key for development (MUST configure in production) - : ImmutableArray.Create(Convert.FromBase64String(signingKeyBase64)); - -builder.Services.AddHttpClient( - "Gatekeeper", - client => - { - client.BaseAddress = new Uri(gatekeeperUrl); - client.Timeout = TimeSpan.FromSeconds(5); - } -); - -var app = builder.Build(); - -using (var conn = new NpgsqlConnection(connectionString)) -{ - conn.Open(); - if (DatabaseSetup.Initialize(conn, app.Logger) is InitError initErr) - Environment.FailFast(initErr.Value); -} - -// Enable CORS -app.UseCors("Dashboard"); - -// Health endpoint for sync service startup checks -app.MapGet("/health", () => Results.Ok(new { status = "healthy", service = "Scheduling.Api" })); - -// Get HttpClientFactory for auth filters -var httpClientFactory = app.Services.GetRequiredService(); -Func getGatekeeperClient = () => httpClientFactory.CreateClient("Gatekeeper"); - -// === FHIR PRACTITIONER ENDPOINTS === - -app.MapGet( - "/Practitioner", - async (Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetAllPractitionersAsync().ConfigureAwait(false); - return result switch - { - GetAllPractitionersOk ok => Results.Ok(ok.Value), - GetAllPractitionersError err => Results.Problem(err.Value.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PractitionerRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/Practitioner/{id}", - async (string id, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetPractitionerByIdAsync(id).ConfigureAwait(false); - return result switch - { - GetPractitionerByIdOk ok when ok.Value.Count > 0 => Results.Ok(ok.Value[0]), - GetPractitionerByIdOk => Results.NotFound(), - GetPractitionerByIdError err => Results.Problem(err.Value.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PractitionerRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapPost( - "/Practitioner", - async (CreatePractitionerRequest request, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - var id = Guid.NewGuid().ToString(); - - var result = await transaction - .Insertfhir_PractitionerAsync( - id, - request.Identifier, - 1L, - request.NameFamily, - request.NameGiven, - request.Qualification ?? string.Empty, - request.Specialty ?? string.Empty, - request.TelecomEmail ?? string.Empty, - request.TelecomPhone ?? string.Empty - ) - .ConfigureAwait(false); - - if (result is InsertOk) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Created( - $"/Practitioner/{id}", - new - { - Id = id, - Identifier = request.Identifier, - Active = true, - NameFamily = request.NameFamily, - NameGiven = request.NameGiven, - Qualification = request.Qualification, - Specialty = request.Specialty, - TelecomEmail = request.TelecomEmail, - TelecomPhone = request.TelecomPhone, - } - ); - } - - return result switch - { - InsertOk => Results.Problem("Unexpected success after handling"), - InsertError err => Results.Problem(err.Value.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PractitionerCreate, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapPut( - "/Practitioner/{id}", - async (string id, UpdatePractitionerRequest request, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - - using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = """ - UPDATE fhir_Practitioner - SET NameFamily = @nameFamily, - NameGiven = @nameGiven, - Qualification = @qualification, - Specialty = @specialty, - TelecomEmail = @telecomEmail, - TelecomPhone = @telecomPhone, - Active = @active - WHERE Id = @id - """; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@nameFamily", request.NameFamily); - cmd.Parameters.AddWithValue("@nameGiven", request.NameGiven); - cmd.Parameters.AddWithValue("@qualification", request.Qualification ?? string.Empty); - cmd.Parameters.AddWithValue("@specialty", request.Specialty ?? string.Empty); - cmd.Parameters.AddWithValue("@telecomEmail", request.TelecomEmail ?? string.Empty); - cmd.Parameters.AddWithValue("@telecomPhone", request.TelecomPhone ?? string.Empty); - cmd.Parameters.AddWithValue("@active", request.Active ? 1 : 0); - - var rowsAffected = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - if (rowsAffected > 0) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Ok( - new - { - Id = id, - Identifier = request.Identifier, - Active = request.Active, - NameFamily = request.NameFamily, - NameGiven = request.NameGiven, - Qualification = request.Qualification, - Specialty = request.Specialty, - TelecomEmail = request.TelecomEmail, - TelecomPhone = request.TelecomPhone, - } - ); - } - - return Results.NotFound(); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PractitionerUpdate, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/Practitioner/_search", - async (string? specialty, Func getConn) => - { - using var conn = getConn(); - - if (specialty is not null) - { - var result = await conn.SearchPractitionersBySpecialtyAsync(specialty) - .ConfigureAwait(false); - return result switch - { - SearchPractitionersOk ok => Results.Ok(ok.Value), - SearchPractitionersError err => Results.Problem(err.Value.Message), - }; - } - else - { - var result = await conn.GetAllPractitionersAsync().ConfigureAwait(false); - return result switch - { - GetAllPractitionersOk ok => Results.Ok(ok.Value), - GetAllPractitionersError err => Results.Problem(err.Value.Message), - }; - } - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.PractitionerRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -// === FHIR APPOINTMENT ENDPOINTS === - -app.MapGet( - "/Appointment", - async (Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetUpcomingAppointmentsAsync().ConfigureAwait(false); - return result switch - { - GetUpcomingAppointmentsOk ok => Results.Ok(ok.Value), - GetUpcomingAppointmentsError err => Results.Problem(err.Value.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.AppointmentRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/Appointment/{id}", - async (string id, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetAppointmentByIdAsync(id).ConfigureAwait(false); - return result switch - { - GetAppointmentByIdOk ok when ok.Value.Count > 0 => Results.Ok(ok.Value[0]), - GetAppointmentByIdOk => Results.NotFound(), - GetAppointmentByIdError err => Results.Problem(err.Value.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequireResourcePermission( - FhirPermissions.AppointmentRead, - signingKey, - getGatekeeperClient, - app.Logger, - "id" - ) - ); - -app.MapPost( - "/Appointment", - async (CreateAppointmentRequest request, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - var id = Guid.NewGuid().ToString(); - var now = DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ); - var start = DateTime.Parse(request.Start, CultureInfo.InvariantCulture); - var end = DateTime.Parse(request.End, CultureInfo.InvariantCulture); - var durationMinutes = (int)(end - start).TotalMinutes; - - var result = await transaction - .Insertfhir_AppointmentAsync( - id, - "booked", - request.ServiceCategory ?? string.Empty, - request.ServiceType ?? string.Empty, - request.ReasonCode ?? string.Empty, - request.Priority, - request.Description ?? string.Empty, - request.Start, - request.End, - durationMinutes, - request.PatientReference, - request.PractitionerReference, - now, - request.Comment ?? string.Empty - ) - .ConfigureAwait(false); - - if (result is InsertOk) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Created( - $"/Appointment/{id}", - new - { - Id = id, - Status = "booked", - ServiceCategory = request.ServiceCategory, - ServiceType = request.ServiceType, - ReasonCode = request.ReasonCode, - Priority = request.Priority, - Description = request.Description, - Start = request.Start, - End = request.End, - MinutesDuration = durationMinutes, - PatientReference = request.PatientReference, - PractitionerReference = request.PractitionerReference, - Created = now, - Comment = request.Comment, - } - ); - } - - return result switch - { - InsertOk => Results.Problem("Unexpected success after handling"), - InsertError err => Results.Problem(err.Value.DetailedMessage), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.AppointmentCreate, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapPut( - "/Appointment/{id}", - async (string id, UpdateAppointmentRequest request, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - - var start = DateTime.Parse(request.Start, CultureInfo.InvariantCulture); - var end = DateTime.Parse(request.End, CultureInfo.InvariantCulture); - var durationMinutes = (int)(end - start).TotalMinutes; - - using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = """ - UPDATE fhir_Appointment - SET ServiceCategory = @serviceCategory, - ServiceType = @serviceType, - ReasonCode = @reasonCode, - Priority = @priority, - Description = @description, - StartTime = @start, - EndTime = @end, - MinutesDuration = @duration, - PatientReference = @patientRef, - PractitionerReference = @practitionerRef, - Comment = @comment, - Status = @status - WHERE Id = @id - """; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue( - "@serviceCategory", - request.ServiceCategory ?? string.Empty - ); - cmd.Parameters.AddWithValue("@serviceType", request.ServiceType ?? string.Empty); - cmd.Parameters.AddWithValue("@reasonCode", request.ReasonCode ?? string.Empty); - cmd.Parameters.AddWithValue("@priority", request.Priority); - cmd.Parameters.AddWithValue("@description", request.Description ?? string.Empty); - cmd.Parameters.AddWithValue("@start", request.Start); - cmd.Parameters.AddWithValue("@end", request.End); - cmd.Parameters.AddWithValue("@duration", durationMinutes); - cmd.Parameters.AddWithValue("@patientRef", request.PatientReference); - cmd.Parameters.AddWithValue("@practitionerRef", request.PractitionerReference); - cmd.Parameters.AddWithValue("@comment", request.Comment ?? string.Empty); - cmd.Parameters.AddWithValue("@status", request.Status); - - var rowsAffected = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - if (rowsAffected > 0) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Ok( - new - { - Id = id, - Status = request.Status, - ServiceCategory = request.ServiceCategory, - ServiceType = request.ServiceType, - ReasonCode = request.ReasonCode, - Priority = request.Priority, - Description = request.Description, - Start = request.Start, - End = request.End, - MinutesDuration = durationMinutes, - PatientReference = request.PatientReference, - PractitionerReference = request.PractitionerReference, - Comment = request.Comment, - } - ); - } - - return Results.NotFound(); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequireResourcePermission( - FhirPermissions.AppointmentUpdate, - signingKey, - getGatekeeperClient, - app.Logger, - "id" - ) - ); - -app.MapPatch( - "/Appointment/{id}/status", - async (string id, string status, Func getConn) => - { - using var conn = getConn(); - var transaction = await conn.BeginTransactionAsync().ConfigureAwait(false); - await using var _ = transaction.ConfigureAwait(false); - - using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = "UPDATE fhir_Appointment SET Status = @status WHERE Id = @id"; - cmd.Parameters.AddWithValue("@status", status); - cmd.Parameters.AddWithValue("@id", id); - - var rowsAffected = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - - if (rowsAffected > 0) - { - await transaction.CommitAsync().ConfigureAwait(false); - return Results.Ok(new { id, status }); - } - - return Results.NotFound(); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequireResourcePermission( - FhirPermissions.AppointmentUpdate, - signingKey, - getGatekeeperClient, - app.Logger, - "id" - ) - ); - -app.MapGet( - "/Patient/{patientId}/Appointment", - async (string patientId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetAppointmentsByPatientAsync($"Patient/{patientId}") - .ConfigureAwait(false); - return result switch - { - GetAppointmentsByPatientOk ok => Results.Ok(ok.Value), - GetAppointmentsByPatientError err => Results.Problem(err.Value.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePatientPermission( - FhirPermissions.AppointmentRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/Practitioner/{practitionerId}/Appointment", - async (string practitionerId, Func getConn) => - { - using var conn = getConn(); - var result = await conn.GetAppointmentsByPractitionerAsync( - $"Practitioner/{practitionerId}" - ) - .ConfigureAwait(false); - return result switch - { - GetAppointmentsByPractitionerOk ok => Results.Ok(ok.Value), - GetAppointmentsByPractitionerError err => Results.Problem(err.Value.Message), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.AppointmentRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -// === SYNC ENDPOINTS === - -app.MapGet( - "/sync/changes", - (long? fromVersion, int? limit, Func getConn) => - { - using var conn = getConn(); - var result = PostgresSyncLogRepository.FetchChanges( - conn, - fromVersion ?? 0, - limit ?? 100 - ); - return result switch - { - SyncLogListOk ok => Results.Ok(ok.Value), - SyncLogListError err => Results.Problem(SyncHelpers.ToMessage(err.Value)), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/origin", - (Func getConn) => - { - using var conn = getConn(); - var result = PostgresSyncSchema.GetOriginId(conn); - return result switch - { - StringSyncOk ok => Results.Ok(new { originId = ok.Value }), - StringSyncError err => Results.Problem(SyncHelpers.ToMessage(err.Value)), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/status", - (Func getConn) => - { - using var conn = getConn(); - var changesResult = PostgresSyncLogRepository.FetchChanges(conn, 0, 1000); - var (pendingCount, failedCount, lastSyncTime) = changesResult switch - { - SyncLogListOk(var logs) => ( - logs.Count(l => l.Operation == SyncOperation.Insert), - 0, - logs.Count > 0 - ? logs.Max(l => l.Timestamp) - : DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ) - ), - SyncLogListError => ( - 0, - 0, - DateTime.UtcNow.ToString( - "yyyy-MM-ddTHH:mm:ss.fffZ", - CultureInfo.InvariantCulture - ) - ), - }; - - return Results.Ok( - new - { - service = "Scheduling.Api", - status = "healthy", - lastSyncTime, - pendingCount, - failedCount, - } - ); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapGet( - "/sync/records", - ( - string? status, - string? search, - int? page, - int? pageSize, - Func getConn - ) => - { - using var conn = getConn(); - var currentPage = page ?? 1; - var size = pageSize ?? 50; - var changesResult = PostgresSyncLogRepository.FetchChanges(conn, 0, 1000); - - return changesResult switch - { - SyncLogListOk(var logs) => Results.Ok( - BuildSyncRecordsResponse(logs, status, search, currentPage, size) - ), - SyncLogListError(var err) => Results.Problem(SyncHelpers.ToMessage(err)), - }; - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.MapPost( - "/sync/records/{id}/retry", - (string id) => - { - // For now, just acknowledge the retry request - // Real implementation would mark the record for re-sync - return Results.Accepted(); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncWrite, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -// Query synced patients from Clinical domain -app.MapGet( - "/sync/patients", - (Func getConn) => - { - using var conn = getConn(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = - "SELECT PatientId, DisplayName, ContactPhone, ContactEmail, SyncedAt FROM sync_ScheduledPatient"; - using var reader = cmd.ExecuteReader(); - var patients = new List(); - while (reader.Read()) - { - patients.Add( - new - { - PatientId = reader.GetString(0), - DisplayName = reader.GetString(1), - ContactPhone = reader.IsDBNull(2) ? null : reader.GetString(2), - ContactEmail = reader.IsDBNull(3) ? null : reader.GetString(3), - SyncedAt = reader.GetString(4), - } - ); - } - return Results.Ok(patients); - } - ) - .AddEndpointFilterFactory( - EndpointFilterFactories.RequirePermission( - FhirPermissions.SyncRead, - signingKey, - getGatekeeperClient, - app.Logger - ) - ); - -app.Run(); - -static object BuildSyncRecordsResponse( - IReadOnlyList logs, - string? statusFilter, - string? search, - int page, - int pageSize -) -{ - var records = logs.Select(l => new - { - id = l.Version.ToString(CultureInfo.InvariantCulture), - entityType = l.TableName, - entityId = l.PkValue, - status = "pending", - lastAttempt = l.Timestamp, - operation = l.Operation, - }); - - if (!string.IsNullOrEmpty(statusFilter)) - { - records = records.Where(r => r.status == statusFilter); - } - - if (!string.IsNullOrEmpty(search)) - { - records = records.Where(r => - r.entityId.Contains(search, StringComparison.OrdinalIgnoreCase) - ); - } - - var recordList = records.ToList(); - var total = recordList.Count; - var pagedRecords = recordList.Skip((page - 1) * pageSize).Take(pageSize).ToList(); - - return new - { - records = pagedRecords, - total, - page, - pageSize, - }; -} - -namespace Scheduling.Api -{ - /// - /// Program entry point marker for WebApplicationFactory. - /// - public partial class Program { } -} diff --git a/Samples/Scheduling/Scheduling.Api/Properties/launchSettings.json b/Samples/Scheduling/Scheduling.Api/Properties/launchSettings.json deleted file mode 100644 index 83ddd5ac..00000000 --- a/Samples/Scheduling/Scheduling.Api/Properties/launchSettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "profiles": { - "Scheduling.Api": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5001", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ConnectionStrings__Postgres": "Host=localhost;Database=scheduling;Username=scheduling;Password=changeme" - } - } - } -} diff --git a/Samples/Scheduling/Scheduling.Api/Queries/CheckSchedulingConflicts.lql b/Samples/Scheduling/Scheduling.Api/Queries/CheckSchedulingConflicts.lql deleted file mode 100644 index 2885529a..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/CheckSchedulingConflicts.lql +++ /dev/null @@ -1,5 +0,0 @@ --- Check for scheduling conflicts --- Parameters: @practitionerRef, @proposedStart, @proposedEnd -fhir_Appointment -|> filter(fn(row) => row.fhir_Appointment.PractitionerReference = @practitionerRef and row.fhir_Appointment.Status != 'cancelled' and row.fhir_Appointment.StartTime < @proposedEnd and row.fhir_Appointment.EndTime > @proposedStart) -|> select(fhir_Appointment.Id, fhir_Appointment.StartTime, fhir_Appointment.EndTime, fhir_Appointment.Status) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetAllPractitioners.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetAllPractitioners.lql deleted file mode 100644 index cd02457f..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetAllPractitioners.lql +++ /dev/null @@ -1,4 +0,0 @@ --- Get all practitioners -fhir_Practitioner -|> select(fhir_Practitioner.Id, fhir_Practitioner.Identifier, fhir_Practitioner.Active, fhir_Practitioner.NameFamily, fhir_Practitioner.NameGiven, fhir_Practitioner.Qualification, fhir_Practitioner.Specialty, fhir_Practitioner.TelecomEmail, fhir_Practitioner.TelecomPhone) -|> order_by(fhir_Practitioner.NameFamily, fhir_Practitioner.NameGiven) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentById.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentById.lql deleted file mode 100644 index d12e4a72..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentById.lql +++ /dev/null @@ -1,5 +0,0 @@ --- Get appointment by ID --- Parameters: @id -fhir_Appointment -|> filter(fn(row) => row.fhir_Appointment.Id = @id) -|> select(fhir_Appointment.Id, fhir_Appointment.Status, fhir_Appointment.ServiceCategory, fhir_Appointment.ServiceType, fhir_Appointment.ReasonCode, fhir_Appointment.Priority, fhir_Appointment.Description, fhir_Appointment.StartTime, fhir_Appointment.EndTime, fhir_Appointment.MinutesDuration, fhir_Appointment.PatientReference, fhir_Appointment.PractitionerReference, fhir_Appointment.Created, fhir_Appointment.Comment) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByPatient.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByPatient.lql deleted file mode 100644 index 11bd7cc8..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByPatient.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Get appointments for a patient --- Parameters: @patientReference -fhir_Appointment -|> filter(fn(row) => row.fhir_Appointment.PatientReference = @patientReference) -|> select(fhir_Appointment.Id, fhir_Appointment.Status, fhir_Appointment.ServiceCategory, fhir_Appointment.ServiceType, fhir_Appointment.ReasonCode, fhir_Appointment.Priority, fhir_Appointment.Description, fhir_Appointment.StartTime, fhir_Appointment.EndTime, fhir_Appointment.MinutesDuration, fhir_Appointment.PatientReference, fhir_Appointment.PractitionerReference, fhir_Appointment.Created, fhir_Appointment.Comment) -|> order_by(fhir_Appointment.StartTime desc) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByPractitioner.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByPractitioner.lql deleted file mode 100644 index 97effa55..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByPractitioner.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Get appointments for a practitioner --- Parameters: @practitionerReference -fhir_Appointment -|> filter(fn(row) => row.fhir_Appointment.PractitionerReference = @practitionerReference and row.fhir_Appointment.Status = 'booked') -|> select(fhir_Appointment.Id, fhir_Appointment.Status, fhir_Appointment.ServiceCategory, fhir_Appointment.ServiceType, fhir_Appointment.ReasonCode, fhir_Appointment.Priority, fhir_Appointment.Description, fhir_Appointment.StartTime, fhir_Appointment.EndTime, fhir_Appointment.MinutesDuration, fhir_Appointment.PatientReference, fhir_Appointment.PractitionerReference, fhir_Appointment.Created, fhir_Appointment.Comment) -|> order_by(fhir_Appointment.StartTime) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByStatus.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByStatus.lql deleted file mode 100644 index 32fa9b8c..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetAppointmentsByStatus.lql +++ /dev/null @@ -1,8 +0,0 @@ --- Get appointments by status with patient and practitioner info --- Parameters: @status, @dateStart, @dateEnd -fhir_Appointment -|> join(sync_ScheduledPatient, on = fhir_Appointment.PatientReference = sync_ScheduledPatient.PatientId) -|> join(fhir_Practitioner, on = fhir_Appointment.PractitionerReference = fhir_Practitioner.Id) -|> filter(fn(row) => row.fhir_Appointment.Status = @status and row.fhir_Appointment.StartTime >= @dateStart and row.fhir_Appointment.StartTime < @dateEnd) -|> select(fhir_Appointment.Id, fhir_Appointment.StartTime, fhir_Appointment.EndTime, fhir_Appointment.Status, sync_ScheduledPatient.DisplayName, fhir_Practitioner.NameFamily, fhir_Practitioner.NameGiven, fhir_Appointment.ServiceType, fhir_Appointment.ReasonCode) -|> order_by(fhir_Appointment.StartTime) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetAvailableSlots.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetAvailableSlots.lql deleted file mode 100644 index 487e8a87..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetAvailableSlots.lql +++ /dev/null @@ -1,7 +0,0 @@ --- Get available slots for a practitioner --- Parameters: @practitionerRef, @fromDate, @toDate -fhir_Slot -|> join(fhir_Schedule, on = fhir_Slot.ScheduleReference = fhir_Schedule.Id) -|> filter(fn(row) => row.fhir_Schedule.PractitionerReference = @practitionerRef and row.fhir_Slot.Status = 'free' and row.fhir_Slot.StartTime >= @fromDate and row.fhir_Slot.StartTime < @toDate) -|> select(fhir_Slot.Id, fhir_Slot.Status, fhir_Slot.StartTime, fhir_Slot.EndTime, fhir_Schedule.PractitionerReference) -|> order_by(fhir_Slot.StartTime) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetPractitionerById.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetPractitionerById.lql deleted file mode 100644 index 8aeb5702..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetPractitionerById.lql +++ /dev/null @@ -1,5 +0,0 @@ --- Get practitioner by ID --- Parameters: @id -fhir_Practitioner -|> filter(fn(row) => row.fhir_Practitioner.Id = @id) -|> select(fhir_Practitioner.Id, fhir_Practitioner.Identifier, fhir_Practitioner.Active, fhir_Practitioner.NameFamily, fhir_Practitioner.NameGiven, fhir_Practitioner.Qualification, fhir_Practitioner.Specialty, fhir_Practitioner.TelecomEmail, fhir_Practitioner.TelecomPhone) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetProviderAvailability.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetProviderAvailability.lql deleted file mode 100644 index b033b7b4..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetProviderAvailability.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Get provider availability schedule --- Parameters: @practitionerRef -fhir_Schedule -|> join(fhir_Practitioner, on = fhir_Schedule.PractitionerReference = fhir_Practitioner.Id) -|> filter(fn(row) => row.fhir_Schedule.PractitionerReference = @practitionerRef and row.fhir_Schedule.Active = 1) -|> select(fhir_Schedule.Id, fhir_Schedule.PractitionerReference, fhir_Practitioner.NameFamily, fhir_Practitioner.NameGiven, fhir_Schedule.PlanningHorizon, fhir_Schedule.Active) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetProviderDailySchedule.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetProviderDailySchedule.lql deleted file mode 100644 index 36c1fd96..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetProviderDailySchedule.lql +++ /dev/null @@ -1,7 +0,0 @@ --- Get provider daily schedule with patient info --- Parameters: @practitionerRef, @dateStart, @dateEnd -fhir_Appointment -|> join(sync_ScheduledPatient, on = fhir_Appointment.PatientReference = sync_ScheduledPatient.PatientId) -|> filter(fn(row) => row.fhir_Appointment.PractitionerReference = @practitionerRef and row.fhir_Appointment.StartTime >= @dateStart and row.fhir_Appointment.StartTime < @dateEnd) -|> select(fhir_Appointment.Id, fhir_Appointment.StartTime, fhir_Appointment.EndTime, fhir_Appointment.MinutesDuration, fhir_Appointment.Status, fhir_Appointment.ServiceCategory, fhir_Appointment.ServiceType, fhir_Appointment.ReasonCode, fhir_Appointment.Description, fhir_Appointment.PatientReference, sync_ScheduledPatient.PatientId, sync_ScheduledPatient.DisplayName, sync_ScheduledPatient.ContactPhone, fhir_Appointment.PractitionerReference) -|> order_by(fhir_Appointment.StartTime) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/GetUpcomingAppointments.lql b/Samples/Scheduling/Scheduling.Api/Queries/GetUpcomingAppointments.lql deleted file mode 100644 index 44893ddb..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/GetUpcomingAppointments.lql +++ /dev/null @@ -1,5 +0,0 @@ --- Get all booked appointments (no limit - calendar needs all appointments) -fhir_Appointment -|> filter(fn(row) => row.fhir_Appointment.Status = 'booked') -|> select(fhir_Appointment.Id, fhir_Appointment.Status, fhir_Appointment.ServiceCategory, fhir_Appointment.ServiceType, fhir_Appointment.ReasonCode, fhir_Appointment.Priority, fhir_Appointment.Description, fhir_Appointment.StartTime, fhir_Appointment.EndTime, fhir_Appointment.MinutesDuration, fhir_Appointment.PatientReference, fhir_Appointment.PractitionerReference, fhir_Appointment.Created, fhir_Appointment.Comment) -|> order_by(fhir_Appointment.StartTime) diff --git a/Samples/Scheduling/Scheduling.Api/Queries/SearchPractitionersBySpecialty.lql b/Samples/Scheduling/Scheduling.Api/Queries/SearchPractitionersBySpecialty.lql deleted file mode 100644 index b7ee6858..00000000 --- a/Samples/Scheduling/Scheduling.Api/Queries/SearchPractitionersBySpecialty.lql +++ /dev/null @@ -1,6 +0,0 @@ --- Search practitioners by specialty --- Parameters: @specialty -fhir_Practitioner -|> filter(fn(row) => row.fhir_Practitioner.Specialty like '%' || @specialty || '%') -|> select(fhir_Practitioner.Id, fhir_Practitioner.Identifier, fhir_Practitioner.Active, fhir_Practitioner.NameFamily, fhir_Practitioner.NameGiven, fhir_Practitioner.Qualification, fhir_Practitioner.Specialty, fhir_Practitioner.TelecomEmail, fhir_Practitioner.TelecomPhone) -|> order_by(fhir_Practitioner.NameFamily, fhir_Practitioner.NameGiven) diff --git a/Samples/Scheduling/Scheduling.Api/Requests.cs b/Samples/Scheduling/Scheduling.Api/Requests.cs deleted file mode 100644 index f88641bb..00000000 --- a/Samples/Scheduling/Scheduling.Api/Requests.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace Scheduling.Api; - -/// -/// Create practitioner request. -/// -internal sealed record CreatePractitionerRequest( - string Identifier, - string NameFamily, - string NameGiven, - string? Qualification, - string? Specialty, - string? TelecomEmail, - string? TelecomPhone -); - -/// -/// Update practitioner request. -/// -internal sealed record UpdatePractitionerRequest( - string Identifier, - bool Active, - string NameFamily, - string NameGiven, - string? Qualification, - string? Specialty, - string? TelecomEmail, - string? TelecomPhone -); - -/// -/// Create appointment request. -/// -internal sealed record CreateAppointmentRequest( - string ServiceCategory, - string ServiceType, - string? ReasonCode, - string Priority, - string? Description, - string Start, - string End, - string PatientReference, - string PractitionerReference, - string? Comment -); - -/// -/// Update appointment request. -/// -internal sealed record UpdateAppointmentRequest( - string ServiceCategory, - string ServiceType, - string? ReasonCode, - string Priority, - string? Description, - string Start, - string End, - string PatientReference, - string PractitionerReference, - string? Comment, - string Status -); diff --git a/Samples/Scheduling/Scheduling.Api/Scheduling.Api.csproj b/Samples/Scheduling/Scheduling.Api/Scheduling.Api.csproj deleted file mode 100644 index 9ea69746..00000000 --- a/Samples/Scheduling/Scheduling.Api/Scheduling.Api.csproj +++ /dev/null @@ -1,80 +0,0 @@ - - - Exe - CA1515;CA2100;RS1035;CA1508;CA2234;CA1812 - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/Scheduling/Scheduling.Api/SyncHelpers.cs b/Samples/Scheduling/Scheduling.Api/SyncHelpers.cs deleted file mode 100644 index 039688ed..00000000 --- a/Samples/Scheduling/Scheduling.Api/SyncHelpers.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Scheduling.Api; - -/// -/// Helper methods for sync operations. -/// -internal static class SyncHelpers -{ - /// - /// Converts a SyncError to a displayable error message. - /// - public static string ToMessage(SyncError error) => - error switch - { - SyncErrorDatabase db => db.Message, - SyncErrorForeignKeyViolation fk => $"FK violation in {fk.TableName}: {fk.Details}", - SyncErrorHashMismatch hash => - $"Hash mismatch: expected {hash.ExpectedHash}, got {hash.ActualHash}", - SyncErrorFullResyncRequired resync => - $"Full resync required: client at {resync.ClientVersion}, oldest available {resync.OldestAvailableVersion}", - SyncErrorDeferredChangeFailed deferred => $"Deferred change failed: {deferred.Reason}", - SyncErrorUnresolvedConflict => "Unresolved conflict detected", - _ => "Unknown sync error", - }; -} diff --git a/Samples/Scheduling/Scheduling.Api/scheduling-schema.yaml b/Samples/Scheduling/Scheduling.Api/scheduling-schema.yaml deleted file mode 100644 index ec1d9107..00000000 --- a/Samples/Scheduling/Scheduling.Api/scheduling-schema.yaml +++ /dev/null @@ -1,162 +0,0 @@ -name: scheduling -tables: -- name: fhir_Practitioner - columns: - - name: Id - type: Text - - name: Identifier - type: Text - - name: Active - type: Int - defaultValue: 1 - - name: NameFamily - type: Text - - name: NameGiven - type: Text - - name: Qualification - type: Text - - name: Specialty - type: Text - - name: TelecomEmail - type: Text - - name: TelecomPhone - type: Text - indexes: - - name: idx_practitioner_identifier - columns: - - Identifier - - name: idx_practitioner_specialty - columns: - - Specialty - primaryKey: - name: PK_fhir_Practitioner - columns: - - Id -- name: fhir_Schedule - columns: - - name: Id - type: Text - - name: Active - type: Int - defaultValue: 1 - - name: PractitionerReference - type: Text - - name: PlanningHorizon - type: Int - defaultValue: 30 - - name: Comment - type: Text - indexes: - - name: idx_schedule_practitioner - columns: - - PractitionerReference - foreignKeys: - - name: FK_fhir_Schedule_PractitionerReference - columns: - - PractitionerReference - referencedTable: fhir_Practitioner - referencedColumns: - - Id - primaryKey: - name: PK_fhir_Schedule - columns: - - Id -- name: fhir_Slot - columns: - - name: Id - type: Text - - name: ScheduleReference - type: Text - - name: Status - type: Text - checkConstraint: 'Status IN (''free'', ''busy'', ''busy-unavailable'', ''busy-tentative'')' - - name: StartTime - type: Text - - name: EndTime - type: Text - - name: Overbooked - type: Int - defaultValue: 0 - - name: Comment - type: Text - indexes: - - name: idx_slot_schedule - columns: - - ScheduleReference - - name: idx_slot_status - columns: - - Status - foreignKeys: - - name: FK_fhir_Slot_ScheduleReference - columns: - - ScheduleReference - referencedTable: fhir_Schedule - referencedColumns: - - Id - primaryKey: - name: PK_fhir_Slot - columns: - - Id -- name: fhir_Appointment - columns: - - name: Id - type: Text - - name: Status - type: Text - checkConstraint: 'Status IN (''proposed'', ''pending'', ''booked'', ''arrived'', ''fulfilled'', ''cancelled'', ''noshow'', ''entered-in-error'', ''checked-in'', ''waitlist'')' - - name: ServiceCategory - type: Text - - name: ServiceType - type: Text - - name: ReasonCode - type: Text - - name: Priority - type: Text - checkConstraint: 'Priority IN (''routine'', ''urgent'', ''asap'', ''stat'')' - - name: Description - type: Text - - name: StartTime - type: Text - - name: EndTime - type: Text - - name: MinutesDuration - type: Int - - name: PatientReference - type: Text - - name: PractitionerReference - type: Text - - name: Created - type: Text - - name: Comment - type: Text - indexes: - - name: idx_appointment_status - columns: - - Status - - name: idx_appointment_patient - columns: - - PatientReference - - name: idx_appointment_practitioner - columns: - - PractitionerReference - primaryKey: - name: PK_fhir_Appointment - columns: - - Id -- name: sync_ScheduledPatient - columns: - - name: PatientId - type: Text - - name: DisplayName - type: Text - - name: ContactPhone - type: Text - - name: ContactEmail - type: Text - - name: SyncedAt - type: Text - defaultValue: CURRENT_TIMESTAMP - primaryKey: - name: PK_sync_ScheduledPatient - columns: - - PatientId diff --git a/Samples/Scheduling/Scheduling.Sync/GlobalUsings.cs b/Samples/Scheduling/Scheduling.Sync/GlobalUsings.cs deleted file mode 100644 index 419e049a..00000000 --- a/Samples/Scheduling/Scheduling.Sync/GlobalUsings.cs +++ /dev/null @@ -1,4 +0,0 @@ -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Hosting; -global using Microsoft.Extensions.Logging; -global using Npgsql; diff --git a/Samples/Scheduling/Scheduling.Sync/Program.cs b/Samples/Scheduling/Scheduling.Sync/Program.cs deleted file mode 100644 index 719217cd..00000000 --- a/Samples/Scheduling/Scheduling.Sync/Program.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Scheduling.Sync; - -var builder = Host.CreateApplicationBuilder(args); - -var connectionString = - Environment.GetEnvironmentVariable("SCHEDULING_CONNECTION_STRING") - ?? builder.Configuration.GetConnectionString("Postgres") - ?? throw new InvalidOperationException("PostgreSQL connection string required"); -var clinicalApiUrl = - Environment.GetEnvironmentVariable("CLINICAL_API_URL") ?? "http://localhost:5080"; - -Console.WriteLine($"[Scheduling.Sync] Clinical API URL: {clinicalApiUrl}"); - -builder.Services.AddSingleton>(_ => - () => - { - var conn = new NpgsqlConnection(connectionString); - conn.Open(); - return conn; - } -); - -builder.Services.AddHostedService(sp => -{ - var logger = sp.GetRequiredService>(); - var getConn = sp.GetRequiredService>(); - return new SchedulingSyncWorker(logger, getConn, clinicalApiUrl); -}); - -var host = builder.Build(); -await host.RunAsync().ConfigureAwait(false); diff --git a/Samples/Scheduling/Scheduling.Sync/Scheduling.Sync.csproj b/Samples/Scheduling/Scheduling.Sync/Scheduling.Sync.csproj deleted file mode 100644 index 7a0841cd..00000000 --- a/Samples/Scheduling/Scheduling.Sync/Scheduling.Sync.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - Exe - - - - - - - - - - - - diff --git a/Samples/Scheduling/Scheduling.Sync/SchedulingSyncWorker.cs b/Samples/Scheduling/Scheduling.Sync/SchedulingSyncWorker.cs deleted file mode 100644 index b1220fba..00000000 --- a/Samples/Scheduling/Scheduling.Sync/SchedulingSyncWorker.cs +++ /dev/null @@ -1,374 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace Scheduling.Sync; - -/// -/// Background worker that syncs patient data from Clinical domain to Scheduling domain. -/// Applies column mappings defined in SyncMappings.json. -/// -/// -/// Instantiated by the DI container as a hosted service. -/// -#pragma warning disable CA1812 // Instantiated by DI -internal sealed class SchedulingSyncWorker : BackgroundService -#pragma warning restore CA1812 -{ - private readonly ILogger _logger; - private readonly Func _getConnection; - private readonly string _clinicalEndpoint; - private readonly int _pollIntervalSeconds; - - /// - /// Creates a new scheduling sync worker. - /// - public SchedulingSyncWorker( - ILogger logger, - Func getConnection, - string clinicalEndpoint - ) - { - _logger = logger; - _getConnection = getConnection; - _clinicalEndpoint = clinicalEndpoint; - _pollIntervalSeconds = int.TryParse( - Environment.GetEnvironmentVariable("POLL_INTERVAL_SECONDS"), - out var interval - ) - ? interval - : 30; - } - - /// - /// Main sync loop - polls Clinical domain for patient changes and applies mappings. - /// FAULT TOLERANT: This worker NEVER crashes. It handles all errors gracefully and retries indefinitely. - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation( - "[SYNC-START] Scheduling.Sync worker starting at {Time}. Target: {Url}, Poll interval: {Interval}s", - DateTimeOffset.Now, - _clinicalEndpoint, - _pollIntervalSeconds - ); - - var consecutiveFailures = 0; - const int maxConsecutiveFailuresBeforeWarning = 3; - - // Main sync loop - NEVER exits except on cancellation - while (!stoppingToken.IsCancellationRequested) - { - try - { - await SyncPatientDataAsync(stoppingToken).ConfigureAwait(false); - - // Reset failure counter on success - if (consecutiveFailures > 0) - { - _logger.LogInformation( - "[SYNC-RECOVERED] Sync recovered after {Count} consecutive failures", - consecutiveFailures - ); - consecutiveFailures = 0; - } - - await Task.Delay(TimeSpan.FromSeconds(_pollIntervalSeconds), stoppingToken) - .ConfigureAwait(false); - } - catch (HttpRequestException ex) - { - consecutiveFailures++; - var retryDelay = Math.Min(5 * consecutiveFailures, 30); // Exponential backoff up to 30s - - if (consecutiveFailures >= maxConsecutiveFailuresBeforeWarning) - { - _logger.LogWarning( - "[SYNC-FAULT] Clinical.Api unreachable for {Count} consecutive attempts. Error: {Message}. Retrying in {Delay}s...", - consecutiveFailures, - ex.Message, - retryDelay - ); - } - else - { - _logger.LogInformation( - "[SYNC-RETRY] Clinical.Api not reachable ({Message}). Attempt {Count}, retrying in {Delay}s...", - ex.Message, - consecutiveFailures, - retryDelay - ); - } - - await Task.Delay(TimeSpan.FromSeconds(retryDelay), stoppingToken) - .ConfigureAwait(false); - } - catch (TaskCanceledException) when (stoppingToken.IsCancellationRequested) - { - _logger.LogInformation("[SYNC-SHUTDOWN] Sync worker shutting down gracefully"); - break; - } - catch (Exception ex) - { - consecutiveFailures++; - var retryDelay = Math.Min(10 * consecutiveFailures, 60); // Longer backoff for unknown errors - - _logger.LogError( - ex, - "[SYNC-ERROR] Unexpected error during sync (attempt {Count}). Retrying in {Delay}s. Error type: {Type}", - consecutiveFailures, - retryDelay, - ex.GetType().Name - ); - - await Task.Delay(TimeSpan.FromSeconds(retryDelay), stoppingToken) - .ConfigureAwait(false); - } - } - - _logger.LogInformation( - "[SYNC-EXIT] Scheduling.Sync worker exited at {Time}", - DateTimeOffset.Now - ); - } - - /// - /// Fetches changes from Clinical domain and applies column mappings to sync_ScheduledPatient. - /// - private async Task SyncPatientDataAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Starting sync cycle from Clinical.Api"); - - using var conn = _getConnection(); - - // Get last sync version - var lastVersion = GetLastSyncVersion(conn); - - // Fetch changes from Clinical domain - var changesUrl = $"{_clinicalEndpoint}/sync/changes?fromVersion={lastVersion}&limit=100"; - - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", GenerateSyncToken()); - - var response = await httpClient - .GetAsync(new Uri(changesUrl), cancellationToken) - .ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning( - "Failed to fetch changes from {Url}: {Status}", - changesUrl, - response.StatusCode - ); - return; - } - - var content = await response - .Content.ReadAsStringAsync(cancellationToken) - .ConfigureAwait(false); - var changes = JsonSerializer.Deserialize(content); - - if (changes is null || changes.Length == 0) - { - _logger.LogDebug("No changes to sync"); - return; - } - - _logger.LogInformation("Processing {Count} changes", changes.Length); - - // Apply changes with column mapping - foreach (var change in changes) - { - ApplyMappedChange(conn, change); - } - - // Update last sync version - UpdateLastSyncVersion(conn, changes.Max(c => c.Version)); - - _logger.LogInformation("Sync cycle complete. Processed {Count} changes", changes.Length); - } - - /// - /// Applies a change from Clinical domain to sync_ScheduledPatient with column mapping. - /// Maps: fhir_Patient -> sync_ScheduledPatient - /// Transforms: DisplayName = concat(GivenName, ' ', FamilyName) - /// - private void ApplyMappedChange(NpgsqlConnection connection, SyncChange change) - { - try - { - if (change.TableName != "fhir_patient") - { - return; // Only sync patient data - } - - var data = JsonSerializer.Deserialize>( - change.Payload ?? "{}" - ); - - if (data is null) - { - _logger.LogWarning("Failed to parse row data for change {Version}", change.Version); - return; - } - - // Apply column mapping (keys are lowercase - PostgreSQL folds identifiers) - var patientId = data.TryGetValue("id", out var id) ? id.GetString() : null; - var givenName = data.TryGetValue("givenname", out var gn) ? gn.GetString() : ""; - var familyName = data.TryGetValue("familyname", out var fn) ? fn.GetString() : ""; - var phone = data.TryGetValue("phone", out var p) ? p.GetString() : null; - var email = data.TryGetValue("email", out var e) ? e.GetString() : null; - - if (patientId is null) - { - _logger.LogWarning("Patient change missing Id field"); - return; - } - - // Transform: DisplayName = concat(GivenName, ' ', FamilyName) - var displayName = $"{givenName} {familyName}".Trim(); - - // Upsert to sync_ScheduledPatient - if (change.Operation == SyncChange.Delete) - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM sync_ScheduledPatient WHERE PatientId = @id"; - cmd.Parameters.AddWithValue("@id", patientId); - cmd.ExecuteNonQuery(); - - _logger.LogDebug("Deleted patient {PatientId} from sync table", patientId); - } - else - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO sync_ScheduledPatient (PatientId, DisplayName, ContactPhone, ContactEmail, SyncedAt) - VALUES (@id, @name, @phone, @email, NOW()) - ON CONFLICT (PatientId) DO UPDATE SET - DisplayName = excluded.DisplayName, - ContactPhone = excluded.ContactPhone, - ContactEmail = excluded.ContactEmail, - SyncedAt = NOW() - """; - - cmd.Parameters.AddWithValue("@id", patientId); - cmd.Parameters.AddWithValue("@name", displayName); - cmd.Parameters.AddWithValue("@phone", (object?)phone ?? DBNull.Value); - cmd.Parameters.AddWithValue("@email", (object?)email ?? DBNull.Value); - cmd.ExecuteNonQuery(); - - _logger.LogDebug( - "Synced patient {PatientId}: {DisplayName}", - patientId, - displayName - ); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to apply change {Version}", change.Version); - } - } - - private static long GetLastSyncVersion(NpgsqlConnection connection) - { - // Ensure _sync_state table exists - using var createCmd = connection.CreateCommand(); - createCmd.CommandText = """ - CREATE TABLE IF NOT EXISTS _sync_state ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """; - createCmd.ExecuteNonQuery(); - - using var cmd = connection.CreateCommand(); - cmd.CommandText = "SELECT value FROM _sync_state WHERE key = 'last_clinical_sync_version'"; - - var result = cmd.ExecuteScalar(); - return result is string str && long.TryParse(str, out var version) ? version : 0; - } - - private static void UpdateLastSyncVersion(NpgsqlConnection connection, long version) - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO _sync_state (key, value) VALUES ('last_clinical_sync_version', @version) - ON CONFLICT (key) DO UPDATE SET value = excluded.value - """; - cmd.Parameters.AddWithValue( - "@version", - version.ToString(System.Globalization.CultureInfo.InvariantCulture) - ); - cmd.ExecuteNonQuery(); - } - - private static readonly string[] SyncRoles = ["sync-client", "clinician", "scheduler", "admin"]; - - /// - /// Generates a JWT token for sync worker authentication. - /// Uses the dev mode signing key (32 zeros) for E2E testing. - /// - private static string GenerateSyncToken() - { - var signingKey = new byte[32]; // 32 zeros = dev mode key - var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"HS256","typ":"JWT"}""")); - var expiration = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); - var payload = Base64UrlEncode( - Encoding.UTF8.GetBytes( - JsonSerializer.Serialize( - new - { - sub = "scheduling-sync-worker", - name = "Scheduling Sync Worker", - email = "sync@scheduling.local", - jti = Guid.NewGuid().ToString(), - exp = expiration, - roles = SyncRoles, - } - ) - ) - ); - var signature = ComputeHmacSignature(header, payload, signingKey); - return $"{header}.{payload}.{signature}"; - } - - private static string Base64UrlEncode(byte[] input) => - Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - private static string ComputeHmacSignature(string header, string payload, byte[] key) - { - var data = Encoding.UTF8.GetBytes($"{header}.{payload}"); - using var hmac = new HMACSHA256(key); - var hash = hmac.ComputeHash(data); - return Base64UrlEncode(hash); - } -} - -/// -/// Represents a sync change from the Clinical domain. -/// Matches the SyncLogEntry schema returned by /sync/changes endpoint. -/// -/// Instantiated via JSON deserialization. -#pragma warning disable CA1812 // Instantiated via deserialization -internal sealed record SyncChange( - long Version, - string TableName, - string PkValue, - int Operation, - string? Payload, - string Origin, - string Timestamp -) -{ - /// Insert operation (0). - public const int Insert = 0; - - /// Update operation (1). - public const int Update = 1; - - /// Delete operation (2). - public const int Delete = 2; -} diff --git a/Samples/Scheduling/Scheduling.Sync/SyncMappings.json b/Samples/Scheduling/Scheduling.Sync/SyncMappings.json deleted file mode 100644 index 6bace334..00000000 --- a/Samples/Scheduling/Scheduling.Sync/SyncMappings.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "mappings": [ - { - "source_table": "fhir_Patient", - "target_table": "sync_ScheduledPatient", - "column_mappings": [ - { - "source": "Id", - "target": "PatientId" - }, - { - "source": null, - "target": "DisplayName", - "transform": "lql", - "expression": "concat(GivenName, ' ', FamilyName)" - }, - { - "source": "Phone", - "target": "ContactPhone" - }, - { - "source": "Email", - "target": "ContactEmail" - } - ] - } - ], - "sync_config": { - "source_endpoint": "http://localhost:5001", - "target_connection": "Host=localhost;Database=scheduling;Username=clinic;Password=clinic123", - "poll_interval_seconds": 30, - "batch_size": 100 - } -} diff --git a/Samples/Shared/Authorization/AuthHelpers.cs b/Samples/Shared/Authorization/AuthHelpers.cs deleted file mode 100644 index 84d304e1..00000000 --- a/Samples/Shared/Authorization/AuthHelpers.cs +++ /dev/null @@ -1,206 +0,0 @@ -namespace Samples.Authorization; - -using System.Collections.Immutable; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -/// -/// Static helper methods for authentication and authorization. -/// -public static class AuthHelpers -{ - private static readonly Action LogTokenValidationFailed = - LoggerMessage.Define( - LogLevel.Error, - new EventId(1, "TokenValidationFailed"), - "Token validation failed" - ); - - /// - /// Extracts the Bearer token from an Authorization header. - /// - /// The Authorization header value. - /// The token if present, null otherwise. - public static string? ExtractBearerToken(string? authHeader) => - authHeader?.StartsWith("Bearer ", StringComparison.Ordinal) == true - ? authHeader["Bearer ".Length..] - : null; - - /// - /// Validates a JWT token locally without network calls. - /// - /// The JWT token to validate. - /// The HMAC-SHA256 signing key. - /// Optional logger for error reporting. - /// AuthSuccess with claims or AuthFailure with reason. - public static object ValidateTokenLocally( - string token, - ImmutableArray signingKey, - ILogger? logger = null - ) - { - try - { - var parts = token.Split('.'); - if (parts.Length != 3) - { - return new AuthFailure("Invalid token format"); - } - - var keyArray = signingKey.ToArray(); - var expectedSignature = ComputeSignature(parts[0], parts[1], keyArray); - if ( - !CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(expectedSignature), - Encoding.UTF8.GetBytes(parts[2]) - ) - ) - { - return new AuthFailure("Invalid signature"); - } - - var payloadBytes = Base64UrlDecode(parts[1]); - using var doc = JsonDocument.Parse(payloadBytes); - var root = doc.RootElement; - - var exp = root.GetProperty("exp").GetInt64(); - if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp) - { - return new AuthFailure("Token expired"); - } - - var jti = root.GetProperty("jti").GetString() ?? string.Empty; - - var roles = root.TryGetProperty("roles", out var rolesElement) - ? [.. rolesElement.EnumerateArray().Select(e => e.GetString() ?? string.Empty)] - : ImmutableArray.Empty; - - var claims = new AuthClaims( - UserId: root.GetProperty("sub").GetString() ?? string.Empty, - DisplayName: root.TryGetProperty("name", out var nameElem) - ? nameElem.GetString() - : null, - Email: root.TryGetProperty("email", out var emailElem) - ? emailElem.GetString() - : null, - Roles: roles, - Jti: jti, - ExpiresAt: exp - ); - - return new AuthSuccess(claims); - } - catch (Exception ex) - { - if (logger is not null) - { - LogTokenValidationFailed(logger, ex); - } - return new AuthFailure("Token validation failed"); - } - } - - /// - /// Checks permission via Gatekeeper API. - /// - /// The HTTP client configured for Gatekeeper. - /// The Bearer token for authorization. - /// The permission code to check. - /// Optional resource type for record-level access. - /// Optional resource ID for record-level access. - /// PermissionResult indicating if access is allowed. - public static async Task CheckPermissionAsync( - HttpClient httpClient, - string token, - string permission, - string? resourceType = null, - string? resourceId = null - ) - { - try - { - var url = $"/authz/check?permission={Uri.EscapeDataString(permission)}"; - if (!string.IsNullOrEmpty(resourceType)) - { - url += $"&resourceType={Uri.EscapeDataString(resourceType)}"; - } - if (!string.IsNullOrEmpty(resourceId)) - { - url += $"&resourceId={Uri.EscapeDataString(resourceId)}"; - } - - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( - "Bearer", - token - ); - - using var response = await httpClient.SendAsync(request).ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) - { - return new PermissionResult(false, $"Gatekeeper returned {response.StatusCode}"); - } - - var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - using var doc = JsonDocument.Parse(content); - var root = doc.RootElement; - - var allowed = - root.TryGetProperty("Allowed", out var allowedElem) && allowedElem.GetBoolean(); - var reason = root.TryGetProperty("Reason", out var reasonElem) - ? reasonElem.GetString() ?? "unknown" - : "unknown"; - - return new PermissionResult(allowed, reason); - } - catch (Exception ex) - { - return new PermissionResult(false, $"Permission check failed: {ex.Message}"); - } - } - - /// - /// Creates an IResult for unauthorized responses. - /// - /// The reason for the unauthorized response. - /// A 401 Unauthorized result. - public static IResult Unauthorized(string reason) => - Results.Json(new { Error = "Unauthorized", Reason = reason }, statusCode: 401); - - /// - /// Creates an IResult for forbidden responses. - /// - /// The reason for the forbidden response. - /// A 403 Forbidden result. - public static IResult Forbidden(string reason) => - Results.Json(new { Error = "Forbidden", Reason = reason }, statusCode: 403); - - private static string Base64UrlEncode(byte[] input) => - Convert - .ToBase64String(input) - .Replace("+", "-", StringComparison.Ordinal) - .Replace("/", "_", StringComparison.Ordinal) - .TrimEnd('='); - - private static byte[] Base64UrlDecode(string input) - { - var padded = input - .Replace("-", "+", StringComparison.Ordinal) - .Replace("_", "/", StringComparison.Ordinal); - var padding = (4 - (padded.Length % 4)) % 4; - padded += new string('=', padding); - return Convert.FromBase64String(padded); - } - - private static string ComputeSignature(string header, string payload, byte[] key) - { - var data = Encoding.UTF8.GetBytes($"{header}.{payload}"); - using var hmac = new HMACSHA256(key); - var hash = hmac.ComputeHash(data); - return Base64UrlEncode(hash); - } -} diff --git a/Samples/Shared/Authorization/AuthRecords.cs b/Samples/Shared/Authorization/AuthRecords.cs deleted file mode 100644 index 097553ce..00000000 --- a/Samples/Shared/Authorization/AuthRecords.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Samples.Authorization; - -using System.Collections.Immutable; - -/// -/// Claims extracted from a validated JWT token. -/// -/// The user's unique identifier. -/// The user's display name. -/// The user's email address. -/// The roles assigned to the user. -/// The JWT token ID. -/// The Unix timestamp when the token expires. -public sealed record AuthClaims( - string UserId, - string? DisplayName, - string? Email, - ImmutableArray Roles, - string Jti, - long ExpiresAt -); - -/// -/// Successful authentication with claims. -/// -/// The authenticated user's claims. -public sealed record AuthSuccess(AuthClaims Claims); - -/// -/// Failed authentication with reason. -/// -/// The reason for the failure. -public sealed record AuthFailure(string Reason); - -/// -/// Result of a permission check. -/// -/// Whether the permission was granted. -/// The reason for the decision. -public sealed record PermissionResult(bool Allowed, string Reason); - -/// -/// Configuration for authentication and authorization. -/// -/// The JWT signing key. -/// The base URL of the Gatekeeper service. -public sealed record AuthConfig(ImmutableArray SigningKey, Uri GatekeeperBaseUrl); diff --git a/Samples/Shared/Authorization/Authorization.csproj b/Samples/Shared/Authorization/Authorization.csproj deleted file mode 100644 index 7608f91d..00000000 --- a/Samples/Shared/Authorization/Authorization.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - enable - enable - Samples.Authorization - - - - - - diff --git a/Samples/Shared/Authorization/EndpointFilterFactories.cs b/Samples/Shared/Authorization/EndpointFilterFactories.cs deleted file mode 100644 index 6f224c5b..00000000 --- a/Samples/Shared/Authorization/EndpointFilterFactories.cs +++ /dev/null @@ -1,195 +0,0 @@ -namespace Samples.Authorization; - -using System.Collections.Immutable; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -/// -/// Endpoint filter factories for authorization. -/// -public static class EndpointFilterFactories -{ - /// - /// Creates a filter that requires authentication (valid token) only. - /// - /// The JWT signing key. - /// Logger for errors. - /// An endpoint filter factory. - public static Func< - EndpointFilterFactoryContext, - EndpointFilterDelegate, - EndpointFilterDelegate - > RequireAuth(ImmutableArray signingKey, ILogger logger) => - (context, next) => - async invocationContext => - { - var authHeader = - invocationContext.HttpContext.Request.Headers.Authorization.FirstOrDefault(); - var token = AuthHelpers.ExtractBearerToken(authHeader); - - if (token is null) - { - return AuthHelpers.Unauthorized("Missing authorization header"); - } - - return AuthHelpers.ValidateTokenLocally(token, signingKey, logger) switch - { - AuthSuccess success => await InvokeWithClaims( - invocationContext, - next, - success.Claims - ) - .ConfigureAwait(false), - AuthFailure failure => AuthHelpers.Unauthorized(failure.Reason), - }; - }; - - /// - /// Creates a filter that requires a specific permission. - /// - /// The permission code required. - /// The JWT signing key. - /// Factory to get HTTP client for Gatekeeper. - /// Logger for errors. - /// Optional function to extract resource ID from context. - /// An endpoint filter factory. - public static Func< - EndpointFilterFactoryContext, - EndpointFilterDelegate, - EndpointFilterDelegate - > RequirePermission( - string permission, - ImmutableArray signingKey, - Func getHttpClient, - ILogger logger, - Func? getResourceId = null - ) => - (context, next) => - async invocationContext => - { - var authHeader = - invocationContext.HttpContext.Request.Headers.Authorization.FirstOrDefault(); - var token = AuthHelpers.ExtractBearerToken(authHeader); - - if (token is null) - { - return AuthHelpers.Unauthorized("Missing authorization header"); - } - - var validationResult = AuthHelpers.ValidateTokenLocally(token, signingKey, logger); - if (validationResult is not AuthSuccess authSuccess) - { - return AuthHelpers.Unauthorized(((AuthFailure)validationResult).Reason); - } - - // In dev mode (signing key is all zeros), skip Gatekeeper permission check - // This allows E2E testing without requiring Gatekeeper user setup - if (IsDevModeKey(signingKey)) - { - return await InvokeWithClaims(invocationContext, next, authSuccess.Claims) - .ConfigureAwait(false); - } - - // Check permission via Gatekeeper - var resourceId = getResourceId?.Invoke(invocationContext); - var resourceType = GetResourceTypeFromPermission(permission); - - using var client = getHttpClient(); - var permResult = await AuthHelpers - .CheckPermissionAsync(client, token, permission, resourceType, resourceId) - .ConfigureAwait(false); - - return permResult.Allowed - ? await InvokeWithClaims(invocationContext, next, authSuccess.Claims) - .ConfigureAwait(false) - : AuthHelpers.Forbidden(permResult.Reason); - }; - - /// - /// Creates a filter for patient-scoped endpoints where the patient ID is a route parameter. - /// - /// The permission code required. - /// The JWT signing key. - /// Factory to get HTTP client for Gatekeeper. - /// Logger for errors. - /// The route parameter name for patient ID. - /// An endpoint filter factory. - public static Func< - EndpointFilterFactoryContext, - EndpointFilterDelegate, - EndpointFilterDelegate - > RequirePatientPermission( - string permission, - ImmutableArray signingKey, - Func getHttpClient, - ILogger logger, - string patientIdParamName = "patientId" - ) => - RequirePermission( - permission, - signingKey, - getHttpClient, - logger, - ctx => ExtractRouteValue(ctx, patientIdParamName) - ); - - /// - /// Creates a filter for resource-scoped endpoints where the resource ID is a route parameter. - /// - /// The permission code required. - /// The JWT signing key. - /// Factory to get HTTP client for Gatekeeper. - /// Logger for errors. - /// The route parameter name for resource ID. - /// An endpoint filter factory. - public static Func< - EndpointFilterFactoryContext, - EndpointFilterDelegate, - EndpointFilterDelegate - > RequireResourcePermission( - string permission, - ImmutableArray signingKey, - Func getHttpClient, - ILogger logger, - string idParamName = "id" - ) => - RequirePermission( - permission, - signingKey, - getHttpClient, - logger, - ctx => ExtractRouteValue(ctx, idParamName) - ); - - private static string? ExtractRouteValue( - EndpointFilterInvocationContext ctx, - string paramName - ) => - ctx.HttpContext.Request.RouteValues.TryGetValue(paramName, out var value) - ? value?.ToString() - : null; - - private static string? GetResourceTypeFromPermission(string permission) - { - var colonIndex = permission.IndexOf(':', StringComparison.Ordinal); - return colonIndex > 0 ? permission[..colonIndex] : null; - } - - private static async ValueTask InvokeWithClaims( - EndpointFilterInvocationContext context, - EndpointFilterDelegate next, - AuthClaims claims - ) - { - // Store claims in HttpContext.Items for endpoint access if needed - context.HttpContext.Items["AuthClaims"] = claims; - return await next(context).ConfigureAwait(false); - } - - /// - /// Checks if the signing key is the default dev key (32 zeros). - /// When this key is used, Gatekeeper permission checks are bypassed for E2E testing. - /// - private static bool IsDevModeKey(ImmutableArray signingKey) => - signingKey.Length == 32 && signingKey.All(b => b == 0); -} diff --git a/Samples/Shared/Authorization/PermissionConstants.cs b/Samples/Shared/Authorization/PermissionConstants.cs deleted file mode 100644 index d59323c6..00000000 --- a/Samples/Shared/Authorization/PermissionConstants.cs +++ /dev/null @@ -1,98 +0,0 @@ -namespace Samples.Authorization; - -/// -/// FHIR-style permission codes for Clinical and Scheduling domains. -/// -public static class FhirPermissions -{ - // Patient resource permissions - /// Read patient records. - public const string PatientRead = "patient:read"; - - /// Create patient records. - public const string PatientCreate = "patient:create"; - - /// Update patient records. - public const string PatientUpdate = "patient:update"; - - /// Delete patient records. - public const string PatientDelete = "patient:delete"; - - /// Full patient access (wildcard). - public const string PatientAll = "patient:*"; - - // Encounter resource permissions - /// Read encounter records. - public const string EncounterRead = "encounter:read"; - - /// Create encounter records. - public const string EncounterCreate = "encounter:create"; - - /// Update encounter records. - public const string EncounterUpdate = "encounter:update"; - - /// Full encounter access (wildcard). - public const string EncounterAll = "encounter:*"; - - // Condition resource permissions - /// Read condition records. - public const string ConditionRead = "condition:read"; - - /// Create condition records. - public const string ConditionCreate = "condition:create"; - - /// Update condition records. - public const string ConditionUpdate = "condition:update"; - - /// Full condition access (wildcard). - public const string ConditionAll = "condition:*"; - - // MedicationRequest resource permissions - /// Read medication request records. - public const string MedicationRequestRead = "medicationrequest:read"; - - /// Create medication request records. - public const string MedicationRequestCreate = "medicationrequest:create"; - - /// Update medication request records. - public const string MedicationRequestUpdate = "medicationrequest:update"; - - /// Full medication request access (wildcard). - public const string MedicationRequestAll = "medicationrequest:*"; - - // Practitioner resource permissions - /// Read practitioner records. - public const string PractitionerRead = "practitioner:read"; - - /// Create practitioner records. - public const string PractitionerCreate = "practitioner:create"; - - /// Update practitioner records. - public const string PractitionerUpdate = "practitioner:update"; - - /// Full practitioner access (wildcard). - public const string PractitionerAll = "practitioner:*"; - - // Appointment resource permissions - /// Read appointment records. - public const string AppointmentRead = "appointment:read"; - - /// Create appointment records. - public const string AppointmentCreate = "appointment:create"; - - /// Update appointment records. - public const string AppointmentUpdate = "appointment:update"; - - /// Full appointment access (wildcard). - public const string AppointmentAll = "appointment:*"; - - // Sync operation permissions - /// Read sync data. - public const string SyncRead = "sync:read"; - - /// Write sync data. - public const string SyncWrite = "sync:write"; - - /// Full sync access (wildcard). - public const string SyncAll = "sync:*"; -} diff --git a/Samples/Shared/Authorization/TestTokenHelper.cs b/Samples/Shared/Authorization/TestTokenHelper.cs deleted file mode 100644 index 56f266d3..00000000 --- a/Samples/Shared/Authorization/TestTokenHelper.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace Samples.Authorization; - -using System.Collections.Immutable; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -/// -/// Helper for generating JWT tokens in tests. -/// -public static class TestTokenHelper -{ - /// - /// A fixed 32-byte signing key for testing (base64: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=). - /// - public static readonly ImmutableArray TestSigningKey = ImmutableArray.Create( - new byte[32] - ); - - /// - /// Generates a valid JWT token for testing purposes. - /// - /// The user ID (sub claim). - /// The roles to include in the token. - /// Token expiration time in minutes from now. - /// A signed JWT token string. - public static string GenerateToken( - string userId, - ImmutableArray roles, - int expiresInMinutes = 60 - ) - { - var header = new { alg = "HS256", typ = "JWT" }; - var payload = new - { - sub = userId, - jti = Guid.NewGuid().ToString(), - roles, - exp = DateTimeOffset.UtcNow.AddMinutes(expiresInMinutes).ToUnixTimeSeconds(), - iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - }; - - var headerJson = JsonSerializer.Serialize(header); - var payloadJson = JsonSerializer.Serialize(payload); - - var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); - var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); - - var signature = ComputeSignature(headerBase64, payloadBase64, [.. TestSigningKey]); - - return $"{headerBase64}.{payloadBase64}.{signature}"; - } - - /// - /// Generates a token for a clinician with full clinical permissions. - /// - /// The user ID. - /// A JWT token for a clinician. - public static string GenerateClinicianToken(string userId = "test-clinician") => - GenerateToken(userId, ["clinician"]); - - /// - /// Generates a token for a scheduler with scheduling permissions. - /// - /// The user ID. - /// A JWT token for a scheduler. - public static string GenerateSchedulerToken(string userId = "test-scheduler") => - GenerateToken(userId, ["scheduler"]); - - /// - /// Generates a token for a sync client with sync permissions. - /// - /// The user ID. - /// A JWT token for a sync client. - public static string GenerateSyncClientToken(string userId = "test-sync-client") => - GenerateToken(userId, ["sync-client"]); - - /// - /// Generates a token with no roles (authenticated but no permissions). - /// - /// The user ID. - /// A JWT token with no roles. - public static string GenerateNoRoleToken(string userId = "test-user") => - GenerateToken(userId, []); - - /// - /// Generates an expired token for testing expiration handling. - /// - /// The user ID. - /// An expired JWT token. - public static string GenerateExpiredToken(string userId = "test-user") => - GenerateToken(userId, ["clinician"], expiresInMinutes: -60); - - private static string Base64UrlEncode(byte[] input) => - Convert - .ToBase64String(input) - .Replace("+", "-", StringComparison.Ordinal) - .Replace("/", "_", StringComparison.Ordinal) - .TrimEnd('='); - - private static string ComputeSignature(string header, string payload, byte[] key) - { - var data = Encoding.UTF8.GetBytes($"{header}.{payload}"); - using var hmac = new HMACSHA256(key); - var hash = hmac.ComputeHash(data); - return Base64UrlEncode(hash); - } -} diff --git a/Samples/docker/.env.example b/Samples/docker/.env.example deleted file mode 100644 index d8a2a055..00000000 --- a/Samples/docker/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -# Database Passwords -POSTGRES_PASSWORD=changeme_in_production -GATEKEEPER_DB_PASSWORD=changeme_in_production -CLINICAL_DB_PASSWORD=changeme_in_production -SCHEDULING_DB_PASSWORD=changeme_in_production -ICD10_DB_PASSWORD=changeme_in_production - -# JWT Configuration -JWT_SIGNING_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - -# FIDO2/WebAuthn Configuration -FIDO2_SERVER_NAME=Healthcare Platform -FIDO2_SERVER_DOMAIN=localhost -FIDO2_ORIGINS=http://localhost:5173 - -# Embedding Service -EMBEDDING_SERVICE_URL=http://embedding:8000 diff --git a/Samples/docker/.gitignore b/Samples/docker/.gitignore deleted file mode 100644 index 734e9047..00000000 --- a/Samples/docker/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Pre-built Dashboard files (created by start.sh) -dashboard-build/ diff --git a/Samples/docker/Dockerfile.app b/Samples/docker/Dockerfile.app deleted file mode 100644 index 29c4862f..00000000 --- a/Samples/docker/Dockerfile.app +++ /dev/null @@ -1,59 +0,0 @@ -# Single container for ALL .NET services + embedding service -# Runs: Gatekeeper API, Clinical API, Scheduling API, ICD10 API, Clinical Sync, Scheduling Sync, Embedding Service - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src - -COPY . . - -RUN dotnet publish Gatekeeper/Gatekeeper.Api -c Release -o /app/gatekeeper -RUN dotnet publish Samples/Clinical/Clinical.Api -c Release -o /app/clinical-api -RUN dotnet publish Samples/Scheduling/Scheduling.Api -c Release -o /app/scheduling-api -RUN dotnet publish Samples/ICD10/ICD10.Api -c Release -o /app/icd10-api -RUN dotnet publish Samples/Clinical/Clinical.Sync -c Release -o /app/clinical-sync -RUN dotnet publish Samples/Scheduling/Scheduling.Sync -c Release -o /app/scheduling-sync - -# Runtime image -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime - -# Install Python for embedding service and ICD-10 import -RUN apt-get update && apt-get install -y \ - curl \ - python3 \ - python3-pip \ - python3-venv \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app -RUN mkdir -p /app/logs - -# Copy all published apps -COPY --from=build /app/gatekeeper ./gatekeeper -COPY --from=build /app/clinical-api ./clinical-api -COPY --from=build /app/scheduling-api ./scheduling-api -COPY --from=build /app/icd10-api ./icd10-api -COPY --from=build /app/clinical-sync ./clinical-sync -COPY --from=build /app/scheduling-sync ./scheduling-sync - -# Copy embedding service -COPY Samples/ICD10/embedding-service/main.py ./embedding-service/main.py -COPY Samples/ICD10/embedding-service/requirements.txt ./embedding-service/requirements.txt - -# Copy ICD-10 import script -COPY Samples/ICD10/scripts/CreateDb/import_postgres.py ./import_icd10.py - -# Install Python dependencies (embedding service + import script) -RUN pip install --break-system-packages --no-cache-dir \ - psycopg2-binary requests click \ - fastapi uvicorn sentence-transformers torch pydantic numpy - -# Pre-download the model so startup is fast -RUN python3 -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('abhinand/MedEmbed-small-v0.1')" - -# Copy entrypoint script -COPY Samples/docker/start-services.sh ./start-services.sh -RUN chmod +x ./start-services.sh - -EXPOSE 5002 5080 5001 5090 8000 - -ENTRYPOINT ["./start-services.sh"] diff --git a/Samples/docker/Dockerfile.dashboard b/Samples/docker/Dockerfile.dashboard deleted file mode 100644 index 84287c1a..00000000 --- a/Samples/docker/Dockerfile.dashboard +++ /dev/null @@ -1,13 +0,0 @@ -# Dashboard - serves pre-built H5 static files -# NOTE: H5 transpiler doesn't work in Docker Linux -# Build Dashboard locally first: dotnet publish -c Release -FROM nginx:alpine - -# Copy pre-built wwwroot from local build -COPY Samples/docker/dashboard-build/wwwroot /usr/share/nginx/html -COPY Samples/docker/nginx.conf /etc/nginx/nginx.conf - -EXPOSE 80 - -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1 diff --git a/Samples/docker/README.md b/Samples/docker/README.md deleted file mode 100644 index 7a47e41a..00000000 --- a/Samples/docker/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Docker Setup - -3 containers. That's it. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ app │ -│ Gatekeeper:5002 Clinical:5080 Scheduling:5001 │ -│ ICD10:5090 ClinicalSync SchedulingSync │ -└────────────────────────┬────────────────────────────────┘ - │ -┌────────────────────────┼────────────────────────────────┐ -│ db │ -│ Postgres:5432 │ -│ ├── gatekeeper │ -│ ├── clinical │ -│ ├── scheduling │ -│ └── icd10 │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ dashboard │ -│ nginx:5173 (static H5 files) │ -└─────────────────────────────────────────────────────────┘ -``` - -## Why This Split? - -| Container | Runtime | Why separate | -|-----------|---------|--------------| -| db | Postgres | Stateful. Don't rebuild the database. | -| app | .NET 9 | All APIs tightly coupled. Same codebase, same deploy. | -| dashboard | nginx | Static files. Different runtime. | - -## Dashboard Note - -H5 transpiler doesn't work in Docker Linux. Build locally first: - -```bash -cd Samples/Dashboard/Dashboard.Web -dotnet publish -c Release -``` - -Then serve the static files however you want (nginx, python, etc). - -## Usage - -```bash -# Start everything -./scripts/start.sh - -# Fresh start (wipe databases) -./scripts/start.sh --fresh - -# Rebuild containers -./scripts/start.sh --build -``` - -## Ports - -| Service | Port | -|---------|------| -| Postgres | 5432 | -| Gatekeeper API | 5002 | -| Clinical API | 5080 | -| Scheduling API | 5001 | -| ICD10 API | 5090 | -| Dashboard | 5173 | - -## Files - -``` -docker/ -├── docker-compose.yml # 3 services -├── Dockerfile.app # All .NET services -├── Dockerfile.dashboard # nginx + static files -├── start-services.sh # Entrypoint for app container -├── init-db/ -│ └── init.sql # Creates all 4 databases -└── nginx.conf # Dashboard config -``` diff --git a/Samples/docker/docker-compose.yml b/Samples/docker/docker-compose.yml deleted file mode 100644 index fbab56e9..00000000 --- a/Samples/docker/docker-compose.yml +++ /dev/null @@ -1,58 +0,0 @@ -services: - db: - image: pgvector/pgvector:pg16 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} - volumes: - - db-data:/var/lib/postgresql/data - - ./init-db:/docker-entrypoint-initdb.d - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 3s - retries: 10 - - app: - build: - context: ../.. - dockerfile: Samples/docker/Dockerfile.app - environment: - ConnectionStrings__Postgres: Host=db;Database=gatekeeper;Username=gatekeeper;Password=${DB_PASSWORD:-changeme} - Jwt__SigningKey: ${JWT_SIGNING_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=} - Fido2__ServerName: Healthcare Platform - Fido2__ServerDomain: localhost - Fido2__Origins__0: http://localhost:5173 - ConnectionStrings__Postgres_Clinical: Host=db;Database=clinical;Username=clinical;Password=${DB_PASSWORD:-changeme} - ConnectionStrings__Postgres_Scheduling: Host=db;Database=scheduling;Username=scheduling;Password=${DB_PASSWORD:-changeme} - ConnectionStrings__Postgres_ICD10: Host=db;Database=icd10;Username=icd10;Password=${DB_PASSWORD:-changeme} - CLINICAL_CONNECTION_STRING: Host=db;Database=clinical;Username=clinical;Password=${DB_PASSWORD:-changeme} - SCHEDULING_CONNECTION_STRING: Host=db;Database=scheduling;Username=scheduling;Password=${DB_PASSWORD:-changeme} - SCHEDULING_API_URL: http://localhost:5001 - CLINICAL_API_URL: http://localhost:5080 - Gatekeeper__BaseUrl: http://localhost:5002 - Services__ClinicalApi: http://localhost:5080 - Services__SchedulingApi: http://localhost:5001 - ports: - - "5002:5002" - - "5080:5080" - - "5001:5001" - - "5090:5090" - - "8000:8000" - depends_on: - db: - condition: service_healthy - - dashboard: - build: - context: ../.. - dockerfile: Samples/docker/Dockerfile.dashboard - ports: - - "5173:80" - depends_on: - - app - -volumes: - db-data: diff --git a/Samples/docker/init-db/init.sh b/Samples/docker/init-db/init.sh deleted file mode 100755 index a9f60cc0..00000000 --- a/Samples/docker/init-db/init.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -set -e - -# Create databases and users using environment variable for password -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE DATABASE gatekeeper; - CREATE DATABASE clinical; - CREATE DATABASE scheduling; - CREATE DATABASE icd10; - - CREATE USER gatekeeper WITH PASSWORD '$POSTGRES_PASSWORD'; - CREATE USER clinical WITH PASSWORD '$POSTGRES_PASSWORD'; - CREATE USER scheduling WITH PASSWORD '$POSTGRES_PASSWORD'; - CREATE USER icd10 WITH PASSWORD '$POSTGRES_PASSWORD'; - - GRANT ALL PRIVILEGES ON DATABASE gatekeeper TO gatekeeper; - GRANT ALL PRIVILEGES ON DATABASE clinical TO clinical; - GRANT ALL PRIVILEGES ON DATABASE scheduling TO scheduling; - GRANT ALL PRIVILEGES ON DATABASE icd10 TO icd10; -EOSQL - -# Grant schema privileges -for db in gatekeeper clinical scheduling icd10; do - psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$db" <<-EOSQL - GRANT ALL ON SCHEMA public TO $db; -EOSQL -done - -# Enable pgvector extension for ICD10 database (vector similarity search) -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "icd10" <<-EOSQL - CREATE EXTENSION IF NOT EXISTS vector; -EOSQL diff --git a/Samples/docker/nginx.conf b/Samples/docker/nginx.conf deleted file mode 100644 index 82cd8493..00000000 --- a/Samples/docker/nginx.conf +++ /dev/null @@ -1,39 +0,0 @@ -worker_processes auto; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - # Gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml; - gzip_min_length 1000; - - server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Health check endpoint - location /health { - return 200 '{"status":"healthy","service":"Dashboard"}'; - add_header Content-Type application/json; - } - - # SPA routing - serve index.html for all routes - location / { - try_files $uri $uri/ /index.html; - } - - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - } -} diff --git a/Samples/docker/start-services.sh b/Samples/docker/start-services.sh deleted file mode 100644 index 93bca368..00000000 --- a/Samples/docker/start-services.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/bin/bash -set -e - -echo "Starting all services..." - -# Start embedding service first (needed by ICD10 API for AI search) -cd /app/embedding-service -python3 -m uvicorn main:app --host 0.0.0.0 --port 8000 & -echo "Started embedding service on :8000" - -# Wait for embedding service to be ready (model loading takes time) -echo "Waiting for embedding service to load model..." -for i in {1..60}; do - if curl -s http://localhost:8000/health | grep -q "healthy"; then - echo "Embedding service ready!" - break - fi - echo " Waiting for embedding model... ($i/60)" - sleep 5 -done - -# Gatekeeper API -cd /app/gatekeeper -ASPNETCORE_URLS=http://+:5002 dotnet Gatekeeper.Api.dll & - -echo "Waiting for Gatekeeper..." -sleep 5 - -# Clinical API -cd /app/clinical-api -ConnectionStrings__Postgres="$ConnectionStrings__Postgres_Clinical" \ -ASPNETCORE_URLS=http://+:5080 dotnet Clinical.Api.dll & - -# Scheduling API -cd /app/scheduling-api -ConnectionStrings__Postgres="$ConnectionStrings__Postgres_Scheduling" \ -ASPNETCORE_URLS=http://+:5001 dotnet Scheduling.Api.dll & - -# ICD10 API - point to local embedding service -cd /app/icd10-api -ConnectionStrings__Postgres="$ConnectionStrings__Postgres_ICD10" \ -EmbeddingService__BaseUrl="http://localhost:8000" \ -ASPNETCORE_URLS=http://+:5090 dotnet ICD10.Api.dll & - -echo "Waiting for APIs to initialize..." -sleep 10 - -# Import ICD-10 data if not already imported -echo "Checking ICD-10 data..." -ICD10_HEALTH=$(curl -s http://localhost:5090/health 2>/dev/null || echo '{"Status":"error"}') - -# Check for embeddings via direct database query -EMBEDDING_COUNT=$(PGPASSWORD=changeme psql -h db -U postgres -d icd10 -t -c "SELECT COUNT(*) FROM icd10_code_embedding;" 2>/dev/null | tr -d ' ' || echo "0") -CODE_COUNT=$(PGPASSWORD=changeme psql -h db -U postgres -d icd10 -t -c "SELECT COUNT(*) FROM icd10_code;" 2>/dev/null | tr -d ' ' || echo "0") - -echo " Codes loaded: $CODE_COUNT" -echo " Embeddings: $EMBEDDING_COUNT" - -NEED_IMPORT=false - -if echo "$ICD10_HEALTH" | grep -q "unhealthy"; then - echo "ICD-10 codes not loaded - need full import" - NEED_IMPORT=true -elif [ "$EMBEDDING_COUNT" = "0" ] && [ "$CODE_COUNT" != "0" ]; then - echo "ICD-10 codes loaded but no embeddings - need to generate embeddings" - NEED_IMPORT=true -fi - -if [ "$NEED_IMPORT" = "true" ]; then - echo "Starting ICD-10 import from CDC..." - echo "This will take several minutes (downloading + generating embeddings)..." - cd /app - EMBEDDING_SERVICE_URL="http://localhost:8000" \ - python3 import_icd10.py --connection-string "$ConnectionStrings__Postgres_ICD10" \ - || echo "ICD-10 import failed" - echo "ICD-10 import complete with embeddings" -else - echo "ICD-10 data and embeddings already loaded" -fi - -# Sync workers -cd /app/clinical-sync -dotnet Clinical.Sync.dll & - -cd /app/scheduling-sync -dotnet Scheduling.Sync.dll & - -echo "All services started" -echo " Embedding: :8000" -echo " Gatekeeper: :5002" -echo " Clinical: :5080" -echo " Scheduling: :5001" -echo " ICD10: :5090" - -wait diff --git a/Samples/readme.md b/Samples/readme.md deleted file mode 100644 index 3c18ee24..00000000 --- a/Samples/readme.md +++ /dev/null @@ -1,157 +0,0 @@ -# Healthcare Samples - -A complete demonstration of the DataProvider suite: three FHIR-compliant microservices with bidirectional sync, semantic search, and a React dashboard. - -This sample showcases: -- **DataProvider** - Compile-time safe SQL queries for all database operations -- **Sync Framework** - Bidirectional data synchronization between Clinical and Scheduling domains -- **LQL** - Lambda Query Language for complex queries -- **RAG Search** - Semantic medical code search with pgvector embeddings -- **FHIR Compliance** - - All medical data follows [FHIR R5 spec](https://build.fhir.org/resourcelist.html) - - Follows the FHIR [access control rules](https://build.fhir.org/security.html). - -## Quick Start - -```bash -# Run all APIs locally against Docker Postgres -./scripts/start-local.sh - -# Run everything in Docker containers -./scripts/start.sh - -# Run APIs + sync workers -./scripts/start.sh --sync -``` - -| Service | URL | -|---------|-----| -| Clinical API | http://localhost:5080 | -| Scheduling API | http://localhost:5001 | -| ICD10 API | http://localhost:5090 | -| Dashboard | http://localhost:8080 | - -## Architecture - -``` -Dashboard.Web (React/H5) - | - +--> Clinical.Api <---- Clinical.Sync <-+ - | (PostgreSQL) | - | fhir_Patient, fhir_Encounter | Practitioner->Provider - | | - +--> Scheduling.Api <-- Scheduling.Sync <+ - | (PostgreSQL) Patient->ScheduledPatient - | fhir_Practitioner, fhir_Appointment - | - +--> ICD10.Api - (PostgreSQL + pgvector) - icd10_code, achi_code, embeddings -``` - -## Data Ownership - -| Domain | Owns | Receives via Sync | -|--------|------|-------------------| -| Clinical | fhir_Patient, fhir_Encounter, fhir_Condition, fhir_MedicationRequest | sync_Provider | -| Scheduling | fhir_Practitioner, fhir_Appointment, fhir_Schedule, fhir_Slot | sync_ScheduledPatient | -| ICD10 | icd10_chapter, icd10_block, icd10_category, icd10_code, achi_block, achi_code | N/A (read-only reference) | - -## API Endpoints - -### Clinical (`:5080`) -- `GET/POST /fhir/Patient` - Patients -- `GET /fhir/Patient/_search?q=smith` - Search -- `GET/POST /fhir/Patient/{id}/Encounter` - Encounters -- `GET/POST /fhir/Patient/{id}/Condition` - Conditions -- `GET/POST /fhir/Patient/{id}/MedicationRequest` - Medications -- `GET /sync/changes?fromVersion=0` - Sync feed - -### Scheduling (`:5001`) -- `GET/POST /Practitioner` - Practitioners -- `GET /Practitioner/_search?specialty=cardiology` - Search -- `GET/POST /Appointment` - Appointments -- `PATCH /Appointment/{id}/status` - Update status -- `GET /sync/changes?fromVersion=0` - Sync feed - -### ICD10 (`:5090`) -- `GET /api/icd10/chapters` - ICD-10 chapters -- `GET /api/icd10/chapters/{id}/blocks` - Blocks within chapter -- `GET /api/icd10/blocks/{id}/categories` - Categories within block -- `GET /api/icd10/categories/{id}/codes` - Codes within category -- `GET /api/icd10/codes/{code}` - Direct code lookup (supports `?format=fhir`) -- `GET /api/icd10/codes?q={query}&limit=20` - Text search -- `GET /api/achi/blocks` - ACHI procedure blocks -- `GET /api/achi/codes/{code}` - ACHI code lookup -- `GET /api/achi/codes?q={query}&limit=20` - ACHI text search -- `POST /api/search` - RAG semantic search (requires embedding service) -- `GET /health` - Health check - -## Dashboard - -Serve static files and open http://localhost:8080: - -```bash -cd Dashboard/Dashboard.Web/wwwroot -python3 -m http.server 8080 -``` - -Built with H5 transpiler (C#->JavaScript) + React 18. - -## Project Structure - -``` -Samples/ -+-- scripts/ -| +-- start.sh # Docker startup script -| +-- start-local.sh # Local dev startup script -| +-- clean.sh # Clean Docker environment -| +-- clean-local.sh # Clean local environment -+-- Clinical/ -| +-- Clinical.Api/ # REST API (PostgreSQL) -| +-- Clinical.Api.Tests/ # E2E tests -| +-- Clinical.Sync/ # Pulls from Scheduling -+-- Scheduling/ -| +-- Scheduling.Api/ # REST API (PostgreSQL) -| +-- Scheduling.Api.Tests/ # E2E tests -| +-- Scheduling.Sync/ # Pulls from Clinical -+-- ICD10/ -| +-- ICD10.Api/ # REST API (PostgreSQL + pgvector) -| +-- ICD10.Api.Tests/ # E2E tests -| +-- ICD10.Cli/ # Interactive TUI client -| +-- ICD10.Cli.Tests/ # CLI E2E tests -| +-- embedding-service/ # Python FastAPI embedding service -| +-- scripts/ # DB import + embedding generation -+-- Dashboard/ - +-- Dashboard.Web/ # React UI (H5) -``` - -## Tech Stack - -- .NET 9, ASP.NET Core Minimal API -- PostgreSQL with pgvector (semantic search) -- DataProvider (SQL->extension methods) -- Sync Framework (bidirectional sync) -- LQL (Lambda Query Language) -- MedEmbed (medical text embeddings) -- H5 transpiler + React 18 - -## Testing - -```bash -# Run all sample tests -dotnet test --filter "FullyQualifiedName~Samples" - -# ICD10 RAG search tests (requires embedding service) -cd ICD10/scripts/Dependencies && ./start.sh -dotnet test --filter "FullyQualifiedName~ICD10.Api.Tests" - -# Integration tests (requires APIs running) -dotnet test --filter "FullyQualifiedName~Dashboard.Integration.Tests" -``` - -## Learn More - -- [DataProvider Documentation](../DataProvider/README.md) -- [Sync Framework Documentation](../Sync/README.md) -- [LQL Documentation](../Lql/README.md) diff --git a/Samples/scripts/clean-local.sh b/Samples/scripts/clean-local.sh deleted file mode 100755 index e8445648..00000000 --- a/Samples/scripts/clean-local.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# Healthcare Samples - Clean local development environment -# Kills running services and drops the Postgres database volume -# Usage: ./clean-local.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SAMPLES_DIR="$(dirname "$SCRIPT_DIR")" -REPO_ROOT="$(dirname "$SAMPLES_DIR")" - -kill_port() { - local port=$1 - local pids - pids=$(lsof -ti :"$port" 2>/dev/null || true) - if [ -n "$pids" ]; then - echo "Killing processes on port $port: $pids" - echo "$pids" | xargs kill -9 2>/dev/null || true - sleep 0.5 - fi -} - -echo "Clearing ports..." -kill_port 5002 -kill_port 5080 -kill_port 5001 -kill_port 5090 -kill_port 5173 - -echo "Removing Postgres volume..." -cd "$REPO_ROOT" -docker compose -f docker-compose.postgres.yml down -v 2>/dev/null || true - -echo "Clean complete." diff --git a/Samples/scripts/clean.sh b/Samples/scripts/clean.sh deleted file mode 100755 index 9800e940..00000000 --- a/Samples/scripts/clean.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# Healthcare Samples - Clean Docker environment -# Kills running services and drops all Docker volumes -# Usage: ./clean.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SAMPLES_DIR="$(dirname "$SCRIPT_DIR")" - -kill_port() { - local port=$1 - local pids - pids=$(lsof -ti :"$port" 2>/dev/null || true) - if [ -n "$pids" ]; then - echo "Killing processes on port $port: $pids" - echo "$pids" | xargs kill -9 2>/dev/null || true - sleep 0.5 - fi -} - -echo "Clearing ports..." -kill_port 5432 -kill_port 5002 -kill_port 5080 -kill_port 5001 -kill_port 5090 -kill_port 5173 - -echo "Removing Docker volumes..." -cd "$SAMPLES_DIR/docker" -docker compose down -v - -echo "Clean complete." diff --git a/Samples/scripts/start-local.sh b/Samples/scripts/start-local.sh deleted file mode 100755 index d5069c25..00000000 --- a/Samples/scripts/start-local.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/bin/bash -# Healthcare Samples - Local Development -# Runs all 4 APIs locally against docker-compose.postgres.yml -# -# Prerequisites: -# docker compose -f docker-compose.postgres.yml up -d -# -# Usage: ./start-local.sh [--fresh] -# --fresh: Drop postgres volume and recreate - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SAMPLES_DIR="$(dirname "$SCRIPT_DIR")" -REPO_ROOT="$(dirname "$SAMPLES_DIR")" -PIDS=() - -for arg in "$@"; do - case $arg in - --fresh) "$SCRIPT_DIR/clean-local.sh" ;; - esac -done - -# ── Ensure Postgres is running ────────────────────────────────────── -cd "$REPO_ROOT" - -if ! pg_isready -h localhost -p 5432 -q 2>/dev/null; then - echo "Postgres not running. Starting via docker-compose.postgres.yml..." - docker compose -f docker-compose.postgres.yml up -d - echo "Waiting for Postgres..." - for i in {1..30}; do - if pg_isready -h localhost -p 5432 -q 2>/dev/null; then - echo "Postgres ready!" - break - fi - sleep 1 - done -fi - -# ── Set up Python venv (shared by embedding service + import) ───── -VENV_DIR="$SAMPLES_DIR/ICD10/.venv" -EMBED_DIR="$SAMPLES_DIR/ICD10/embedding-service" - -echo "" -echo "Setting up Python environment..." -if [ ! -d "$VENV_DIR" ]; then - python3 -m venv "$VENV_DIR" -fi -"$VENV_DIR/bin/pip" install -q \ - -r "$EMBED_DIR/requirements.txt" \ - psycopg2-binary click requests -echo "Python environment ready." - -# ── Start Embedding Service ─────────────────────────────────────── -echo "Starting Embedding Service on :8000 (model loading may take a moment)..." -"$VENV_DIR/bin/python" -m uvicorn main:app --host 0.0.0.0 --port 8000 \ - --app-dir "$EMBED_DIR" \ - 2>&1 | sed 's/^/ [embedding] /' & -PIDS+=($!) - -# ── ICD10 data population (runs after APIs + embedding are ready) ─ -populate_icd10() { - local CONN_STR="Host=localhost;Database=icd10;Username=icd10;Password=$DB_PASS" - local SCRIPTS_DIR="$SAMPLES_DIR/ICD10/scripts/CreateDb" - - # Wait for ICD10 API to be ready - echo " [icd10-import] Waiting for ICD10 API..." - for i in {1..60}; do - if curl -sf http://localhost:5090/health >/dev/null 2>&1; then - echo " [icd10-import] ICD10 API is up." - break - fi - sleep 2 - done - - # Wait for embedding service to be ready (needed for AI search) - echo " [icd10-import] Waiting for embedding service..." - for i in {1..120}; do - if curl -sf http://localhost:8000/health >/dev/null 2>&1; then - echo " [icd10-import] Embedding service ready." - break - fi - sleep 2 - done - - # Check if data already exists (query the chapters endpoint) - local CHAPTERS - CHAPTERS=$(curl -sf http://localhost:5090/api/icd10/chapters 2>/dev/null || echo "[]") - if [ "$CHAPTERS" = "[]" ] || [ "$CHAPTERS" = "" ]; then - echo " [icd10-import] No ICD10 data found. Running full Postgres import..." - EMBEDDING_SERVICE_URL="http://localhost:8000" \ - "$VENV_DIR/bin/python" "$SCRIPTS_DIR/import_postgres.py" \ - --connection-string "$CONN_STR" || echo " [icd10-import] Import encountered errors (check logs above)" - else - echo " [icd10-import] ICD10 codes already populated. Generating missing embeddings..." - EMBEDDING_SERVICE_URL="http://localhost:8000" \ - "$VENV_DIR/bin/python" "$SCRIPTS_DIR/import_postgres.py" \ - --connection-string "$CONN_STR" --embeddings-only || echo " [icd10-import] Embedding generation encountered errors" - fi -} - -# ── Build all projects (avoids parallel build contention) ─────────── -echo "" -echo "Building all projects..." -dotnet build "$REPO_ROOT/Gatekeeper/Gatekeeper.Api/Gatekeeper.Api.csproj" --nologo -v q -dotnet build "$SAMPLES_DIR/Clinical/Clinical.Api/Clinical.Api.csproj" --nologo -v q -dotnet build "$SAMPLES_DIR/Scheduling/Scheduling.Api/Scheduling.Api.csproj" --nologo -v q -dotnet build "$SAMPLES_DIR/ICD10/ICD10.Api/ICD10.Api.csproj" --nologo -v q -dotnet build "$SAMPLES_DIR/Dashboard/Dashboard.Web/Dashboard.Web.csproj" -c Release --nologo -v q -echo "All projects built." - -# ── Cleanup on exit ───────────────────────────────────────────────── -cleanup() { - echo "" - echo "Shutting down..." - for pid in "${PIDS[@]}"; do - kill "$pid" 2>/dev/null || true - done - wait 2>/dev/null || true - echo "All services stopped." -} -trap cleanup EXIT INT TERM - -# ── Start APIs (--no-build since we pre-built above) ──────────────── -echo "" -DB_PASS="${DB_PASSWORD:-changeme}" - -echo "Starting Gatekeeper.Api on :5002..." -ConnectionStrings__Postgres="Host=localhost;Database=gatekeeper;Username=gatekeeper;Password=$DB_PASS" \ - dotnet run --no-build --project "$REPO_ROOT/Gatekeeper/Gatekeeper.Api/Gatekeeper.Api.csproj" --no-launch-profile \ - --urls "http://localhost:5002" \ - 2>&1 | sed 's/^/ [gatekeeper] /' & -PIDS+=($!) - -echo "Starting Clinical.Api on :5080..." -ConnectionStrings__Postgres="Host=localhost;Database=clinical;Username=clinical;Password=$DB_PASS" \ - dotnet run --no-build --project "$SAMPLES_DIR/Clinical/Clinical.Api/Clinical.Api.csproj" --no-launch-profile \ - --urls "http://localhost:5080" \ - 2>&1 | sed 's/^/ [clinical] /' & -PIDS+=($!) - -echo "Starting Scheduling.Api on :5001..." -ConnectionStrings__Postgres="Host=localhost;Database=scheduling;Username=scheduling;Password=$DB_PASS" \ - dotnet run --no-build --project "$SAMPLES_DIR/Scheduling/Scheduling.Api/Scheduling.Api.csproj" --no-launch-profile \ - --urls "http://localhost:5001" \ - 2>&1 | sed 's/^/ [scheduling] /' & -PIDS+=($!) - -echo "Starting ICD10.Api on :5090..." -ConnectionStrings__Postgres="Host=localhost;Database=icd10;Username=icd10;Password=$DB_PASS" \ - dotnet run --no-build --project "$SAMPLES_DIR/ICD10/ICD10.Api/ICD10.Api.csproj" --no-launch-profile \ - --urls "http://localhost:5090" \ - 2>&1 | sed 's/^/ [icd10] /' & -PIDS+=($!) - -echo "Starting Dashboard on :5173..." -python3 -m http.server 5173 --directory "$SAMPLES_DIR/Dashboard/Dashboard.Web/wwwroot" \ - 2>&1 | sed 's/^/ [dashboard] /' & -PIDS+=($!) - -# Populate ICD10 data in background (waits for API, then imports if empty) -populate_icd10 & -PIDS+=($!) - -echo "" -echo "════════════════════════════════════════" -echo " Gatekeeper: http://localhost:5002" -echo " Clinical: http://localhost:5080" -echo " Scheduling: http://localhost:5001" -echo " ICD10: http://localhost:5090" -echo " Embedding: http://localhost:8000" -echo " Dashboard: http://localhost:5173" -echo "════════════════════════════════════════" -echo " Press Ctrl+C to stop all services" -echo "" - -wait diff --git a/Samples/scripts/start.sh b/Samples/scripts/start.sh deleted file mode 100755 index 2c43ce7d..00000000 --- a/Samples/scripts/start.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -# Healthcare Samples - Docker Compose wrapper -# Usage: ./start.sh [--fresh] [--build] -# --fresh: Drop volumes and start clean -# --build: Force rebuild containers - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SAMPLES_DIR="$(dirname "$SCRIPT_DIR")" - -BUILD="" - -for arg in "$@"; do - case $arg in - --fresh) "$SCRIPT_DIR/clean.sh" ;; - --build) BUILD="--build" ;; - esac -done - -# Build Dashboard locally (H5 transpiler doesn't work in Docker Linux) -echo "Building Dashboard locally (H5 requires native build)..." -cd "$SAMPLES_DIR/Dashboard/Dashboard.Web" -dotnet publish -c Release -o "$SAMPLES_DIR/docker/dashboard-build" --nologo -v q -echo "Dashboard built successfully" - -cd "$SAMPLES_DIR/docker" - -echo "Starting services..." -docker compose up $BUILD - -# 3 containers: -# db: Postgres with all databases (localhost:5432) -# app: All .NET APIs + sync workers -# - Gatekeeper: localhost:5002 -# - Clinical: localhost:5080 -# - Scheduling: localhost:5001 -# - ICD10: localhost:5090 -# dashboard: Static files (localhost:5173) diff --git a/Sync/Sync/BatchManager.cs b/Sync/Nimblesite.Sync.Core/BatchManager.cs similarity index 99% rename from Sync/Sync/BatchManager.cs rename to Sync/Nimblesite.Sync.Core/BatchManager.cs index 1863d7cf..77bdbd9c 100644 --- a/Sync/Sync/BatchManager.cs +++ b/Sync/Nimblesite.Sync.Core/BatchManager.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Manages batch fetching and processing for sync operations. diff --git a/Sync/Sync/ChangeApplier.cs b/Sync/Nimblesite.Sync.Core/ChangeApplier.cs similarity index 99% rename from Sync/Sync/ChangeApplier.cs rename to Sync/Nimblesite.Sync.Core/ChangeApplier.cs index 94596981..7b632d0f 100644 --- a/Sync/Sync/ChangeApplier.cs +++ b/Sync/Nimblesite.Sync.Core/ChangeApplier.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Applies changes to the local database with FK violation defer/retry. diff --git a/Sync/Sync/ConflictResolver.cs b/Sync/Nimblesite.Sync.Core/ConflictResolver.cs similarity index 98% rename from Sync/Sync/ConflictResolver.cs rename to Sync/Nimblesite.Sync.Core/ConflictResolver.cs index af93bdbf..e1dc5e42 100644 --- a/Sync/Sync/ConflictResolver.cs +++ b/Sync/Nimblesite.Sync.Core/ConflictResolver.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Conflict resolution strategy type. diff --git a/Sync/Nimblesite.Sync.Core/GlobalUsings.cs b/Sync/Nimblesite.Sync.Core/GlobalUsings.cs new file mode 100644 index 00000000..069f0a13 --- /dev/null +++ b/Sync/Nimblesite.Sync.Core/GlobalUsings.cs @@ -0,0 +1,113 @@ +// Type aliases for Result types to reduce verbosity +global using BatchApplyResultError = Outcome.Result< + Nimblesite.Sync.Core.BatchApplyResult, + Nimblesite.Sync.Core.SyncError +>.Error; +global using BatchApplyResultOk = Outcome.Result< + Nimblesite.Sync.Core.BatchApplyResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using BatchApplyResultResult = Outcome.Result< + Nimblesite.Sync.Core.BatchApplyResult, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncError = Outcome.Result.Error< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncResult = Outcome.Result; +global using ConflictResolutionError = Outcome.Result< + Nimblesite.Sync.Core.ConflictResolution, + Nimblesite.Sync.Core.SyncError +>.Error; +global using ConflictResolutionOk = Outcome.Result< + Nimblesite.Sync.Core.ConflictResolution, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using ConflictResolutionResult = Outcome.Result< + Nimblesite.Sync.Core.ConflictResolution, + Nimblesite.Sync.Core.SyncError +>; +global using IntSyncError = Outcome.Result.Error< + int, + Nimblesite.Sync.Core.SyncError +>; +global using IntSyncOk = Outcome.Result.Ok< + int, + Nimblesite.Sync.Core.SyncError +>; +global using IntSyncResult = Outcome.Result; +global using PullResultError = Outcome.Result< + Nimblesite.Sync.Core.PullResult, + Nimblesite.Sync.Core.SyncError +>.Error; +global using PullResultOk = Outcome.Result< + Nimblesite.Sync.Core.PullResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using PullResultResult = Outcome.Result< + Nimblesite.Sync.Core.PullResult, + Nimblesite.Sync.Core.SyncError +>; +global using PushResultError = Outcome.Result< + Nimblesite.Sync.Core.PushResult, + Nimblesite.Sync.Core.SyncError +>.Error; +global using PushResultOk = Outcome.Result< + Nimblesite.Sync.Core.PushResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using PushResultResult = Outcome.Result< + Nimblesite.Sync.Core.PushResult, + Nimblesite.Sync.Core.SyncError +>; +global using SyncBatchError = Outcome.Result< + Nimblesite.Sync.Core.SyncBatch, + Nimblesite.Sync.Core.SyncError +>.Error; +global using SyncBatchOk = Outcome.Result< + Nimblesite.Sync.Core.SyncBatch, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncBatchResult = Outcome.Result< + Nimblesite.Sync.Core.SyncBatch, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogEntryResult = Outcome.Result< + Nimblesite.Sync.Core.SyncLogEntry, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncResultError = Outcome.Result< + Nimblesite.Sync.Core.SyncResult, + Nimblesite.Sync.Core.SyncError +>.Error; +global using SyncResultOk = Outcome.Result< + Nimblesite.Sync.Core.SyncResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncResultResult = Outcome.Result< + Nimblesite.Sync.Core.SyncResult, + Nimblesite.Sync.Core.SyncError +>; diff --git a/Sync/Sync/HashVerifier.cs b/Sync/Nimblesite.Sync.Core/HashVerifier.cs similarity index 99% rename from Sync/Sync/HashVerifier.cs rename to Sync/Nimblesite.Sync.Core/HashVerifier.cs index 7b6a4931..c0cc63f8 100644 --- a/Sync/Sync/HashVerifier.cs +++ b/Sync/Nimblesite.Sync.Core/HashVerifier.cs @@ -2,7 +2,7 @@ using System.Text; using System.Text.Json; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Computes and verifies hashes for sync verification. diff --git a/Sync/Sync/LqlExpressionEvaluator.cs b/Sync/Nimblesite.Sync.Core/LqlExpressionEvaluator.cs similarity index 99% rename from Sync/Sync/LqlExpressionEvaluator.cs rename to Sync/Nimblesite.Sync.Core/LqlExpressionEvaluator.cs index 57ea16f5..11cafa7f 100644 --- a/Sync/Sync/LqlExpressionEvaluator.cs +++ b/Sync/Nimblesite.Sync.Core/LqlExpressionEvaluator.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.RegularExpressions; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Evaluates simple LQL expressions on JSON data for sync mapping transforms. diff --git a/Sync/Sync/MappedSyncCoordinator.cs b/Sync/Nimblesite.Sync.Core/MappedSyncCoordinator.cs similarity index 99% rename from Sync/Sync/MappedSyncCoordinator.cs rename to Sync/Nimblesite.Sync.Core/MappedSyncCoordinator.cs index c127c88c..03db0659 100644 --- a/Sync/Sync/MappedSyncCoordinator.cs +++ b/Sync/Nimblesite.Sync.Core/MappedSyncCoordinator.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Result of mapped sync pull operation. diff --git a/Sync/Sync/MappingConfig.cs b/Sync/Nimblesite.Sync.Core/MappingConfig.cs similarity index 98% rename from Sync/Sync/MappingConfig.cs rename to Sync/Nimblesite.Sync.Core/MappingConfig.cs index 6e653355..1613d6b8 100644 --- a/Sync/Sync/MappingConfig.cs +++ b/Sync/Nimblesite.Sync.Core/MappingConfig.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Direction of sync mapping. Spec Section 7.6. @@ -75,7 +75,7 @@ public sealed record PkMapping(string SourceColumn, string TargetColumn); /// Target column name. /// Transform type to apply. /// Constant value when Transform=Constant. -/// LQL expression when Transform=Lql. +/// LQL expression when Transform=Nimblesite.Lql.Core. public sealed record ColumnMapping( string? Source, string Target, diff --git a/Sync/Sync/MappingConfigParser.cs b/Sync/Nimblesite.Sync.Core/MappingConfigParser.cs similarity index 99% rename from Sync/Sync/MappingConfigParser.cs rename to Sync/Nimblesite.Sync.Core/MappingConfigParser.cs index 6318a07a..5fa886b7 100644 --- a/Sync/Sync/MappingConfigParser.cs +++ b/Sync/Nimblesite.Sync.Core/MappingConfigParser.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Parses JSON mapping configuration files. diff --git a/Sync/Sync/MappingEngine.cs b/Sync/Nimblesite.Sync.Core/MappingEngine.cs similarity index 99% rename from Sync/Sync/MappingEngine.cs rename to Sync/Nimblesite.Sync.Core/MappingEngine.cs index 53bef99e..e19fecaa 100644 --- a/Sync/Sync/MappingEngine.cs +++ b/Sync/Nimblesite.Sync.Core/MappingEngine.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Result of applying a mapping to a sync log entry. diff --git a/Sync/Sync/MappingState.cs b/Sync/Nimblesite.Sync.Core/MappingState.cs similarity index 99% rename from Sync/Sync/MappingState.cs rename to Sync/Nimblesite.Sync.Core/MappingState.cs index 0bf4e0c6..e7631a59 100644 --- a/Sync/Sync/MappingState.cs +++ b/Sync/Nimblesite.Sync.Core/MappingState.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Per-mapping sync state for version-based tracking. diff --git a/Sync/Nimblesite.Sync.Core/Nimblesite.Sync.Core.csproj b/Sync/Nimblesite.Sync.Core/Nimblesite.Sync.Core.csproj new file mode 100644 index 00000000..feb2051d --- /dev/null +++ b/Sync/Nimblesite.Sync.Core/Nimblesite.Sync.Core.csproj @@ -0,0 +1,22 @@ + + + Library + Nimblesite.Sync.Core + Nimblesite.Sync.Core + $(NoWarn); + + + + + + + + + + + + + + + + diff --git a/Sync/Sync/SubscriptionManager.cs b/Sync/Nimblesite.Sync.Core/SubscriptionManager.cs similarity index 99% rename from Sync/Sync/SubscriptionManager.cs rename to Sync/Nimblesite.Sync.Core/SubscriptionManager.cs index 0325a9e8..465a7893 100644 --- a/Sync/Sync/SubscriptionManager.cs +++ b/Sync/Nimblesite.Sync.Core/SubscriptionManager.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Type of subscription for real-time notifications. diff --git a/Sync/Sync/SyncBatch.cs b/Sync/Nimblesite.Sync.Core/SyncBatch.cs similarity index 98% rename from Sync/Sync/SyncBatch.cs rename to Sync/Nimblesite.Sync.Core/SyncBatch.cs index fab536ca..e94ac812 100644 --- a/Sync/Sync/SyncBatch.cs +++ b/Sync/Nimblesite.Sync.Core/SyncBatch.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Represents a batch of changes to be synced. Maps to spec Section 12. diff --git a/Sync/Sync/SyncCoordinator.cs b/Sync/Nimblesite.Sync.Core/SyncCoordinator.cs similarity index 99% rename from Sync/Sync/SyncCoordinator.cs rename to Sync/Nimblesite.Sync.Core/SyncCoordinator.cs index be4a23cf..1c7a2f62 100644 --- a/Sync/Sync/SyncCoordinator.cs +++ b/Sync/Nimblesite.Sync.Core/SyncCoordinator.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Result of a sync pull operation. diff --git a/Sync/Sync/SyncError.cs b/Sync/Nimblesite.Sync.Core/SyncError.cs similarity index 98% rename from Sync/Sync/SyncError.cs rename to Sync/Nimblesite.Sync.Core/SyncError.cs index f6c301aa..62210bea 100644 --- a/Sync/Sync/SyncError.cs +++ b/Sync/Nimblesite.Sync.Core/SyncError.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Base type for sync errors. Use pattern matching on derived types. diff --git a/Sync/Sync/SyncLogEntry.cs b/Sync/Nimblesite.Sync.Core/SyncLogEntry.cs similarity index 98% rename from Sync/Sync/SyncLogEntry.cs rename to Sync/Nimblesite.Sync.Core/SyncLogEntry.cs index 184ed19f..46605727 100644 --- a/Sync/Sync/SyncLogEntry.cs +++ b/Sync/Nimblesite.Sync.Core/SyncLogEntry.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Represents a single entry in the unified change log (_sync_log). diff --git a/Sync/Sync/SyncOperation.cs b/Sync/Nimblesite.Sync.Core/SyncOperation.cs similarity index 92% rename from Sync/Sync/SyncOperation.cs rename to Sync/Nimblesite.Sync.Core/SyncOperation.cs index 7720f67e..2eadec29 100644 --- a/Sync/Sync/SyncOperation.cs +++ b/Sync/Nimblesite.Sync.Core/SyncOperation.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Represents the type of change operation tracked in the sync log. diff --git a/Sync/Sync/SyncSchemaDefinition.cs b/Sync/Nimblesite.Sync.Core/SyncSchemaDefinition.cs similarity index 97% rename from Sync/Sync/SyncSchemaDefinition.cs rename to Sync/Nimblesite.Sync.Core/SyncSchemaDefinition.cs index aa26d55e..578ae91d 100644 --- a/Sync/Sync/SyncSchemaDefinition.cs +++ b/Sync/Nimblesite.Sync.Core/SyncSchemaDefinition.cs @@ -1,7 +1,7 @@ -using Migration; -using P = Migration.PortableTypes; +using Nimblesite.DataProvider.Migration.Core; +using P = Nimblesite.DataProvider.Migration.Core.PortableTypes; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Database-agnostic sync schema definition using the Migration framework. diff --git a/Sync/Sync/SyncState.cs b/Sync/Nimblesite.Sync.Core/SyncState.cs similarity index 98% rename from Sync/Sync/SyncState.cs rename to Sync/Nimblesite.Sync.Core/SyncState.cs index c3277af5..8769ed77 100644 --- a/Sync/Sync/SyncState.cs +++ b/Sync/Nimblesite.Sync.Core/SyncState.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Represents the sync state for a replica. Maps to _sync_state table (Appendix A). diff --git a/Sync/Sync/SyncTrackingState.cs b/Sync/Nimblesite.Sync.Core/SyncTrackingState.cs similarity index 99% rename from Sync/Sync/SyncTrackingState.cs rename to Sync/Nimblesite.Sync.Core/SyncTrackingState.cs index 0355c011..acf00ecc 100644 --- a/Sync/Sync/SyncTrackingState.cs +++ b/Sync/Nimblesite.Sync.Core/SyncTrackingState.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Per-mapping sync state. Spec Section 7.5.2 - _sync_mapping_state table. diff --git a/Sync/Sync/TombstoneManager.cs b/Sync/Nimblesite.Sync.Core/TombstoneManager.cs similarity index 99% rename from Sync/Sync/TombstoneManager.cs rename to Sync/Nimblesite.Sync.Core/TombstoneManager.cs index bedb0279..6bd8c07a 100644 --- a/Sync/Sync/TombstoneManager.cs +++ b/Sync/Nimblesite.Sync.Core/TombstoneManager.cs @@ -1,4 +1,4 @@ -namespace Sync; +namespace Nimblesite.Sync.Core; /// /// Manages tombstone retention and purging. diff --git a/Sync/Sync.Http.Tests/CrossDatabaseSyncTests.cs b/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncTests.cs similarity index 99% rename from Sync/Sync.Http.Tests/CrossDatabaseSyncTests.cs rename to Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncTests.cs index 9e251c57..e2fb2597 100644 --- a/Sync/Sync.Http.Tests/CrossDatabaseSyncTests.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Http.Tests; +namespace Nimblesite.Sync.Http.Tests; /// /// E2E integration tests for cross-database sync between SQLite and PostgreSQL. diff --git a/Sync/Nimblesite.Sync.Http.Tests/GlobalUsings.cs b/Sync/Nimblesite.Sync.Http.Tests/GlobalUsings.cs new file mode 100644 index 00000000..18536ddd --- /dev/null +++ b/Sync/Nimblesite.Sync.Http.Tests/GlobalUsings.cs @@ -0,0 +1,27 @@ +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Mvc.Testing; +global using Microsoft.Data.Sqlite; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; +global using Nimblesite.Sync.Core; +global using Nimblesite.Sync.Postgres; +global using Nimblesite.Sync.SQLite; +global using Npgsql; +global using Testcontainers.PostgreSql; +global using Xunit; +// Type aliases for Result types - matching Sync patterns using Outcome package +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncOk = Outcome.Result.Ok< + string, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; diff --git a/Sync/Sync.Http.Tests/HttpEndpointTests.cs b/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointTests.cs similarity index 99% rename from Sync/Sync.Http.Tests/HttpEndpointTests.cs rename to Sync/Nimblesite.Sync.Http.Tests/HttpEndpointTests.cs index f1ee8b4c..3b7daff1 100644 --- a/Sync/Sync.Http.Tests/HttpEndpointTests.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; -namespace Sync.Http.Tests; +namespace Nimblesite.Sync.Http.Tests; /// /// Custom WebApplicationFactory that sets the correct content root path. @@ -365,7 +365,7 @@ public void ApplyChange_WithSuppression_DoesNotEcho() /// /// E2E tests proving REAL sync over HTTP works. /// Uses WebApplicationFactory for real ASP.NET Core server + real SQLite databases. -/// This is the PROOF that Sync.Http extension methods work in a real scenario. +/// This is the PROOF that Nimblesite.Sync.Http extension methods work in a real scenario. /// public sealed class HttpSyncE2ETests : IClassFixture, IDisposable { diff --git a/Sync/Sync.Http.Tests/HttpMappingE2ETests.cs b/Sync/Nimblesite.Sync.Http.Tests/HttpMappingE2ETests.cs similarity index 99% rename from Sync/Sync.Http.Tests/HttpMappingE2ETests.cs rename to Sync/Nimblesite.Sync.Http.Tests/HttpMappingE2ETests.cs index ec098989..d43d2448 100644 --- a/Sync/Sync.Http.Tests/HttpMappingE2ETests.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/HttpMappingE2ETests.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Sync.Http.Tests; +namespace Nimblesite.Sync.Http.Tests; #pragma warning disable CA1001 // Type owns disposable fields - disposed via IAsyncLifetime.DisposeAsync diff --git a/Sync/Sync.Http.Tests/Sync.Http.Tests.csproj b/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj similarity index 61% rename from Sync/Sync.Http.Tests/Sync.Http.Tests.csproj rename to Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj index 0168490c..5757afeb 100644 --- a/Sync/Sync.Http.Tests/Sync.Http.Tests.csproj +++ b/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj @@ -1,12 +1,12 @@ Exe true - Sync.Http.Tests + Nimblesite.Sync.Http.Tests false CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859;CA1849;CA2234;CA1812;CA2007;CA2000;xUnit1030 @@ -18,8 +18,8 @@ all runtime; build; native; contentfiles; analyzers - - + + @@ -29,9 +29,9 @@ - - - - + + + + diff --git a/Sync/Sync.Http.Tests/SyncFailureIsolationTests.cs b/Sync/Nimblesite.Sync.Http.Tests/SyncFailureIsolationTests.cs similarity index 99% rename from Sync/Sync.Http.Tests/SyncFailureIsolationTests.cs rename to Sync/Nimblesite.Sync.Http.Tests/SyncFailureIsolationTests.cs index e6ff1b8b..cb5da907 100644 --- a/Sync/Sync.Http.Tests/SyncFailureIsolationTests.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/SyncFailureIsolationTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Http.Tests; +namespace Nimblesite.Sync.Http.Tests; /// /// FAILING tests that isolate sync failures observed in production. diff --git a/Sync/Sync.Http.Tests/TestProgram.cs b/Sync/Nimblesite.Sync.Http.Tests/TestProgram.cs similarity index 69% rename from Sync/Sync.Http.Tests/TestProgram.cs rename to Sync/Nimblesite.Sync.Http.Tests/TestProgram.cs index 9eeaba20..cff01aeb 100644 --- a/Sync/Sync.Http.Tests/TestProgram.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/TestProgram.cs @@ -1,13 +1,13 @@ // Test-only Program entry point for WebApplicationFactory support -// Sync.Http is a LIBRARY - this test project spins up the server for testing. +// Nimblesite.Sync.Http is a LIBRARY - this test project spins up the server for testing. -using Sync.Http; +using Nimblesite.Sync.Http; var options = new WebApplicationOptions { Args = args, ContentRootPath = AppContext.BaseDirectory }; var builder = WebApplication.CreateBuilder(options); -// Add sync API services using the extension method from Sync.Http library +// Add sync API services using the extension method from Nimblesite.Sync.Http library builder.Services.AddSyncApiServices(builder.Environment.IsDevelopment()); var app = builder.Build(); @@ -18,7 +18,7 @@ // Use request timeout middleware app.UseSyncRequestTimeout(); -// Map all sync endpoints using the extension method from Sync.Http library +// Map all sync endpoints using the extension method from Nimblesite.Sync.Http library app.MapSyncEndpoints(); app.Run(); diff --git a/Sync/Sync.Http/ApiSubscriptionManager.cs b/Sync/Nimblesite.Sync.Http/ApiSubscriptionManager.cs similarity index 99% rename from Sync/Sync.Http/ApiSubscriptionManager.cs rename to Sync/Nimblesite.Sync.Http/ApiSubscriptionManager.cs index 8b255627..73098fc8 100644 --- a/Sync/Sync.Http/ApiSubscriptionManager.cs +++ b/Sync/Nimblesite.Sync.Http/ApiSubscriptionManager.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using System.Threading.Channels; -namespace Sync.Http; +namespace Nimblesite.Sync.Http; /// /// Manages real-time SSE subscriptions for sync API. diff --git a/Sync/Nimblesite.Sync.Http/GlobalUsings.cs b/Sync/Nimblesite.Sync.Http/GlobalUsings.cs new file mode 100644 index 00000000..ad874904 --- /dev/null +++ b/Sync/Nimblesite.Sync.Http/GlobalUsings.cs @@ -0,0 +1 @@ +global using Nimblesite.Sync.Core; diff --git a/Sync/Sync.Http/Sync.Http.csproj b/Sync/Nimblesite.Sync.Http/Nimblesite.Sync.Http.csproj similarity index 55% rename from Sync/Sync.Http/Sync.Http.csproj rename to Sync/Nimblesite.Sync.Http/Nimblesite.Sync.Http.csproj index 4a1ea377..744c2ea8 100644 --- a/Sync/Sync.Http/Sync.Http.csproj +++ b/Sync/Nimblesite.Sync.Http/Nimblesite.Sync.Http.csproj @@ -6,18 +6,19 @@ in YOUR OWN host project. --> Library - Sync.Http + Nimblesite.Sync.Http + Nimblesite.Sync.Http $(NoWarn);CA1515;CA1307;CA2007;EPC13;CS1591 - - - + + + - + diff --git a/Sync/Sync.Http/SyncApiModels.cs b/Sync/Nimblesite.Sync.Http/SyncApiModels.cs similarity index 97% rename from Sync/Sync.Http/SyncApiModels.cs rename to Sync/Nimblesite.Sync.Http/SyncApiModels.cs index e37f950f..2b139c5e 100644 --- a/Sync/Sync.Http/SyncApiModels.cs +++ b/Sync/Nimblesite.Sync.Http/SyncApiModels.cs @@ -1,4 +1,4 @@ -namespace Sync.Http; +namespace Nimblesite.Sync.Http; /// /// Request to push changes to the sync server. diff --git a/Sync/Sync.Http/SyncEndpointExtensions.cs b/Sync/Nimblesite.Sync.Http/SyncEndpointExtensions.cs similarity index 99% rename from Sync/Sync.Http/SyncEndpointExtensions.cs rename to Sync/Nimblesite.Sync.Http/SyncEndpointExtensions.cs index 0ad5e9bb..bb3975f6 100644 --- a/Sync/Sync.Http/SyncEndpointExtensions.cs +++ b/Sync/Nimblesite.Sync.Http/SyncEndpointExtensions.cs @@ -3,7 +3,7 @@ using System.Text.Json; using System.Threading.RateLimiting; -namespace Sync.Http; +namespace Nimblesite.Sync.Http; /// /// Extension methods for configuring sync API endpoints and services. diff --git a/Sync/Sync.Http/SyncHelpers.cs b/Sync/Nimblesite.Sync.Http/SyncHelpers.cs similarity index 99% rename from Sync/Sync.Http/SyncHelpers.cs rename to Sync/Nimblesite.Sync.Http/SyncHelpers.cs index 1a41fc65..4ac29bba 100644 --- a/Sync/Sync.Http/SyncHelpers.cs +++ b/Sync/Nimblesite.Sync.Http/SyncHelpers.cs @@ -4,7 +4,7 @@ // TODO: Logging!! #pragma warning disable IDE0060 // Remove unused parameter -namespace Sync.Http; +namespace Nimblesite.Sync.Http; /// /// Helper methods for sync database operations. diff --git a/Sync/Nimblesite.Sync.Integration.Tests/GlobalUsings.cs b/Sync/Nimblesite.Sync.Integration.Tests/GlobalUsings.cs new file mode 100644 index 00000000..122a9c79 --- /dev/null +++ b/Sync/Nimblesite.Sync.Integration.Tests/GlobalUsings.cs @@ -0,0 +1,22 @@ +global using System.Text.Json; +global using Microsoft.Data.Sqlite; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; +global using Nimblesite.Sync.Core; +global using Nimblesite.Sync.Postgres; +global using Nimblesite.Sync.SQLite; +global using Npgsql; +global using Testcontainers.PostgreSql; +global using Xunit; +// Type aliases for Result types - matching Sync patterns using Outcome package +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; diff --git a/Sync/Sync.Integration.Tests/HttpMappingSyncTests.cs b/Sync/Nimblesite.Sync.Integration.Tests/HttpMappingSyncTests.cs similarity index 99% rename from Sync/Sync.Integration.Tests/HttpMappingSyncTests.cs rename to Sync/Nimblesite.Sync.Integration.Tests/HttpMappingSyncTests.cs index 54b0acc8..551edd73 100644 --- a/Sync/Sync.Integration.Tests/HttpMappingSyncTests.cs +++ b/Sync/Nimblesite.Sync.Integration.Tests/HttpMappingSyncTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Integration.Tests; +namespace Nimblesite.Sync.Integration.Tests; /// /// REAL E2E HTTP tests proving LQL/MappingEngine transforms data between DBs with DIFFERENT SCHEMAS. diff --git a/Samples/Clinical/Clinical.Api.Tests/Clinical.Api.Tests.csproj b/Sync/Nimblesite.Sync.Integration.Tests/Nimblesite.Sync.Integration.Tests.csproj similarity index 55% rename from Samples/Clinical/Clinical.Api.Tests/Clinical.Api.Tests.csproj rename to Sync/Nimblesite.Sync.Integration.Tests/Nimblesite.Sync.Integration.Tests.csproj index b9fe3948..90c06bef 100644 --- a/Samples/Clinical/Clinical.Api.Tests/Clinical.Api.Tests.csproj +++ b/Sync/Nimblesite.Sync.Integration.Tests/Nimblesite.Sync.Integration.Tests.csproj @@ -2,8 +2,8 @@ Library true - Clinical.Api.Tests - CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859;CA1849;CA2234;CA1812;CA2007;CA2000;xUnit1030 + Nimblesite.Sync.Integration.Tests + CS1591;CA1707;CA1307;CA1062;CA1515;CA2100 @@ -13,16 +13,21 @@ all runtime; build; native; contentfiles; analyzers - + + + + - all runtime; build; native; contentfiles; analyzers; buildtransitive + all - - + + + + diff --git a/Sync/Sync.Postgres.Tests/CrossDatabaseSyncTests.cs b/Sync/Nimblesite.Sync.Postgres.Tests/CrossDatabaseSyncTests.cs similarity index 99% rename from Sync/Sync.Postgres.Tests/CrossDatabaseSyncTests.cs rename to Sync/Nimblesite.Sync.Postgres.Tests/CrossDatabaseSyncTests.cs index f53679f8..90ff4cf9 100644 --- a/Sync/Sync.Postgres.Tests/CrossDatabaseSyncTests.cs +++ b/Sync/Nimblesite.Sync.Postgres.Tests/CrossDatabaseSyncTests.cs @@ -1,10 +1,10 @@ using System.Diagnostics.CodeAnalysis; -namespace Sync.Postgres.Tests; +namespace Nimblesite.Sync.Postgres.Tests; /// /// E2E integration tests for bi-directional sync between SQLite and PostgreSQL. -/// Tests the full sync protocol per spec.md Sections 7-15. +/// Tests the full sync protocol per docs/specs/sync-spec.md Sections 7-15. /// Uses Testcontainers for real PostgreSQL instance. /// [SuppressMessage( diff --git a/Sync/Nimblesite.Sync.Postgres.Tests/GlobalUsings.cs b/Sync/Nimblesite.Sync.Postgres.Tests/GlobalUsings.cs new file mode 100644 index 00000000..4d6863d6 --- /dev/null +++ b/Sync/Nimblesite.Sync.Postgres.Tests/GlobalUsings.cs @@ -0,0 +1,43 @@ +global using Microsoft.Data.Sqlite; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; +global using Nimblesite.Sync.Core; +global using Nimblesite.Sync.SQLite; +global using Npgsql; +global using Testcontainers.PostgreSql; +global using Xunit; +// Type aliases for Result types - matching Sync patterns using Outcome package +global using BatchApplyResultOk = Outcome.Result< + Nimblesite.Sync.Core.BatchApplyResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using LongSyncOk = Outcome.Result.Ok< + long, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncOk = Outcome.Result.Ok< + string, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientOk = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; diff --git a/Sync/Sync.Postgres.Tests/Sync.Postgres.Tests.csproj b/Sync/Nimblesite.Sync.Postgres.Tests/Nimblesite.Sync.Postgres.Tests.csproj similarity index 72% rename from Sync/Sync.Postgres.Tests/Sync.Postgres.Tests.csproj rename to Sync/Nimblesite.Sync.Postgres.Tests/Nimblesite.Sync.Postgres.Tests.csproj index ada0ef60..60d73f3a 100644 --- a/Sync/Sync.Postgres.Tests/Sync.Postgres.Tests.csproj +++ b/Sync/Nimblesite.Sync.Postgres.Tests/Nimblesite.Sync.Postgres.Tests.csproj @@ -1,24 +1,24 @@ Library - Sync.Postgres.Tests + Nimblesite.Sync.Postgres.Tests false true CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859;CA1849;CA2007;CA1001 - - - + + + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Sync/Sync.Postgres.Tests/PostgresRepositoryTests.cs b/Sync/Nimblesite.Sync.Postgres.Tests/PostgresRepositoryTests.cs similarity index 99% rename from Sync/Sync.Postgres.Tests/PostgresRepositoryTests.cs rename to Sync/Nimblesite.Sync.Postgres.Tests/PostgresRepositoryTests.cs index d354a995..fcc53a32 100644 --- a/Sync/Sync.Postgres.Tests/PostgresRepositoryTests.cs +++ b/Sync/Nimblesite.Sync.Postgres.Tests/PostgresRepositoryTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Postgres.Tests; +namespace Nimblesite.Sync.Postgres.Tests; /// /// Integration tests for Postgres repositories. diff --git a/Sync/Nimblesite.Sync.Postgres/GlobalUsings.cs b/Sync/Nimblesite.Sync.Postgres/GlobalUsings.cs new file mode 100644 index 00000000..1444ffdf --- /dev/null +++ b/Sync/Nimblesite.Sync.Postgres/GlobalUsings.cs @@ -0,0 +1,97 @@ +global using Microsoft.Extensions.Logging; +global using Nimblesite.Sync.Core; +global using Npgsql; +// Type aliases for Result types - matching Nimblesite.Sync.SQLite patterns using Outcome package +global using BoolSyncError = Outcome.Result.Error< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncResult = Outcome.Result; +global using ColumnInfoListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using ColumnInfoListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using ColumnInfoListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using LongSyncError = Outcome.Result.Error< + long, + Nimblesite.Sync.Core.SyncError +>; +global using LongSyncOk = Outcome.Result.Ok< + long, + Nimblesite.Sync.Core.SyncError +>; +global using LongSyncResult = Outcome.Result; +global using StringSyncError = Outcome.Result.Error< + string, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncOk = Outcome.Result.Ok< + string, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncResult = Outcome.Result; +global using SyncClientError = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>.Error; +global using SyncClientListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientOk = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncClientResult = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; diff --git a/Sync/Sync.Postgres/Sync.Postgres.csproj b/Sync/Nimblesite.Sync.Postgres/Nimblesite.Sync.Postgres.csproj similarity index 59% rename from Sync/Sync.Postgres/Sync.Postgres.csproj rename to Sync/Nimblesite.Sync.Postgres/Nimblesite.Sync.Postgres.csproj index b95a97ca..8a810051 100644 --- a/Sync/Sync.Postgres/Sync.Postgres.csproj +++ b/Sync/Nimblesite.Sync.Postgres/Nimblesite.Sync.Postgres.csproj @@ -1,16 +1,17 @@ Library - Sync.Postgres + Nimblesite.Sync.Postgres + Nimblesite.Sync.Postgres $(NoWarn); - + - + diff --git a/Sync/Sync.Postgres/PostgresChangeApplier.cs b/Sync/Nimblesite.Sync.Postgres/PostgresChangeApplier.cs similarity index 99% rename from Sync/Sync.Postgres/PostgresChangeApplier.cs rename to Sync/Nimblesite.Sync.Postgres/PostgresChangeApplier.cs index 4775007d..6edd1cd8 100644 --- a/Sync/Sync.Postgres/PostgresChangeApplier.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresChangeApplier.cs @@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; -namespace Sync.Postgres; +namespace Nimblesite.Sync.Postgres; /// /// Applies sync changes to PostgreSQL tables. diff --git a/Sync/Sync.Postgres/PostgresSyncClientRepository.cs b/Sync/Nimblesite.Sync.Postgres/PostgresSyncClientRepository.cs similarity index 99% rename from Sync/Sync.Postgres/PostgresSyncClientRepository.cs rename to Sync/Nimblesite.Sync.Postgres/PostgresSyncClientRepository.cs index 0d1a1c39..8b1d7386 100644 --- a/Sync/Sync.Postgres/PostgresSyncClientRepository.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresSyncClientRepository.cs @@ -1,4 +1,4 @@ -namespace Sync.Postgres; +namespace Nimblesite.Sync.Postgres; /// /// Repository for managing sync clients in PostgreSQL. diff --git a/Sync/Sync.Postgres/PostgresSyncLogRepository.cs b/Sync/Nimblesite.Sync.Postgres/PostgresSyncLogRepository.cs similarity index 99% rename from Sync/Sync.Postgres/PostgresSyncLogRepository.cs rename to Sync/Nimblesite.Sync.Postgres/PostgresSyncLogRepository.cs index f975571f..f041b446 100644 --- a/Sync/Sync.Postgres/PostgresSyncLogRepository.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresSyncLogRepository.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace Sync.Postgres; +namespace Nimblesite.Sync.Postgres; /// /// Repository for sync log operations in PostgreSQL. diff --git a/Sync/Sync.Postgres/PostgresSyncSchema.cs b/Sync/Nimblesite.Sync.Postgres/PostgresSyncSchema.cs similarity index 99% rename from Sync/Sync.Postgres/PostgresSyncSchema.cs rename to Sync/Nimblesite.Sync.Postgres/PostgresSyncSchema.cs index fe85b011..208aba62 100644 --- a/Sync/Sync.Postgres/PostgresSyncSchema.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresSyncSchema.cs @@ -1,4 +1,4 @@ -namespace Sync.Postgres; +namespace Nimblesite.Sync.Postgres; /// /// Schema management for PostgreSQL sync tables. diff --git a/Sync/Sync.Postgres/PostgresSyncSession.cs b/Sync/Nimblesite.Sync.Postgres/PostgresSyncSession.cs similarity index 97% rename from Sync/Sync.Postgres/PostgresSyncSession.cs rename to Sync/Nimblesite.Sync.Postgres/PostgresSyncSession.cs index d61a9da4..89ff0177 100644 --- a/Sync/Sync.Postgres/PostgresSyncSession.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresSyncSession.cs @@ -1,4 +1,4 @@ -namespace Sync.Postgres; +namespace Nimblesite.Sync.Postgres; /// /// Manages sync session state for PostgreSQL. diff --git a/Sync/Sync.Postgres/PostgresTriggerGenerator.cs b/Sync/Nimblesite.Sync.Postgres/PostgresTriggerGenerator.cs similarity index 99% rename from Sync/Sync.Postgres/PostgresTriggerGenerator.cs rename to Sync/Nimblesite.Sync.Postgres/PostgresTriggerGenerator.cs index 5a3c0a69..85e16535 100644 --- a/Sync/Sync.Postgres/PostgresTriggerGenerator.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresTriggerGenerator.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Text; -namespace Sync.Postgres; +namespace Nimblesite.Sync.Postgres; /// /// Column information for trigger generation. diff --git a/Sync/Sync.SQLite.Tests/ChangeApplierIntegrationTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/ChangeApplierIntegrationTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/ChangeApplierIntegrationTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/ChangeApplierIntegrationTests.cs index 91853398..47e10d9a 100644 --- a/Sync/Sync.SQLite.Tests/ChangeApplierIntegrationTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/ChangeApplierIntegrationTests.cs @@ -1,7 +1,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Integration tests for ChangeApplierSQLite. diff --git a/Sync/Sync.SQLite.Tests/EndToEndSyncTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/EndToEndSyncTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/EndToEndSyncTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/EndToEndSyncTests.cs index 3288c532..6b3b1172 100644 --- a/Sync/Sync.SQLite.Tests/EndToEndSyncTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/EndToEndSyncTests.cs @@ -1,7 +1,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Real end-to-end integration tests that sync data between two SQLite databases. @@ -99,7 +99,7 @@ public void Sync_MultipleRecords_AllSynced() InsertPerson(_sourceDb, "p2", "Bob", "bob@example.com"); InsertPerson(_sourceDb, "p3", "Charlie", "charlie@example.com"); - // Act: Sync + // Act: Nimblesite.Sync.Core var changes = FetchChangesFromSource(); Assert.Equal(3, changes.Count); diff --git a/Sync/Nimblesite.Sync.SQLite.Tests/GlobalUsings.cs b/Sync/Nimblesite.Sync.SQLite.Tests/GlobalUsings.cs new file mode 100644 index 00000000..6106a95b --- /dev/null +++ b/Sync/Nimblesite.Sync.SQLite.Tests/GlobalUsings.cs @@ -0,0 +1,73 @@ +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; +global using Nimblesite.Sync.Core; +// Type aliases for Result types in tests +global using BatchApplyResultOk = Outcome.Result< + Nimblesite.Sync.Core.BatchApplyResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using BoolSyncError = Outcome.Result.Error< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncResult = Outcome.Result; +global using ColumnInfoListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using IntSyncOk = Outcome.Result.Ok< + int, + Nimblesite.Sync.Core.SyncError +>; +global using LongSyncOk = Outcome.Result.Ok< + long, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncError = Outcome.Result.Error< + string, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncOk = Outcome.Result.Ok< + string, + Nimblesite.Sync.Core.SyncError +>; +global using SubscriptionListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SubscriptionOk = Outcome.Result< + Nimblesite.Sync.Core.SyncSubscription?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncBatchOk = Outcome.Result< + Nimblesite.Sync.Core.SyncBatch, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncClientListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientOk = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; diff --git a/Sync/Sync.SQLite.Tests/Sync.SQLite.Tests.csproj b/Sync/Nimblesite.Sync.SQLite.Tests/Nimblesite.Sync.SQLite.Tests.csproj similarity index 73% rename from Sync/Sync.SQLite.Tests/Sync.SQLite.Tests.csproj rename to Sync/Nimblesite.Sync.SQLite.Tests/Nimblesite.Sync.SQLite.Tests.csproj index c5fcd082..3b8edd19 100644 --- a/Sync/Sync.SQLite.Tests/Sync.SQLite.Tests.csproj +++ b/Sync/Nimblesite.Sync.SQLite.Tests/Nimblesite.Sync.SQLite.Tests.csproj @@ -2,7 +2,7 @@ Library true - Sync.SQLite.Tests + Nimblesite.Sync.SQLite.Tests CS1591;CA1707;CA1307;CA1062;CA1515;CA2100 @@ -13,8 +13,8 @@ all runtime; build; native; contentfiles; analyzers - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -22,7 +22,7 @@ - - + + diff --git a/Sync/Sync.SQLite.Tests/SchemaAndTriggerTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/SchemaAndTriggerTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/SchemaAndTriggerTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/SchemaAndTriggerTests.cs index 172bc79a..358c1def 100644 --- a/Sync/Sync.SQLite.Tests/SchemaAndTriggerTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/SchemaAndTriggerTests.cs @@ -1,7 +1,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Integration tests for SyncSchema and TriggerGenerator. diff --git a/Sync/Sync.SQLite.Tests/SpecComplianceTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/SpecComplianceTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/SpecComplianceTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/SpecComplianceTests.cs index bd7af0d6..7c3226f5 100644 --- a/Sync/Sync.SQLite.Tests/SpecComplianceTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/SpecComplianceTests.cs @@ -2,10 +2,10 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// -/// Integration tests proving spec.md compliance. +/// Integration tests proving docs/specs/sync-spec.md compliance. /// Every spec section with testable requirements is covered. /// NO MOCKS - REAL SQLITE DATABASES ONLY! /// diff --git a/Sync/Sync.SQLite.Tests/SpecConformanceTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/SpecConformanceTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/SpecConformanceTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/SpecConformanceTests.cs index 65365ee2..6fca3fe5 100644 --- a/Sync/Sync.SQLite.Tests/SpecConformanceTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/SpecConformanceTests.cs @@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Integration tests verifying conformance to the .NET Sync Framework Specification. diff --git a/Sync/Sync.SQLite.Tests/SqliteExtensionIntegrationTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/SqliteExtensionIntegrationTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/SqliteExtensionIntegrationTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/SqliteExtensionIntegrationTests.cs index 31047738..efc410f9 100644 --- a/Sync/Sync.SQLite.Tests/SqliteExtensionIntegrationTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/SqliteExtensionIntegrationTests.cs @@ -1,7 +1,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Integration tests for SqliteConnectionSyncExtensions. diff --git a/Sync/Sync.SQLite.Tests/SubscriptionIntegrationTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/SubscriptionIntegrationTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/SubscriptionIntegrationTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/SubscriptionIntegrationTests.cs index c9ade205..18966afb 100644 --- a/Sync/Sync.SQLite.Tests/SubscriptionIntegrationTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/SubscriptionIntegrationTests.cs @@ -1,7 +1,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Integration tests for real-time subscriptions (Spec Section 10). diff --git a/Sync/Sync.SQLite.Tests/SyncRepositoryIntegrationTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/SyncRepositoryIntegrationTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/SyncRepositoryIntegrationTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/SyncRepositoryIntegrationTests.cs index d91881dd..27644655 100644 --- a/Sync/Sync.SQLite.Tests/SyncRepositoryIntegrationTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/SyncRepositoryIntegrationTests.cs @@ -1,7 +1,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Integration tests for SQLite repository classes. diff --git a/Sync/Sync.SQLite.Tests/TestLogger.cs b/Sync/Nimblesite.Sync.SQLite.Tests/TestLogger.cs similarity index 86% rename from Sync/Sync.SQLite.Tests/TestLogger.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/TestLogger.cs index e63b18c5..f0369c63 100644 --- a/Sync/Sync.SQLite.Tests/TestLogger.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/TestLogger.cs @@ -1,4 +1,4 @@ -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Static logger instance for tests. diff --git a/Sync/Sync.SQLite.Tests/TombstoneIntegrationTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/TombstoneIntegrationTests.cs similarity index 99% rename from Sync/Sync.SQLite.Tests/TombstoneIntegrationTests.cs rename to Sync/Nimblesite.Sync.SQLite.Tests/TombstoneIntegrationTests.cs index 7a6a2ae5..4923eef3 100644 --- a/Sync/Sync.SQLite.Tests/TombstoneIntegrationTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/TombstoneIntegrationTests.cs @@ -3,7 +3,7 @@ using Microsoft.Data.Sqlite; using Xunit; -namespace Sync.SQLite.Tests; +namespace Nimblesite.Sync.SQLite.Tests; /// /// Integration tests for tombstone retention (Spec Section 13). diff --git a/Sync/Sync.SQLite/ChangeApplierSQLite.cs b/Sync/Nimblesite.Sync.SQLite/ChangeApplierSQLite.cs similarity index 99% rename from Sync/Sync.SQLite/ChangeApplierSQLite.cs rename to Sync/Nimblesite.Sync.SQLite/ChangeApplierSQLite.cs index bd206c5b..315df807 100644 --- a/Sync/Sync.SQLite/ChangeApplierSQLite.cs +++ b/Sync/Nimblesite.Sync.SQLite/ChangeApplierSQLite.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Applies sync changes to SQLite database. diff --git a/Sync/Nimblesite.Sync.SQLite/GlobalUsings.cs b/Sync/Nimblesite.Sync.SQLite/GlobalUsings.cs new file mode 100644 index 00000000..9ef6a8ed --- /dev/null +++ b/Sync/Nimblesite.Sync.SQLite/GlobalUsings.cs @@ -0,0 +1,176 @@ +global using Nimblesite.Sync.Core; +// Type aliases for Result types to reduce verbosity in Nimblesite.Sync.SQLite +global using BoolSyncError = Outcome.Result.Error< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncResult = Outcome.Result; +global using ColumnInfoListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using ColumnInfoListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using ColumnInfoListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using IntSyncError = Outcome.Result.Error< + int, + Nimblesite.Sync.Core.SyncError +>; +global using IntSyncOk = Outcome.Result.Ok< + int, + Nimblesite.Sync.Core.SyncError +>; +global using IntSyncResult = Outcome.Result; +global using LongSyncError = Outcome.Result.Error< + long, + Nimblesite.Sync.Core.SyncError +>; +global using LongSyncOk = Outcome.Result.Ok< + long, + Nimblesite.Sync.Core.SyncError +>; +global using LongSyncResult = Outcome.Result; +global using MappingStateError = Outcome.Result< + Nimblesite.Sync.Core.MappingStateEntry?, + Nimblesite.Sync.Core.SyncError +>.Error; +global using MappingStateListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using MappingStateListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using MappingStateListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using MappingStateOk = Outcome.Result< + Nimblesite.Sync.Core.MappingStateEntry?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using MappingStateResult = Outcome.Result< + Nimblesite.Sync.Core.MappingStateEntry?, + Nimblesite.Sync.Core.SyncError +>; +global using RecordHashError = Outcome.Result< + Nimblesite.Sync.Core.RecordHashEntry?, + Nimblesite.Sync.Core.SyncError +>.Error; +global using RecordHashOk = Outcome.Result< + Nimblesite.Sync.Core.RecordHashEntry?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using RecordHashResult = Outcome.Result< + Nimblesite.Sync.Core.RecordHashEntry?, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncError = Outcome.Result.Error< + string, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncOk = Outcome.Result.Ok< + string, + Nimblesite.Sync.Core.SyncError +>; +global using StringSyncResult = Outcome.Result; +global using SubscriptionError = Outcome.Result< + Nimblesite.Sync.Core.SyncSubscription?, + Nimblesite.Sync.Core.SyncError +>.Error; +global using SubscriptionListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SubscriptionListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SubscriptionListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SubscriptionOk = Outcome.Result< + Nimblesite.Sync.Core.SyncSubscription?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SubscriptionResult = Outcome.Result< + Nimblesite.Sync.Core.SyncSubscription?, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientError = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>.Error; +global using SyncClientListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncClientOk = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncClientResult = Outcome.Result< + Nimblesite.Sync.Core.SyncClient?, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; diff --git a/Sync/Sync.SQLite/MappingRepository.cs b/Sync/Nimblesite.Sync.SQLite/MappingRepository.cs similarity index 99% rename from Sync/Sync.SQLite/MappingRepository.cs rename to Sync/Nimblesite.Sync.SQLite/MappingRepository.cs index 59128c3c..94df1dc8 100644 --- a/Sync/Sync.SQLite/MappingRepository.cs +++ b/Sync/Nimblesite.Sync.SQLite/MappingRepository.cs @@ -1,7 +1,7 @@ using System.Globalization; using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// SQLite repository for sync mapping state and record hashes. diff --git a/Sync/Sync.SQLite/MappingStateRepository.cs b/Sync/Nimblesite.Sync.SQLite/MappingStateRepository.cs similarity index 99% rename from Sync/Sync.SQLite/MappingStateRepository.cs rename to Sync/Nimblesite.Sync.SQLite/MappingStateRepository.cs index 0a889a13..97072e21 100644 --- a/Sync/Sync.SQLite/MappingStateRepository.cs +++ b/Sync/Nimblesite.Sync.SQLite/MappingStateRepository.cs @@ -1,7 +1,7 @@ using System.Globalization; using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Repository for mapping state and record hash tracking. diff --git a/Sync/Nimblesite.Sync.SQLite/Nimblesite.Sync.SQLite.csproj b/Sync/Nimblesite.Sync.SQLite/Nimblesite.Sync.SQLite.csproj new file mode 100644 index 00000000..8c488cbd --- /dev/null +++ b/Sync/Nimblesite.Sync.SQLite/Nimblesite.Sync.SQLite.csproj @@ -0,0 +1,16 @@ + + + Library + Nimblesite.Sync.SQLite + Nimblesite.Sync.SQLite + + + + + + + + + + + diff --git a/Sync/Sync.SQLite/SqliteConnectionSyncExtensions.cs b/Sync/Nimblesite.Sync.SQLite/SqliteConnectionSyncExtensions.cs similarity index 99% rename from Sync/Sync.SQLite/SqliteConnectionSyncExtensions.cs rename to Sync/Nimblesite.Sync.SQLite/SqliteConnectionSyncExtensions.cs index 239e9e10..da7a6eea 100644 --- a/Sync/Sync.SQLite/SqliteConnectionSyncExtensions.cs +++ b/Sync/Nimblesite.Sync.SQLite/SqliteConnectionSyncExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Extension methods for SQLite sync operations. diff --git a/Sync/Sync.SQLite/SubscriptionRepository.cs b/Sync/Nimblesite.Sync.SQLite/SubscriptionRepository.cs similarity index 99% rename from Sync/Sync.SQLite/SubscriptionRepository.cs rename to Sync/Nimblesite.Sync.SQLite/SubscriptionRepository.cs index 45d1c8a1..f99e80dd 100644 --- a/Sync/Sync.SQLite/SubscriptionRepository.cs +++ b/Sync/Nimblesite.Sync.SQLite/SubscriptionRepository.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Repository for managing sync subscriptions in SQLite. diff --git a/Sync/Sync.SQLite/SyncClientRepository.cs b/Sync/Nimblesite.Sync.SQLite/SyncClientRepository.cs similarity index 99% rename from Sync/Sync.SQLite/SyncClientRepository.cs rename to Sync/Nimblesite.Sync.SQLite/SyncClientRepository.cs index 56561c15..e69be65c 100644 --- a/Sync/Sync.SQLite/SyncClientRepository.cs +++ b/Sync/Nimblesite.Sync.SQLite/SyncClientRepository.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Repository for managing sync clients in SQLite. diff --git a/Sync/Sync.SQLite/SyncLogRepository.cs b/Sync/Nimblesite.Sync.SQLite/SyncLogRepository.cs similarity index 99% rename from Sync/Sync.SQLite/SyncLogRepository.cs rename to Sync/Nimblesite.Sync.SQLite/SyncLogRepository.cs index 31760149..368330ed 100644 --- a/Sync/Sync.SQLite/SyncLogRepository.cs +++ b/Sync/Nimblesite.Sync.SQLite/SyncLogRepository.cs @@ -1,7 +1,7 @@ using System.Globalization; using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Static methods for sync log operations. diff --git a/Sync/Sync.SQLite/SyncSchema.cs b/Sync/Nimblesite.Sync.SQLite/SyncSchema.cs similarity index 99% rename from Sync/Sync.SQLite/SyncSchema.cs rename to Sync/Nimblesite.Sync.SQLite/SyncSchema.cs index abe6b0a2..f83eaa12 100644 --- a/Sync/Sync.SQLite/SyncSchema.cs +++ b/Sync/Nimblesite.Sync.SQLite/SyncSchema.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Creates and manages sync schema tables for SQLite. diff --git a/Sync/Sync.SQLite/SyncSession.cs b/Sync/Nimblesite.Sync.SQLite/SyncSession.cs similarity index 98% rename from Sync/Sync.SQLite/SyncSession.cs rename to Sync/Nimblesite.Sync.SQLite/SyncSession.cs index a4892d4c..76a13813 100644 --- a/Sync/Sync.SQLite/SyncSession.cs +++ b/Sync/Nimblesite.Sync.SQLite/SyncSession.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Manages sync session state for trigger suppression in SQLite. diff --git a/Sync/Sync.SQLite/TriggerGenerator.cs b/Sync/Nimblesite.Sync.SQLite/TriggerGenerator.cs similarity index 99% rename from Sync/Sync.SQLite/TriggerGenerator.cs rename to Sync/Nimblesite.Sync.SQLite/TriggerGenerator.cs index 71b9f5a5..e3a03770 100644 --- a/Sync/Sync.SQLite/TriggerGenerator.cs +++ b/Sync/Nimblesite.Sync.SQLite/TriggerGenerator.cs @@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; -namespace Sync.SQLite; +namespace Nimblesite.Sync.SQLite; /// /// Column information for trigger generation. diff --git a/Sync/Sync.Tests/BatchManagerTests.cs b/Sync/Nimblesite.Sync.Tests/BatchManagerTests.cs similarity index 99% rename from Sync/Sync.Tests/BatchManagerTests.cs rename to Sync/Nimblesite.Sync.Tests/BatchManagerTests.cs index d6b2be87..4d9e7317 100644 --- a/Sync/Sync.Tests/BatchManagerTests.cs +++ b/Sync/Nimblesite.Sync.Tests/BatchManagerTests.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Outcome; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; public sealed class BatchManagerTests : IDisposable { diff --git a/Sync/Sync.Tests/ChangeApplierTests.cs b/Sync/Nimblesite.Sync.Tests/ChangeApplierTests.cs similarity index 99% rename from Sync/Sync.Tests/ChangeApplierTests.cs rename to Sync/Nimblesite.Sync.Tests/ChangeApplierTests.cs index 51d14c21..3fbb4040 100644 --- a/Sync/Sync.Tests/ChangeApplierTests.cs +++ b/Sync/Nimblesite.Sync.Tests/ChangeApplierTests.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Outcome; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; public sealed class ChangeApplierTests : IDisposable { diff --git a/Sync/Sync.Tests/ConflictResolverTests.cs b/Sync/Nimblesite.Sync.Tests/ConflictResolverTests.cs similarity index 99% rename from Sync/Sync.Tests/ConflictResolverTests.cs rename to Sync/Nimblesite.Sync.Tests/ConflictResolverTests.cs index 0c246307..d6220360 100644 --- a/Sync/Sync.Tests/ConflictResolverTests.cs +++ b/Sync/Nimblesite.Sync.Tests/ConflictResolverTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; public sealed class ConflictResolverTests { diff --git a/Sync/Nimblesite.Sync.Tests/GlobalUsings.cs b/Sync/Nimblesite.Sync.Tests/GlobalUsings.cs new file mode 100644 index 00000000..b7bb1c4c --- /dev/null +++ b/Sync/Nimblesite.Sync.Tests/GlobalUsings.cs @@ -0,0 +1,83 @@ +global using Nimblesite.Sync.Core; +global using Xunit; +// Type aliases for Outcome Result types to simplify test assertions +global using BatchApplyResultError = Outcome.Result< + Nimblesite.Sync.Core.BatchApplyResult, + Nimblesite.Sync.Core.SyncError +>.Error; +global using BatchApplyResultOk = Outcome.Result< + Nimblesite.Sync.Core.BatchApplyResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using BoolSyncError = Outcome.Result.Error< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncOk = Outcome.Result.Ok< + bool, + Nimblesite.Sync.Core.SyncError +>; +global using BoolSyncResult = Outcome.Result; +global using ConflictResolutionError = Outcome.Result< + Nimblesite.Sync.Core.ConflictResolution, + Nimblesite.Sync.Core.SyncError +>.Error; +global using ConflictResolutionOk = Outcome.Result< + Nimblesite.Sync.Core.ConflictResolution, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using IntSyncOk = Outcome.Result.Ok< + int, + Nimblesite.Sync.Core.SyncError +>; +global using PullResultError = Outcome.Result< + Nimblesite.Sync.Core.PullResult, + Nimblesite.Sync.Core.SyncError +>.Error; +// SyncCoordinator result types +global using PullResultOk = Outcome.Result< + Nimblesite.Sync.Core.PullResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using PushResultError = Outcome.Result< + Nimblesite.Sync.Core.PushResult, + Nimblesite.Sync.Core.SyncError +>.Error; +global using PushResultOk = Outcome.Result< + Nimblesite.Sync.Core.PushResult, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncLogEntryError = Outcome.Result< + Nimblesite.Sync.Core.SyncLogEntry, + Nimblesite.Sync.Core.SyncError +>.Error; +global using SyncLogEntryOk = Outcome.Result< + Nimblesite.Sync.Core.SyncLogEntry, + Nimblesite.Sync.Core.SyncError +>.Ok; +global using SyncLogListError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncLogListResult = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +global using SyncResultError = Outcome.Result< + Nimblesite.Sync.Core.SyncResult, + Nimblesite.Sync.Core.SyncError +>.Error; +global using SyncResultOk = Outcome.Result< + Nimblesite.Sync.Core.SyncResult, + Nimblesite.Sync.Core.SyncError +>.Ok; diff --git a/Sync/Sync.Tests/HashVerifierTests.cs b/Sync/Nimblesite.Sync.Tests/HashVerifierTests.cs similarity index 99% rename from Sync/Sync.Tests/HashVerifierTests.cs rename to Sync/Nimblesite.Sync.Tests/HashVerifierTests.cs index d0a36656..374b78ff 100644 --- a/Sync/Sync.Tests/HashVerifierTests.cs +++ b/Sync/Nimblesite.Sync.Tests/HashVerifierTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; public sealed class HashVerifierTests { diff --git a/Sync/Sync.Tests/LqlExpressionEvaluatorTests.cs b/Sync/Nimblesite.Sync.Tests/LqlExpressionEvaluatorTests.cs similarity index 99% rename from Sync/Sync.Tests/LqlExpressionEvaluatorTests.cs rename to Sync/Nimblesite.Sync.Tests/LqlExpressionEvaluatorTests.cs index 50d9db69..a2da08ab 100644 --- a/Sync/Sync.Tests/LqlExpressionEvaluatorTests.cs +++ b/Sync/Nimblesite.Sync.Tests/LqlExpressionEvaluatorTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Tests for LqlExpressionEvaluator and LQL transforms in MappingEngine. diff --git a/Sync/Sync.Tests/LqlMappingCornerCaseTests.cs b/Sync/Nimblesite.Sync.Tests/LqlMappingCornerCaseTests.cs similarity index 99% rename from Sync/Sync.Tests/LqlMappingCornerCaseTests.cs rename to Sync/Nimblesite.Sync.Tests/LqlMappingCornerCaseTests.cs index 5a4d9421..0c5a1c57 100644 --- a/Sync/Sync.Tests/LqlMappingCornerCaseTests.cs +++ b/Sync/Nimblesite.Sync.Tests/LqlMappingCornerCaseTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Corner case and edge case tests for LQL mapping. diff --git a/Sync/Sync.Tests/MappingConfigParserTests.cs b/Sync/Nimblesite.Sync.Tests/MappingConfigParserTests.cs similarity index 99% rename from Sync/Sync.Tests/MappingConfigParserTests.cs rename to Sync/Nimblesite.Sync.Tests/MappingConfigParserTests.cs index 62ce2792..9c18dde6 100644 --- a/Sync/Sync.Tests/MappingConfigParserTests.cs +++ b/Sync/Nimblesite.Sync.Tests/MappingConfigParserTests.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging.Abstractions; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Tests for MappingConfigParser - JSON configuration parsing. diff --git a/Sync/Sync.Tests/MappingEngineTests.cs b/Sync/Nimblesite.Sync.Tests/MappingEngineTests.cs similarity index 99% rename from Sync/Sync.Tests/MappingEngineTests.cs rename to Sync/Nimblesite.Sync.Tests/MappingEngineTests.cs index ebe0b1c7..921369b6 100644 --- a/Sync/Sync.Tests/MappingEngineTests.cs +++ b/Sync/Nimblesite.Sync.Tests/MappingEngineTests.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging.Abstractions; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Tests for MappingEngine - data transformation during sync. diff --git a/Sync/Sync.Tests/Sync.Tests.csproj b/Sync/Nimblesite.Sync.Tests/Nimblesite.Sync.Tests.csproj similarity index 79% rename from Sync/Sync.Tests/Sync.Tests.csproj rename to Sync/Nimblesite.Sync.Tests/Nimblesite.Sync.Tests.csproj index 53b04c07..21ece00c 100644 --- a/Sync/Sync.Tests/Sync.Tests.csproj +++ b/Sync/Nimblesite.Sync.Tests/Nimblesite.Sync.Tests.csproj @@ -2,7 +2,7 @@ Library true - Sync.Tests + Nimblesite.Sync.Tests CS1591;CA1707;CA1307;CA1062;CA1515;CA2100;CA1822;CA1859 @@ -13,8 +13,8 @@ all runtime; build; native; contentfiles; analyzers - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -22,6 +22,6 @@ - + diff --git a/Sync/Sync.Tests/SubscriptionManagerTests.cs b/Sync/Nimblesite.Sync.Tests/SubscriptionManagerTests.cs similarity index 99% rename from Sync/Sync.Tests/SubscriptionManagerTests.cs rename to Sync/Nimblesite.Sync.Tests/SubscriptionManagerTests.cs index a219942d..0a21eeb6 100644 --- a/Sync/Sync.Tests/SubscriptionManagerTests.cs +++ b/Sync/Nimblesite.Sync.Tests/SubscriptionManagerTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Tests for SubscriptionManager. diff --git a/Sync/Sync.Tests/SyncCoordinatorTests.cs b/Sync/Nimblesite.Sync.Tests/SyncCoordinatorTests.cs similarity index 99% rename from Sync/Sync.Tests/SyncCoordinatorTests.cs rename to Sync/Nimblesite.Sync.Tests/SyncCoordinatorTests.cs index 3378dee5..98255bb6 100644 --- a/Sync/Sync.Tests/SyncCoordinatorTests.cs +++ b/Sync/Nimblesite.Sync.Tests/SyncCoordinatorTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Integration tests for SyncCoordinator. diff --git a/Sync/Sync.Tests/SyncErrorTests.cs b/Sync/Nimblesite.Sync.Tests/SyncErrorTests.cs similarity index 99% rename from Sync/Sync.Tests/SyncErrorTests.cs rename to Sync/Nimblesite.Sync.Tests/SyncErrorTests.cs index f60f8791..91f14b0f 100644 --- a/Sync/Sync.Tests/SyncErrorTests.cs +++ b/Sync/Nimblesite.Sync.Tests/SyncErrorTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Tests for SyncError types. diff --git a/Sync/Sync.Tests/SyncIntegrationTests.cs b/Sync/Nimblesite.Sync.Tests/SyncIntegrationTests.cs similarity index 99% rename from Sync/Sync.Tests/SyncIntegrationTests.cs rename to Sync/Nimblesite.Sync.Tests/SyncIntegrationTests.cs index 96fbe4a5..c10d5e36 100644 --- a/Sync/Sync.Tests/SyncIntegrationTests.cs +++ b/Sync/Nimblesite.Sync.Tests/SyncIntegrationTests.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Real end-to-end integration tests syncing between two SQLite databases. diff --git a/Sync/Sync.Tests/SyncStateTests.cs b/Sync/Nimblesite.Sync.Tests/SyncStateTests.cs similarity index 99% rename from Sync/Sync.Tests/SyncStateTests.cs rename to Sync/Nimblesite.Sync.Tests/SyncStateTests.cs index 08ece19d..bd87af93 100644 --- a/Sync/Sync.Tests/SyncStateTests.cs +++ b/Sync/Nimblesite.Sync.Tests/SyncStateTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// Tests for SyncState, SyncSession, and SyncClient records. diff --git a/Sync/Sync.Tests/TestDb.cs b/Sync/Nimblesite.Sync.Tests/TestDb.cs similarity index 99% rename from Sync/Sync.Tests/TestDb.cs rename to Sync/Nimblesite.Sync.Tests/TestDb.cs index d3293a36..525bfac4 100644 --- a/Sync/Sync.Tests/TestDb.cs +++ b/Sync/Nimblesite.Sync.Tests/TestDb.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; /// /// File-based SQLite database for integration testing. diff --git a/Sync/Sync.Tests/Testing.ruleset b/Sync/Nimblesite.Sync.Tests/Testing.ruleset similarity index 63% rename from Sync/Sync.Tests/Testing.ruleset rename to Sync/Nimblesite.Sync.Tests/Testing.ruleset index 04b1806e..47edbc3f 100644 --- a/Sync/Sync.Tests/Testing.ruleset +++ b/Sync/Nimblesite.Sync.Tests/Testing.ruleset @@ -1,5 +1,5 @@ - + diff --git a/Sync/Sync.Tests/TombstoneManagerTests.cs b/Sync/Nimblesite.Sync.Tests/TombstoneManagerTests.cs similarity index 99% rename from Sync/Sync.Tests/TombstoneManagerTests.cs rename to Sync/Nimblesite.Sync.Tests/TombstoneManagerTests.cs index bffef567..659be809 100644 --- a/Sync/Sync.Tests/TombstoneManagerTests.cs +++ b/Sync/Nimblesite.Sync.Tests/TombstoneManagerTests.cs @@ -1,4 +1,4 @@ -namespace Sync.Tests; +namespace Nimblesite.Sync.Tests; public sealed class TombstoneManagerTests { diff --git a/Sync/README.md b/Sync/README.md index 9afebc76..ad2fd811 100644 --- a/Sync/README.md +++ b/Sync/README.md @@ -1,404 +1,21 @@ # Sync Framework -A database-agnostic, offline-first synchronization framework for .NET applications. Enables two-way data synchronization between distributed replicas with conflict resolution, tombstone management, and real-time subscriptions. - -## Overview - -The Sync framework provides: - -- **Offline-first architecture** - Work locally, sync when connected -- **Two-way synchronization** - Pull changes from server, push local changes -- **Conflict resolution** - Last-write-wins, server-wins, client-wins, or custom strategies -- **Foreign key handling** - Automatic deferred retry for FK violations during sync -- **Tombstone management** - Safe deletion tracking for late-syncing clients -- **Real-time subscriptions** - Subscribe to changes on specific records or tables -- **Hash verification** - SHA-256 integrity checking for batches and databases -- **Database agnostic** - Currently supports SQLite and PostgreSQL +A database-agnostic, offline-first synchronization framework for .NET. Enables two-way data sync between distributed replicas with conflict resolution, tombstone management, and real-time subscriptions. ## Projects | Project | Description | |---------|-------------| -| `Sync` | Core synchronization engine (platform-agnostic) | -| `Sync.SQLite` | SQLite-specific implementation | -| `Sync.Postgres` | PostgreSQL-specific implementation | -| `Sync.Api` | REST API server with SSE real-time subscriptions | +| `Sync` | Core synchronization engine | +| `Sync.SQLite` | SQLite implementation | +| `Sync.Postgres` | PostgreSQL implementation | +| `Sync.Api` | REST API server with SSE subscriptions | | `Sync.Tests` | Core engine tests | | `Sync.SQLite.Tests` | SQLite integration tests | | `Sync.Postgres.Tests` | PostgreSQL integration tests | | `Sync.Api.Tests` | API endpoint tests | | `Sync.Integration.Tests` | Cross-database E2E tests | -## Getting Started - -### Prerequisites - -- .NET 9.0 SDK -- For PostgreSQL: Docker (or a local PostgreSQL instance) - -### Installation - -Add the appropriate NuGet packages to your project: - -```xml - - - - - - - -``` - -### Basic Setup (SQLite) - -#### 1. Initialize the Sync Schema - -```csharp -using Microsoft.Data.Sqlite; -using Sync.SQLite; - -// Create your database connection -using var connection = new SqliteConnection("Data Source=myapp.db"); -connection.Open(); - -// Create sync tables (_sync_log, _sync_state, _sync_session, etc.) -SyncSchema.CreateSchema(connection); -SyncSchema.InitializeSyncState(connection, originId: Guid.NewGuid().ToString()); -``` - -#### 2. Add Triggers to Your Tables - -```csharp -// Generate and apply sync triggers for a table -var triggerResult = TriggerGenerator.GenerateTriggers(connection, "Person"); -if (triggerResult is TriggerListOk ok) -{ - foreach (var trigger in ok.Value) - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = trigger; - cmd.ExecuteNonQuery(); - } -} -``` - -This creates INSERT, UPDATE, and DELETE triggers that automatically log changes to `_sync_log`. - -#### 3. Perform Synchronization - -```csharp -using Sync; - -// Create delegate functions for database operations -Func fetchRemoteChanges = (fromVersion, batchSize) => - SyncLogRepository.FetchChanges(remoteConnection, fromVersion, batchSize); - -Func applyChange = (entry) => - ChangeApplierSQLite.ApplyChange(localConnection, entry); - -Func enableSuppression = () => - SyncSessionManager.EnableSuppression(localConnection); - -Func disableSuppression = () => - SyncSessionManager.DisableSuppression(localConnection); - -// Pull changes from remote -var pullResult = SyncCoordinator.Pull( - fetchRemoteChanges, - applyChange, - enableSuppression, - disableSuppression, - getLastServerVersion: () => SyncLogRepository.GetLastServerVersion(localConnection), - updateLastServerVersion: (v) => SyncLogRepository.UpdateLastServerVersion(localConnection, v), - localOriginId: myOriginId, - config: new BatchConfig(BatchSize: 1000, MaxRetryPasses: 3), - logger: NullLogger.Instance -); - -// Push local changes to remote -var pushResult = SyncCoordinator.Push( - fetchLocalChanges: (fromVersion, batchSize) => - SyncLogRepository.FetchChanges(localConnection, fromVersion, batchSize), - sendToRemote: (batch) => ApplyBatchToRemote(remoteConnection, batch), - getLastPushVersion: () => SyncLogRepository.GetLastPushVersion(localConnection), - updateLastPushVersion: (v) => SyncLogRepository.UpdateLastPushVersion(localConnection, v), - config: new BatchConfig(), - logger: NullLogger.Instance -); -``` - -### Using the REST API - -#### Start the API Server - -```bash -cd Sync/Sync.Api -dotnet run -``` - -#### API Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/sync/changes` | GET | Pull changes from server | -| `/sync/changes` | POST | Push changes to server | -| `/sync/clients` | POST | Register a sync client | -| `/sync/state` | GET | Get server sync state | -| `/sync/subscribe` | GET | Subscribe to real-time changes (SSE) | -| `/sync/subscribe/{id}` | DELETE | Unsubscribe | - -#### Pull Changes - -```bash -curl "http://localhost:5000/sync/changes?fromVersion=0&batchSize=100&connectionString=Data%20Source=server.db&dbType=sqlite" -``` - -Response: -```json -{ - "changes": [ - { - "version": 1, - "tableName": "Person", - "pkValue": "{\"Id\":1}", - "operation": "Insert", - "payload": "{\"Id\":1,\"Name\":\"Alice\",\"Email\":\"alice@example.com\"}", - "origin": "client-abc", - "timestamp": "2025-01-15T10:30:00.000Z" - } - ], - "fromVersion": 0, - "toVersion": 1, - "hasMore": false -} -``` - -#### Push Changes - -```bash -curl -X POST "http://localhost:5000/sync/changes?connectionString=Data%20Source=server.db&dbType=sqlite" \ - -H "Content-Type: application/json" \ - -d '{ - "OriginId": "client-xyz", - "Changes": [ - { - "version": 0, - "tableName": "Person", - "pkValue": "{\"Id\":2}", - "operation": "Insert", - "payload": "{\"Id\":2,\"Name\":\"Bob\"}", - "origin": "client-xyz", - "timestamp": "2025-01-15T11:00:00.000Z" - } - ] - }' -``` - -#### Real-Time Subscriptions (SSE) - -```bash -# Subscribe to all changes on the Person table -curl "http://localhost:5000/sync/subscribe?tableName=Person" - -# Subscribe to a specific record -curl "http://localhost:5000/sync/subscribe?tableName=Person&pkValue=1" -``` - -### PostgreSQL Setup - -#### 1. Start PostgreSQL with Docker - -From the repository root: - -```bash -docker-compose up -d -``` - -This starts a single PostgreSQL container on `localhost:5432` (user: postgres, password: postgres, database: gigs). The C# migrations handle schema creation. - -#### 2. Initialize Schema - -```csharp -using Npgsql; -using Sync.Postgres; - -using var connection = new NpgsqlConnection( - "Host=localhost;Port=5432;Database=gigs;Username=postgres;Password=postgres"); -connection.Open(); - -PostgresSyncSchema.CreateSchema(connection); -PostgresSyncSchema.InitializeSyncState(connection, originId: Guid.NewGuid().ToString()); -``` - -## Architecture - -### Sync Tables - -The framework creates these tables in your database: - -| Table | Purpose | -|-------|---------| -| `_sync_log` | Change log with version, table, PK, operation, payload, origin, timestamp | -| `_sync_state` | Local replica state (origin_id, last_server_version, last_push_version) | -| `_sync_session` | Trigger suppression flag (sync_active) | -| `_sync_clients` | Server-side client tracking for tombstone management | -| `_sync_subscriptions` | Real-time subscription registrations | - -### Change Capture - -When you modify a tracked table: -1. AFTER trigger fires (if `sync_active = 0`) -2. Trigger inserts row into `_sync_log` with: - - Auto-incrementing version - - Table name and primary key (JSON) - - Operation (Insert/Update/Delete) - - Full row payload (JSON) for Insert/Update, NULL for Delete - - Origin ID (prevents echo during sync) - - UTC timestamp - -### Sync Flow - -**Pull (receive changes):** -1. Enable trigger suppression (`sync_active = 1`) -2. Fetch batch from remote (version > lastServerVersion) -3. Apply changes with FK violation defer/retry -4. Skip changes from own origin (echo prevention) -5. Update lastServerVersion -6. Repeat until no more changes -7. Disable trigger suppression - -**Push (send changes):** -1. Fetch local changes (version > lastPushVersion) -2. Send batch to remote -3. Update lastPushVersion -4. Repeat until no more changes - -### Conflict Resolution - -When the same row is modified by different origins: - -```csharp -// Default: Last-write-wins (by timestamp, version as tiebreaker) -var resolved = ConflictResolver.Resolve( - localEntry, - remoteEntry, - ConflictStrategy.LastWriteWins -); - -// Or use custom resolution -var resolved = ConflictResolver.ResolveCustom( - localEntry, - remoteEntry, - (local, remote) => /* your merge logic */ -); -``` - -### Hash Verification - -Verify data integrity with SHA-256 hashes: - -```csharp -// Hash a batch of changes -var batchHash = HashVerifier.ComputeBatchHash(changes); - -// Hash entire database state -var dbHash = HashVerifier.ComputeDatabaseHash( - fetchAllChanges: () => SyncLogRepository.FetchAll(connection) -); - -// Verify batch integrity -var isValid = HashVerifier.VerifyHash(expectedHash, actualHash); -``` - -## Running Tests - -```bash -# All tests -dotnet test - -# Specific test projects -dotnet test --filter "FullyQualifiedName~Sync.Tests" -dotnet test --filter "FullyQualifiedName~Sync.SQLite.Tests" -dotnet test --filter "FullyQualifiedName~Sync.Postgres.Tests" -dotnet test --filter "FullyQualifiedName~Sync.Api.Tests" - -# Cross-database integration tests (requires Docker) -dotnet test --filter "FullyQualifiedName~Sync.Integration.Tests" -``` - -## Configuration - -### BatchConfig - -```csharp -var config = new BatchConfig( - BatchSize: 1000, // Changes per batch (default: 1000) - MaxRetryPasses: 3 // FK violation retry attempts (default: 3) -); -``` - -### Tombstone Management - -```csharp -// Calculate safe version to purge (all clients have synced past this) -var safeVersion = TombstoneManager.CalculateSafePurgeVersion( - getAllClients: () => SyncClientRepository.GetAll(connection) -); - -// Purge old tombstones -TombstoneManager.PurgeTombstones( - purge: (version) => SyncLogRepository.PurgeBefore(connection, version), - safeVersion -); - -// Detect stale clients (90 days inactive by default) -var staleClients = TombstoneManager.FindStaleClients( - getAllClients: () => SyncClientRepository.GetAll(connection), - inactivityThreshold: TimeSpan.FromDays(90) -); -``` - -## Error Handling - -All operations return `Result`: - -```csharp -var result = SyncCoordinator.Pull(...); - -if (result is PullResultOk ok) -{ - Console.WriteLine($"Pulled {ok.Value.ChangesApplied} changes"); -} -else if (result is PullResultError error) -{ - switch (error.Value) - { - case SyncErrorForeignKeyViolation fk: - Console.WriteLine($"FK violation: {fk.Message}"); - break; - case SyncErrorFullResyncRequired: - Console.WriteLine("Client fell too far behind, full resync needed"); - break; - case SyncErrorHashMismatch hash: - Console.WriteLine($"Data integrity error: {hash.Expected} != {hash.Actual}"); - break; - // ... handle other error types - } -} -``` - -## Design Principles - -This framework follows the coding rules from `CLAUDE.md`: - -- **No exceptions** - All fallible operations return `Result` -- **No classes** - Uses records and static methods (FP style) -- **No interfaces** - Uses `Func` and `Action` for abstractions -- **Integration testing** - No mocks, tests use real databases -- **Copious logging** - All operations log via `ILogger` - -## License +## Documentation -See repository root for license information. +- Full specification: [docs/specs/sync-spec.md](../docs/specs/sync-spec.md) diff --git a/Sync/Sync.Http.Tests/GlobalUsings.cs b/Sync/Sync.Http.Tests/GlobalUsings.cs deleted file mode 100644 index 4c500560..00000000 --- a/Sync/Sync.Http.Tests/GlobalUsings.cs +++ /dev/null @@ -1,17 +0,0 @@ -global using Microsoft.AspNetCore.Hosting; -global using Microsoft.AspNetCore.Mvc.Testing; -global using Microsoft.Data.Sqlite; -global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Logging.Abstractions; -global using Npgsql; -global using Sync.Postgres; -global using Sync.SQLite; -global using Testcontainers.PostgreSql; -global using Xunit; -// Type aliases for Result types - matching Sync patterns using Outcome package -global using BoolSyncOk = Outcome.Result.Ok; -global using StringSyncOk = Outcome.Result.Ok; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; diff --git a/Sync/Sync.Integration.Tests/GlobalUsings.cs b/Sync/Sync.Integration.Tests/GlobalUsings.cs deleted file mode 100644 index f34a81b0..00000000 --- a/Sync/Sync.Integration.Tests/GlobalUsings.cs +++ /dev/null @@ -1,15 +0,0 @@ -global using System.Text.Json; -global using Microsoft.Data.Sqlite; -global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Logging.Abstractions; -global using Npgsql; -global using Sync.Postgres; -global using Sync.SQLite; -global using Testcontainers.PostgreSql; -global using Xunit; -// Type aliases for Result types - matching Sync patterns using Outcome package -global using BoolSyncOk = Outcome.Result.Ok; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; diff --git a/Sync/Sync.Integration.Tests/Sync.Integration.Tests.csproj b/Sync/Sync.Integration.Tests/Sync.Integration.Tests.csproj deleted file mode 100644 index 6fcaf567..00000000 --- a/Sync/Sync.Integration.Tests/Sync.Integration.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - Library - true - Sync.Integration.Tests - CS1591;CA1707;CA1307;CA1062;CA1515;CA2100 - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - - - - - - - - diff --git a/Sync/Sync.Postgres.Tests/GlobalUsings.cs b/Sync/Sync.Postgres.Tests/GlobalUsings.cs deleted file mode 100644 index 19be8bb5..00000000 --- a/Sync/Sync.Postgres.Tests/GlobalUsings.cs +++ /dev/null @@ -1,27 +0,0 @@ -global using Microsoft.Data.Sqlite; -global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Logging.Abstractions; -global using Npgsql; -global using Sync.SQLite; -global using Testcontainers.PostgreSql; -global using Xunit; -// Type aliases for Result types - matching Sync patterns using Outcome package -global using BatchApplyResultOk = Outcome.Result.Ok< - Sync.BatchApplyResult, - Sync.SyncError ->; -global using BoolSyncOk = Outcome.Result.Ok; -global using LongSyncOk = Outcome.Result.Ok; -global using StringSyncOk = Outcome.Result.Ok; -global using SyncClientListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncClientOk = Outcome.Result.Ok< - Sync.SyncClient?, - Sync.SyncError ->; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; diff --git a/Sync/Sync.Postgres/GlobalUsings.cs b/Sync/Sync.Postgres/GlobalUsings.cs deleted file mode 100644 index 69b458ff..00000000 --- a/Sync/Sync.Postgres/GlobalUsings.cs +++ /dev/null @@ -1,57 +0,0 @@ -global using Microsoft.Extensions.Logging; -global using Npgsql; -// Type aliases for Result types - matching Sync.SQLite patterns using Outcome package -global using BoolSyncError = Outcome.Result.Error; -global using BoolSyncOk = Outcome.Result.Ok; -global using BoolSyncResult = Outcome.Result; -global using ColumnInfoListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using ColumnInfoListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using ColumnInfoListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using LongSyncError = Outcome.Result.Error; -global using LongSyncOk = Outcome.Result.Ok; -global using LongSyncResult = Outcome.Result; -global using StringSyncError = Outcome.Result.Error; -global using StringSyncOk = Outcome.Result.Ok; -global using StringSyncResult = Outcome.Result; -global using SyncClientError = Outcome.Result.Error< - Sync.SyncClient?, - Sync.SyncError ->; -global using SyncClientListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncClientListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncClientListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using SyncClientOk = Outcome.Result.Ok< - Sync.SyncClient?, - Sync.SyncError ->; -global using SyncClientResult = Outcome.Result; -global using SyncLogListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncLogListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; diff --git a/Sync/Sync.SQLite.Tests/GlobalUsings.cs b/Sync/Sync.SQLite.Tests/GlobalUsings.cs deleted file mode 100644 index 7d22bccf..00000000 --- a/Sync/Sync.SQLite.Tests/GlobalUsings.cs +++ /dev/null @@ -1,42 +0,0 @@ -global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Logging.Abstractions; -// Type aliases for Result types in tests -global using BatchApplyResultOk = Outcome.Result.Ok< - Sync.BatchApplyResult, - Sync.SyncError ->; -global using BoolSyncError = Outcome.Result.Error; -global using BoolSyncOk = Outcome.Result.Ok; -global using BoolSyncResult = Outcome.Result; -global using ColumnInfoListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using IntSyncOk = Outcome.Result.Ok; -global using LongSyncOk = Outcome.Result.Ok; -global using StringSyncError = Outcome.Result.Error; -global using StringSyncOk = Outcome.Result.Ok; -global using SubscriptionListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SubscriptionOk = Outcome.Result.Ok< - Sync.SyncSubscription?, - Sync.SyncError ->; -global using SyncBatchOk = Outcome.Result.Ok< - Sync.SyncBatch, - Sync.SyncError ->; -global using SyncClientListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncClientOk = Outcome.Result.Ok< - Sync.SyncClient?, - Sync.SyncError ->; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; diff --git a/Sync/Sync.SQLite/GlobalUsings.cs b/Sync/Sync.SQLite/GlobalUsings.cs deleted file mode 100644 index 0ef3e79f..00000000 --- a/Sync/Sync.SQLite/GlobalUsings.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Type aliases for Result types to reduce verbosity in Sync.SQLite -global using BoolSyncError = Outcome.Result.Error; -global using BoolSyncOk = Outcome.Result.Ok; -global using BoolSyncResult = Outcome.Result; -global using ColumnInfoListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using ColumnInfoListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using ColumnInfoListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using IntSyncError = Outcome.Result.Error; -global using IntSyncOk = Outcome.Result.Ok; -global using IntSyncResult = Outcome.Result; -global using LongSyncError = Outcome.Result.Error; -global using LongSyncOk = Outcome.Result.Ok; -global using LongSyncResult = Outcome.Result; -global using MappingStateError = Outcome.Result.Error< - Sync.MappingStateEntry?, - Sync.SyncError ->; -global using MappingStateListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using MappingStateListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using MappingStateListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using MappingStateOk = Outcome.Result.Ok< - Sync.MappingStateEntry?, - Sync.SyncError ->; -global using MappingStateResult = Outcome.Result; -global using RecordHashError = Outcome.Result.Error< - Sync.RecordHashEntry?, - Sync.SyncError ->; -global using RecordHashOk = Outcome.Result.Ok< - Sync.RecordHashEntry?, - Sync.SyncError ->; -global using RecordHashResult = Outcome.Result; -global using StringSyncError = Outcome.Result.Error; -global using StringSyncOk = Outcome.Result.Ok; -global using StringSyncResult = Outcome.Result; -global using SubscriptionError = Outcome.Result.Error< - Sync.SyncSubscription?, - Sync.SyncError ->; -global using SubscriptionListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SubscriptionListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SubscriptionListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using SubscriptionOk = Outcome.Result.Ok< - Sync.SyncSubscription?, - Sync.SyncError ->; -global using SubscriptionResult = Outcome.Result; -global using SyncClientError = Outcome.Result.Error< - Sync.SyncClient?, - Sync.SyncError ->; -global using SyncClientListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncClientListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncClientListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using SyncClientOk = Outcome.Result.Ok< - Sync.SyncClient?, - Sync.SyncError ->; -global using SyncClientResult = Outcome.Result; -global using SyncLogListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncLogListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; diff --git a/Sync/Sync.SQLite/Sync.SQLite.csproj b/Sync/Sync.SQLite/Sync.SQLite.csproj deleted file mode 100644 index 364a329d..00000000 --- a/Sync/Sync.SQLite/Sync.SQLite.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - Library - Sync.SQLite - - - - - - - - - - - diff --git a/Sync/Sync.Tests/GlobalUsings.cs b/Sync/Sync.Tests/GlobalUsings.cs deleted file mode 100644 index bfe5bd6c..00000000 --- a/Sync/Sync.Tests/GlobalUsings.cs +++ /dev/null @@ -1,67 +0,0 @@ -global using Xunit; -// Type aliases for Outcome Result types to simplify test assertions -global using BatchApplyResultError = Outcome.Result.Error< - Sync.BatchApplyResult, - Sync.SyncError ->; -global using BatchApplyResultOk = Outcome.Result.Ok< - Sync.BatchApplyResult, - Sync.SyncError ->; -global using BoolSyncError = Outcome.Result.Error; -global using BoolSyncOk = Outcome.Result.Ok; -global using BoolSyncResult = Outcome.Result; -global using ConflictResolutionError = Outcome.Result< - Sync.ConflictResolution, - Sync.SyncError ->.Error; -global using ConflictResolutionOk = Outcome.Result.Ok< - Sync.ConflictResolution, - Sync.SyncError ->; -global using IntSyncOk = Outcome.Result.Ok; -global using PullResultError = Outcome.Result.Error< - Sync.PullResult, - Sync.SyncError ->; -// SyncCoordinator result types -global using PullResultOk = Outcome.Result.Ok< - Sync.PullResult, - Sync.SyncError ->; -global using PushResultError = Outcome.Result.Error< - Sync.PushResult, - Sync.SyncError ->; -global using PushResultOk = Outcome.Result.Ok< - Sync.PushResult, - Sync.SyncError ->; -global using SyncLogEntryError = Outcome.Result.Error< - Sync.SyncLogEntry, - Sync.SyncError ->; -global using SyncLogEntryOk = Outcome.Result.Ok< - Sync.SyncLogEntry, - Sync.SyncError ->; -global using SyncLogListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncLogListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using SyncResultError = Outcome.Result.Error< - Sync.SyncResult, - Sync.SyncError ->; -global using SyncResultOk = Outcome.Result.Ok< - Sync.SyncResult, - Sync.SyncError ->; diff --git a/Sync/Sync/GlobalUsings.cs b/Sync/Sync/GlobalUsings.cs deleted file mode 100644 index cafb6066..00000000 --- a/Sync/Sync/GlobalUsings.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Type aliases for Result types to reduce verbosity -global using BatchApplyResultError = Outcome.Result.Error< - Sync.BatchApplyResult, - Sync.SyncError ->; -global using BatchApplyResultOk = Outcome.Result.Ok< - Sync.BatchApplyResult, - Sync.SyncError ->; -global using BatchApplyResultResult = Outcome.Result; -global using BoolSyncError = Outcome.Result.Error; -global using BoolSyncOk = Outcome.Result.Ok; -global using BoolSyncResult = Outcome.Result; -global using ConflictResolutionError = Outcome.Result< - Sync.ConflictResolution, - Sync.SyncError ->.Error; -global using ConflictResolutionOk = Outcome.Result.Ok< - Sync.ConflictResolution, - Sync.SyncError ->; -global using ConflictResolutionResult = Outcome.Result; -global using IntSyncError = Outcome.Result.Error; -global using IntSyncOk = Outcome.Result.Ok; -global using IntSyncResult = Outcome.Result; -global using PullResultError = Outcome.Result.Error< - Sync.PullResult, - Sync.SyncError ->; -global using PullResultOk = Outcome.Result.Ok< - Sync.PullResult, - Sync.SyncError ->; -global using PullResultResult = Outcome.Result; -global using PushResultError = Outcome.Result.Error< - Sync.PushResult, - Sync.SyncError ->; -global using PushResultOk = Outcome.Result.Ok< - Sync.PushResult, - Sync.SyncError ->; -global using PushResultResult = Outcome.Result; -global using SyncBatchError = Outcome.Result.Error< - Sync.SyncBatch, - Sync.SyncError ->; -global using SyncBatchOk = Outcome.Result.Ok< - Sync.SyncBatch, - Sync.SyncError ->; -global using SyncBatchResult = Outcome.Result; -global using SyncLogEntryResult = Outcome.Result; -global using SyncLogListError = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Error, Sync.SyncError>; -global using SyncLogListOk = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->.Ok, Sync.SyncError>; -global using SyncLogListResult = Outcome.Result< - System.Collections.Generic.IReadOnlyList, - Sync.SyncError ->; -global using SyncResultError = Outcome.Result.Error< - Sync.SyncResult, - Sync.SyncError ->; -global using SyncResultOk = Outcome.Result.Ok< - Sync.SyncResult, - Sync.SyncError ->; -global using SyncResultResult = Outcome.Result; diff --git a/Sync/Sync/Sync.csproj b/Sync/Sync/Sync.csproj deleted file mode 100644 index 9d70653d..00000000 --- a/Sync/Sync/Sync.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - Library - Sync - $(NoWarn); - - - - - - - - - - - - - - - - diff --git a/Website/src/about.md b/Website/src/about.md index 843e6a98..264212f2 100644 --- a/Website/src/about.md +++ b/Website/src/about.md @@ -28,7 +28,7 @@ description: "DataProvider is an open-source .NET toolkit for type-safe database
  • Migrations - YAML schemas: Database-agnostic, version-controlled schema definitions
  • Sync - Offline-first: Bidirectional synchronization with conflict resolution
  • Gatekeeper - Auth: WebAuthn authentication and role-based access control
  • -
  • Healthcare Samples - FHIR-compliant microservices with ICD-10 RAG search, demonstrating the full stack
  • +
  • Healthcare Samples - FHIR-compliant microservices with ICD-10 RAG search, demonstrating the full stack (separate repo)
  • Get Involved

    diff --git a/Website/src/docs/samples.md b/Website/src/docs/samples.md index eba2218a..5db2baf1 100644 --- a/Website/src/docs/samples.md +++ b/Website/src/docs/samples.md @@ -6,6 +6,8 @@ description: Three FHIR-compliant .NET microservices with bidirectional sync, IC A complete demonstration of the DataProvider suite: three FHIR-compliant microservices with bidirectional sync, semantic search, and a React dashboard. +The Healthcare Samples live in their own repository: [MelbourneDeveloper/HealthcareSamples](https://github.com/MelbourneDeveloper/HealthcareSamples). + ## What It Demonstrates - **DataProvider** - Compile-time safe SQL queries for all database operations @@ -41,6 +43,20 @@ Dashboard.Web (React/H5) | ICD10 API | http://localhost:5090 | Medical code search with RAG | | Dashboard | http://localhost:8080 | React UI (H5 transpiler) | +## NuGet Packages Used + +The Healthcare Samples consume DataProvider toolkit packages from NuGet: + +| Package | Purpose | +|---------|---------| +| `MelbourneDev.DataProvider` | Source-generated SQL extension methods | +| `MelbourneDev.Lql.Postgres` | LQL transpilation to PostgreSQL | +| `MelbourneDev.Sync` | Core sync framework | +| `MelbourneDev.Sync.Postgres` | PostgreSQL sync provider | +| `MelbourneDev.Migration` | YAML schema migrations | +| `MelbourneDev.Migration.Postgres` | PostgreSQL DDL generation | +| `MelbourneDev.Selecta` | SQL result formatting | + ## ICD-10 Microservice The ICD-10 microservice provides clinical coders with RAG (Retrieval-Augmented Generation) search capabilities and standard lookup functionality for ICD-10 diagnosis codes. @@ -88,20 +104,6 @@ Response: } ``` -### Embedding Pipeline - -The embedding service runs as a Docker container with FastAPI + sentence-transformers + MedEmbed: - -``` -icd10.Api (C#) --> POST /embed --> Docker Container (FastAPI + MedEmbed) - | - embedding vector - | - cosine similarity vs stored embeddings - | - ranked results -``` - ### ICD-10 API Endpoints | Endpoint | Description | @@ -113,16 +115,6 @@ icd10.Api (C#) --> POST /embed --> Docker Container (FastAPI + MedEmbed) | `POST /api/search` | RAG semantic search | | `GET /api/achi/codes/{code}` | ACHI procedure code lookup | -### Import Pipeline - -```bash -# Import 74,260+ ICD-10 codes from CMS.gov (FREE) -python scripts/import_icd10cm.py --db-path icd10.db - -# Generate embeddings (takes ~30-60 minutes) -python scripts/generate_embeddings.py --db-path icd10.db -``` - ## Data Ownership | Domain | Owns | Receives via Sync | @@ -133,17 +125,11 @@ python scripts/generate_embeddings.py --db-path icd10.db ## Quick Start -```bash -# Run all APIs locally against Docker Postgres -./scripts/start-local.sh - -# Run everything in Docker containers -./scripts/start.sh -``` +See the [HealthcareSamples repository](https://github.com/MelbourneDeveloper/HealthcareSamples) for full setup instructions. ## Tech Stack -- .NET 9, ASP.NET Core Minimal API +- .NET 10, ASP.NET Core Minimal API - PostgreSQL with pgvector (semantic search) - DataProvider (SQL to extension methods) - Sync Framework (bidirectional sync) diff --git a/Website/src/index.njk b/Website/src/index.njk index 15cbdc3f..4e5347e7 100644 --- a/Website/src/index.njk +++ b/Website/src/index.njk @@ -110,7 +110,7 @@ description: "Simplifying Database Connectivity in .NET with source-generated SQ
    HC

    Healthcare Samples

    -

    FHIR-compliant microservices with ICD-10 RAG semantic search and bidirectional sync.

    +

    FHIR-compliant microservices with ICD-10 RAG semantic search and bidirectional sync. Separate repo.

    Documentation →
    diff --git a/coverage-thresholds.json b/coverage-thresholds.json new file mode 100644 index 00000000..b9b680be --- /dev/null +++ b/coverage-thresholds.json @@ -0,0 +1,51 @@ +{ + "default_threshold": 90, + "projects": { + "DataProvider/Nimblesite.DataProvider.Core": { + "threshold": 88, + "include": "[Nimblesite.DataProvider.Core]*,[Nimblesite.Sql.Model]*,[Nimblesite.Lql.SQLite]*" + }, + "DataProvider/Nimblesite.DataProvider.Example": { + "threshold": 38, + "include": "[Nimblesite.DataProvider.Core]*,[Nimblesite.DataProvider.Example]*,[Nimblesite.Lql.SQLite]*,[Nimblesite.Sql.Model]*" + }, + "Lql/Nimblesite.Lql.Core": { + "threshold": 71, + "include": "[Nimblesite.Lql.Core]*,[Nimblesite.Lql.Postgres]*,[Nimblesite.Lql.SqlServer]*,[Nimblesite.Lql.SQLite]*" + }, + "Lql/Nimblesite.Lql.TypeProvider.FSharp": { + "threshold": 90, + "include": "[Nimblesite.Lql.TypeProvider.FSharp]*,[Nimblesite.Lql.Core]*,[Nimblesite.Lql.SQLite]*,[Nimblesite.Sql.Model]*" + }, + "Migration/Nimblesite.DataProvider.Migration.Core": { + "threshold": 90, + "include": "[Nimblesite.DataProvider.Migration.Core]*,[Nimblesite.DataProvider.Migration.SQLite]*,[Nimblesite.DataProvider.Migration.Postgres]*" + }, + "Sync/Nimblesite.Sync.Core": { + "threshold": 90, + "include": "[Nimblesite.Sync.Core]*" + }, + "Sync/Nimblesite.Sync.SQLite": { + "threshold": 90, + "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.SQLite]*" + }, + "Sync/Nimblesite.Sync.Postgres": { + "threshold": 90, + "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.Postgres]*,[Nimblesite.Sync.SQLite]*" + }, + "Sync/Nimblesite.Sync.Integration": { + "threshold": 90, + "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.SQLite]*,[Nimblesite.Sync.Postgres]*,[Nimblesite.Sync.Http]*" + }, + "Sync/Nimblesite.Sync.Http": { + "threshold": 90, + "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.SQLite]*,[Nimblesite.Sync.Postgres]*,[Nimblesite.Sync.Http]*" + }, + "Lql/lql-lsp-rust": { + "threshold": 92 + }, + "Lql/LqlExtension": { + "threshold": 90 + } + } +} diff --git a/coverlet.runsettings b/coverlet.runsettings index a2b54ece..e432e2ab 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -4,7 +4,7 @@ - json,lcov,opencover,cobertura + json,cobertura [*]*.Generated*,[*]*.g.* **/obj/**/*,**/bin/**/*,**/Migrations/**/* diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml index c7c64f34..b2676172 100644 --- a/docker-compose.postgres.yml +++ b/docker-compose.postgres.yml @@ -6,7 +6,6 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} volumes: - postgres-data:/var/lib/postgresql/data - - ./Samples/docker/init-db:/docker-entrypoint-initdb.d ports: - "5432:5432" healthcheck: diff --git a/Website/DataProvider-Design-System.md b/docs/specs/dataprovider-design-system.md similarity index 100% rename from Website/DataProvider-Design-System.md rename to docs/specs/dataprovider-design-system.md diff --git a/Gatekeeper/spec.md b/docs/specs/gatekeeper-spec.md similarity index 100% rename from Gatekeeper/spec.md rename to docs/specs/gatekeeper-spec.md diff --git a/Lql/LqlWebsite/design-system.md b/docs/specs/lql-design-system.md similarity index 100% rename from Lql/LqlWebsite/design-system.md rename to docs/specs/lql-design-system.md diff --git a/Lql/Lql/readme.md b/docs/specs/lql-spec.md similarity index 100% rename from Lql/Lql/readme.md rename to docs/specs/lql-spec.md diff --git a/Migration/migration_exe_spec.md b/docs/specs/migration-cli-spec.md similarity index 85% rename from Migration/migration_exe_spec.md rename to docs/specs/migration-cli-spec.md index 95754fc2..2ec05bbe 100644 --- a/Migration/migration_exe_spec.md +++ b/docs/specs/migration-cli-spec.md @@ -34,8 +34,8 @@ If a project defines its schema in C# code (e.g., `ExampleSchema.cs`, `ClinicalS 1. Schema is defined in a **separate Migrations assembly** (e.g., `MyProject.Migrations/`) with NO dependencies on generated code 2. Build step compiles the Migrations assembly first -3. Schema.Export.Cli exports C# schema to YAML file -4. Migration.Cli reads YAML and creates database +3. Migration.Cli `export` subcommand exports C# schema to YAML file +4. Migration.Cli `migrate` subcommand reads YAML and creates database 5. DataProvider code generation runs against the created database 6. Main project (e.g., `MyProject.Api/`) compiles with generated code @@ -84,17 +84,17 @@ The API/main assembly: ## MSBuild Integration -Consumer projects call Schema.Export.Cli then Migration.Cli in pre-build targets: +Consumer projects call Migration.Cli with `export` then `migrate` subcommands in pre-build targets: ```xml - + - + ``` @@ -107,8 +107,8 @@ To avoid circular dependencies, the build order MUST be: ``` 1. Migration/Migration.csproj # Core types 2. MyProject.Migrations/ # Schema definition (refs Migration only) -3. Schema.Export.Cli # Export schema to YAML -4. Migration.Cli # Create DB from YAML +3. Migration.Cli export # Export schema to YAML +4. Migration.Cli migrate # Create DB from YAML 5. DataProvider code generation # Generate C# from DB 6. MyProject.Api/ # Main project with generated code ``` diff --git a/Migration/spec.md b/docs/specs/migration-spec.md similarity index 99% rename from Migration/spec.md rename to docs/specs/migration-spec.md index c191e0e8..5b0d7e4b 100644 --- a/Migration/spec.md +++ b/docs/specs/migration-spec.md @@ -134,7 +134,7 @@ var schema = Schema.Define("MyApp") ### 4.3 YAML Schema Format -Schema files use YAML format. See `migration_exe_spec.md` for CLI usage. The YAML format mirrors the C# records: +Schema files use YAML format. See [migration-cli-spec.md](migration-cli-spec.md) for CLI usage. The YAML format mirrors the C# records: ```yaml name: MyApp diff --git a/Sync/spec.md b/docs/specs/sync-spec.md similarity index 100% rename from Sync/spec.md rename to docs/specs/sync-spec.md diff --git a/Website/spec.md b/docs/specs/website-spec.md similarity index 100% rename from Website/spec.md rename to docs/specs/website-spec.md diff --git a/package-lock.json b/package-lock.json index f8984f20..09d11905 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "DataProviderWebsite", + "name": "Nimblesite.DataProvider.CoreWebsite", "lockfileVersion": 3, "requires": true, "packages": {