From ca09e0ab602b846c10676a2196f4d100c8902927 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 28 Jul 2026 15:40:17 +0000 Subject: [PATCH 1/4] plan and analyze --- .github/copilot-instructions.md | 2 +- .specify/feature.json | 2 +- .../checklists/requirements.md | 34 +++++ .../contracts/export-endpoints.md | 143 ++++++++++++++++++ specs/028-csv-data-export/data-model.md | 99 ++++++++++++ specs/028-csv-data-export/plan.md | 95 ++++++++++++ specs/028-csv-data-export/quickstart.md | 121 +++++++++++++++ specs/028-csv-data-export/research.md | 63 ++++++++ specs/028-csv-data-export/spec.md | 94 ++++++++++++ 9 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 specs/028-csv-data-export/checklists/requirements.md create mode 100644 specs/028-csv-data-export/contracts/export-endpoints.md create mode 100644 specs/028-csv-data-export/data-model.md create mode 100644 specs/028-csv-data-export/plan.md create mode 100644 specs/028-csv-data-export/quickstart.md create mode 100644 specs/028-csv-data-export/research.md create mode 100644 specs/028-csv-data-export/spec.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 40854da..8813803 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -137,5 +137,5 @@ From `src/BikeTracking.Frontend`: For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -[specs/027-mileage-rate-savings/plan.md](../specs/027-mileage-rate-savings/plan.md) +[specs/028-csv-data-export/plan.md](../specs/028-csv-data-export/plan.md) diff --git a/.specify/feature.json b/.specify/feature.json index 1225662..2caf49e 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/027-mileage-rate-savings" + "feature_directory": "specs/028-csv-data-export" } diff --git a/specs/028-csv-data-export/checklists/requirements.md b/specs/028-csv-data-export/checklists/requirements.md new file mode 100644 index 0000000..d550e05 --- /dev/null +++ b/specs/028-csv-data-export/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: CSV Data Export + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Specification is ready for `/speckit.plan`. diff --git a/specs/028-csv-data-export/contracts/export-endpoints.md b/specs/028-csv-data-export/contracts/export-endpoints.md new file mode 100644 index 0000000..cde30e5 --- /dev/null +++ b/specs/028-csv-data-export/contracts/export-endpoints.md @@ -0,0 +1,143 @@ +# API Contracts: Export Endpoints + +**Route group**: `/api/exports` + +**Auth**: All endpoints require the `X-User-Id` header (existing scheme). Returns `401 Unauthorized` if header is absent or invalid. Data is always scoped to the authenticated rider. + +--- + +## GET /api/exports/expenses + +Downloads all expense records for the authenticated user as a single CSV file. + +### Request + +```http +GET /api/exports/expenses HTTP/1.1 +X-User-Id: {userId} +``` + +No query parameters. No request body. + +### Success Response + +**Status**: `200 OK` + +**Headers**: +```http +Content-Type: text/csv; charset=utf-8 +Content-Disposition: attachment; filename="expenses-export.csv" +``` + +**Body**: UTF-8 CSV file with BOM-free encoding. + +``` +ExpenseId,Date,Amount,Notes,CreatedAtUtc +101,2026-01-15,49.95,Chain replacement,2026-01-15T10:23:00Z +102,2026-02-03,12.00,,2026-02-03T08:00:00Z +103,2026-03-10,7.50,"Tyre, inner tube",2026-03-10T12:00:00Z +``` + +**Column definitions**: +| Column | Format | Nullable | +|--------------|-------------------------------|----------| +| `ExpenseId` | Integer | No | +| `Date` | `yyyy-MM-dd` | No | +| `Amount` | Decimal (no currency symbol) | No | +| `Notes` | String, RFC 4180 quoted | Yes (blank) | +| `CreatedAtUtc` | ISO 8601 (`yyyy-MM-ddTHH:mm:ssZ`) | No | + +**Empty dataset**: Returns a CSV with only the header row (no data rows). + +### Error Responses + +| Status | Condition | +|--------|-----------| +| `401 Unauthorized` | Missing or invalid `X-User-Id` header | +| `500 Internal Server Error` | Unexpected server failure | + +--- + +## GET /api/exports/rides + +Downloads all ride records for the authenticated user as a ZIP archive containing one CSV file per calendar year. + +### Request + +```http +GET /api/exports/rides HTTP/1.1 +X-User-Id: {userId} +``` + +No query parameters. No request body. + +### Success Response + +**Status**: `200 OK` + +**Headers**: +```http +Content-Type: application/zip +Content-Disposition: attachment; filename="ride-history-export.zip" +``` + +**Body**: Binary ZIP file. + +**ZIP contents**: +``` +ride-history-export.zip +├── 2024.csv ← all rides with RideDateTimeLocal.Year == 2024 +├── 2025.csv ← all rides with RideDateTimeLocal.Year == 2025 +└── 2026.csv ← all rides with RideDateTimeLocal.Year == 2026 +``` + +Each per-year CSV format: +``` +RideId,Date,Miles,RideMinutes,Temperature,GasPricePerGallon,WindSpeedMph,WindDirectionDeg,RelativeHumidityPercent,CloudCoverPercent,PrecipitationType,Note,WeatherUserOverridden,Difficulty,PrimaryTravelDirection,WindResistanceRating,ImportSource,SnapshotAverageCarMpg,SnapshotMileageRateCents,SnapshotYearlyGoalMiles,SnapshotOilChangePrice,CreatedAtUtc +1,2025-06-15T07:30:00,12.5,45,68.0,3.459,8.2,45,55,10,,Morning commute,false,3,NE,2,,25.0,6700,2000,79.00,2025-06-15T12:35:00Z +2,2025-06-16T07:28:00,12.5,43,71.0,,,,,,,,"Windy, tough ride",false,5,North,4,,,,,2025-06-16T12:30:00Z +``` + +**Column definitions**: +| Column | Format | Nullable | +|---------------------------|--------------------------------|---------------| +| `RideId` | Integer | No | +| `Date` | `yyyy-MM-ddTHH:mm:ss` | No | +| `Miles` | Decimal | No | +| `RideMinutes` | Integer | Yes (blank) | +| `Temperature` | Decimal (°F) | Yes (blank) | +| `GasPricePerGallon` | Decimal | Yes (blank) | +| `WindSpeedMph` | Decimal | Yes (blank) | +| `WindDirectionDeg` | Integer (0–360) | Yes (blank) | +| `RelativeHumidityPercent` | Integer (0–100) | Yes (blank) | +| `CloudCoverPercent` | Integer (0–100) | Yes (blank) | +| `PrecipitationType` | String, RFC 4180 quoted | Yes (blank) | +| `Note` | String, RFC 4180 quoted | Yes (blank) | +| `WeatherUserOverridden` | `true` / `false` | No | +| `Difficulty` | Integer (1–5) | Yes (blank) | +| `PrimaryTravelDirection` | String | Yes (blank) | +| `WindResistanceRating` | Integer (−4 to +4) | Yes (blank) | +| `ImportSource` | String | Yes (blank) | +| `SnapshotAverageCarMpg` | Decimal | Yes (blank) | +| `SnapshotMileageRateCents`| Decimal | Yes (blank) | +| `SnapshotYearlyGoalMiles` | Decimal | Yes (blank) | +| `SnapshotOilChangePrice` | Decimal | Yes (blank) | +| `CreatedAtUtc` | ISO 8601 (`yyyy-MM-ddTHH:mm:ssZ`) | No | + +**Empty dataset**: If the user has no rides, the ZIP contains a single `{currentYear}.csv` with only the header row. + +### Error Responses + +| Status | Condition | +|--------|-----------| +| `401 Unauthorized` | Missing or invalid `X-User-Id` header | +| `500 Internal Server Error` | Unexpected server failure | + +--- + +## Notes + +- Both endpoints are registered in `ExportEndpoints.cs` under `.RequireAuthorization()`. +- Neither endpoint modifies any data. +- Both endpoints use `Results.File(...)` (or equivalent `FileStreamResult`) to stream binary content directly. +- `Content-Disposition` uses the `attachment` disposition type so all browsers/fetch clients treat the response as a download rather than an inline render. diff --git a/specs/028-csv-data-export/data-model.md b/specs/028-csv-data-export/data-model.md new file mode 100644 index 0000000..45e24b4 --- /dev/null +++ b/specs/028-csv-data-export/data-model.md @@ -0,0 +1,99 @@ +# Data Model: CSV Data Export + +## Overview + +This feature introduces no new database tables and no schema migrations. It is a read-only export over two existing entities: `ExpenseEntity` and `RideEntity`. + +--- + +## Existing Entities Used + +### ExpenseEntity (`Expenses` table) + +| Column | C# Type | Nullable | Export Column Name | Notes | +|----------------|-------------|----------|--------------------|------------------------------------------------| +| `Id` | `long` | No | `ExpenseId` | Primary key | +| `RiderId` | `long` | No | _(scoping only)_ | Used in WHERE clause; not exported | +| `ExpenseDate` | `DateTime` | No | `Date` | Formatted as `yyyy-MM-dd` | +| `Amount` | `decimal` | No | `Amount` | Raw decimal, no currency symbol | +| `Notes` | `string?` | Yes | `Notes` | Blank cell when null; quoted if contains comma | +| `IsDeleted` | `bool` | No | _(filter only)_ | WHERE `IsDeleted = false`; never exported | +| `CreatedAtUtc` | `DateTime` | No | `CreatedAtUtc` | Formatted as ISO 8601 (`yyyy-MM-ddTHH:mm:ssZ`) | + +**Export filter**: `WHERE RiderId = @riderId AND IsDeleted = false ORDER BY ExpenseDate DESC` + +**CSV column order**: `ExpenseId`, `Date`, `Amount`, `Notes`, `CreatedAtUtc` + +--- + +### RideEntity (`Rides` table) + +| Column | C# Type | Nullable | Export Column Name | Notes | +|---------------------------|------------|----------|---------------------------|-------------------------------------------------| +| `Id` | `int` | No | `RideId` | Primary key | +| `RiderId` | `long` | No | _(scoping only)_ | Used in WHERE clause; not exported | +| `RideDateTimeLocal` | `DateTime` | No | `Date` | Formatted as `yyyy-MM-ddTHH:mm:ss` (local time) | +| `Miles` | `decimal` | No | `Miles` | Raw decimal | +| `RideMinutes` | `int?` | Yes | `RideMinutes` | Blank when null | +| `Temperature` | `decimal?` | Yes | `Temperature` | Blank when null; Fahrenheit | +| `GasPricePerGallon` | `decimal?` | Yes | `GasPricePerGallon` | Blank when null | +| `WindSpeedMph` | `decimal?` | Yes | `WindSpeedMph` | Blank when null | +| `WindDirectionDeg` | `int?` | Yes | `WindDirectionDeg` | Blank when null | +| `RelativeHumidityPercent` | `int?` | Yes | `RelativeHumidityPercent` | Blank when null | +| `CloudCoverPercent` | `int?` | Yes | `CloudCoverPercent` | Blank when null | +| `PrecipitationType` | `string?` | Yes | `PrecipitationType` | Blank when null; quoted if contains comma | +| `Notes` | `string?` | Yes | `Note` | Blank when null; quoted if contains comma/quote | +| `WeatherUserOverridden` | `bool` | No | `WeatherUserOverridden` | Exported as `true`/`false` | +| `Difficulty` | `int?` | Yes | `Difficulty` | Blank when null; 1–5 | +| `PrimaryTravelDirection` | `string?` | Yes | `PrimaryTravelDirection` | Blank when null; quoted if contains comma | +| `WindResistanceRating` | `int?` | Yes | `WindResistanceRating` | Blank when null; −4 to +4 | +| `ImportSource` | `string?` | Yes | `ImportSource` | Blank when null | +| `SnapshotAverageCarMpg` | `decimal?` | Yes | `SnapshotAverageCarMpg` | Blank when null; captured from settings at ride time | +| `SnapshotMileageRateCents`| `decimal?` | Yes | `SnapshotMileageRateCents`| Blank when null; captured from settings at ride time | +| `SnapshotYearlyGoalMiles` | `decimal?` | Yes | `SnapshotYearlyGoalMiles` | Blank when null; captured from settings at ride time | +| `SnapshotOilChangePrice` | `decimal?` | Yes | `SnapshotOilChangePrice` | Blank when null; captured from settings at ride time | +| `CreatedAtUtc` | `DateTime` | No | `CreatedAtUtc` | Formatted as ISO 8601 (`yyyy-MM-ddTHH:mm:ssZ`) | + +**Export filter**: `WHERE RiderId = @riderId ORDER BY RideDateTimeLocal DESC` + +**Grouping for ZIP**: Rides are grouped by `RideDateTimeLocal.Year`. Each year produces one `{year}.csv` entry inside the ZIP. + +**CSV column order**: `RideId`, `Date`, `Miles`, `RideMinutes`, `Temperature`, `GasPricePerGallon`, `WindSpeedMph`, `WindDirectionDeg`, `RelativeHumidityPercent`, `CloudCoverPercent`, `PrecipitationType`, `Note`, `WeatherUserOverridden`, `Difficulty`, `PrimaryTravelDirection`, `WindResistanceRating`, `ImportSource`, `SnapshotAverageCarMpg`, `SnapshotMileageRateCents`, `SnapshotYearlyGoalMiles`, `SnapshotOilChangePrice`, `CreatedAtUtc` + +--- + +## CsvRowBuilder (Utility) + +A shared in-project helper in `Application/Export/CsvRowBuilder.cs`. No new NuGet dependency. + +**Responsibility**: Produce a single RFC 4180-compliant CSV row string from a sequence of field values. + +**Quoting rules**: +- A field is quoted if it contains `,`, `"`, `\r`, or `\n`. +- An embedded `"` is escaped as `""`. +- Null fields are rendered as empty string (no quotes). +- Boolean fields are rendered as `true` or `false`. +- DateTime fields are formatted by the caller before passing in. + +**Interface**: +```csharp +// Application/Export/CsvRowBuilder.cs +public static class CsvRowBuilder +{ + public static string BuildRow(IEnumerable fields); + public static string BuildHeader(IEnumerable columnNames); +} +``` + +--- + +## ZIP Structure + +``` +ride-history-export.zip +├── 2024.csv +├── 2025.csv +└── 2026.csv +``` + +Each per-year CSV begins with the standard ride header row, followed by one data row per ride for that year ordered by `RideDateTimeLocal` descending. If a user has no rides, the ZIP contains a single `{currentYear}.csv` with only the header row (see Assumption in spec). diff --git a/specs/028-csv-data-export/plan.md b/specs/028-csv-data-export/plan.md new file mode 100644 index 0000000..c15296d --- /dev/null +++ b/specs/028-csv-data-export/plan.md @@ -0,0 +1,95 @@ +# Implementation Plan: CSV Data Export + +**Branch**: `028-csv-data-export` | **Date**: 2026-07-28 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/028-csv-data-export/spec.md` + +## Summary + +Add two export buttons to the Settings page: one downloads all expense records as a single CSV file; the other downloads ride history as a ZIP archive containing one CSV per calendar year. Both exports are scoped to the authenticated user, generated synchronously, contain raw data rows only (no totals), and must handle special characters via RFC 4180 quoting. + +Technical approach: two new backend application services (`ExpenseCsvExportService`, `RideHistoryCsvExportService`) behind a new `ExportEndpoints` route group (`GET /api/exports/expenses` and `GET /api/exports/rides`). ZIP creation uses the built-in `System.IO.Compression.ZipArchive`. CSV quoting uses a lightweight in-project `CsvRowBuilder` helper (no new NuGet dependency needed). Frontend adds a new `export-api.ts` service with blob-download helpers and two buttons on `SettingsPage`. + +## Technical Context + +**Language/Version**: .NET 10 / C# (backend), TypeScript / React 19 (frontend) + +**Primary Dependencies**: ASP.NET Core Minimal API, EF Core 10 / SQLite, System.IO.Compression (built-in), Vite + React 19, React Router v6, Vitest, Playwright + +**Storage**: SQLite via EF Core — reads `Rides` and `Expenses` tables; no schema changes, no migrations + +**Testing**: xUnit + custom `ApiHost` harness (backend integration tests), Vitest (frontend unit), Playwright (E2E) + +**Target Platform**: Local-first, DevContainer + .NET Aspire; packaged desktop targets (Windows/macOS/Linux) + +**Project Type**: Full-stack web service (Minimal API) + React SPA + +**Performance Goals**: SC-001 — expenses CSV in <5 s for 5,000 records; SC-002 — rides ZIP in <10 s for 5,000 rides spanning 10 years + +**Constraints**: Synchronous generation; no job queue, no background processing, no scheduling; user-scoped data only; no new database tables or migrations + +**Scale/Scope**: Up to 5,000 expense records and 5,000 ride records per user + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Gate | Status | Notes | +|------|--------|-------| +| TDD mandatory — failing test before implementation | ✅ PASS | Tests written first per constitution §3 | +| E2E required on every PR | ✅ PASS | Playwright E2E covering both export buttons required | +| Ports-and-adapters — strict boundary protection | ✅ PASS | Export services live in `Application/Export/`; DbContext injected through constructor; no direct EF leakage into endpoints | +| Domain outcomes as Result-style values | ✅ PASS | No new domain decisions needed; read-only export, no state mutation | +| Write model transactional relational, explicit audit logs | ✅ PASS | No writes; read-only feature | +| Short-lived branches, PR-only merge | ✅ PASS | Feature branch `028-csv-data-export` | +| No unjustified new projects | ✅ PASS | No new projects; feature lives entirely in existing `BikeTracking.Api` and `BikeTracking.Frontend` | + +**Post-design re-check**: No violations introduced in Phase 1 design. No migrations required. No new NuGet packages added. + +## Project Structure + +### Documentation (this feature) + +```text +specs/028-csv-data-export/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── export-endpoints.md +└── tasks.md # Phase 2 output (/speckit.tasks command — NOT created here) +``` + +### Source Code + +```text +src/BikeTracking.Api/ +├── Application/ +│ └── Export/ # NEW folder +│ ├── CsvRowBuilder.cs # NEW — RFC 4180 quoting helper +│ ├── ExpenseCsvExportService.cs # NEW — reads Expenses, produces CSV string +│ └── RideHistoryCsvExportService.cs # NEW — reads Rides, produces ZIP stream +├── Endpoints/ +│ └── ExportEndpoints.cs # NEW — maps /api/exports group +└── Contracts/ + └── ExportContracts.cs # NEW — (empty placeholder; no request DTOs needed) + +src/BikeTracking.Api.Tests/ +└── Endpoints/ + └── Export/ + ├── ExpenseExportEndpointTests.cs # NEW — xUnit integration tests + └── RideExportEndpointTests.cs # NEW — xUnit integration tests + +src/BikeTracking.Frontend/src/ +├── services/ +│ └── export-api.ts # NEW — fetch + blob-download helpers +└── pages/settings/ + ├── SettingsPage.tsx # MODIFIED — add two export buttons + state + └── SettingsPage.test.tsx # MODIFIED — add export button unit tests + +src/BikeTracking.Frontend/tests/e2e/ +└── export.spec.ts # NEW — Playwright E2E for both exports +``` + +**Structure Decision**: All backend code is added to the existing `BikeTracking.Api` project. No new C# projects. Frontend changes are scoped to `SettingsPage` and a new `export-api.ts` service. This follows the established flat-folder-per-concern pattern used throughout the application. diff --git a/specs/028-csv-data-export/quickstart.md b/specs/028-csv-data-export/quickstart.md new file mode 100644 index 0000000..ec589cb --- /dev/null +++ b/specs/028-csv-data-export/quickstart.md @@ -0,0 +1,121 @@ +# Quickstart: CSV Data Export — Validation Guide + +This guide describes how to validate that the CSV Data Export feature works end-to-end after implementation. Use it to confirm both export buttons on the Settings page work as specified. + +## Prerequisites + +- App running via Aspire: `dotnet run --project src/BikeTracking.AppHost` +- Aspire Dashboard open at http://localhost:19629 +- At least one user account with rides and expenses seeded (manual or via the Record Ride / Record Expense flows) + +## Scenario 1 — Export Expenses (single CSV) + +### Setup + +Seed at least 3 expense records for the test user, including one with a note containing a comma (e.g., `"Chain, lube"`). + +### Steps + +1. Log in as the test user. +2. Navigate to **Settings** (`/settings`). +3. Click the **"Export Expenses"** button. +4. Observe: a file download is triggered immediately. + +### Expected outcomes + +| Checkpoint | Expected | +|------------|---------| +| File name | `expenses-export.csv` | +| HTTP response code from API | `200 OK` | +| First row of file | `ExpenseId,Date,Amount,Notes,CreatedAtUtc` | +| Data rows | One per expense record; no totals or summary rows | +| Record count | Matches the number of expenses recorded | +| Notes with commas | Properly quoted (e.g., `"Chain, lube"`) | +| Notes that are blank | Empty cell (no placeholder text) | +| File opens in Excel/LibreOffice | Columns parsed correctly without manual repair | + +### Empty dataset check + +Create a fresh user with no expenses. Click "Export Expenses". Verify the downloaded file contains only the header row. + +--- + +## Scenario 2 — Export Ride History (ZIP with per-year CSVs) + +### Setup + +Seed rides spanning at least 2 calendar years (e.g., some in 2025, some in 2026). Include at least one ride with a note containing a double-quote (e.g., `"6" of snow"`). + +### Steps + +1. Log in as the test user. +2. Navigate to **Settings** (`/settings`). +3. Click the **"Export Ride History"** button. +4. Observe: a ZIP file download is triggered. + +### Expected outcomes + +| Checkpoint | Expected | +|------------|---------| +| File name | `ride-history-export.zip` | +| HTTP response code from API | `200 OK` | +| ZIP contents | One `.csv` file per year represented in the data (e.g., `2025.csv`, `2026.csv`) | +| Each CSV first row | `RideId,Date,Miles,RideMinutes,...,CreatedAtUtc` (full header) | +| Each CSV data rows | One per ride for that year; no totals or summary rows | +| Notes with double-quotes | Double-quote escaped (`""`) inside quoted field | +| Optional fields with no data | Empty cell; column still present | +| All files open in Excel/LibreOffice | Columns parsed correctly | + +### Single-year check + +Create or filter to a user with rides in one year only. Verify the ZIP contains exactly one CSV file. + +### Empty dataset check + +Create a fresh user with no rides. Click "Export Ride History". Verify the ZIP contains a single CSV named `{currentYear}.csv` with only the header row. + +--- + +## Scenario 3 — User Isolation + +### Steps + +1. Log in as **User A**, click "Export Expenses". Note the record count. +2. Log out. Log in as **User B** (a different user with different expense data). +3. Click "Export Expenses". Note the record count. + +### Expected outcome + +User A's export contains only User A's data; User B's export contains only User B's data. No cross-user records appear. + +--- + +## Scenario 4 — Independent Button Operation + +### Steps + +1. Click "Export Expenses". Wait for download to complete. +2. Click "Export Ride History". Wait for download to complete. +3. Repeat in the other order. + +### Expected outcome + +Each button triggers its own independent download. Triggering one does not affect or cancel the other. + +--- + +## API-Level Validation (optional, using the `.http` file or curl) + +```bash +# Expense CSV +curl -H "X-User-Id: 1" http://localhost:{api_port}/api/exports/expenses \ + --output expenses-export.csv + +# Ride history ZIP +curl -H "X-User-Id: 1" http://localhost:{api_port}/api/exports/rides \ + --output ride-history-export.zip +``` + +Inspect output with `head -5 expenses-export.csv` and `unzip -l ride-history-export.zip`. + +See [contracts/export-endpoints.md](./contracts/export-endpoints.md) for full endpoint specifications and [data-model.md](./data-model.md) for column definitions. diff --git a/specs/028-csv-data-export/research.md b/specs/028-csv-data-export/research.md new file mode 100644 index 0000000..d2117da --- /dev/null +++ b/specs/028-csv-data-export/research.md @@ -0,0 +1,63 @@ +# Research: CSV Data Export + +## Decision 1 — CSV Generation Strategy + +**Decision**: Implement a lightweight `CsvRowBuilder` helper class in `Application/Export/` using manual RFC 4180 quoting (wrap in double-quotes when value contains comma, double-quote, or newline; escape embedded double-quotes by doubling them). + +**Rationale**: The project already has a hand-rolled `CsvExpenseParser` and `SampleCsvGenerator` using `StringBuilder` — this establishes a precedent for manual CSV handling. Adding `CsvHelper` (a third-party NuGet package) for write-only export would introduce a dependency heavier than the problem warrants. A 20-line `CsvRowBuilder` covers RFC 4180 requirements completely and keeps the dependency surface flat. + +**Alternatives considered**: +- `CsvHelper` NuGet package — well-established library; rejected because it introduces a new dependency for simple write-only logic that an in-project utility handles cleanly. +- `StringBuilder` ad-hoc per service — rejected because duplicated quoting logic across two services invites divergence; a shared helper keeps it DRY. + +--- + +## Decision 2 — ZIP Creation + +**Decision**: Use `System.IO.Compression.ZipArchive` (built-in .NET BCL) to assemble the ride history ZIP in memory via a `MemoryStream`. Write one `ZipArchiveEntry` per year named `{year}.csv`, then return the stream as the response body. + +**Rationale**: `System.IO.Compression` is part of the .NET BCL — zero new dependencies. The target dataset (5,000 rides across 10 years) generates at most ~10 CSV files totalling a few hundred KB; in-memory assembly is safe and avoids temp-file cleanup concerns. + +**Alternatives considered**: +- Temp-file on disk — rejected; adds file I/O, temp-path management, and cleanup complexity. +- Third-party ZIP libraries (SharpZipLib, ZipFile.CreateFromDirectory) — rejected; BCL covers the requirement fully. + +--- + +## Decision 3 — API Endpoint Placement + +**Decision**: New `ExportEndpoints` class mapping a `/api/exports` route group with two GET endpoints: `GET /api/exports/expenses` and `GET /api/exports/rides`. + +**Rationale**: Grouping export endpoints under `/api/exports` mirrors how the existing codebase organises feature-scoped endpoints (`/api/rides`, `/api/expenses`, `/api/imports`). A dedicated group keeps export concerns isolated and avoids polluting the rides or expenses groups with binary-download endpoints. + +**Alternatives considered**: +- Add `GET /api/expenses/export` inside `ExpensesEndpoints` — rejected; mixes record-mutation and bulk-export concerns in the same class. +- Single polymorph endpoint `GET /api/exports?type=expenses|rides` — rejected; a query parameter switch makes route documentation and Swagger descriptions less clear than two explicit routes. + +--- + +## Decision 4 — Export Column Set + +**Decision**: Export columns are sourced directly from `RideEntity` / `ExpenseEntity`. `HasReceipt` is omitted from expenses export (the spec says "raw field values"; receipt presence is metadata about attachments, not a stored expense attribute). The `IsDeleted` soft-delete flag is filtered out server-side and never exported. `CreatedAtUtc` is read directly from the entity, not from the existing `RideHistoryRow` or `ExpenseHistoryRow` response contracts (those DTOs omit `CreatedAtUtc`); the export service queries the entity directly. + +**Expense CSV columns**: `ExpenseId`, `Date`, `Amount`, `Notes`, `CreatedAtUtc` + +**Ride CSV columns**: `RideId`, `Date`, `Miles`, `RideMinutes`, `Temperature`, `GasPricePerGallon`, `WindSpeedMph`, `WindDirectionDeg`, `RelativeHumidityPercent`, `CloudCoverPercent`, `PrecipitationType`, `Note`, `WeatherUserOverridden`, `Difficulty`, `PrimaryTravelDirection`, `WindResistanceRating`, `ImportSource`, `SnapshotAverageCarMpg`, `SnapshotMileageRateCents`, `SnapshotYearlyGoalMiles`, `SnapshotOilChangePrice`, `CreatedAtUtc` + +**Rationale**: The spec explicitly states the column set mirrors existing data models. `HasReceipt` is a computed derived flag from `ReceiptPath`, not a stored field — omitting it keeps exports clean. `IsDeleted` is a soft-delete mechanism; records where `IsDeleted = true` are already excluded from all other read operations. + +**Alternatives considered**: +- Include all `ExpenseEntity` fields including `ReceiptPath` — rejected; filesystem path is an internal implementation detail inappropriate for user exports. +- Include `HasReceipt` — rejected; same reason — it is derived metadata, not raw stored data. + +--- + +## Decision 5 — Frontend Download Mechanism + +**Decision**: Trigger downloads via the Fetch API, receive the binary response as a `Blob`, create an object URL, inject a transient `` element with the `download` attribute, click it programmatically, then revoke the URL. This mirrors the existing `downloadExpenseReceipt` pattern already in `expenses-api.ts`. + +**Rationale**: Consistent with how receipt downloads already work in the codebase. The blob approach handles both the CSV and ZIP without any additional libraries. The `Content-Disposition: attachment; filename="..."` header from the API provides the suggested filename. + +**Alternatives considered**: +- Direct `window.location.href` navigation — rejected; does not work reliably with authenticated requests (no way to pass `X-User-Id` header). +- Anchor tag with `href` pointing to API URL and auth via query param — rejected; the session uses a header-based auth scheme; leaking user ID in the URL query string is undesirable. diff --git a/specs/028-csv-data-export/spec.md b/specs/028-csv-data-export/spec.md new file mode 100644 index 0000000..d5bc0ba --- /dev/null +++ b/specs/028-csv-data-export/spec.md @@ -0,0 +1,94 @@ +# Feature Specification: CSV Data Export + +**Feature Branch**: `028-csv-data-export` + +**Created**: 2026-07-28 + +**Status**: Draft + +**Input**: User description: "As a user I want to export all my data to csv. On the settings page, add a button to export expenses to csv and a button to export ride history to csv. Ride history should have one csv per year and download that as a zip file. Do them separately. Do not include totals, I just want raw data." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Export Expenses to CSV (Priority: P1) + +A user on the Settings page wants to download all their expense records as a single CSV file so they can analyse or archive their data outside the app. + +**Why this priority**: Expenses are flat data with no time-bucketing, making this the simplest and highest-value export to deliver first. A standalone working button provides immediate user value. + +**Independent Test**: Can be fully tested by navigating to Settings, clicking "Export Expenses", and verifying a correctly structured CSV file downloads with the expected raw expense records. + +**Acceptance Scenarios**: + +1. **Given** a user has recorded one or more expenses, **When** they click "Export Expenses" on the Settings page, **Then** a CSV file downloads containing one row per expense with all raw field values and no totals or summary rows. +2. **Given** a user has no expenses recorded, **When** they click "Export Expenses", **Then** a CSV file downloads containing only the header row. +3. **Given** the exported CSV, **When** opened in a spreadsheet application, **Then** all columns are correctly labelled and every expense record is present and accurate. + +--- + +### User Story 2 - Export Ride History to CSV (Priority: P2) + +A user on the Settings page wants to download their full ride history as a ZIP archive containing one CSV file per calendar year, so they can manage large datasets year by year. + +**Why this priority**: Ride history can span multiple years and is the other major data type in the app. Delivering this as a ZIP-per-year makes the download manageable and self-organising for users. + +**Independent Test**: Can be fully tested by navigating to Settings, clicking "Export Ride History", and verifying a ZIP file downloads containing one correctly structured CSV per year represented in the ride data. + +**Acceptance Scenarios**: + +1. **Given** a user has rides across multiple calendar years, **When** they click "Export Ride History" on the Settings page, **Then** a ZIP file downloads containing one CSV file per year (e.g., `2024.csv`, `2025.csv`), each with all rides for that year as raw data rows and no totals. +2. **Given** a user has rides in only one calendar year, **When** they click "Export Ride History", **Then** the ZIP contains exactly one CSV file for that year. +3. **Given** a user has no rides recorded, **When** they click "Export Ride History", **Then** a ZIP downloads containing a single CSV with only the header row (or an empty ZIP — see Assumptions). +4. **Given** the exported per-year CSV files, **When** opened in a spreadsheet application, **Then** each file contains correctly labelled columns and every ride for that year is present and accurate. + +--- + +### Edge Cases + +- What happens when the user's dataset is very large (hundreds of rides or expenses)? The download should still complete without a browser timeout. +- What happens if a ride or expense record has optional fields that were not filled in? Empty cells should be exported as blank rather than omitted columns. +- What happens if data contains commas or special characters (e.g., in notes fields)? Values must be properly quoted in the CSV. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The Settings page MUST display a clearly labelled "Export Expenses" button. +- **FR-002**: The Settings page MUST display a clearly labelled "Export Ride History" button. +- **FR-003**: The two export buttons MUST operate independently; triggering one MUST NOT affect the other. +- **FR-004**: Clicking "Export Expenses" MUST download a single CSV file containing all of the current user's expense records as raw data rows. +- **FR-005**: The expenses CSV MUST include a header row with column names matching the expense fields (e.g., `Date`, `Amount`, `Notes`, `CreatedAtUtc`). +- **FR-006**: The expenses CSV MUST NOT include totals, subtotals, or any computed summary rows. +- **FR-007**: Clicking "Export Ride History" MUST download a ZIP archive containing one CSV file per calendar year in which the current user has recorded rides. +- **FR-008**: Each per-year CSV within the ZIP MUST include a header row and one data row per ride for that year, containing all ride fields as raw values. +- **FR-009**: Each per-year CSV MUST NOT include totals, subtotals, or any computed summary rows. +- **FR-010**: Per-year CSV files inside the ZIP MUST be named clearly by year (e.g., `2024.csv`). +- **FR-011**: Special characters and commas within field values MUST be properly escaped/quoted so the CSV is valid and parseable by standard spreadsheet tools. +- **FR-012**: Exports MUST be scoped to the currently authenticated user's data only. + +### Key Entities + +- **Expense Record**: A single logged expense belonging to the user, with fields such as `Date`, `Amount`, `Notes`, and `CreatedAtUtc`. +- **Ride Record**: A single logged ride belonging to the user, with fields such as date, distance, duration, and any other recorded attributes. +- **Yearly Ride CSV**: A CSV file representing all ride records for a given calendar year. +- **Ride History ZIP**: An archive bundling all yearly ride CSV files for download. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user can trigger and receive a completed expense CSV download in under 5 seconds for datasets up to 5,000 records. +- **SC-002**: A user can trigger and receive a completed ride history ZIP download in under 10 seconds for datasets spanning up to 10 years with up to 5,000 total rides. +- **SC-003**: 100% of expense records and 100% of ride records present in the system are included in their respective exports — no records are omitted or duplicated. +- **SC-004**: Exported files open correctly in standard spreadsheet tools (e.g., Excel, LibreOffice Calc) without manual repair. +- **SC-005**: No summary, computed, or total rows appear in any exported file. + +## Assumptions + +- The Settings page already exists; this feature adds two export buttons to it without redesigning the page layout. +- "Raw data" means the stored field values as-is; no unit conversions, rounding, or formatting are applied during export. +- When a user has no rides, the ZIP will contain a single CSV with only the header row (rather than an empty ZIP), to ensure users always receive a usable file. +- The export is a one-off on-demand action; no scheduling, email delivery, or background processing is required. +- Exports are generated synchronously in the browser or triggered via the existing API without requiring a separate job queue. +- The column set for each CSV mirrors the fields available in the existing ride and expense data models; no new fields are introduced by this feature. +- Authentication and user-scoping use the existing session/auth mechanism already in place. From 421edc0993a9516856439429530e73a6f4b16693 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 28 Jul 2026 16:09:19 +0000 Subject: [PATCH 2/4] implementation --- specs/028-csv-data-export/tasks.md | 199 ++++++++ .../Application/Export/CsvRowBuilderTests.cs | 202 ++++++++ .../Export/ExpenseExportEndpointTests.cs | 436 ++++++++++++++++++ .../Export/RideExportEndpointTests.cs | 253 ++++++++++ .../Application/Export/CsvRowBuilder.cs | 78 ++++ .../Export/ExpenseCsvExportService.cs | 51 ++ .../Export/RideHistoryCsvExportService.cs | 108 +++++ .../Endpoints/ExportEndpoints.cs | 71 +++ src/BikeTracking.Api/Program.cs | 5 + src/BikeTracking.Api/export.http | 16 + .../src/pages/settings/SettingsPage.test.tsx | 136 ++++++ .../src/pages/settings/SettingsPage.tsx | 49 ++ .../src/services/export-api.ts | 82 ++++ .../tests/e2e/export.spec.ts | 222 +++++++++ 14 files changed, 1908 insertions(+) create mode 100644 specs/028-csv-data-export/tasks.md create mode 100644 src/BikeTracking.Api.Tests/Application/Export/CsvRowBuilderTests.cs create mode 100644 src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs create mode 100644 src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs create mode 100644 src/BikeTracking.Api/Application/Export/CsvRowBuilder.cs create mode 100644 src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs create mode 100644 src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs create mode 100644 src/BikeTracking.Api/Endpoints/ExportEndpoints.cs create mode 100644 src/BikeTracking.Api/export.http create mode 100644 src/BikeTracking.Frontend/src/services/export-api.ts create mode 100644 src/BikeTracking.Frontend/tests/e2e/export.spec.ts diff --git a/specs/028-csv-data-export/tasks.md b/specs/028-csv-data-export/tasks.md new file mode 100644 index 0000000..115be59 --- /dev/null +++ b/specs/028-csv-data-export/tasks.md @@ -0,0 +1,199 @@ +--- +description: "Task list for CSV Data Export feature implementation" +--- + +# Tasks: CSV Data Export + +**Input**: Design documents from `specs/028-csv-data-export/` + +**Prerequisites**: [plan.md](./plan.md) · [spec.md](./spec.md) · [data-model.md](./data-model.md) · [contracts/export-endpoints.md](./contracts/export-endpoints.md) · [research.md](./research.md) · [quickstart.md](./quickstart.md) + +**Branch**: `028-csv-data-export` + +**Constitution gate**: TDD is mandatory — failing-test proof required before every implementation task. E2E required on every PR. + +--- + +## Format: `[ID] [P?] [Story?] Description` + +- **[P]**: Can run in parallel (different files, no conflicting dependencies) +- **[US1]** / **[US2]**: Which user story this task belongs to +- Exact file paths are included in every task description + +--- + +## Phase 1: Foundational (Blocking Prerequisites) + +**Purpose**: Implement the shared `CsvRowBuilder` RFC 4180 helper used by both export services. Neither export service can be implemented until this is in place. + +**⚠️ CRITICAL**: Complete both tasks before starting any user story phase. + +- [X] T001 Write failing unit tests for `CsvRowBuilder` RFC 4180 quoting rules (null → blank, comma/quote/newline → quoted, embedded `"` → `""`, bool → `true`/`false`) in `src/BikeTracking.Api.Tests/Application/Export/CsvRowBuilderTests.cs` **[RED — tests must fail]** +- [X] T002 Implement `CsvRowBuilder` static class with `BuildRow(IEnumerable fields)` and `BuildHeader(IEnumerable columnNames)` methods in `src/BikeTracking.Api/Application/Export/CsvRowBuilder.cs` (passes T001) + +**Checkpoint**: `CsvRowBuilder` unit tests pass — both export services can now be implemented. + +--- + +## Phase 2: User Story 1 — Export Expenses to CSV (Priority: P1) 🎯 MVP + +**Goal**: A user clicks "Export Expenses" on the Settings page and receives a single UTF-8 CSV file (`expenses-export.csv`) containing all their raw expense records, correctly quoted per RFC 4180, with no totals or summary rows. + +**Independent Test**: Navigate to Settings, click "Export Expenses", verify `expenses-export.csv` downloads with header `ExpenseId,Date,Amount,Notes,CreatedAtUtc`, one data row per expense, proper quoting, and no total rows. Also verify empty-dataset case returns header-only CSV. + +### Tests for User Story 1 (write first — RED before implementation) + +- [X] T003 [P] [US1] Write failing backend integration tests covering: 200 OK with correct `Content-Disposition` / `Content-Type`, header row, data rows (multi-record), empty dataset (header-only), RFC 4180 quoting of Notes field, user-scoping (no cross-user data), and 401 for missing auth header in `src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs` **[RED — tests must fail]** +- [X] T004 [P] [US1] Write failing frontend unit tests asserting "Export Expenses" button renders on `SettingsPage`, is clickable, calls `fetchExpensesCsv`, and triggers a blob download in `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx` **[RED — tests must fail]** + +### Implementation for User Story 1 + +- [X] T005 [P] [US1] Implement `ExpenseCsvExportService` that queries `Expenses` WHERE `RiderId = @riderId AND IsDeleted = false ORDER BY ExpenseDate DESC`, maps to CSV rows using `CsvRowBuilder`, and returns the UTF-8 CSV string with header `ExpenseId,Date,Amount,Notes,CreatedAtUtc` (Date as `yyyy-MM-dd`, CreatedAtUtc as ISO 8601, decimal as raw, null Notes as blank) in `src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs` +- [X] T006 [US1] Create `ExportEndpoints` route group `/api/exports`, register `GET /api/exports/expenses` delegating to `ExpenseCsvExportService` and returning `Results.File(...)` with `Content-Type: text/csv; charset=utf-8` and `Content-Disposition: attachment; filename="expenses-export.csv"`, then register `ExportEndpoints` in `src/BikeTracking.Api/Endpoints/ExportEndpoints.cs` and `src/BikeTracking.Api/Program.cs` +- [X] T007 [P] [US1] Add `fetchExpensesCsv()` async function that calls `GET /api/exports/expenses` with `X-User-Id` header, receives the response as a `Blob`, creates an object URL, injects a transient `` element, clicks it, then revokes the URL — mirroring the existing `downloadExpenseReceipt` pattern in `src/BikeTracking.Frontend/src/services/export-api.ts` +- [X] T008 [US1] Add "Export Expenses" button and per-button loading/error state to `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx` (button calls `fetchExpensesCsv` from `export-api.ts`; depends on T006 and T007) +- [X] T009 [US1] Write Playwright E2E test covering: expense CSV downloads with correct filename, header row present, at least one data row, empty-dataset header-only, and buttons operate independently in `src/BikeTracking.Frontend/tests/e2e/export.spec.ts` + +**Checkpoint**: User Story 1 fully functional and independently testable — expense CSV export works end-to-end. + +--- + +## Phase 3: User Story 2 — Export Ride History to CSV (Priority: P2) + +**Goal**: A user clicks "Export Ride History" on the Settings page and receives `ride-history-export.zip` containing one CSV per calendar year (`2024.csv`, `2025.csv`, etc.), each with all rides for that year as raw data rows, correctly quoted, and no totals. + +**Independent Test**: Navigate to Settings, click "Export Ride History", verify `ride-history-export.zip` downloads, unzip reveals one `{year}.csv` per year in the data, each CSV has header `RideId,Date,Miles,…,CreatedAtUtc` with one data row per ride, proper RFC 4180 quoting, and no total rows. Also verify empty-dataset case returns ZIP containing header-only `{currentYear}.csv`. + +### Tests for User Story 2 (write first — RED before implementation) + +- [X] T010 [P] [US2] Write failing backend integration tests covering: 200 OK with `Content-Type: application/zip` and correct `Content-Disposition`, ZIP contains one CSV per year, each CSV has correct header and data rows, rides grouped by `RideDateTimeLocal.Year`, empty dataset returns ZIP with single header-only `{currentYear}.csv`, user-scoping, RFC 4180 quoting of Notes/PrecipitationType, null optional fields render as blank cells, and 401 for missing auth header in `src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs` **[RED — tests must fail]** +- [X] T011 [P] [US2] Write failing frontend unit tests asserting "Export Ride History" button renders on `SettingsPage`, is clickable, calls `fetchRideHistoryZip`, triggers a blob download, and operates independently from the "Export Expenses" button — extend `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx` **[RED — tests must fail]** + +### Implementation for User Story 2 + +- [X] T012 [P] [US2] Implement `RideHistoryCsvExportService` that queries `Rides` WHERE `RiderId = @riderId ORDER BY RideDateTimeLocal DESC`, groups rides by `RideDateTimeLocal.Year`, creates a `MemoryStream`-backed `ZipArchive` via `System.IO.Compression`, writes one `ZipArchiveEntry` named `{year}.csv` per year using `CsvRowBuilder` with the full 23-column ride header (RideId, Date, Miles, RideMinutes, …, CreatedAtUtc), handles the empty-dataset case by writing a header-only entry for the current year, and returns the sealed `MemoryStream` in `src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs` +- [X] T013 [US2] Add `GET /api/exports/rides` route to the existing `ExportEndpoints` route group, delegating to `RideHistoryCsvExportService` and returning `Results.File(stream, "application/zip", "ride-history-export.zip")` in `src/BikeTracking.Api/Endpoints/ExportEndpoints.cs` +- [X] T014 [P] [US2] Add `fetchRideHistoryZip()` async function that calls `GET /api/exports/rides` with `X-User-Id` header, receives the response as a `Blob`, creates an object URL, injects a transient `` element, clicks it, then revokes the URL — extend `src/BikeTracking.Frontend/src/services/export-api.ts` +- [X] T015 [US2] Add "Export Ride History" button and per-button loading/error state to `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx` (button calls `fetchRideHistoryZip` from `export-api.ts`; depends on T013 and T014) +- [X] T016 [US2] Extend Playwright E2E test to cover: ride history ZIP downloads with correct filename, ZIP contains expected per-year CSVs, each CSV has full ride header, multi-year split is correct, empty-dataset ZIP contains header-only CSV, and both export buttons operate independently in `src/BikeTracking.Frontend/tests/e2e/export.spec.ts` + +**Checkpoint**: User Stories 1 AND 2 fully functional and independently testable. + +--- + +## Phase 4: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation, smoke-test tooling, and documentation alignment. + +- [X] T017 Run all quickstart.md validation scenarios: Scenario 1 (expense CSV), Scenario 2 (ride history ZIP), Scenario 3 (user isolation), and Scenario 4 (independent button operation) against the running Aspire stack as described in `specs/028-csv-data-export/quickstart.md` +- [X] T018 [P] Add `.http` request file with `GET /api/exports/expenses` and `GET /api/exports/rides` examples (with `X-User-Id` header) for manual API-level smoke testing at `src/BikeTracking.Api/export.http` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Phase 1: Foundational (T001 → T002) + └─ must complete before Phases 2 and 3 + +Phase 2: User Story 1 (T003–T009) + └─ depends on Phase 1 completion + └─ can proceed in parallel with Phase 3 if team capacity allows + +Phase 3: User Story 2 (T010–T016) + └─ depends on Phase 1 completion + └─ T013 also depends on T006 (ExportEndpoints created in US1) + └─ can proceed in parallel with Phase 2 if team capacity allows + +Phase 4: Polish (T017–T018) + └─ depends on Phase 2 + Phase 3 completion +``` + +### User Story Dependencies + +| Story | Depends on | Can be independent? | +|-------|-----------|---------------------| +| US1 (P1) | Phase 1 only | ✅ Yes | +| US2 (P2) | Phase 1 + T006 (ExportEndpoints created by US1) | Mostly — add ride route after US1 creates the route group | + +### Within Each Story (strict ordering) + +1. **RED tests first** (T003/T004 for US1, T010/T011 for US2) — must fail before implementation begins +2. **Service before endpoint** (T005 before T006; T012 before T013) +3. **Frontend helper before UI** (T007 before T008; T014 before T015) +4. **E2E last** (T009 after T006+T008; T016 after T013+T015) + +### Parallel Opportunities Within Each Phase + +**Phase 1 (Foundational)**: +``` +T001 (CsvRowBuilder tests) → T002 (implement CsvRowBuilder) [sequential] +``` + +**Phase 2 (US1)**: +``` +T003 (backend tests [P]) ─┐ +T004 (frontend tests [P]) ─┘ both RED in parallel + +T005 (ExpenseCsvExportService [P]) ─┐ +T007 (export-api.ts fetchExpensesCsv [P]) ─┘ both in parallel after T002 + +T006 (ExportEndpoints expense route) → T008 (SettingsPage button) → T009 (E2E) [sequential] +``` + +**Phase 3 (US2)**: +``` +T010 (backend tests [P]) ─┐ +T011 (frontend tests [P]) ─┘ both RED in parallel + +T012 (RideHistoryCsvExportService [P]) ─┐ +T014 (export-api.ts fetchRideHistoryZip [P]) ─┘ both in parallel after T002 + +T013 (ExportEndpoints rides route) → T015 (SettingsPage button) → T016 (E2E) [sequential] +``` + +--- + +## Implementation Strategy + +### MVP Scope (User Story 1 Only) + +1. Complete Phase 1: Foundational (T001 → T002) +2. Complete Phase 2: User Story 1 (T003 → T009) +3. **STOP and validate**: run expense CSV export end-to-end using quickstart Scenario 1 and Scenario 3 +4. Merge MVP — delivers immediate user value with expenses export + +### Incremental Delivery + +1. **Foundation** (Phase 1) → `CsvRowBuilder` ready +2. **US1** (Phase 2) → Expense export works → MVP demo / merge +3. **US2** (Phase 3) → Ride history ZIP works → Full feature demo / merge +4. **Polish** (Phase 4) → Final validation pass + +### Parallel Team Strategy + +With two developers after Phase 1: +- **Developer A**: Phase 2 (User Story 1) — `ExpenseCsvExportService`, expense endpoint, `fetchExpensesCsv`, Settings button +- **Developer B**: Phase 3 (User Story 2) — `RideHistoryCsvExportService`, wait for T006, then ride endpoint, `fetchRideHistoryZip`, Settings button + +--- + +## New Files Summary + +| File | Status | Story | +|------|--------|-------| +| `src/BikeTracking.Api/Application/Export/CsvRowBuilder.cs` | NEW | Foundational | +| `src/BikeTracking.Api.Tests/Application/Export/CsvRowBuilderTests.cs` | NEW | Foundational | +| `src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs` | NEW | US1 | +| `src/BikeTracking.Api/Endpoints/ExportEndpoints.cs` | NEW | US1 | +| `src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs` | NEW | US1 | +| `src/BikeTracking.Frontend/src/services/export-api.ts` | NEW | US1 + US2 | +| `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx` | MODIFIED | US1 + US2 | +| `src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx` | MODIFIED | US1 + US2 | +| `src/BikeTracking.Frontend/tests/e2e/export.spec.ts` | NEW | US1 + US2 | +| `src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs` | NEW | US2 | +| `src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs` | NEW | US2 | +| `src/BikeTracking.Api/Program.cs` | MODIFIED | US1 | +| `src/BikeTracking.Api/export.http` | NEW | Polish | diff --git a/src/BikeTracking.Api.Tests/Application/Export/CsvRowBuilderTests.cs b/src/BikeTracking.Api.Tests/Application/Export/CsvRowBuilderTests.cs new file mode 100644 index 0000000..aaa4927 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/Export/CsvRowBuilderTests.cs @@ -0,0 +1,202 @@ +using BikeTracking.Api.Application.Export; + +namespace BikeTracking.Api.Tests.Application.Export; + +public sealed class CsvRowBuilderTests +{ + // ────────────────────────────────────────────────────────────────────── + // BuildHeader + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildHeader_WithSingleColumn_ReturnsSingleField() + { + var result = CsvRowBuilder.BuildHeader(["ExpenseId"]); + + Assert.Equal("ExpenseId", result); + } + + [Fact] + public void BuildHeader_WithMultipleColumns_ReturnsCommaSeparated() + { + var result = CsvRowBuilder.BuildHeader(["ExpenseId", "Date", "Amount", "Notes", "CreatedAtUtc"]); + + Assert.Equal("ExpenseId,Date,Amount,Notes,CreatedAtUtc", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — null handling + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_NullField_RendersAsBlank() + { + var result = CsvRowBuilder.BuildRow(["123", null, "2026-01-01"]); + + Assert.Equal("123,,2026-01-01", result); + } + + [Fact] + public void BuildRow_AllNullFields_RendersAsAllBlanks() + { + var result = CsvRowBuilder.BuildRow([null, null, null]); + + Assert.Equal(",,", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — plain fields (no quoting required) + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_PlainFields_NoQuoting() + { + var result = CsvRowBuilder.BuildRow(["101", "2026-01-15", "49.95"]); + + Assert.Equal("101,2026-01-15,49.95", result); + } + + [Fact] + public void BuildRow_EmptyString_NoQuoting() + { + var result = CsvRowBuilder.BuildRow(["101", "", "49.95"]); + + Assert.Equal("101,,49.95", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — RFC 4180 quoting: comma triggers quoting + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_FieldContainsComma_IsQuoted() + { + var result = CsvRowBuilder.BuildRow(["Tyre, inner tube"]); + + Assert.Equal("\"Tyre, inner tube\"", result); + } + + [Fact] + public void BuildRow_FieldContainsCommaInMiddle_QuotedField() + { + var result = CsvRowBuilder.BuildRow(["101", "2026-01-15", "49.95", "Chain, oil, lube"]); + + Assert.Equal("101,2026-01-15,49.95,\"Chain, oil, lube\"", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — RFC 4180 quoting: double-quote triggers quoting and escaping + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_FieldContainsDoubleQuote_IsQuotedAndDoubled() + { + var result = CsvRowBuilder.BuildRow(["She said \"hello\""]); + + Assert.Equal("\"She said \"\"hello\"\"\"", result); + } + + [Fact] + public void BuildRow_FieldContainsDoubleQuoteAtStart_IsQuotedAndDoubled() + { + var result = CsvRowBuilder.BuildRow(["\"quoted\""]); + + Assert.Equal("\"\"\"quoted\"\"\"", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — RFC 4180 quoting: newline characters trigger quoting + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_FieldContainsNewline_IsQuoted() + { + var result = CsvRowBuilder.BuildRow(["line1\nline2"]); + + Assert.Equal("\"line1\nline2\"", result); + } + + [Fact] + public void BuildRow_FieldContainsCarriageReturn_IsQuoted() + { + var result = CsvRowBuilder.BuildRow(["line1\rline2"]); + + Assert.Equal("\"line1\rline2\"", result); + } + + [Fact] + public void BuildRow_FieldContainsCRLF_IsQuoted() + { + var result = CsvRowBuilder.BuildRow(["line1\r\nline2"]); + + Assert.Equal("\"line1\r\nline2\"", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — bool rendering + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_BoolTrueAsString_PassesThroughAsLiteral() + { + // CsvRowBuilder.BuildRow works with string? — callers pre-convert booleans. + // "true" and "false" are plain strings with no special characters. + var result = CsvRowBuilder.BuildRow(["true"]); + Assert.Equal("true", result); + } + + [Fact] + public void BuildRow_BoolFalseAsString_PassesThroughAsLiteral() + { + var result = CsvRowBuilder.BuildRow(["false"]); + Assert.Equal("false", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — combined complex row (realistic expense row) + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_RealisticExpenseRowWithQuotedNotes_FormatsCorrectly() + { + var result = CsvRowBuilder.BuildRow(["103", "2026-03-10", "7.50", "Tyre, inner tube", "2026-03-10T12:00:00Z"]); + + Assert.Equal("103,2026-03-10,7.50,\"Tyre, inner tube\",2026-03-10T12:00:00Z", result); + } + + [Fact] + public void BuildRow_ExpenseRowWithNullNotes_BlankNotesCell() + { + var result = CsvRowBuilder.BuildRow(["102", "2026-02-03", "12.00", null, "2026-02-03T08:00:00Z"]); + + Assert.Equal("102,2026-02-03,12.00,,2026-02-03T08:00:00Z", result); + } + + // ────────────────────────────────────────────────────────────────────── + // BuildRow — single-field edge cases + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildRow_EmptySequence_ReturnsEmptyString() + { + var result = CsvRowBuilder.BuildRow([]); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void BuildRow_SingleNullField_ReturnsBlank() + { + var result = CsvRowBuilder.BuildRow([null]); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void BuildRow_SinglePlainField_ReturnsFieldAsIs() + { + var result = CsvRowBuilder.BuildRow(["hello"]); + + Assert.Equal("hello", result); + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs new file mode 100644 index 0000000..c272d15 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs @@ -0,0 +1,436 @@ +using System.Net; +using BikeTracking.Api.Application.Export; +using BikeTracking.Api.Endpoints; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BikeTracking.Api.Tests.Endpoints.Export; + +public sealed class ExpenseExportEndpointTests +{ + // ────────────────────────────────────────────────────────────────────── + // 200 OK — response headers + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_ReturnsOk() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-ok"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetExpenseExport_ReturnsCorrectContentType() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-content-type"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + + Assert.Equal("text/csv", response.Content.Headers.ContentType?.MediaType); + Assert.Equal("utf-8", response.Content.Headers.ContentType?.CharSet); + } + + [Fact] + public async Task GetExpenseExport_ReturnsCorrectContentDisposition() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-disposition"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + + var disposition = response.Content.Headers.ContentDisposition; + Assert.NotNull(disposition); + Assert.Equal("attachment", disposition.DispositionType); + Assert.Equal("expenses-export.csv", disposition.FileName); + } + + // ────────────────────────────────────────────────────────────────────── + // Header row + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_FirstLineIsHeaderRow() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-header"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.True(lines.Length >= 1); + Assert.Equal("ExpenseId,Date,Amount,Notes,CreatedAtUtc", lines[0]); + } + + // ────────────────────────────────────────────────────────────────────── + // Empty dataset — header-only CSV + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_WithNoExpenses_ReturnsHeaderOnly() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-empty"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Single(lines); + Assert.Equal("ExpenseId,Date,Amount,Notes,CreatedAtUtc", lines[0]); + } + + // ────────────────────────────────────────────────────────────────────── + // Multi-record export — data rows present + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_WithMultipleExpenses_ReturnsAllDataRows() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-multi"); + + await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 15), 49.95m, "Chain replacement", false); + await host.SeedExpenseAsync(userId, new DateTime(2026, 2, 3), 12.00m, null, false); + await host.SeedExpenseAsync(userId, new DateTime(2026, 3, 10), 7.50m, "Tyre, inner tube", false); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // header + 3 data rows + Assert.Equal(4, lines.Length); + } + + [Fact] + public async Task GetExpenseExport_DataRowContainsExpectedFields() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-fields"); + + await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 15), 49.95m, "Chain replacement", false); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(2, lines.Length); + var dataRow = lines[1]; + Assert.Contains("2026-01-15", dataRow); + Assert.Contains("49.95", dataRow); + Assert.Contains("Chain replacement", dataRow); + } + + // ────────────────────────────────────────────────────────────────────── + // RFC 4180 quoting of Notes field + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_NotesWithComma_IsRfc4180Quoted() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-quoted"); + + await host.SeedExpenseAsync(userId, new DateTime(2026, 3, 10), 7.50m, "Tyre, inner tube", false); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(2, lines.Length); + Assert.Contains("\"Tyre, inner tube\"", lines[1]); + } + + [Fact] + public async Task GetExpenseExport_NullNotes_RendersAsBlankCell() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-null-notes"); + + await host.SeedExpenseAsync(userId, new DateTime(2026, 2, 3), 12.00m, null, false); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(2, lines.Length); + // Notes cell is blank — row ends with two commas before CreatedAtUtc or empty Notes cell + var fields = SplitCsvRow(lines[1]); + Assert.Equal(string.Empty, fields[3]); // Notes is index 3 + } + + // ────────────────────────────────────────────────────────────────────── + // User-scoping — no cross-user data + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_ReturnsOnlyAuthenticatedUserExpenses() + { + await using var host = await ExportApiHost.StartAsync(); + var riderA = await host.SeedUserAsync("scope-rider-a"); + var riderB = await host.SeedUserAsync("scope-rider-b"); + + await host.SeedExpenseAsync(riderA, new DateTime(2026, 1, 1), 10m, "Rider A expense", false); + await host.SeedExpenseAsync(riderB, new DateTime(2026, 1, 2), 99m, "Rider B expense", false); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", riderA); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // header + 1 row for rider A only + Assert.Equal(2, lines.Length); + Assert.Contains("Rider A expense", body); + Assert.DoesNotContain("Rider B expense", body); + } + + // ────────────────────────────────────────────────────────────────────── + // Soft-deleted expenses must be excluded + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_ExcludesSoftDeletedExpenses() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("export-deleted"); + + await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 1), 10m, "Active expense", false); + await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 2), 20m, "Deleted expense", true); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(2, lines.Length); + Assert.Contains("Active expense", body); + Assert.DoesNotContain("Deleted expense", body); + } + + // ────────────────────────────────────────────────────────────────────── + // 401 for missing auth header + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExpenseExport_WithoutAuthHeader_Returns401() + { + await using var host = await ExportApiHost.StartAsync(); + + var response = await host.Client.GetAsync("/api/exports/expenses"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // ────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────── + + /// + /// Naïve CSV row splitter for assertion purposes (handles simple quoted fields). + /// + private static string[] SplitCsvRow(string row) + { + var fields = new List(); + var inQuotes = false; + var current = new System.Text.StringBuilder(); + + for (var i = 0; i < row.Length; i++) + { + var ch = row[i]; + + if (inQuotes) + { + if (ch == '"' && i + 1 < row.Length && row[i + 1] == '"') + { + current.Append('"'); + i++; // skip escaped quote + } + else if (ch == '"') + { + inQuotes = false; + } + else + { + current.Append(ch); + } + } + else + { + if (ch == '"') + { + inQuotes = true; + } + else if (ch == ',') + { + fields.Add(current.ToString()); + current.Clear(); + } + else + { + current.Append(ch); + } + } + } + + fields.Add(current.ToString()); + return [.. fields]; + } +} + +// ────────────────────────────────────────────────────────────────────── +// Test Host and helpers +// ────────────────────────────────────────────────────────────────────── + +internal sealed class ExportApiHost(WebApplication app) : IAsyncDisposable +{ + public WebApplication App { get; } = app; + public HttpClient Client { get; } = app.GetTestClient(); + + public static async Task StartAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var databaseName = Guid.NewGuid().ToString(); + builder.Services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName) + ); + + builder + .Services.AddAuthentication("export-test") + .AddScheme("export-test", _ => { }); + builder.Services.AddAuthorization(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapExportEndpoints(); + await app.StartAsync(); + + return new ExportApiHost(app); + } + + public async Task SeedUserAsync(string displayName) + { + using var scope = App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var user = new UserEntity + { + DisplayName = displayName, + NormalizedName = displayName.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + return user.UserId; + } + + public async Task SeedExpenseAsync( + long riderId, + DateTime expenseDate, + decimal amount, + string? notes, + bool isDeleted + ) + { + using var scope = App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Expenses.Add(new ExpenseEntity + { + RiderId = riderId, + ExpenseDate = expenseDate, + Amount = amount, + Notes = notes, + IsDeleted = isDeleted, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }); + + await dbContext.SaveChangesAsync(); + } + + public async Task SeedRideAsync( + long riderId, + DateTime rideDateTimeLocal, + decimal miles, + string? notes = null + ) + { + using var scope = App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Rides.Add(new RideEntity + { + RiderId = riderId, + RideDateTimeLocal = rideDateTimeLocal, + Miles = miles, + Notes = notes, + WeatherUserOverridden = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + }); + + await dbContext.SaveChangesAsync(); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.StopAsync(); + await App.DisposeAsync(); + } +} + +internal sealed class ExportTestAuthSchemeOptions : AuthenticationSchemeOptions; + +internal sealed class ExportTestAuthHandler( + IOptionsMonitor options, + Microsoft.Extensions.Logging.ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder +) : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + var userIdString = Request.Headers["X-User-Id"].FirstOrDefault(); + if (string.IsNullOrEmpty(userIdString)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var claims = new[] { new System.Security.Claims.Claim("sub", userIdString) }; + var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name); + var principal = new System.Security.Claims.ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} + +internal static class ExportHttpClientExtensions +{ + public static async Task GetWithExportAuthAsync( + this HttpClient client, + string requestUri, + long userId + ) + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Add("X-User-Id", userId.ToString()); + return await client.SendAsync(request); + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs new file mode 100644 index 0000000..08de415 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs @@ -0,0 +1,253 @@ +using System.IO.Compression; +using System.Net; + +namespace BikeTracking.Api.Tests.Endpoints.Export; + +public sealed class RideExportEndpointTests +{ + // ────────────────────────────────────────────────────────────────────── + // 200 OK — response headers + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_ReturnsOk() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-ok"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetRideExport_ReturnsCorrectContentType() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-ctype"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + + Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task GetRideExport_ReturnsCorrectContentDisposition() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-disposition"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + + var disposition = response.Content.Headers.ContentDisposition; + Assert.NotNull(disposition); + Assert.Equal("attachment", disposition.DispositionType); + Assert.Equal("ride-history-export.zip", disposition.FileName); + } + + // ────────────────────────────────────────────────────────────────────── + // Empty dataset — ZIP containing header-only CSV for current year + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_WithNoRides_ReturnsZipWithSingleHeaderOnlyCsv() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-empty"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + var bytes = await response.Content.ReadAsByteArrayAsync(); + + using var archive = new ZipArchive(new System.IO.MemoryStream(bytes), ZipArchiveMode.Read); + + Assert.Single(archive.Entries); + + var entry = archive.Entries[0]; + var currentYear = DateTime.UtcNow.Year.ToString(); + Assert.Equal($"{currentYear}.csv", entry.Name); + + using var reader = new System.IO.StreamReader(entry.Open()); + var content = await reader.ReadToEndAsync(); + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Only header row + Assert.Single(lines); + Assert.StartsWith("RideId,", lines[0]); + } + + // ────────────────────────────────────────────────────────────────────── + // ZIP contains one CSV per year + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_WithRidesInMultipleYears_ReturnsOneCsvPerYear() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-multi-year"); + + await host.SeedRideAsync(userId, new DateTime(2024, 6, 15), 12.5m); + await host.SeedRideAsync(userId, new DateTime(2025, 3, 1), 8.0m); + await host.SeedRideAsync(userId, new DateTime(2026, 1, 10), 10.0m); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + var bytes = await response.Content.ReadAsByteArrayAsync(); + + using var archive = new ZipArchive(new System.IO.MemoryStream(bytes), ZipArchiveMode.Read); + var entryNames = archive.Entries.Select(e => e.Name).ToHashSet(); + + Assert.Contains("2024.csv", entryNames); + Assert.Contains("2025.csv", entryNames); + Assert.Contains("2026.csv", entryNames); + Assert.Equal(3, archive.Entries.Count); + } + + // ────────────────────────────────────────────────────────────────────── + // Each CSV has correct header + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_EachYearCsvHasCorrectHeader() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-header"); + + await host.SeedRideAsync(userId, new DateTime(2025, 6, 1), 10.0m); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + var bytes = await response.Content.ReadAsByteArrayAsync(); + + using var archive = new ZipArchive(new System.IO.MemoryStream(bytes), ZipArchiveMode.Read); + var entry2025 = archive.Entries.Single(e => e.Name == "2025.csv"); + + using var reader = new System.IO.StreamReader(entry2025.Open()); + var firstLine = await reader.ReadLineAsync(); + + Assert.NotNull(firstLine); + Assert.StartsWith("RideId,Date,Miles,RideMinutes", firstLine); + Assert.EndsWith(",CreatedAtUtc", firstLine); + } + + // ────────────────────────────────────────────────────────────────────── + // Data rows grouped correctly by year + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_RidesGroupedCorrectlyByYear() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-grouping"); + + await host.SeedRideAsync(userId, new DateTime(2024, 12, 31), 5.0m, "Last day 2024"); + await host.SeedRideAsync(userId, new DateTime(2025, 1, 1), 6.0m, "First day 2025"); + await host.SeedRideAsync(userId, new DateTime(2025, 6, 15), 8.0m, "Mid 2025"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + var bytes = await response.Content.ReadAsByteArrayAsync(); + + using var archive = new ZipArchive(new System.IO.MemoryStream(bytes), ZipArchiveMode.Read); + + var csv2024 = ReadAllLinesFromEntry(archive, "2024.csv"); + var csv2025 = ReadAllLinesFromEntry(archive, "2025.csv"); + + // 2024: header + 1 row + Assert.Equal(2, csv2024.Length); + Assert.Contains("Last day 2024", csv2024[1]); + + // 2025: header + 2 rows + Assert.Equal(3, csv2025.Length); + } + + // ────────────────────────────────────────────────────────────────────── + // RFC 4180 quoting + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_NoteWithComma_IsRfc4180Quoted() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-quoted"); + + await host.SeedRideAsync(userId, new DateTime(2025, 6, 1), 10.0m, "Windy, tough ride"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + var bytes = await response.Content.ReadAsByteArrayAsync(); + + using var archive = new ZipArchive(new System.IO.MemoryStream(bytes), ZipArchiveMode.Read); + var lines = ReadAllLinesFromEntry(archive, "2025.csv"); + + Assert.Equal(2, lines.Length); + Assert.Contains("\"Windy, tough ride\"", lines[1]); + } + + [Fact] + public async Task GetRideExport_NullOptionalFields_RenderAsBlankCells() + { + await using var host = await ExportApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ride-export-null-fields"); + + // Seed a ride with only required fields; all optional fields null + await host.SeedRideAsync(userId, new DateTime(2025, 6, 1), 10.0m, null); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", userId); + var bytes = await response.Content.ReadAsByteArrayAsync(); + + using var archive = new ZipArchive(new System.IO.MemoryStream(bytes), ZipArchiveMode.Read); + var lines = ReadAllLinesFromEntry(archive, "2025.csv"); + + Assert.Equal(2, lines.Length); + // Row should contain multiple blank cells (consecutive commas) + Assert.Contains(",,", lines[1]); + } + + // ────────────────────────────────────────────────────────────────────── + // User-scoping + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_ReturnsOnlyAuthenticatedUserRides() + { + await using var host = await ExportApiHost.StartAsync(); + var riderA = await host.SeedUserAsync("ride-scope-a"); + var riderB = await host.SeedUserAsync("ride-scope-b"); + + await host.SeedRideAsync(riderA, new DateTime(2025, 1, 1), 5.0m, "Rider A ride"); + await host.SeedRideAsync(riderB, new DateTime(2025, 1, 2), 9.0m, "Rider B ride"); + + var response = await host.Client.GetWithExportAuthAsync("/api/exports/rides", riderA); + var bytes = await response.Content.ReadAsByteArrayAsync(); + + using var archive = new ZipArchive(new System.IO.MemoryStream(bytes), ZipArchiveMode.Read); + var lines = ReadAllLinesFromEntry(archive, "2025.csv"); + + // header + 1 ride for riderA only + Assert.Equal(2, lines.Length); + Assert.Contains("Rider A ride", string.Join('\n', lines)); + Assert.DoesNotContain("Rider B ride", string.Join('\n', lines)); + } + + // ────────────────────────────────────────────────────────────────────── + // 401 for missing auth header + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetRideExport_WithoutAuthHeader_Returns401() + { + await using var host = await ExportApiHost.StartAsync(); + + var response = await host.Client.GetAsync("/api/exports/rides"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // ────────────────────────────────────────────────────────────────────── + // Helper + // ────────────────────────────────────────────────────────────────────── + + private static string[] ReadAllLinesFromEntry(ZipArchive archive, string entryName) + { + var entry = archive.Entries.Single(e => e.Name == entryName); + using var reader = new System.IO.StreamReader(entry.Open()); + var content = reader.ReadToEnd(); + return content.Split('\n', StringSplitOptions.RemoveEmptyEntries); + } +} diff --git a/src/BikeTracking.Api/Application/Export/CsvRowBuilder.cs b/src/BikeTracking.Api/Application/Export/CsvRowBuilder.cs new file mode 100644 index 0000000..5471267 --- /dev/null +++ b/src/BikeTracking.Api/Application/Export/CsvRowBuilder.cs @@ -0,0 +1,78 @@ +using System.Text; + +namespace BikeTracking.Api.Application.Export; + +/// +/// Lightweight RFC 4180-compliant CSV row builder. +/// Produces a single CSV row string from a sequence of string fields. +/// +/// +/// Quoting rules: +/// +/// A field is wrapped in double-quotes when it contains a comma, double-quote, carriage return, or line feed. +/// An embedded double-quote is escaped by doubling it: """. +/// fields are rendered as an empty string (no quotes). +/// Boolean callers should pass "true" or "false" as literals before calling. +/// +/// +public static class CsvRowBuilder +{ + /// + /// Builds a comma-separated header row from the supplied column names. + /// Column names are written verbatim with no quoting. + /// + public static string BuildHeader(IEnumerable columnNames) => + string.Join(',', columnNames); + + /// + /// Builds a single RFC 4180-compliant CSV data row from the supplied fields. + /// + public static string BuildRow(IEnumerable fields) + { + var sb = new StringBuilder(); + var first = true; + + foreach (var field in fields) + { + if (!first) + { + sb.Append(','); + } + + first = false; + + if (field is null || field.Length == 0) + { + // null or empty → blank cell, no quotes needed + continue; + } + + if (NeedsQuoting(field)) + { + sb.Append('"'); + foreach (var ch in field) + { + if (ch == '"') + { + sb.Append("\"\""); + } + else + { + sb.Append(ch); + } + } + + sb.Append('"'); + } + else + { + sb.Append(field); + } + } + + return sb.ToString(); + } + + private static bool NeedsQuoting(string field) => + field.Contains(',') || field.Contains('"') || field.Contains('\r') || field.Contains('\n'); +} diff --git a/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs b/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs new file mode 100644 index 0000000..6ae0920 --- /dev/null +++ b/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs @@ -0,0 +1,51 @@ +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Application.Export; + +/// +/// Reads all non-deleted expense records for a rider and produces a UTF-8 +/// RFC 4180-compliant CSV string with header row. +/// +/// +/// Output columns: ExpenseId, Date, Amount, Notes, CreatedAtUtc +/// Filter: WHERE RiderId = @riderId AND IsDeleted = false ORDER BY ExpenseDate DESC +/// +public sealed class ExpenseCsvExportService(BikeTrackingDbContext db) +{ + private static readonly string[] Headers = + ["ExpenseId", "Date", "Amount", "Notes", "CreatedAtUtc"]; + + /// + /// Generates the full CSV content as a UTF-8 string. + /// Returns a header-only CSV when the rider has no expenses. + /// + public async Task ExportAsync(long riderId, CancellationToken cancellationToken = default) + { + var expenses = await db.Expenses + .Where(e => e.RiderId == riderId && !e.IsDeleted) + .OrderByDescending(e => e.ExpenseDate) + .ToListAsync(cancellationToken); + + var lines = new List(expenses.Count + 1) + { + CsvRowBuilder.BuildHeader(Headers) + }; + + foreach (var expense in expenses) + { + var row = CsvRowBuilder.BuildRow( + [ + expense.Id.ToString(), + expense.ExpenseDate.ToString("yyyy-MM-dd"), + expense.Amount.ToString("G29"), + expense.Notes, + expense.CreatedAtUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), + ]); + + lines.Add(row); + } + + return string.Join('\n', lines) + '\n'; + } +} diff --git a/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs b/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs new file mode 100644 index 0000000..970687a --- /dev/null +++ b/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs @@ -0,0 +1,108 @@ +using System.IO.Compression; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Application.Export; + +/// +/// Reads all ride records for a rider and produces a ZIP archive containing +/// one RFC 4180-compliant CSV file per calendar year. +/// +/// +/// Each per-year CSV filename: {year}.csv
+/// Export filter: WHERE RiderId = @riderId ORDER BY RideDateTimeLocal DESC
+/// Grouping: rides grouped by RideDateTimeLocal.Year
+/// Empty dataset: returns a ZIP containing a single header-only CSV for the current year. +///
+public sealed class RideHistoryCsvExportService(BikeTrackingDbContext db) +{ + private static readonly string[] Headers = + [ + "RideId", "Date", "Miles", "RideMinutes", "Temperature", "GasPricePerGallon", + "WindSpeedMph", "WindDirectionDeg", "RelativeHumidityPercent", "CloudCoverPercent", + "PrecipitationType", "Note", "WeatherUserOverridden", "Difficulty", + "PrimaryTravelDirection", "WindResistanceRating", "ImportSource", + "SnapshotAverageCarMpg", "SnapshotMileageRateCents", "SnapshotYearlyGoalMiles", + "SnapshotOilChangePrice", "CreatedAtUtc", + ]; + + /// + /// Generates the ride history ZIP archive and returns a sealed . + /// The caller is responsible for disposing the returned stream. + /// + public async Task ExportAsync(long riderId, CancellationToken cancellationToken = default) + { + var rides = await db.Rides + .Where(r => r.RiderId == riderId) + .OrderByDescending(r => r.RideDateTimeLocal) + .ToListAsync(cancellationToken); + + var ms = new MemoryStream(); + + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + if (rides.Count == 0) + { + // Empty dataset: write a single header-only CSV for the current year. + var currentYear = DateTime.UtcNow.Year; + WriteYearCsv(archive, currentYear, []); + } + else + { + var byYear = rides.GroupBy(r => r.RideDateTimeLocal.Year); + + foreach (var group in byYear.OrderByDescending(g => g.Key)) + { + WriteYearCsv(archive, group.Key, [.. group]); + } + } + } + + ms.Position = 0; + return ms; + } + + private static void WriteYearCsv( + ZipArchive archive, + int year, + IReadOnlyList rides + ) + { + var entry = archive.CreateEntry($"{year}.csv"); + + using var writer = new System.IO.StreamWriter(entry.Open(), System.Text.Encoding.UTF8); + + writer.WriteLine(CsvRowBuilder.BuildHeader(Headers)); + + foreach (var ride in rides) + { + var row = CsvRowBuilder.BuildRow( + [ + ride.Id.ToString(), + ride.RideDateTimeLocal.ToString("yyyy-MM-ddTHH:mm:ss"), + ride.Miles.ToString("G29"), + ride.RideMinutes?.ToString(), + ride.Temperature?.ToString("G29"), + ride.GasPricePerGallon?.ToString("G29"), + ride.WindSpeedMph?.ToString("G29"), + ride.WindDirectionDeg?.ToString(), + ride.RelativeHumidityPercent?.ToString(), + ride.CloudCoverPercent?.ToString(), + ride.PrecipitationType, + ride.Notes, + ride.WeatherUserOverridden ? "true" : "false", + ride.Difficulty?.ToString(), + ride.PrimaryTravelDirection, + ride.WindResistanceRating?.ToString(), + ride.ImportSource, + ride.SnapshotAverageCarMpg?.ToString("G29"), + ride.SnapshotMileageRateCents?.ToString("G29"), + ride.SnapshotYearlyGoalMiles?.ToString("G29"), + ride.SnapshotOilChangePrice?.ToString("G29"), + ride.CreatedAtUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), + ]); + + writer.WriteLine(row); + } + } +} diff --git a/src/BikeTracking.Api/Endpoints/ExportEndpoints.cs b/src/BikeTracking.Api/Endpoints/ExportEndpoints.cs new file mode 100644 index 0000000..f6855c8 --- /dev/null +++ b/src/BikeTracking.Api/Endpoints/ExportEndpoints.cs @@ -0,0 +1,71 @@ +using BikeTracking.Api.Application.Export; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace BikeTracking.Api.Endpoints; + +public static class ExportEndpoints +{ + public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/exports").RequireAuthorization(); + + group + .MapGet("/expenses", GetExpensesCsv) + .WithName("ExportExpensesCsv") + .WithSummary("Export all expense records for the authenticated rider as CSV") + .Produces(StatusCodes.Status200OK, "text/csv") + .Produces(StatusCodes.Status401Unauthorized); + + group + .MapGet("/rides", GetRideHistoryZip) + .WithName("ExportRideHistoryZip") + .WithSummary("Export all ride records for the authenticated rider as a per-year ZIP archive") + .Produces(StatusCodes.Status200OK, "application/zip") + .Produces(StatusCodes.Status401Unauthorized); + + return endpoints; + } + + private static async Task GetExpensesCsv( + HttpContext context, + ExpenseCsvExportService exportService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + var csv = await exportService.ExportAsync(riderId, cancellationToken); + var bytes = System.Text.Encoding.UTF8.GetBytes(csv); + + return Results.File( + bytes, + contentType: "text/csv; charset=utf-8", + fileDownloadName: "expenses-export.csv" + ); + } + + private static async Task GetRideHistoryZip( + HttpContext context, + RideHistoryCsvExportService exportService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + var stream = await exportService.ExportAsync(riderId, cancellationToken); + + return Results.File( + stream, + contentType: "application/zip", + fileDownloadName: "ride-history-export.zip" + ); + } +} diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index 41e8ae5..b66c81a 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -1,5 +1,6 @@ using BikeTracking.Api.Application.Dashboard; using BikeTracking.Api.Application.Events; +using BikeTracking.Api.Application.Export; using BikeTracking.Api.Application.ExpenseImports; using BikeTracking.Api.Application.Expenses; using BikeTracking.Api.Application.Imports; @@ -64,7 +65,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -167,6 +171,7 @@ app.MapUsersEndpoints(); app.MapRidesEndpoints(); app.MapExpensesEndpoints(); +app.MapExportEndpoints(); app.MapExpenseImportEndpoints(); app.MapImportEndpoints(); app.MapMonthlyImportEndpoints(); diff --git a/src/BikeTracking.Api/export.http b/src/BikeTracking.Api/export.http new file mode 100644 index 0000000..90e320f --- /dev/null +++ b/src/BikeTracking.Api/export.http @@ -0,0 +1,16 @@ +@ApiService_HostAddress = http://localhost:5436 +@RiderId = 1 + +### Export expenses as CSV +### Downloads all expense records for the authenticated rider as expenses-export.csv +GET {{ApiService_HostAddress}}/api/exports/expenses +X-User-Id: {{RiderId}} + +### + +### Export ride history as ZIP +### Downloads all ride records as a per-year ZIP archive: ride-history-export.zip +GET {{ApiService_HostAddress}}/api/exports/rides +X-User-Id: {{RiderId}} + +### diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx index 849aab4..dcf32f1 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { SettingsPage } from './SettingsPage' import * as usersApi from '../../services/users-api' import * as ridesService from '../../services/ridesService' +import * as exportApi from '../../services/export-api' import { disposePwaBootstrap } from '../../services/pwa/bootstrap' vi.mock('../../services/users-api', () => ({ @@ -20,10 +21,17 @@ vi.mock('../../services/ridesService', () => ({ deleteRidePreset: vi.fn(), })) +vi.mock('../../services/export-api', () => ({ + fetchExpensesCsv: vi.fn(), + fetchRideHistoryZip: vi.fn(), +})) + const mockGetUserSettings = vi.mocked(usersApi.getUserSettings) const mockSaveUserSettings = vi.mocked(usersApi.saveUserSettings) const mockGetRidePresets = vi.mocked(ridesService.getRidePresets) const mockCreateRidePreset = vi.mocked(ridesService.createRidePreset) +const mockFetchExpensesCsv = vi.mocked(exportApi.fetchExpensesCsv) +const mockFetchRideHistoryZip = vi.mocked(exportApi.fetchRideHistoryZip) const mockUpdateRidePreset = vi.mocked(ridesService.updateRidePreset) const mockDeleteRidePreset = vi.mocked(ridesService.deleteRidePreset) @@ -767,4 +775,132 @@ describe('SettingsPage', () => { ) }) }) + + // ───────────────────────────────────────────────────────────────────────── + // Export Expenses button (US1) + // ───────────────────────────────────────────────────────────────────────── + + it('renders an Export Expenses button', async () => { + mockGetUserSettings.mockResolvedValue(buildDefaultSettings()) + mockGetRidePresets.mockResolvedValue({ presets: [], generatedAtUtc: '2026-01-01T00:00:00Z' }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /export expenses/i })).toBeInTheDocument() + }) + }) + + it('calls fetchExpensesCsv when Export Expenses button is clicked', async () => { + mockGetUserSettings.mockResolvedValue(buildDefaultSettings()) + mockGetRidePresets.mockResolvedValue({ presets: [], generatedAtUtc: '2026-01-01T00:00:00Z' }) + mockFetchExpensesCsv.mockResolvedValue(undefined) + + render( + + + + ) + + const exportBtn = await screen.findByRole('button', { name: /export expenses/i }) + fireEvent.click(exportBtn) + + await waitFor(() => { + expect(mockFetchExpensesCsv).toHaveBeenCalledTimes(1) + }) + }) + + // ───────────────────────────────────────────────────────────────────────── + // Export Ride History button (US2) + // ───────────────────────────────────────────────────────────────────────── + + it('renders an Export Ride History button', async () => { + mockGetUserSettings.mockResolvedValue(buildDefaultSettings()) + mockGetRidePresets.mockResolvedValue({ presets: [], generatedAtUtc: '2026-01-01T00:00:00Z' }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /export ride history/i })).toBeInTheDocument() + }) + }) + + it('calls fetchRideHistoryZip when Export Ride History button is clicked', async () => { + mockGetUserSettings.mockResolvedValue(buildDefaultSettings()) + mockGetRidePresets.mockResolvedValue({ presets: [], generatedAtUtc: '2026-01-01T00:00:00Z' }) + mockFetchRideHistoryZip.mockResolvedValue(undefined) + + render( + + + + ) + + const exportBtn = await screen.findByRole('button', { name: /export ride history/i }) + fireEvent.click(exportBtn) + + await waitFor(() => { + expect(mockFetchRideHistoryZip).toHaveBeenCalledTimes(1) + }) + }) + + it('Export Expenses and Export Ride History buttons operate independently', async () => { + mockGetUserSettings.mockResolvedValue(buildDefaultSettings()) + mockGetRidePresets.mockResolvedValue({ presets: [], generatedAtUtc: '2026-01-01T00:00:00Z' }) + mockFetchExpensesCsv.mockResolvedValue(undefined) + mockFetchRideHistoryZip.mockResolvedValue(undefined) + + render( + + + + ) + + const expensesBtn = await screen.findByRole('button', { name: /export expenses/i }) + const ridesBtn = await screen.findByRole('button', { name: /export ride history/i }) + + fireEvent.click(expensesBtn) + + await waitFor(() => { + expect(mockFetchExpensesCsv).toHaveBeenCalledTimes(1) + expect(mockFetchRideHistoryZip).not.toHaveBeenCalled() + }) + + fireEvent.click(ridesBtn) + + await waitFor(() => { + expect(mockFetchRideHistoryZip).toHaveBeenCalledTimes(1) + expect(mockFetchExpensesCsv).toHaveBeenCalledTimes(1) // still only 1 + }) + }) }) + +// ───────────────────────────────────────────────────────────────────────── +// Shared helpers +// ───────────────────────────────────────────────────────────────────────── + +function buildDefaultSettings() { + return { + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + dashboardGallonsAvoidedEnabled: true, + dashboardGoalProgressEnabled: true, + weatherApiKey: null, + eiaGasApiKey: null, + }, + } +} diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx index 3874852..1c2ec09 100644 --- a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx @@ -18,6 +18,7 @@ import { } from '../../services/ridesService' import { PERIOD_TAG_DEFAULT_DIRECTIONS } from '../../services/ridesService' import { getPwaSnapshot, promptPwaInstall, subscribePwaSnapshot } from '../../services/pwa/bootstrap' +import { fetchExpensesCsv, fetchRideHistoryZip } from '../../services/export-api' import './SettingsPage.css' interface SettingsFormSnapshot { @@ -99,6 +100,10 @@ export function SettingsPage() { const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [success, setSuccess] = useState('') + const [exportingExpenses, setExportingExpenses] = useState(false) + const [exportExpensesError, setExportExpensesError] = useState('') + const [exportingRides, setExportingRides] = useState(false) + const [exportRidesError, setExportRidesError] = useState('') const [ridePresets, setRidePresets] = useState([]) const [editingPresetId, setEditingPresetId] = useState(null) const [presetName, setPresetName] = useState('') @@ -392,6 +397,50 @@ export function SettingsPage() {

+
+

Export Data

+
+ + {exportExpensesError && ( +

+ {exportExpensesError} +

+ )} + + {exportRidesError && ( +

+ {exportRidesError} +

+ )} +
+
+

Install App

diff --git a/src/BikeTracking.Frontend/src/services/export-api.ts b/src/BikeTracking.Frontend/src/services/export-api.ts new file mode 100644 index 0000000..0f2d540 --- /dev/null +++ b/src/BikeTracking.Frontend/src/services/export-api.ts @@ -0,0 +1,82 @@ +import { getApiBaseUrl } from "./api-config"; + +const SESSION_KEY = "bike_tracking_auth_session"; + +function getAuthHeaders(): Record { + const headers: Record = {}; + + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) { + return headers; + } + + const parsed = JSON.parse(raw) as { userId?: number }; + if (typeof parsed.userId === "number" && parsed.userId > 0) { + headers["X-User-Id"] = parsed.userId.toString(); + } + } catch { + // Ignore malformed session payloads and continue unauthenticated. + } + + return headers; +} + +/** + * Downloads all expense records for the authenticated rider as a UTF-8 CSV file. + * Mirrors the existing `downloadExpenseReceipt` blob-download pattern. + */ +export async function fetchExpensesCsv(): Promise { + const response = await fetch(`${getApiBaseUrl()}/api/exports/expenses`, { + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Expense export failed: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = "expenses-export.csv"; + document.body.append(link); + link.click(); + link.remove(); + + // Revoke the object URL after a short delay to allow the download to start. + setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 100); +} + +/** + * Downloads all ride records for the authenticated rider as a ZIP archive + * containing one CSV per calendar year. + * Mirrors the existing `downloadExpenseReceipt` blob-download pattern. + */ +export async function fetchRideHistoryZip(): Promise { + const response = await fetch(`${getApiBaseUrl()}/api/exports/rides`, { + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Ride history export failed: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = "ride-history-export.zip"; + document.body.append(link); + link.click(); + link.remove(); + + // Revoke the object URL after a short delay to allow the download to start. + setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 100); +} diff --git a/src/BikeTracking.Frontend/tests/e2e/export.spec.ts b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts new file mode 100644 index 0000000..4622920 --- /dev/null +++ b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts @@ -0,0 +1,222 @@ +import { expect, test } from "@playwright/test"; +import { createAndLoginUser, uniqueUser } from "./support/auth-helpers"; +import { recordExpense } from "./support/expense-helpers"; +import { recordRide } from "./support/ride-helpers"; + +const TEST_PIN = "87654321"; + +test.describe("028-csv-data-export e2e", () => { + // ───────────────────────────────────────────────────────────────────────── + // Scenario 1: Expense CSV export + // ───────────────────────────────────────────────────────────────────────── + + test("Scenario 1: expense CSV downloads with correct filename, header row, and data row", async ({ + page, + }) => { + const userName = uniqueUser("e2e-export-expense"); + await createAndLoginUser(page, userName, TEST_PIN); + + await recordExpense(page, { + expenseDate: "2026-01-15", + amount: "49.95", + note: "Chain replacement", + }); + + await page.goto("/settings"); + + // Intercept the download + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /export expenses/i }).click(), + ]); + + expect(download.suggestedFilename()).toBe("expenses-export.csv"); + + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const content = Buffer.concat(chunks).toString("utf-8"); + const lines = content.split("\n").filter((l) => l.trim().length > 0); + + // Header row + expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc"); + + // At least one data row + expect(lines.length).toBeGreaterThanOrEqual(2); + + // Data row contains the expense data + expect(lines.slice(1).join("\n")).toContain("Chain replacement"); + expect(lines.slice(1).join("\n")).toContain("2026-01-15"); + }); + + test("Scenario 1 (empty dataset): expense CSV with no expenses returns header-only", async ({ + page, + }) => { + const userName = uniqueUser("e2e-export-expense-empty"); + await createAndLoginUser(page, userName, TEST_PIN); + + await page.goto("/settings"); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /export expenses/i }).click(), + ]); + + expect(download.suggestedFilename()).toBe("expenses-export.csv"); + + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const content = Buffer.concat(chunks).toString("utf-8"); + const lines = content.split("\n").filter((l) => l.trim().length > 0); + + expect(lines).toHaveLength(1); + expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc"); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario 2: Ride history ZIP export + // ───────────────────────────────────────────────────────────────────────── + + test("Scenario 2: ride history ZIP downloads with correct filename", async ({ + page, + }) => { + const userName = uniqueUser("e2e-export-ride"); + await createAndLoginUser(page, userName, TEST_PIN); + + await recordRide(page, { + rideDateTimeLocal: "2026-01-15T07:30", + miles: "12.5", + }); + + await page.goto("/settings"); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /export ride history/i }).click(), + ]); + + expect(download.suggestedFilename()).toBe("ride-history-export.zip"); + }); + + test("Scenario 2 (empty dataset): ride ZIP with no rides returns ZIP file", async ({ + page, + }) => { + const userName = uniqueUser("e2e-export-ride-empty"); + await createAndLoginUser(page, userName, TEST_PIN); + + await page.goto("/settings"); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /export ride history/i }).click(), + ]); + + expect(download.suggestedFilename()).toBe("ride-history-export.zip"); + // Verify the response came back (file has non-zero size) + const path = await download.path(); + expect(path).toBeTruthy(); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario 3: User isolation + // ───────────────────────────────────────────────────────────────────────── + + test("Scenario 3: expense export is scoped to the authenticated user only", async ({ + page, + browser, + }) => { + const userA = uniqueUser("e2e-export-isolation-a"); + const userB = uniqueUser("e2e-export-isolation-b"); + + // User A records an expense + await createAndLoginUser(page, userA, TEST_PIN); + await recordExpense(page, { + expenseDate: "2026-03-01", + amount: "25.00", + note: "User A only expense", + }); + + await page.goto("/settings"); + const [downloadA] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /export expenses/i }).click(), + ]); + + const streamA = await downloadA.createReadStream(); + const chunksA: Buffer[] = []; + for await (const chunk of streamA) { + chunksA.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const contentA = Buffer.concat(chunksA).toString("utf-8"); + + expect(contentA).toContain("User A only expense"); + + // User B session (separate context) + const contextB = await browser.newContext(); + const pageB = await contextB.newPage(); + await createAndLoginUser(pageB, userB, TEST_PIN); + + await pageB.goto("/settings"); + const [downloadB] = await Promise.all([ + pageB.waitForEvent("download"), + pageB.getByRole("button", { name: /export expenses/i }).click(), + ]); + + const streamB = await downloadB.createReadStream(); + const chunksB: Buffer[] = []; + for await (const chunk of streamB) { + chunksB.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const contentB = Buffer.concat(chunksB).toString("utf-8"); + + // User B's export must NOT contain User A's data + expect(contentB).not.toContain("User A only expense"); + + await contextB.close(); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario 4: Independent button operation + // ───────────────────────────────────────────────────────────────────────── + + test("Scenario 4: Export Expenses and Export Ride History buttons operate independently", async ({ + page, + }) => { + const userName = uniqueUser("e2e-export-independent"); + await createAndLoginUser(page, userName, TEST_PIN); + + await page.goto("/settings"); + + // Both buttons should be visible + await expect( + page.getByRole("button", { name: /export expenses/i }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /export ride history/i }), + ).toBeVisible(); + + // Click Expenses only — ride history button should remain enabled + const [expensesDownload] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /export expenses/i }).click(), + ]); + expect(expensesDownload.suggestedFilename()).toBe("expenses-export.csv"); + + // Ride history button is still clickable + await expect( + page.getByRole("button", { name: /export ride history/i }), + ).toBeEnabled(); + + // Click Ride History independently + const [ridesDownload] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /export ride history/i }).click(), + ]); + expect(ridesDownload.suggestedFilename()).toBe("ride-history-export.zip"); + }); +}); From ec25a9e2708adb82e8612cf995a720c26f49214b Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 28 Jul 2026 16:18:09 +0000 Subject: [PATCH 3/4] don't include ids --- .../contracts/export-endpoints.md | 16 ++- .../Export/ExpenseExportEndpointTests.cs | 100 ++++++++++++------ .../Export/RideExportEndpointTests.cs | 4 +- .../Export/ExpenseCsvExportService.cs | 23 ++-- .../Export/RideHistoryCsvExportService.cs | 40 ++++--- 5 files changed, 117 insertions(+), 66 deletions(-) diff --git a/specs/028-csv-data-export/contracts/export-endpoints.md b/specs/028-csv-data-export/contracts/export-endpoints.md index cde30e5..c52c197 100644 --- a/specs/028-csv-data-export/contracts/export-endpoints.md +++ b/specs/028-csv-data-export/contracts/export-endpoints.md @@ -32,16 +32,15 @@ Content-Disposition: attachment; filename="expenses-export.csv" **Body**: UTF-8 CSV file with BOM-free encoding. ``` -ExpenseId,Date,Amount,Notes,CreatedAtUtc -101,2026-01-15,49.95,Chain replacement,2026-01-15T10:23:00Z -102,2026-02-03,12.00,,2026-02-03T08:00:00Z -103,2026-03-10,7.50,"Tyre, inner tube",2026-03-10T12:00:00Z +Date,Amount,Notes,CreatedAtUtc +2026-01-15,49.95,Chain replacement,2026-01-15T10:23:00Z +2026-02-03,12.00,,2026-02-03T08:00:00Z +2026-03-10,7.50,"Tyre, inner tube",2026-03-10T12:00:00Z ``` **Column definitions**: | Column | Format | Nullable | |--------------|-------------------------------|----------| -| `ExpenseId` | Integer | No | | `Date` | `yyyy-MM-dd` | No | | `Amount` | Decimal (no currency symbol) | No | | `Notes` | String, RFC 4180 quoted | Yes (blank) | @@ -93,15 +92,14 @@ ride-history-export.zip Each per-year CSV format: ``` -RideId,Date,Miles,RideMinutes,Temperature,GasPricePerGallon,WindSpeedMph,WindDirectionDeg,RelativeHumidityPercent,CloudCoverPercent,PrecipitationType,Note,WeatherUserOverridden,Difficulty,PrimaryTravelDirection,WindResistanceRating,ImportSource,SnapshotAverageCarMpg,SnapshotMileageRateCents,SnapshotYearlyGoalMiles,SnapshotOilChangePrice,CreatedAtUtc -1,2025-06-15T07:30:00,12.5,45,68.0,3.459,8.2,45,55,10,,Morning commute,false,3,NE,2,,25.0,6700,2000,79.00,2025-06-15T12:35:00Z -2,2025-06-16T07:28:00,12.5,43,71.0,,,,,,,,"Windy, tough ride",false,5,North,4,,,,,2025-06-16T12:30:00Z +Date,Miles,RideMinutes,Temperature,GasPricePerGallon,WindSpeedMph,WindDirectionDeg,RelativeHumidityPercent,CloudCoverPercent,PrecipitationType,Note,WeatherUserOverridden,Difficulty,PrimaryTravelDirection,WindResistanceRating,ImportSource,SnapshotAverageCarMpg,SnapshotMileageRateCents,SnapshotYearlyGoalMiles,SnapshotOilChangePrice,CreatedAtUtc +2025-06-15T07:30:00,12.5,45,68.0,3.459,8.2,45,55,10,,Morning commute,false,3,NE,2,,25.0,6700,2000,79.00,2025-06-15T12:35:00Z +2025-06-16T07:28:00,12.5,43,71.0,,,,,,,,"Windy, tough ride",false,5,North,4,,,,,2025-06-16T12:30:00Z ``` **Column definitions**: | Column | Format | Nullable | |---------------------------|--------------------------------|---------------| -| `RideId` | Integer | No | | `Date` | `yyyy-MM-ddTHH:mm:ss` | No | | `Miles` | Decimal | No | | `RideMinutes` | Integer | Yes (blank) | diff --git a/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs index c272d15..47dd1f4 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs @@ -68,7 +68,7 @@ public async Task GetExpenseExport_FirstLineIsHeaderRow() var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.True(lines.Length >= 1); - Assert.Equal("ExpenseId,Date,Amount,Notes,CreatedAtUtc", lines[0]); + Assert.Equal("Date,Amount,Notes,CreatedAtUtc", lines[0]); } // ────────────────────────────────────────────────────────────────────── @@ -86,7 +86,7 @@ public async Task GetExpenseExport_WithNoExpenses_ReturnsHeaderOnly() var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Single(lines); - Assert.Equal("ExpenseId,Date,Amount,Notes,CreatedAtUtc", lines[0]); + Assert.Equal("Date,Amount,Notes,CreatedAtUtc", lines[0]); } // ────────────────────────────────────────────────────────────────────── @@ -99,9 +99,21 @@ public async Task GetExpenseExport_WithMultipleExpenses_ReturnsAllDataRows() await using var host = await ExportApiHost.StartAsync(); var userId = await host.SeedUserAsync("export-multi"); - await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 15), 49.95m, "Chain replacement", false); + await host.SeedExpenseAsync( + userId, + new DateTime(2026, 1, 15), + 49.95m, + "Chain replacement", + false + ); await host.SeedExpenseAsync(userId, new DateTime(2026, 2, 3), 12.00m, null, false); - await host.SeedExpenseAsync(userId, new DateTime(2026, 3, 10), 7.50m, "Tyre, inner tube", false); + await host.SeedExpenseAsync( + userId, + new DateTime(2026, 3, 10), + 7.50m, + "Tyre, inner tube", + false + ); var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); var body = await response.Content.ReadAsStringAsync(); @@ -117,7 +129,13 @@ public async Task GetExpenseExport_DataRowContainsExpectedFields() await using var host = await ExportApiHost.StartAsync(); var userId = await host.SeedUserAsync("export-fields"); - await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 15), 49.95m, "Chain replacement", false); + await host.SeedExpenseAsync( + userId, + new DateTime(2026, 1, 15), + 49.95m, + "Chain replacement", + false + ); var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); var body = await response.Content.ReadAsStringAsync(); @@ -140,7 +158,13 @@ public async Task GetExpenseExport_NotesWithComma_IsRfc4180Quoted() await using var host = await ExportApiHost.StartAsync(); var userId = await host.SeedUserAsync("export-quoted"); - await host.SeedExpenseAsync(userId, new DateTime(2026, 3, 10), 7.50m, "Tyre, inner tube", false); + await host.SeedExpenseAsync( + userId, + new DateTime(2026, 3, 10), + 7.50m, + "Tyre, inner tube", + false + ); var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId); var body = await response.Content.ReadAsStringAsync(); @@ -165,7 +189,7 @@ public async Task GetExpenseExport_NullNotes_RendersAsBlankCell() Assert.Equal(2, lines.Length); // Notes cell is blank — row ends with two commas before CreatedAtUtc or empty Notes cell var fields = SplitCsvRow(lines[1]); - Assert.Equal(string.Empty, fields[3]); // Notes is index 3 + Assert.Equal(string.Empty, fields[2]); // Notes is index 2 } // ────────────────────────────────────────────────────────────────────── @@ -179,8 +203,20 @@ public async Task GetExpenseExport_ReturnsOnlyAuthenticatedUserExpenses() var riderA = await host.SeedUserAsync("scope-rider-a"); var riderB = await host.SeedUserAsync("scope-rider-b"); - await host.SeedExpenseAsync(riderA, new DateTime(2026, 1, 1), 10m, "Rider A expense", false); - await host.SeedExpenseAsync(riderB, new DateTime(2026, 1, 2), 99m, "Rider B expense", false); + await host.SeedExpenseAsync( + riderA, + new DateTime(2026, 1, 1), + 10m, + "Rider A expense", + false + ); + await host.SeedExpenseAsync( + riderB, + new DateTime(2026, 1, 2), + 99m, + "Rider B expense", + false + ); var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", riderA); var body = await response.Content.ReadAsStringAsync(); @@ -349,17 +385,19 @@ bool isDeleted using var scope = App.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Expenses.Add(new ExpenseEntity - { - RiderId = riderId, - ExpenseDate = expenseDate, - Amount = amount, - Notes = notes, - IsDeleted = isDeleted, - Version = 1, - CreatedAtUtc = DateTime.UtcNow, - UpdatedAtUtc = DateTime.UtcNow, - }); + dbContext.Expenses.Add( + new ExpenseEntity + { + RiderId = riderId, + ExpenseDate = expenseDate, + Amount = amount, + Notes = notes, + IsDeleted = isDeleted, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + } + ); await dbContext.SaveChangesAsync(); } @@ -374,16 +412,18 @@ public async Task SeedRideAsync( using var scope = App.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Rides.Add(new RideEntity - { - RiderId = riderId, - RideDateTimeLocal = rideDateTimeLocal, - Miles = miles, - Notes = notes, - WeatherUserOverridden = false, - Version = 1, - CreatedAtUtc = DateTime.UtcNow, - }); + dbContext.Rides.Add( + new RideEntity + { + RiderId = riderId, + RideDateTimeLocal = rideDateTimeLocal, + Miles = miles, + Notes = notes, + WeatherUserOverridden = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + } + ); await dbContext.SaveChangesAsync(); } diff --git a/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs index 08de415..5effad3 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs @@ -72,7 +72,7 @@ public async Task GetRideExport_WithNoRides_ReturnsZipWithSingleHeaderOnlyCsv() // Only header row Assert.Single(lines); - Assert.StartsWith("RideId,", lines[0]); + Assert.StartsWith("Date,", lines[0]); } // ────────────────────────────────────────────────────────────────────── @@ -123,7 +123,7 @@ public async Task GetRideExport_EachYearCsvHasCorrectHeader() var firstLine = await reader.ReadLineAsync(); Assert.NotNull(firstLine); - Assert.StartsWith("RideId,Date,Miles,RideMinutes", firstLine); + Assert.StartsWith("Date,Miles,RideMinutes", firstLine); Assert.EndsWith(",CreatedAtUtc", firstLine); } diff --git a/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs b/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs index 6ae0920..c238b01 100644 --- a/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs +++ b/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs @@ -8,35 +8,32 @@ namespace BikeTracking.Api.Application.Export; /// RFC 4180-compliant CSV string with header row. /// /// -/// Output columns: ExpenseId, Date, Amount, Notes, CreatedAtUtc +/// Output columns: Date, Amount, Notes, CreatedAtUtc /// Filter: WHERE RiderId = @riderId AND IsDeleted = false ORDER BY ExpenseDate DESC /// public sealed class ExpenseCsvExportService(BikeTrackingDbContext db) { - private static readonly string[] Headers = - ["ExpenseId", "Date", "Amount", "Notes", "CreatedAtUtc"]; + private static readonly string[] Headers = ["Date", "Amount", "Notes", "CreatedAtUtc"]; ///

/// Generates the full CSV content as a UTF-8 string. /// Returns a header-only CSV when the rider has no expenses. /// - public async Task ExportAsync(long riderId, CancellationToken cancellationToken = default) + public async Task ExportAsync( + long riderId, + CancellationToken cancellationToken = default + ) { - var expenses = await db.Expenses - .Where(e => e.RiderId == riderId && !e.IsDeleted) + var expenses = await db + .Expenses.Where(e => e.RiderId == riderId && !e.IsDeleted) .OrderByDescending(e => e.ExpenseDate) .ToListAsync(cancellationToken); - var lines = new List(expenses.Count + 1) - { - CsvRowBuilder.BuildHeader(Headers) - }; + var lines = new List(expenses.Count + 1) { CsvRowBuilder.BuildHeader(Headers) }; foreach (var expense in expenses) { - var row = CsvRowBuilder.BuildRow( - [ - expense.Id.ToString(), + var row = CsvRowBuilder.BuildRow([ expense.ExpenseDate.ToString("yyyy-MM-dd"), expense.Amount.ToString("G29"), expense.Notes, diff --git a/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs b/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs index 970687a..e1c926e 100644 --- a/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs +++ b/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs @@ -18,22 +18,40 @@ public sealed class RideHistoryCsvExportService(BikeTrackingDbContext db) { private static readonly string[] Headers = [ - "RideId", "Date", "Miles", "RideMinutes", "Temperature", "GasPricePerGallon", - "WindSpeedMph", "WindDirectionDeg", "RelativeHumidityPercent", "CloudCoverPercent", - "PrecipitationType", "Note", "WeatherUserOverridden", "Difficulty", - "PrimaryTravelDirection", "WindResistanceRating", "ImportSource", - "SnapshotAverageCarMpg", "SnapshotMileageRateCents", "SnapshotYearlyGoalMiles", - "SnapshotOilChangePrice", "CreatedAtUtc", + "Date", + "Miles", + "RideMinutes", + "Temperature", + "GasPricePerGallon", + "WindSpeedMph", + "WindDirectionDeg", + "RelativeHumidityPercent", + "CloudCoverPercent", + "PrecipitationType", + "Note", + "WeatherUserOverridden", + "Difficulty", + "PrimaryTravelDirection", + "WindResistanceRating", + "ImportSource", + "SnapshotAverageCarMpg", + "SnapshotMileageRateCents", + "SnapshotYearlyGoalMiles", + "SnapshotOilChangePrice", + "CreatedAtUtc", ]; /// /// Generates the ride history ZIP archive and returns a sealed . /// The caller is responsible for disposing the returned stream. /// - public async Task ExportAsync(long riderId, CancellationToken cancellationToken = default) + public async Task ExportAsync( + long riderId, + CancellationToken cancellationToken = default + ) { - var rides = await db.Rides - .Where(r => r.RiderId == riderId) + var rides = await db + .Rides.Where(r => r.RiderId == riderId) .OrderByDescending(r => r.RideDateTimeLocal) .ToListAsync(cancellationToken); @@ -76,9 +94,7 @@ private static void WriteYearCsv( foreach (var ride in rides) { - var row = CsvRowBuilder.BuildRow( - [ - ride.Id.ToString(), + var row = CsvRowBuilder.BuildRow([ ride.RideDateTimeLocal.ToString("yyyy-MM-ddTHH:mm:ss"), ride.Miles.ToString("G29"), ride.RideMinutes?.ToString(), From d350eca1a26e0800467b3313317b5066201620f6 Mon Sep 17 00:00:00 2001 From: aligneddev Date: Tue, 28 Jul 2026 16:40:13 +0000 Subject: [PATCH 4/4] fix the tests --- src/BikeTracking.Frontend/tests/e2e/export.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BikeTracking.Frontend/tests/e2e/export.spec.ts b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts index 4622920..f9ccedc 100644 --- a/src/BikeTracking.Frontend/tests/e2e/export.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts @@ -41,7 +41,7 @@ test.describe("028-csv-data-export e2e", () => { const lines = content.split("\n").filter((l) => l.trim().length > 0); // Header row - expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc"); + expect(lines[0]).toBe("Date,Amount,Notes,CreatedAtUtc"); // At least one data row expect(lines.length).toBeGreaterThanOrEqual(2); @@ -75,7 +75,7 @@ test.describe("028-csv-data-export e2e", () => { const lines = content.split("\n").filter((l) => l.trim().length > 0); expect(lines).toHaveLength(1); - expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc"); + expect(lines[0]).toBe("Date,Amount,Notes,CreatedAtUtc"); }); // ─────────────────────────────────────────────────────────────────────────