Skip to content

Commit 6fc31c2

Browse files
author
aligneddev
committed
can record rides, all tests are passing
1 parent 5690dbf commit 6fc31c2

7 files changed

Lines changed: 186 additions & 14 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System.Security.Claims;
2+
using System.Text.Encodings.Web;
3+
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.Extensions.Options;
5+
6+
namespace BikeTracking.Api.Infrastructure.Security;
7+
8+
public sealed class UserIdHeaderAuthenticationSchemeOptions : AuthenticationSchemeOptions { }
9+
10+
public sealed class UserIdHeaderAuthenticationHandler
11+
: AuthenticationHandler<UserIdHeaderAuthenticationSchemeOptions>
12+
{
13+
public const string SchemeName = "UserIdHeader";
14+
public const string UserIdHeaderName = "X-User-Id";
15+
16+
public UserIdHeaderAuthenticationHandler(
17+
IOptionsMonitor<UserIdHeaderAuthenticationSchemeOptions> options,
18+
ILoggerFactory logger,
19+
UrlEncoder encoder
20+
)
21+
: base(options, logger, encoder) { }
22+
23+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
24+
{
25+
var userIdString = Request.Headers[UserIdHeaderName].FirstOrDefault();
26+
if (string.IsNullOrWhiteSpace(userIdString))
27+
{
28+
return Task.FromResult(AuthenticateResult.NoResult());
29+
}
30+
31+
if (!long.TryParse(userIdString, out var userId) || userId <= 0)
32+
{
33+
return Task.FromResult(AuthenticateResult.Fail("Invalid X-User-Id header."));
34+
}
35+
36+
var claims = new[] { new Claim("sub", userId.ToString()) };
37+
var identity = new ClaimsIdentity(claims, SchemeName);
38+
var principal = new ClaimsPrincipal(identity);
39+
var ticket = new AuthenticationTicket(principal, SchemeName);
40+
41+
return Task.FromResult(AuthenticateResult.Success(ticket));
42+
}
43+
}

src/BikeTracking.Api/Program.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
builder.Services.AddScoped<SignupService>();
2626
builder.Services.AddScoped<IdentifyService>();
2727

28+
builder
29+
.Services.AddAuthentication(UserIdHeaderAuthenticationHandler.SchemeName)
30+
.AddScheme<UserIdHeaderAuthenticationSchemeOptions, UserIdHeaderAuthenticationHandler>(
31+
UserIdHeaderAuthenticationHandler.SchemeName,
32+
_ => { }
33+
);
34+
builder.Services.AddAuthorization();
35+
2836
builder.Services.AddScoped<RecordRideService>();
2937
builder.Services.AddScoped<GetRideDefaultsService>();
3038

@@ -70,6 +78,8 @@
7078
app.MapGet("/", () => Results.Ok(new { message = "Bike Tracking API is running." }));
7179
app.UseCors();
7280
app.UseHttpLogging();
81+
app.UseAuthentication();
82+
app.UseAuthorization();
7383
app.MapUsersEndpoints();
7484
app.MapRidesEndpoints();
7585
app.MapDefaultEndpoints();

src/BikeTracking.Frontend/playwright-report/index.html

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/BikeTracking.Frontend/src/components/app-header/app-header.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
color: #0f172a;
5555
}
5656

