diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 934ac94..fedfde7 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -11,7 +11,8 @@
"Bash(npm create:*)",
"Bash(npm install:*)",
"Bash(npm run:*)",
- "Bash(npx playwright:*)"
+ "Bash(npx playwright:*)",
+ "Bash(dotnet test:*)"
]
}
}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 88e1352..cc14f64 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,10 +7,28 @@ on:
pull_request:
jobs:
+ # ── Unit Tests ─────────────────────────────────────────────────────────────
+ unit-tests:
+ name: Unit Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Run unit tests
+ run: dotnet test src/backend/tests/unit/Randall.Domain.UnitTests.csproj --logger "console;verbosity=normal"
+
# ── E2E Tests ──────────────────────────────────────────────────────────────
e2e:
name: E2E Tests
runs-on: ubuntu-latest
+ needs: unit-tests
steps:
- name: Checkout
diff --git a/src/backend/Randall.slnx b/src/backend/Randall.slnx
index 5e21be4..51d8016 100644
--- a/src/backend/Randall.slnx
+++ b/src/backend/Randall.slnx
@@ -5,4 +5,8 @@
+
+
+
+
diff --git a/src/backend/tests/unit/Common/ResultTests.cs b/src/backend/tests/unit/Common/ResultTests.cs
new file mode 100644
index 0000000..51eed99
--- /dev/null
+++ b/src/backend/tests/unit/Common/ResultTests.cs
@@ -0,0 +1,53 @@
+using Randall.Domain.Common;
+
+namespace Randall.Domain.UnitTests.Common;
+
+public class ResultTests
+{
+ [Fact]
+ public void Success_IsSuccessIsTrue()
+ {
+ var result = Result.Success();
+
+ Assert.True(result.IsSuccess);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void Failure_IsSuccessIsFalse()
+ {
+ var result = Result.Failure("Something went wrong");
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal("Something went wrong", result.Error);
+ }
+
+ [Fact]
+ public void SuccessOfT_ContainsValue()
+ {
+ var result = Result.Success(42);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(42, result.Value);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void FailureOfT_ValueIsDefault()
+ {
+ var result = Result.Failure("error");
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal(default, result.Value);
+ Assert.Equal("error", result.Error);
+ }
+
+ [Fact]
+ public void FailureOfT_ReferenceType_ValueIsNull()
+ {
+ var result = Result.Failure("error");
+
+ Assert.False(result.IsSuccess);
+ Assert.Null(result.Value);
+ }
+}
diff --git a/src/backend/tests/unit/Randall.Domain.UnitTests.csproj b/src/backend/tests/unit/Randall.Domain.UnitTests.csproj
new file mode 100644
index 0000000..d3517dd
--- /dev/null
+++ b/src/backend/tests/unit/Randall.Domain.UnitTests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/backend/tests/unit/Reservations/ReservationCancelTests.cs b/src/backend/tests/unit/Reservations/ReservationCancelTests.cs
new file mode 100644
index 0000000..9edae56
--- /dev/null
+++ b/src/backend/tests/unit/Reservations/ReservationCancelTests.cs
@@ -0,0 +1,48 @@
+using Randall.Domain.Reservations;
+
+namespace Randall.Domain.UnitTests.Reservations;
+
+public class ReservationCancelTests
+{
+ private static Reservation CreateActiveReservation()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ return Reservation.Create(Guid.NewGuid(), "jane@company.com", "Jane", today, today).Value!;
+ }
+
+ [Fact]
+ public void Cancel_ActiveReservation_Succeeds()
+ {
+ var reservation = CreateActiveReservation();
+
+ var result = reservation.Cancel();
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(ReservationStatus.Cancelled, reservation.Status);
+ }
+
+ [Fact]
+ public void Cancel_AlreadyCancelledReservation_Fails()
+ {
+ var reservation = CreateActiveReservation();
+ reservation.Cancel();
+
+ var result = reservation.Cancel();
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains("already cancelled", result.Error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Cancel_DoesNotChangeOtherFields()
+ {
+ var reservation = CreateActiveReservation();
+ var originalDate = reservation.Date;
+ var originalEmail = reservation.EmployeeEmail;
+
+ reservation.Cancel();
+
+ Assert.Equal(originalDate, reservation.Date);
+ Assert.Equal(originalEmail, reservation.EmployeeEmail);
+ }
+}
diff --git a/src/backend/tests/unit/Reservations/ReservationCreateTests.cs b/src/backend/tests/unit/Reservations/ReservationCreateTests.cs
new file mode 100644
index 0000000..3e74151
--- /dev/null
+++ b/src/backend/tests/unit/Reservations/ReservationCreateTests.cs
@@ -0,0 +1,98 @@
+using Randall.Domain.Reservations;
+
+namespace Randall.Domain.UnitTests.Reservations;
+
+public class ReservationCreateTests
+{
+ private static readonly Guid WorkplaceId = Guid.NewGuid();
+ private const string Email = "jane@company.com";
+ private const string Name = "Jane Smith";
+
+ [Fact]
+ public void Create_WithValidFutureDate_Succeeds()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var result = Reservation.Create(WorkplaceId, Email, Name, today, today);
+
+ Assert.True(result.IsSuccess);
+ Assert.NotNull(result.Value);
+ }
+
+ [Fact]
+ public void Create_WithTodayAsDate_Succeeds()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var result = Reservation.Create(WorkplaceId, Email, Name, today, today);
+
+ Assert.True(result.IsSuccess);
+ }
+
+ [Fact]
+ public void Create_WithMaxAdvanceDate_Succeeds()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var maxDate = today.AddDays(Reservation.MaxAdvanceDays);
+
+ var result = Reservation.Create(WorkplaceId, Email, Name, maxDate, today);
+
+ Assert.True(result.IsSuccess);
+ }
+
+ [Fact]
+ public void Create_WithPastDate_Fails()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var yesterday = today.AddDays(-1);
+
+ var result = Reservation.Create(WorkplaceId, Email, Name, yesterday, today);
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains("past", result.Error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_BeyondMaxAdvanceDays_Fails()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var tooFar = today.AddDays(Reservation.MaxAdvanceDays + 1);
+
+ var result = Reservation.Create(WorkplaceId, Email, Name, tooFar, today);
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains(Reservation.MaxAdvanceDays.ToString(), result.Error);
+ }
+
+ [Fact]
+ public void Create_SetsStatusToActive()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var result = Reservation.Create(WorkplaceId, Email, Name, today, today);
+
+ Assert.Equal(ReservationStatus.Active, result.Value!.Status);
+ }
+
+ [Fact]
+ public void Create_PopulatesAllFields()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var result = Reservation.Create(WorkplaceId, Email, Name, today, today);
+
+ var reservation = result.Value!;
+ Assert.Equal(WorkplaceId, reservation.WorkplaceId);
+ Assert.Equal(Email, reservation.EmployeeEmail);
+ Assert.Equal(Name, reservation.EmployeeName);
+ Assert.Equal(today, reservation.Date);
+ Assert.NotEqual(Guid.Empty, reservation.Id);
+ }
+
+ [Fact]
+ public void Create_SetsCreatedAtToApproximatelyNow()
+ {
+ var before = DateTime.UtcNow;
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var result = Reservation.Create(WorkplaceId, Email, Name, today, today);
+ var after = DateTime.UtcNow;
+
+ Assert.InRange(result.Value!.CreatedAt, before, after);
+ }
+}
diff --git a/src/backend/tests/unit/Users/UserCreateTests.cs b/src/backend/tests/unit/Users/UserCreateTests.cs
new file mode 100644
index 0000000..359048b
--- /dev/null
+++ b/src/backend/tests/unit/Users/UserCreateTests.cs
@@ -0,0 +1,84 @@
+using Randall.Domain.Users;
+
+namespace Randall.Domain.UnitTests.Users;
+
+public class UserCreateTests
+{
+ [Fact]
+ public void Create_WithValidData_Succeeds()
+ {
+ var result = User.Create("jane@company.com", "Jane Smith", "hash");
+
+ Assert.True(result.IsSuccess);
+ Assert.NotNull(result.Value);
+ }
+
+ [Fact]
+ public void Create_NormalisesEmailToLowercase()
+ {
+ var result = User.Create("JANE@COMPANY.COM", "Jane Smith", "hash");
+
+ Assert.Equal("jane@company.com", result.Value!.Email);
+ }
+
+ [Fact]
+ public void Create_TrimsNameWhitespace()
+ {
+ var result = User.Create("jane@company.com", " Jane Smith ", "hash");
+
+ Assert.Equal("Jane Smith", result.Value!.Name);
+ }
+
+ [Fact]
+ public void Create_WithEmptyEmail_Fails()
+ {
+ var result = User.Create("", "Jane Smith", "hash");
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains("email", result.Error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_WithWhitespaceEmail_Fails()
+ {
+ var result = User.Create(" ", "Jane Smith", "hash");
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public void Create_WithEmptyName_Fails()
+ {
+ var result = User.Create("jane@company.com", "", "hash");
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains("name", result.Error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_RegularUser_IsNotApprovedByDefault()
+ {
+ var result = User.Create("jane@company.com", "Jane Smith", "hash");
+
+ Assert.False(result.Value!.IsApproved);
+ Assert.False(result.Value!.IsAdmin);
+ }
+
+ [Fact]
+ public void Create_AdminUser_IsAutoApproved()
+ {
+ var result = User.Create("admin@company.com", "Admin", "hash", isAdmin: true);
+
+ Assert.True(result.Value!.IsAdmin);
+ Assert.True(result.Value!.IsApproved);
+ }
+
+ [Fact]
+ public void Create_AssignsUniqueId()
+ {
+ var a = User.Create("a@company.com", "A", "hash").Value!;
+ var b = User.Create("b@company.com", "B", "hash").Value!;
+
+ Assert.NotEqual(a.Id, b.Id);
+ }
+}
diff --git a/src/backend/tests/unit/Users/UserStateTests.cs b/src/backend/tests/unit/Users/UserStateTests.cs
new file mode 100644
index 0000000..141f0a7
--- /dev/null
+++ b/src/backend/tests/unit/Users/UserStateTests.cs
@@ -0,0 +1,42 @@
+using Randall.Domain.Users;
+
+namespace Randall.Domain.UnitTests.Users;
+
+public class UserStateTests
+{
+ private static User CreateRegularUser() =>
+ User.Create("jane@company.com", "Jane Smith", "hash").Value!;
+
+ [Fact]
+ public void Approve_SetsIsApprovedToTrue()
+ {
+ var user = CreateRegularUser();
+
+ user.Approve();
+
+ Assert.True(user.IsApproved);
+ }
+
+ [Fact]
+ public void MakeAdmin_SetsIsAdminAndIsApproved()
+ {
+ var user = CreateRegularUser();
+
+ user.MakeAdmin();
+
+ Assert.True(user.IsAdmin);
+ Assert.True(user.IsApproved);
+ }
+
+ [Fact]
+ public void MakeAdmin_OnAlreadyApprovedUser_RemainsApproved()
+ {
+ var user = CreateRegularUser();
+ user.Approve();
+
+ user.MakeAdmin();
+
+ Assert.True(user.IsApproved);
+ Assert.True(user.IsAdmin);
+ }
+}
diff --git a/src/backend/tests/unit/Workplaces/WorkplaceTests.cs b/src/backend/tests/unit/Workplaces/WorkplaceTests.cs
new file mode 100644
index 0000000..379b628
--- /dev/null
+++ b/src/backend/tests/unit/Workplaces/WorkplaceTests.cs
@@ -0,0 +1,61 @@
+using Randall.Domain.Workplaces;
+
+namespace Randall.Domain.UnitTests.Workplaces;
+
+public class WorkplaceTests
+{
+ [Fact]
+ public void Create_IsActiveByDefault()
+ {
+ var workplace = new Workplace("A1", "Pod A");
+
+ Assert.True(workplace.IsActive);
+ }
+
+ [Fact]
+ public void Create_SetsNameAndLocation()
+ {
+ var workplace = new Workplace("A1", "Pod A");
+
+ Assert.Equal("A1", workplace.Name);
+ Assert.Equal("Pod A", workplace.Location);
+ }
+
+ [Fact]
+ public void Create_AssignsNonEmptyId()
+ {
+ var workplace = new Workplace("A1", "Pod A");
+
+ Assert.NotEqual(Guid.Empty, workplace.Id);
+ }
+
+ [Fact]
+ public void Deactivate_SetsIsActiveToFalse()
+ {
+ var workplace = new Workplace("A1", "Pod A");
+
+ workplace.Deactivate();
+
+ Assert.False(workplace.IsActive);
+ }
+
+ [Fact]
+ public void Activate_AfterDeactivate_SetsIsActiveToTrue()
+ {
+ var workplace = new Workplace("A1", "Pod A");
+ workplace.Deactivate();
+
+ workplace.Activate();
+
+ Assert.True(workplace.IsActive);
+ }
+
+ [Fact]
+ public void Create_TwoWorkplaces_HaveDifferentIds()
+ {
+ var a = new Workplace("A1", "Pod A");
+ var b = new Workplace("A2", "Pod A");
+
+ Assert.NotEqual(a.Id, b.Id);
+ }
+}