Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/BikeTracking.Frontend/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('DashboardPage', () => {
</BrowserRouter>
)

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 () => {
Expand All @@ -41,6 +41,6 @@ describe('DashboardPage', () => {
</BrowserRouter>
)

expect(screen.getByText(/oil change savings/i)).toBeInTheDocument()
expect(screen.getByText(/oil change savings/i, { selector: 'span' })).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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";

Expand All @@ -119,7 +116,7 @@ function getAuthHeaders(): Record<string, string> {

/** Fetches the advanced statistics dashboard for the authenticated user. */
export async function getAdvancedDashboard(): Promise<AdvancedDashboardResponse> {
const response = await fetch(`${API_BASE_URL}/api/dashboard/advanced`, {
const response = await fetch(`${getApiBaseUrl()}/api/dashboard/advanced`, {
method: "GET",
headers: getAuthHeaders(),
});
Expand Down
54 changes: 54 additions & 0 deletions src/BikeTracking.Frontend/src/services/api-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
26 changes: 26 additions & 0 deletions src/BikeTracking.Frontend/src/services/api-config.ts
Original file line number Diff line number Diff line change
@@ -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"
);
}
9 changes: 3 additions & 6 deletions src/BikeTracking.Frontend/src/services/dashboard-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getApiBaseUrl } from "./api-config";
export interface DashboardMileageMetric {
miles: number;
rideCount: number;
Expand Down Expand Up @@ -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<string, string> {
Expand All @@ -98,7 +95,7 @@ function getAuthHeaders(): Record<string, string> {
}

export async function getDashboard(): Promise<DashboardResponse> {
const response = await fetch(`${API_BASE_URL}/api/dashboard`, {
const response = await fetch(`${getApiBaseUrl()}/api/dashboard`, {
method: "GET",
headers: getAuthHeaders(),
});
Expand Down
15 changes: 6 additions & 9 deletions src/BikeTracking.Frontend/src/services/expense-import-api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -136,7 +133,7 @@ export async function confirmExpenseImport(
request: ConfirmExpenseImportRequest,
): Promise<ApiResult<ExpenseImportSummaryResponse, ErrorResponse>> {
const response = await fetch(
`${API_BASE_URL}/api/expense-imports/${jobId}/confirm`,
`${getApiBaseUrl()}/api/expense-imports/${jobId}/confirm`,
{
method: "POST",
headers: getAuthHeaders(true),
Expand All @@ -163,7 +160,7 @@ export async function getExpenseImportStatus(
jobId: number,
): Promise<ApiResult<ExpenseImportStatusResponse, ErrorResponse>> {
const response = await fetch(
`${API_BASE_URL}/api/expense-imports/${jobId}/status`,
`${getApiBaseUrl()}/api/expense-imports/${jobId}/status`,
{
headers: getAuthHeaders(false),
},
Expand All @@ -187,7 +184,7 @@ export async function getExpenseImportStatus(
export async function deleteExpenseImport(
jobId: number,
): Promise<ApiResult<void, ErrorResponse>> {
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),
});
Expand Down
23 changes: 10 additions & 13 deletions src/BikeTracking.Frontend/src/services/expenses-api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<ApiResult<{ fileName: string }, ErrorResponse>> {
const response = await fetch(
`${API_BASE_URL}/api/expenses/${expenseId}/receipt`,
`${getApiBaseUrl()}/api/expenses/${expenseId}/receipt`,
{
headers: getAuthHeaders(),
},
Expand Down Expand Up @@ -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(),
});

Expand All @@ -195,7 +192,7 @@ export async function editExpense(
expenseId: number,
request: EditExpenseRequest,
): Promise<ApiResult<EditExpenseResponse, ErrorResponse>> {
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),
Expand All @@ -218,7 +215,7 @@ export async function editExpense(
export async function deleteExpense(
expenseId: number,
): Promise<ApiResult<DeleteExpenseResponse, ErrorResponse>> {
const response = await fetch(`${API_BASE_URL}/api/expenses/${expenseId}`, {
const response = await fetch(`${getApiBaseUrl()}/api/expenses/${expenseId}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
Expand All @@ -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(),
Expand All @@ -269,7 +266,7 @@ export async function deleteReceipt(
expenseId: number,
): Promise<ApiResult<undefined, ErrorResponse>> {
const response = await fetch(
`${API_BASE_URL}/api/expenses/${expenseId}/receipt`,
`${getApiBaseUrl()}/api/expenses/${expenseId}/receipt`,
{
method: "DELETE",
headers: getAuthHeaders(),
Expand All @@ -292,7 +289,7 @@ export async function deleteReceipt(
export async function recordExpense(
formData: FormData,
): Promise<ApiResult<RecordExpenseResponse, ErrorResponse>> {
const response = await fetch(`${API_BASE_URL}/api/expenses`, {
const response = await fetch(`${getApiBaseUrl()}/api/expenses`, {
method: "POST",
headers: getAuthHeaders(),
body: formData,
Expand Down
11 changes: 4 additions & 7 deletions src/BikeTracking.Frontend/src/services/import-api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -119,7 +116,7 @@ async function postJson<TSuccess>(
path: string,
payload: unknown,
): Promise<ApiResult<TSuccess>> {
const response = await fetch(`${API_BASE_URL}${path}`, {
const response = await fetch(`${getApiBaseUrl()}${path}`, {
method: "POST",
headers: getAuthHeaders(true),
body: JSON.stringify(payload),
Expand Down Expand Up @@ -148,7 +145,7 @@ async function postJson<TSuccess>(
}

async function getJson<TSuccess>(path: string): Promise<ApiResult<TSuccess>> {
const response = await fetch(`${API_BASE_URL}${path}`, {
const response = await fetch(`${getApiBaseUrl()}${path}`, {
headers: getAuthHeaders(false),
});

Expand Down
Loading
Loading