57-
.nav-link--active {
57+
.nav-link-active {
5858
background: #eff6ff;
5959
color: #1d4ed8;
6060
}

src/BikeTracking.Frontend/src/components/app-header/app-header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ export function AppHeader() {
1616
<NavLink
1717
to="/miles"
1818
className={({ isActive }) =>
19-
isActive ? 'nav-link nav-link--active' : 'nav-link'
19+
isActive ? 'nav-link nav-link-active' : 'nav-link'
2020
}
2121
>
2222
Dashboard
2323
</NavLink>
2424
<NavLink
2525
to="/rides/record"
2626
className={({ isActive }) =>
27-
isActive ? 'nav-link nav-link--active' : 'nav-link'
27+
isActive ? 'nav-link nav-link-active' : 'nav-link'
2828
}
2929
>
3030
Record Ride

src/BikeTracking.Frontend/src/services/ridesService.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,35 +20,77 @@ export interface RideDefaultsResponse {
2020
defaultRideDateTimeLocal: string;
2121
}
2222

23-
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
23+
const API_BASE_URL =
24+
(import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(
25+
/\/$/,
26+
"",
27+
) ?? "http://localhost:5436";
28+
const SESSION_KEY = "bike_tracking_auth_session";
29+
30+
function getAuthHeaders(): Record<string, string> {
31+
const headers: Record<string, string> = {
32+
"Content-Type": "application/json",
33+
};
34+
35+
try {
36+
const raw = sessionStorage.getItem(SESSION_KEY);
37+
if (!raw) {
38+
return headers;
39+
}
40+
41+
const parsed = JSON.parse(raw) as { userId?: number };
42+
if (typeof parsed.userId === "number" && parsed.userId > 0) {
43+
headers["X-User-Id"] = parsed.userId.toString();
44+
}
45+
} catch {
46+
// Ignore malformed session payloads and continue unauthenticated.
47+
}
48+
49+
return headers;
50+
}
51+
52+
async function parseErrorMessage(
53+
response: Response,
54+
fallback: string,
55+
): Promise<string> {
56+
try {
57+
const payload = (await response.json()) as { message?: string };
58+
if (payload.message && payload.message.length > 0) {
59+
return payload.message;
60+
}
61+
} catch {
62+
// Response was not JSON.
63+
}
64+
65+
return fallback;
66+
}
2467

2568
export async function recordRide(
2669
request: RecordRideRequest,
2770
): Promise<RecordRideSuccessResponse> {
28-
const response = await fetch(`${API_BASE}/rides`, {
71+
const response = await fetch(`${API_BASE_URL}/api/rides`, {
2972
method: "POST",
30-
headers: { "Content-Type": "application/json" },
73+
headers: getAuthHeaders(),
3174
body: JSON.stringify(request),
32-
credentials: "include",
3375
});
3476

3577
if (!response.ok) {
36-
const error = await response.json();
37-
throw new Error(error.message || "Failed to record ride");
78+
throw new Error(await parseErrorMessage(response, "Failed to record ride"));
3879
}
3980

4081
return response.json();
4182
}
4283

4384
export async function getRideDefaults(): Promise<RideDefaultsResponse> {
44-
const response = await fetch(`${API_BASE}/rides/defaults`, {
85+
const response = await fetch(`${API_BASE_URL}/api/rides/defaults`, {
4586
method: "GET",
46-
headers: { "Content-Type": "application/json" },
47-
credentials: "include",
87+
headers: getAuthHeaders(),
4888
});
4989

5090
if (!response.ok) {
51-
throw new Error("Failed to fetch ride defaults");
91+
throw new Error(
92+
await parseErrorMessage(response, "Failed to fetch ride defaults"),
93+
);
5294
}
5395

5496
return response.json();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
const TEST_PIN = "87654321";
4+
5+
function uniqueUser(prefix: string): string {
6+
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
7+
}
8+
9+
function toDateTimeLocalValue(date: Date): string {
10+
const pad = (value: number): string => value.toString().padStart(2, "0");
11+
12+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
13+
date.getDate(),
14+
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
15+
}
16+
17+
async function createAndLoginUser(
18+
page: Page,
19+
userName: string,
20+
pin: string,
21+
): Promise<void> {
22+
await page.goto("/signup");
23+
await page.getByLabel("Name").fill(userName);
24+
await page.getByLabel("PIN").fill(pin);
25+
await page.getByRole("button", { name: "Create account" }).click();
26+
27+
await expect(page).toHaveURL("/login");
28+
await page.getByLabel("Name").fill(userName);
29+
await page.getByLabel("PIN").fill(pin);
30+
await page.getByRole("button", { name: "Log in" }).click();
31+
await expect(page).toHaveURL("/miles");
32+
}
33+
34+
test.describe("004-record-ride e2e", () => {
35+
test("records a ride from the record page", async ({ page }) => {
36+
const userName = uniqueUser("e2e-record-ride");
37+
await createAndLoginUser(page, userName, TEST_PIN);
38+
39+
await page.getByRole("link", { name: "Record Ride" }).click();
40+
await expect(page).toHaveURL("/rides/record");
41+
42+
await page
43+
.getByLabel(/date & time/i)
44+
.fill(toDateTimeLocalValue(new Date()));
45+
await page.getByLabel(/miles/i).fill("12.34");
46+
await page.getByLabel(/duration/i).fill("41");
47+
await page.getByLabel(/temperature/i).fill("68");
48+
await page.getByRole("button", { name: "Record Ride" }).click();
49+
50+
await expect(page.getByText(/ride recorded successfully/i)).toBeVisible();
51+
});
52+
53+
test("prefills defaults from the previous ride", async ({ page }) => {
54+
const userName = uniqueUser("e2e-ride-defaults");
55+
await createAndLoginUser(page, userName, TEST_PIN);
56+
57+
await page.goto("/rides/record");
58+
await expect(page).toHaveURL("/rides/record");
59+
60+
await page
61+
.getByLabel(/date & time/i)
62+
.fill(toDateTimeLocalValue(new Date()));
63+
await page.getByLabel(/miles/i).fill("9.75");
64+
await page.getByLabel(/duration/i).fill("35");
65+
await page.getByLabel(/temperature/i).fill("61");
66+
await page.getByRole("button", { name: "Record Ride" }).click();
67+
await expect(page.getByText(/ride recorded successfully/i)).toBeVisible();
68+
69+
await page.goto("/miles");
70+
await page.getByRole("link", { name: "Record Ride" }).click();
71+
await expect(page).toHaveURL("/rides/record");
72+
73+
await expect(page.getByLabel(/miles/i)).toHaveValue("9.75");
74+
await expect(page.getByLabel(/duration/i)).toHaveValue("35");
75+
await expect(page.getByLabel(/temperature/i)).toHaveValue("61");
76+
});
77+
});

0 commit comments

Comments
 (0)