diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f43921d..e7aa99b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,13 +186,65 @@ jobs: path: src/BikeTracking.Frontend/src-tauri/target/release/bundle/deb/BikeTracking_*_amd64.deb retention-days: 1 + # --------------------------------------------------------------------------- + # Job 3b: API smoke test — verify .NET API starts and /health responds + # --------------------------------------------------------------------------- + smoke-test-api: + name: API smoke test + runs-on: ubuntu-latest + needs: [build-frontend] + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET from global.json + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Restore .NET dependencies + run: dotnet restore BikeTracking.slnx + + - name: Publish API self-contained + run: | + dotnet publish src/BikeTracking.Api/BikeTracking.Api.csproj \ + --configuration Release \ + --self-contained false \ + --output /tmp/api-publish \ + --no-restore + + - name: Start API and verify /health + run: | + ASPNETCORE_URLS="http://localhost:5436" \ + ASPNETCORE_ENVIRONMENT="Production" \ + /tmp/api-publish/BikeTracking.Api & + API_PID=$! + echo "API PID: $API_PID" + + # Wait up to 30s for /health to respond 200 + for i in $(seq 1 30); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5436/health || true) + if [ "$STATUS" = "200" ]; then + echo "API healthy after ${i}s" + kill $API_PID + exit 0 + fi + sleep 1 + done + + echo "ERROR: API did not become healthy within 30s" + kill $API_PID 2>/dev/null || true + exit 1 + # --------------------------------------------------------------------------- # Job 4: Publish GitHub Release (skipped when dry_run: true) # --------------------------------------------------------------------------- publish-release: name: Publish GitHub Release runs-on: ubuntu-latest - needs: [package-linux, package-windows] + needs: [package-linux, package-windows, smoke-test-api] if: ${{ !inputs.dry_run }} timeout-minutes: 10 diff --git a/src/BikeTracking.Frontend/src-tauri/tauri.conf.json b/src/BikeTracking.Frontend/src-tauri/tauri.conf.json index f0053ea..924ab60 100644 --- a/src/BikeTracking.Frontend/src-tauri/tauri.conf.json +++ b/src/BikeTracking.Frontend/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' http://localhost:5079 tauri://localhost" + "csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* tauri://localhost" } }, "bundle": { diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx index 30b64b8..a61080d 100644 --- a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx @@ -28,7 +28,7 @@ describe('DashboardPage', () => { ) - expect(screen.getByText(/total expenses/i)).toBeInTheDocument() + expect(screen.getByText(/total expenses/i, { selector: 'span' })).toBeInTheDocument() }) it('renders oil change savings label when available', async () => { @@ -41,6 +41,6 @@ describe('DashboardPage', () => { ) - expect(screen.getByText(/oil change savings/i)).toBeInTheDocument() + expect(screen.getByText(/oil change savings/i, { selector: 'span' })).toBeInTheDocument() }) }) \ No newline at end of file diff --git a/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts b/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts index 44c9713..69b99e3 100644 --- a/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts +++ b/src/BikeTracking.Frontend/src/services/advanced-dashboard-api.ts @@ -1,3 +1,4 @@ +import { getApiBaseUrl } from "./api-config"; /** Savings metrics for a single calendar window (weekly, monthly, yearly, or all-time). */ export interface AdvancedSavingsWindow { /** Window identifier matching the backend period key. */ @@ -88,11 +89,7 @@ export interface AdvancedDashboardResponse { difficultySection: AdvancedDashboardDifficultySection | null; } -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; + const SESSION_KEY = "bike_tracking_auth_session"; @@ -119,7 +116,7 @@ function getAuthHeaders(): Record { /** Fetches the advanced statistics dashboard for the authenticated user. */ export async function getAdvancedDashboard(): Promise { - const response = await fetch(`${API_BASE_URL}/api/dashboard/advanced`, { + const response = await fetch(`${getApiBaseUrl()}/api/dashboard/advanced`, { method: "GET", headers: getAuthHeaders(), }); diff --git a/src/BikeTracking.Frontend/src/services/api-config.test.ts b/src/BikeTracking.Frontend/src/services/api-config.test.ts new file mode 100644 index 0000000..8d563a8 --- /dev/null +++ b/src/BikeTracking.Frontend/src/services/api-config.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getApiBaseUrl } from "./api-config"; + +describe("getApiBaseUrl", () => { + beforeEach(() => { + // Clear any previous Tauri injection + delete (window as { __BIKE_API_URL__?: string }).__BIKE_API_URL__; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + delete (window as { __BIKE_API_URL__?: string }).__BIKE_API_URL__; + }); + + it("returns window.__BIKE_API_URL__ when set (Tauri runtime injection)", () => { + (window as { __BIKE_API_URL__?: string }).__BIKE_API_URL__ = + "http://localhost:5079"; + + expect(getApiBaseUrl()).toBe("http://localhost:5079"); + }); + + it("strips trailing slash from window.__BIKE_API_URL__", () => { + (window as { __BIKE_API_URL__?: string }).__BIKE_API_URL__ = + "http://localhost:5079/"; + + expect(getApiBaseUrl()).toBe("http://localhost:5079"); + }); + + it("window.__BIKE_API_URL__ takes priority over VITE_API_BASE_URL", () => { + (window as { __BIKE_API_URL__?: string }).__BIKE_API_URL__ = + "http://localhost:5079"; + vi.stubEnv("VITE_API_BASE_URL", "http://localhost:9999"); + + expect(getApiBaseUrl()).toBe("http://localhost:5079"); + }); + + it("falls back to VITE_API_BASE_URL when window.__BIKE_API_URL__ absent", () => { + vi.stubEnv("VITE_API_BASE_URL", "http://localhost:9999"); + + expect(getApiBaseUrl()).toBe("http://localhost:9999"); + }); + + it("falls back to localhost:5436 when neither override is set", () => { + // No window.__BIKE_API_URL__ (cleared in beforeEach), no VITE_API_BASE_URL (not defined in test env) + expect(getApiBaseUrl()).toBe("http://localhost:5436"); + }); + + it("supports custom host from Tauri app.conf.json (non-localhost)", () => { + (window as { __BIKE_API_URL__?: string }).__BIKE_API_URL__ = + "http://192.168.1.10:5079"; + + expect(getApiBaseUrl()).toBe("http://192.168.1.10:5079"); + }); +}); diff --git a/src/BikeTracking.Frontend/src/services/api-config.ts b/src/BikeTracking.Frontend/src/services/api-config.ts new file mode 100644 index 0000000..fcfa861 --- /dev/null +++ b/src/BikeTracking.Frontend/src/services/api-config.ts @@ -0,0 +1,26 @@ +declare global { + interface Window { + __BIKE_API_URL__?: string; + } +} + +/** + * Returns the API base URL, resolved lazily at call time so that the + * Tauri runtime injection (`window.__BIKE_API_URL__`) is always visible + * regardless of when this module was first imported. + * + * Priority: + * 1. window.__BIKE_API_URL__ — injected by Tauri at startup from app.conf.json + * 2. import.meta.env.VITE_API_BASE_URL — Vite build-time override (dev/CI) + * 3. http://localhost:5436 — local dev fallback + */ +export function getApiBaseUrl(): string { + return ( + window.__BIKE_API_URL__?.replace(/\/$/, "") ?? + (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( + /\/$/, + "", + ) ?? + "http://localhost:5436" + ); +} diff --git a/src/BikeTracking.Frontend/src/services/dashboard-api.ts b/src/BikeTracking.Frontend/src/services/dashboard-api.ts index bab1e68..941a473 100644 --- a/src/BikeTracking.Frontend/src/services/dashboard-api.ts +++ b/src/BikeTracking.Frontend/src/services/dashboard-api.ts @@ -1,3 +1,4 @@ +import { getApiBaseUrl } from "./api-config"; export interface DashboardMileageMetric { miles: number; rideCount: number; @@ -68,11 +69,7 @@ export interface DashboardResponse { generatedAtUtc: string; } -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; + const SESSION_KEY = "bike_tracking_auth_session"; function getAuthHeaders(): Record { @@ -98,7 +95,7 @@ function getAuthHeaders(): Record { } export async function getDashboard(): Promise { - const response = await fetch(`${API_BASE_URL}/api/dashboard`, { + const response = await fetch(`${getApiBaseUrl()}/api/dashboard`, { method: "GET", headers: getAuthHeaders(), }); diff --git a/src/BikeTracking.Frontend/src/services/expense-import-api.ts b/src/BikeTracking.Frontend/src/services/expense-import-api.ts index 7ef0ff2..17e6660 100644 --- a/src/BikeTracking.Frontend/src/services/expense-import-api.ts +++ b/src/BikeTracking.Frontend/src/services/expense-import-api.ts @@ -1,10 +1,7 @@ import type { ApiResult, ErrorResponse } from "./users-api"; +import { getApiBaseUrl } from "./api-config"; + -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; const SESSION_KEY = "bike_tracking_auth_session"; @@ -110,7 +107,7 @@ export async function previewExpenseImport( const formData = new FormData(); formData.append("file", request.file); - const response = await fetch(`${API_BASE_URL}/api/expense-imports/preview`, { + const response = await fetch(`${getApiBaseUrl()}/api/expense-imports/preview`, { method: "POST", headers: getAuthHeaders(false), body: formData, @@ -136,7 +133,7 @@ export async function confirmExpenseImport( request: ConfirmExpenseImportRequest, ): Promise> { const response = await fetch( - `${API_BASE_URL}/api/expense-imports/${jobId}/confirm`, + `${getApiBaseUrl()}/api/expense-imports/${jobId}/confirm`, { method: "POST", headers: getAuthHeaders(true), @@ -163,7 +160,7 @@ export async function getExpenseImportStatus( jobId: number, ): Promise> { const response = await fetch( - `${API_BASE_URL}/api/expense-imports/${jobId}/status`, + `${getApiBaseUrl()}/api/expense-imports/${jobId}/status`, { headers: getAuthHeaders(false), }, @@ -187,7 +184,7 @@ export async function getExpenseImportStatus( export async function deleteExpenseImport( jobId: number, ): Promise> { - const response = await fetch(`${API_BASE_URL}/api/expense-imports/${jobId}`, { + const response = await fetch(`${getApiBaseUrl()}/api/expense-imports/${jobId}`, { method: "DELETE", headers: getAuthHeaders(false), }); diff --git a/src/BikeTracking.Frontend/src/services/expenses-api.ts b/src/BikeTracking.Frontend/src/services/expenses-api.ts index d3996a8..d34cdf5 100644 --- a/src/BikeTracking.Frontend/src/services/expenses-api.ts +++ b/src/BikeTracking.Frontend/src/services/expenses-api.ts @@ -1,10 +1,7 @@ import type { ApiResult, ErrorResponse } from "./users-api"; +import { getApiBaseUrl } from "./api-config"; + -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; const SESSION_KEY = "bike_tracking_auth_session"; export interface RecordExpenseResponse { @@ -117,14 +114,14 @@ function getAuthenticatedReceiptQuery(): string { } export function getExpenseReceiptUrl(expenseId: number): string { - return `${API_BASE_URL}/api/expenses/${expenseId}/receipt${getAuthenticatedReceiptQuery()}`; + return `${getApiBaseUrl()}/api/expenses/${expenseId}/receipt${getAuthenticatedReceiptQuery()}`; } export async function downloadExpenseReceipt( expenseId: number, ): Promise> { const response = await fetch( - `${API_BASE_URL}/api/expenses/${expenseId}/receipt`, + `${getApiBaseUrl()}/api/expenses/${expenseId}/receipt`, { headers: getAuthHeaders(), }, @@ -173,7 +170,7 @@ export async function getExpenseHistory( if (endDate) params.set("endDate", endDate); const qs = params.size > 0 ? `?${params.toString()}` : ""; - const response = await fetch(`${API_BASE_URL}/api/expenses${qs}`, { + const response = await fetch(`${getApiBaseUrl()}/api/expenses${qs}`, { headers: getAuthHeaders(), }); @@ -195,7 +192,7 @@ export async function editExpense( expenseId: number, request: EditExpenseRequest, ): Promise> { - const response = await fetch(`${API_BASE_URL}/api/expenses/${expenseId}`, { + const response = await fetch(`${getApiBaseUrl()}/api/expenses/${expenseId}`, { method: "PUT", headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -218,7 +215,7 @@ export async function editExpense( export async function deleteExpense( expenseId: number, ): Promise> { - const response = await fetch(`${API_BASE_URL}/api/expenses/${expenseId}`, { + const response = await fetch(`${getApiBaseUrl()}/api/expenses/${expenseId}`, { method: "DELETE", headers: getAuthHeaders(), }); @@ -244,7 +241,7 @@ export async function uploadReceipt( formData.append("receipt", file); const response = await fetch( - `${API_BASE_URL}/api/expenses/${expenseId}/receipt`, + `${getApiBaseUrl()}/api/expenses/${expenseId}/receipt`, { method: "PUT", headers: getAuthHeaders(), @@ -269,7 +266,7 @@ export async function deleteReceipt( expenseId: number, ): Promise> { const response = await fetch( - `${API_BASE_URL}/api/expenses/${expenseId}/receipt`, + `${getApiBaseUrl()}/api/expenses/${expenseId}/receipt`, { method: "DELETE", headers: getAuthHeaders(), @@ -292,7 +289,7 @@ export async function deleteReceipt( export async function recordExpense( formData: FormData, ): Promise> { - const response = await fetch(`${API_BASE_URL}/api/expenses`, { + const response = await fetch(`${getApiBaseUrl()}/api/expenses`, { method: "POST", headers: getAuthHeaders(), body: formData, diff --git a/src/BikeTracking.Frontend/src/services/import-api.ts b/src/BikeTracking.Frontend/src/services/import-api.ts index c08423b..076e1a9 100644 --- a/src/BikeTracking.Frontend/src/services/import-api.ts +++ b/src/BikeTracking.Frontend/src/services/import-api.ts @@ -1,10 +1,7 @@ import { type ApiResult, type ErrorResponse } from "./users-api"; +import { getApiBaseUrl } from "./api-config"; + -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; const SESSION_KEY = "bike_tracking_auth_session"; @@ -119,7 +116,7 @@ async function postJson( path: string, payload: unknown, ): Promise> { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { method: "POST", headers: getAuthHeaders(true), body: JSON.stringify(payload), @@ -148,7 +145,7 @@ async function postJson( } async function getJson(path: string): Promise> { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { headers: getAuthHeaders(false), }); diff --git a/src/BikeTracking.Frontend/src/services/import-progress-realtime.ts b/src/BikeTracking.Frontend/src/services/import-progress-realtime.ts index 46a5339..9bd3561 100644 --- a/src/BikeTracking.Frontend/src/services/import-progress-realtime.ts +++ b/src/BikeTracking.Frontend/src/services/import-progress-realtime.ts @@ -3,12 +3,9 @@ import { HubConnectionState, LogLevel, } from "@microsoft/signalr"; +import { getApiBaseUrl } from "./api-config"; + -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; const SESSION_KEY = "bike_tracking_auth_session"; @@ -67,7 +64,7 @@ export async function subscribeToImportProgress( const connection = new HubConnectionBuilder() .withUrl( - `${API_BASE_URL}/hubs/import-progress?access_token=${tokenQuery}`, + `${getApiBaseUrl()}/hubs/import-progress?access_token=${tokenQuery}`, { headers: userId ? { "X-User-Id": userId } : {}, accessTokenFactory: () => userId ?? "", diff --git a/src/BikeTracking.Frontend/src/services/ridesService.ts b/src/BikeTracking.Frontend/src/services/ridesService.ts index 938bd2c..159f99f 100644 --- a/src/BikeTracking.Frontend/src/services/ridesService.ts +++ b/src/BikeTracking.Frontend/src/services/ridesService.ts @@ -1,3 +1,4 @@ +import { getApiBaseUrl } from "./api-config"; export type CompassDirection = | "North" | "NE" @@ -212,11 +213,7 @@ export interface RideHistoryResponse { totalRows: number; } -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; + const SESSION_KEY = "bike_tracking_auth_session"; const SESSION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; @@ -285,7 +282,7 @@ async function parseErrorMessage( export async function recordRide( request: RecordRideRequest, ): Promise { - const response = await fetch(`${API_BASE_URL}/api/rides`, { + const response = await fetch(`${getApiBaseUrl()}/api/rides`, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify(request), @@ -300,7 +297,7 @@ export async function recordRide( export async function getGasPrice(date: string): Promise { const response = await fetch( - `${API_BASE_URL}/api/rides/gas-price?date=${encodeURIComponent(date)}`, + `${getApiBaseUrl()}/api/rides/gas-price?date=${encodeURIComponent(date)}`, { method: "GET", headers: getAuthHeaders(), @@ -320,7 +317,7 @@ export async function getRideWeather( rideDateTimeLocal: string, ): Promise { const response = await fetch( - `${API_BASE_URL}/api/rides/weather?rideDateTimeLocal=${encodeURIComponent(rideDateTimeLocal)}`, + `${getApiBaseUrl()}/api/rides/weather?rideDateTimeLocal=${encodeURIComponent(rideDateTimeLocal)}`, { method: "GET", headers: getAuthHeaders(), @@ -337,7 +334,7 @@ export async function getRideWeather( } export async function getRidePresets(): Promise { - const response = await fetch(`${API_BASE_URL}/api/rides/presets`, { + const response = await fetch(`${getApiBaseUrl()}/api/rides/presets`, { method: "GET", headers: getAuthHeaders(), }); @@ -354,7 +351,7 @@ export async function getRidePresets(): Promise { export async function createRidePreset( request: UpsertRidePresetRequest, ): Promise { - const response = await fetch(`${API_BASE_URL}/api/rides/presets`, { + const response = await fetch(`${getApiBaseUrl()}/api/rides/presets`, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify(request), @@ -374,7 +371,7 @@ export async function updateRidePreset( request: UpsertRidePresetRequest, ): Promise { const response = await fetch( - `${API_BASE_URL}/api/rides/presets/${presetId}`, + `${getApiBaseUrl()}/api/rides/presets/${presetId}`, { method: "PUT", headers: getAuthHeaders(), @@ -395,7 +392,7 @@ export async function deleteRidePreset( presetId: number, ): Promise { const response = await fetch( - `${API_BASE_URL}/api/rides/presets/${presetId}`, + `${getApiBaseUrl()}/api/rides/presets/${presetId}`, { method: "DELETE", headers: getAuthHeaders(), @@ -415,7 +412,7 @@ export async function editRide( rideId: number, request: EditRideRequest, ): Promise { - const response = await fetch(`${API_BASE_URL}/api/rides/${rideId}`, { + const response = await fetch(`${getApiBaseUrl()}/api/rides/${rideId}`, { method: "PUT", headers: getAuthHeaders(), body: JSON.stringify(request), @@ -464,7 +461,7 @@ export async function editRide( } export async function deleteRide(rideId: number): Promise { - const response = await fetch(`${API_BASE_URL}/api/rides/${rideId}`, { + const response = await fetch(`${getApiBaseUrl()}/api/rides/${rideId}`, { method: "DELETE", headers: getAuthHeaders(), }); @@ -543,8 +540,8 @@ export async function getRideHistory( ): Promise { const queryString = serializeRideHistoryParams(params); const url = queryString - ? `${API_BASE_URL}/api/rides/history?${queryString}` - : `${API_BASE_URL}/api/rides/history`; + ? `${getApiBaseUrl()}/api/rides/history?${queryString}` + : `${getApiBaseUrl()}/api/rides/history`; const response = await fetch(url, { method: "GET", diff --git a/src/BikeTracking.Frontend/src/services/users-api.ts b/src/BikeTracking.Frontend/src/services/users-api.ts index 3d5299a..d9af886 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.ts @@ -1,8 +1,4 @@ -const API_BASE_URL = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", - ) ?? "http://localhost:5436"; +import { getApiBaseUrl } from "./api-config"; const SESSION_KEY = "bike_tracking_auth_session"; const SESSION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; @@ -135,7 +131,7 @@ async function postJson( path: string, payload: unknown, ): Promise> { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { method: "POST", headers: { "Content-Type": "application/json", @@ -176,7 +172,7 @@ async function postJson( async function getJson( path: string, ): Promise> { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { headers: getAuthHeaders(false), }); @@ -207,7 +203,7 @@ async function putJson( path: string, payload: unknown, ): Promise> { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { method: "PUT", headers: getAuthHeaders(true), body: JSON.stringify(payload),