Skip to content

Commit 65a14ac

Browse files
author
aligneddev
committed
Unit and UI Tests
1 parent 84124ef commit 65a14ac

25 files changed

Lines changed: 3065 additions & 133 deletions

.specify/memory/constitution.md

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# Bike Tracking Application Constitution
2-
<!-- Sync Impact Report v1.10.1
3-
Rationale: Clarified the local deployment approach for end-user machines by standardizing SQLite local-file storage as the default profile and documenting safety expectations for storage path and upgrades.
2+
<!-- Sync Impact Report v1.10.2
3+
Rationale: Codified a mandatory post-change verification command matrix so every change runs explicit checks before merge.
44
Modified Sections:
5-
- Mission > Decision Record: Added rationale for SQLite local-file default on user machines
6-
- Technology Stack Requirements > Data & Persistence: Clarified local database default and user-machine storage profile
7-
- Guardrails: Added explicit local SQLite file safety rule for user-machine installs
8-
Status: Approved — local user-machine deployments default to SQLite local-file storage for simplicity and offline reliability
5+
- Development Workflow: Added mandatory post-change verification matrix and command requirements
6+
- Definition of Done: Requires matrix execution evidence for changed scope
7+
- Compliance Audit Checklist: Requires matrix execution and recorded evidence
8+
- Guardrails: Added non-negotiable requirement to run matrix commands after every change
9+
Status: Approved — impacted-layer checks are mandatory after any change; auth/cross-layer changes also require E2E verification
910
Previous Updates:
11+
- v1.10.1: Clarified the local deployment approach for end-user machines by standardizing SQLite local-file storage as the default profile and documenting safety expectations for storage path and upgrades.
1012
- v1.10: Added an explicit engineering mindset for small-batch experimentation, continuous learning, complexity management, mandatory change validation, and proactive security teaching/remediation.
1113
- v1.9: Replaced Blazor WebAssembly frontend direction with Aurelia 2. Updated Principle V and all frontend-related sections for consistency. Added an explicit rule to always reference official Aurelia documentation at https://docs.aurelia.io/.
1214
- v1.8: Scoped Aspire Dashboard to local development only; removed cloud Aspire Dashboard requirement. Clarified local-first deployment priority with Azure as a future target. Strengthened public GitHub repository secret safety guidance.
@@ -168,12 +170,30 @@ Example: "User records a bike ride" slice includes:
168170
- Background function listening to CES to update RideProjection
169171
- Aspire AppHost configuration for frontend + API + database orchestration; Azure CLI deployment scripts for Static Web Apps (frontend) and Container Apps (API)
170172

171-
run `csharpier format .` to enforce code formatting (document in readme.md, `dotnet tool install csharpier -g` is needed); run `dotnet format .` (built-in .NET tool) uses .editorconfig for formatting rules and supports more granular control (e.g., namespace matching, file-scoped namespaces).
173+
Run `csharpier format .` to enforce code formatting (`dotnet tool install csharpier -g` is required). Run `dotnet format .` for additional .editorconfig-driven diagnostics.
172174

173-
Best Practice: Use CSharpier for consistent formatting and dotnet format for linting and code style enforcement (e.g., dotnet_diagnostic.IDE0130.severity=error in .editorconfig).
174-
run Typescript linting and formatting via `npm run lint` and `npm run format` in the frontend directory.
175+
Best Practice: Use CSharpier for consistent formatting and dotnet format for linting/code-style enforcement (for example, dotnet_diagnostic.IDE0130.severity=error).
175176

176-
Test` to run tests; `dotnet run --project src/BikeTracking.AppHost` to start local stack; GitHub Actions for CI/CD to Azure.
177+
Run TypeScript linting and formatting via `npm run lint` and `npm run format` in the frontend directory.
178+
179+
Use `dotnet run --project src/BikeTracking.AppHost` to start the local stack; use GitHub Actions for CI/CD to Azure.
180+
181+
### Post-Change Verification Matrix (Mandatory After Any Change)
182+
183+
After **every** code change, run verification commands based on the changed scope. These checks are required before merge and before phase transitions.
184+
185+
1. **Frontend-only changes** (React/TypeScript/CSS, frontend config):
186+
- `cd src/BikeTracking.Frontend`
187+
- `npm run lint`
188+
- `npm run build`
189+
- `npm run test:unit`
190+
2. **Backend/domain-only changes** (API, F#, persistence, .NET configuration):
191+
- `dotnet test`
192+
3. **Authentication/login/cross-layer changes** (routes, auth context, identify endpoint/service, contracts, frontend+backend touches):
193+
- Run **all impacted-layer commands** above
194+
- Additionally run `cd src/BikeTracking.Frontend && npm run test:e2e`
195+
196+
Evidence from these command runs (terminal output or CI artifacts) must be attached to the work item or PR notes.
177197

178198

179199
### Vertical Slice Implementation Strategy: Minimal-First Approach
@@ -206,6 +226,7 @@ A vertical slice is **production-ready** only when all items are verified:
206226
- [ ] Implementation complete; all tests passing (green + refactor phases)
207227
- [ ] Code review: architecture compliance verified, naming conventions followed, validation discipline observed
208228
- [ ] Change validation complete: compile succeeds, coding standards checks pass, automated behavior tests pass
229+
- [ ] Post-change verification matrix executed for the impacted scope and evidence recorded
209230
- [ ] Feature branch deployed locally via `dotnet run` (entire Aspire stack: frontend, API, database)
210231
- [ ] Integration tests pass; manual E2E test via Playwright (if critical user journey)
211232
- [ ] All validation layers implemented: client-side (React validation), API (DTO DataAnnotations), database (constraints)
@@ -313,6 +334,7 @@ Tests suggested by agent must receive explicit user approval before implementati
313334
- [ ] Data validation implemented at three layers: client (React), API (Minimal API), database (constraints)
314335
- [ ] Test coverage for domain logic ≥85%; F# discriminated unions and ROP patterns tested
315336
- [ ] Every change validated: compile/build, coding standards, automated tests, and pipeline deployment checks
337+
- [ ] Post-change verification matrix executed for the changed scope (frontend, backend/domain, or auth/cross-layer) with evidence captured
316338
- [ ] Security issues recognized, explained, and remediated (or explicitly accepted by user)
317339
- [ ] All SAMPLE_/DEMO_ data removed from code before merge
318340
- [ ] Secrets NOT committed; `.gitignore` verified; pre-commit hook prevents credential leakage
@@ -348,6 +370,7 @@ Breaking these guarantees causes architectural decay and technical debt accrual:
348370
- **Event schema is append-only** — never mutate existing events. If schema changes needed, create new event type and version old events. Immutability is non-negotiable.
349371
- **F# domain types must marshal through EF Core value converters** — no raw EF entities exposed to C# API layer. C# records serve as API DTOs; converters handle F#-to-C# translation.
350372
- **Tests must pass before merge** — no exceptions, no "fix later" debt. CI/CD pipeline blocks merge if test suite fails.
373+
- **Post-change verification matrix must run after any change** — no change is complete without executing required commands for impacted scope; auth/cross-layer changes also require `npm run test:e2e`.
351374
- **Three-layer validation enforced** — if field validated in React form, also validated in API DTOs and database constraints. No single-layer validation.
352375
- **OAuth token required on all user endpoints** — anonymous access forbidden for personal data. Public data endpoints explicitly marked; separate authorization logic. (Optional for single-user local deployment; mandatory for cloud/multi-user.)
353376
- **SAMPLE_/DEMO_ data never in production** — automated linting prevents prefixed data from deploying. Merge blocked if test data detected.
@@ -437,5 +460,5 @@ Always commit before continuing to a new phase.
437460

438461
---
439462

440-
**Version**: 1.10.1 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-16
463+
**Version**: 1.10.2 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-18
441464

BikeTracking.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<Solution>
22
<Folder Name="/src/">
33
<Project Path="src/BikeTracking.Api/BikeTracking.Api.csproj" />
4+
<Project Path="src/BikeTracking.Api.Tests/BikeTracking.Api.Tests.csproj" />
45
<Project Path="src/BikeTracking.AppHost/BikeTracking.AppHost.csproj" />
56
<Project Path="src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj" />
67
<Project Path="src/BikeTracking.Frontend/BikeTracking.Frontend.esproj" />

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Local-first Bike Tracking application built with .NET Aspire orchestration, .NET
2121
## Prerequisites
2222

2323
- .NET SDK 10.x
24-
- Node.js 20+ and npm
24+
- Node.js 24+ and npm
2525
- CSharpier global tool (required for formatting checks):
2626

2727
```powershell
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
using BikeTracking.Api.Application.Users;
2+
using BikeTracking.Api.Contracts;
3+
using BikeTracking.Api.Infrastructure.Persistence;
4+
using BikeTracking.Api.Tests.TestSupport;
5+
using Microsoft.EntityFrameworkCore;
6+
7+
namespace BikeTracking.Api.Tests.Application.Users;
8+
9+
public sealed class IdentifyServiceTests
10+
{
11+
[Fact]
12+
public async Task IdentifyAsync_ReturnsValidationFailure_WhenRequestIsInvalid()
13+
{
14+
var (_, service, _) = CreateService(pin => pin == "1234");
15+
16+
var result = await service.IdentifyAsync(
17+
new IdentifyRequest("", "12ab"),
18+
CancellationToken.None
19+
);
20+
21+
Assert.Equal(IdentifyResultType.ValidationFailed, result.ResultType);
22+
Assert.NotNull(result.Error);
23+
Assert.Contains("Name is required.", result.Error.Details ?? []);
24+
Assert.Contains("PIN must contain only numeric characters.", result.Error.Details ?? []);
25+
}
26+
27+
[Fact]
28+
public async Task IdentifyAsync_ReturnsUnauthorized_WhenUserNotFound()
29+
{
30+
var (_, service, _) = CreateService(pin => pin == "1234");
31+
32+
var result = await service.IdentifyAsync(
33+
new IdentifyRequest("Unknown", "1234"),
34+
CancellationToken.None
35+
);
36+
37+
Assert.Equal(IdentifyResultType.Unauthorized, result.ResultType);
38+
}
39+
40+
[Fact]
41+
public async Task IdentifyAsync_ReturnsUnauthorizedAndIncrementsAttemptState_WhenPinIsWrong()
42+
{
43+
var (dbContext, service, _) = CreateService(pin => pin == "1234");
44+
var user = await SeedUserAsync(dbContext);
45+
46+
var result = await service.IdentifyAsync(
47+
new IdentifyRequest("Alice", "0000"),
48+
CancellationToken.None
49+
);
50+
51+
Assert.Equal(IdentifyResultType.Unauthorized, result.ResultType);
52+
53+
var updatedState = await dbContext.AuthAttemptStates.SingleAsync(x =>
54+
x.UserId == user.UserId
55+
);
56+
Assert.Equal(1, updatedState.ConsecutiveWrongCount);
57+
Assert.NotNull(updatedState.LastWrongAttemptUtc);
58+
Assert.NotNull(updatedState.DelayUntilUtc);
59+
}
60+
61+
[Fact]
62+
public async Task IdentifyAsync_ReturnsSuccessAndResetsAttemptState_WhenPinMatches()
63+
{
64+
var (dbContext, service, _) = CreateService(pin => pin == "1234");
65+
var user = await SeedUserAsync(
66+
dbContext,
67+
new AuthAttemptStateEntity
68+
{
69+
ConsecutiveWrongCount = 3,
70+
DelayUntilUtc = DateTime.UtcNow.AddSeconds(-1),
71+
LastWrongAttemptUtc = DateTime.UtcNow.AddSeconds(-2),
72+
}
73+
);
74+
75+
var result = await service.IdentifyAsync(
76+
new IdentifyRequest("Alice", "1234"),
77+
CancellationToken.None
78+
);
79+
80+
Assert.Equal(IdentifyResultType.Success, result.ResultType);
81+
Assert.NotNull(result.Response);
82+
Assert.Equal(user.UserId, result.Response.UserId);
83+
Assert.Equal("Alice", result.Response.UserName);
84+
Assert.True(result.Response.Authorized);
85+
86+
var updatedState = await dbContext.AuthAttemptStates.SingleAsync(x =>
87+
x.UserId == user.UserId
88+
);
89+
Assert.Equal(0, updatedState.ConsecutiveWrongCount);
90+
Assert.Null(updatedState.DelayUntilUtc);
91+
Assert.NotNull(updatedState.LastSuccessfulAuthUtc);
92+
}
93+
94+
[Fact]
95+
public async Task IdentifyAsync_CreatesAttemptState_WhenMissing()
96+
{
97+
var (dbContext, service, _) = CreateService(pin => pin == "1234");
98+
var user = await SeedUserAsync(dbContext, attemptState: null);
99+
100+
var result = await service.IdentifyAsync(
101+
new IdentifyRequest("Alice", "0000"),
102+
CancellationToken.None
103+
);
104+
105+
Assert.Equal(IdentifyResultType.Unauthorized, result.ResultType);
106+
107+
var state = await dbContext.AuthAttemptStates.SingleAsync(x => x.UserId == user.UserId);
108+
Assert.Equal(1, state.ConsecutiveWrongCount);
109+
}
110+
111+
[Fact]
112+
public async Task IdentifyAsync_ReturnsThrottled_WhenDelayWindowIsActive()
113+
{
114+
var (dbContext, service, pinHasher) = CreateService(pin => pin == "1234");
115+
116+
await SeedUserAsync(
117+
dbContext,
118+
new AuthAttemptStateEntity
119+
{
120+
ConsecutiveWrongCount = 2,
121+
DelayUntilUtc = DateTime.UtcNow.AddSeconds(5),
122+
LastWrongAttemptUtc = DateTime.UtcNow,
123+
}
124+
);
125+
126+
var result = await service.IdentifyAsync(
127+
new IdentifyRequest("Alice", "1234"),
128+
CancellationToken.None
129+
);
130+
131+
Assert.Equal(IdentifyResultType.Throttled, result.ResultType);
132+
Assert.InRange(result.RetryAfterSeconds, 1, 5);
133+
Assert.Equal(0, pinHasher.VerifyCallCount);
134+
}
135+
136+
[Fact]
137+
public async Task IdentifyAsync_UsesProgressiveDelaySteps_ForWrongAttempts()
138+
{
139+
var (dbContext, service, _) = CreateService(pin => pin == "1234");
140+
var user = await SeedUserAsync(
141+
dbContext,
142+
new AuthAttemptStateEntity { ConsecutiveWrongCount = 0 }
143+
);
144+
145+
var expectedSteps = new[] { 1, 2, 3, 5, 8, 15, 30 };
146+
147+
foreach (var expectedDelaySeconds in expectedSteps)
148+
{
149+
var result = await service.IdentifyAsync(
150+
new IdentifyRequest("Alice", "0000"),
151+
CancellationToken.None
152+
);
153+
154+
Assert.Equal(IdentifyResultType.Unauthorized, result.ResultType);
155+
156+
var state = await dbContext.AuthAttemptStates.SingleAsync(x => x.UserId == user.UserId);
157+
Assert.NotNull(state.DelayUntilUtc);
158+
Assert.NotNull(state.LastWrongAttemptUtc);
159+
160+
var delaySeconds = (int)
161+
Math.Round(
162+
(state.DelayUntilUtc!.Value - state.LastWrongAttemptUtc!.Value).TotalSeconds
163+
);
164+
Assert.Equal(expectedDelaySeconds, delaySeconds);
165+
166+
state.DelayUntilUtc = DateTime.UtcNow.AddSeconds(-1);
167+
await dbContext.SaveChangesAsync();
168+
}
169+
}
170+
171+
[Fact]
172+
public async Task IdentifyAsync_RespectsConfiguredMaxThrottleSeconds()
173+
{
174+
var (dbContext, service, _) = CreateService(
175+
pin => pin == "1234",
176+
options =>
177+
{
178+
options.Throttle.StepsSeconds = [10];
179+
options.Throttle.MaxSeconds = 4;
180+
}
181+
);
182+
183+
var user = await SeedUserAsync(
184+
dbContext,
185+
new AuthAttemptStateEntity { ConsecutiveWrongCount = 0 }
186+
);
187+
188+
var result = await service.IdentifyAsync(
189+
new IdentifyRequest("Alice", "0000"),
190+
CancellationToken.None
191+
);
192+
193+
Assert.Equal(IdentifyResultType.Unauthorized, result.ResultType);
194+
195+
var state = await dbContext.AuthAttemptStates.SingleAsync(x => x.UserId == user.UserId);
196+
var delaySeconds = (int)
197+
Math.Round(
198+
(state.DelayUntilUtc!.Value - state.LastWrongAttemptUtc!.Value).TotalSeconds
199+
);
200+
201+
Assert.Equal(4, delaySeconds);
202+
}
203+
204+
private static (BikeTrackingDbContext, IdentifyService, DelegatePinHasher) CreateService(
205+
Func<string, bool> verifyPin,
206+
Action<IdentityOptions>? configureOptions = null
207+
)
208+
{
209+
var options = TestFactories.IdentityOptions(configureOptions);
210+
var dbContext = TestFactories.CreateDbContext();
211+
var validator = new PinPolicyValidator(options);
212+
var pinHasher = new DelegatePinHasher(verifyPin);
213+
var service = new IdentifyService(dbContext, validator, pinHasher, options);
214+
215+
return (dbContext, service, pinHasher);
216+
}
217+
218+
private static async Task<UserEntity> SeedUserAsync(
219+
BikeTrackingDbContext dbContext,
220+
AuthAttemptStateEntity? attemptState = null
221+
)
222+
{
223+
var user = new UserEntity
224+
{
225+
DisplayName = "Alice",
226+
NormalizedName = "ALICE",
227+
CreatedAtUtc = DateTime.UtcNow,
228+
IsActive = true,
229+
Credential = new UserCredentialEntity
230+
{
231+
PinHash = [1, 2, 3, 4],
232+
PinSalt = [5, 6, 7, 8],
233+
HashAlgorithm = "PBKDF2-SHA256",
234+
IterationCount = 10000,
235+
CredentialVersion = 1,
236+
UpdatedAtUtc = DateTime.UtcNow,
237+
},
238+
AuthAttemptState = attemptState,
239+
};
240+
241+
dbContext.Users.Add(user);
242+
await dbContext.SaveChangesAsync();
243+
244+
return user;
245+
}
246+
}

0 commit comments

Comments
 (0)