diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cc14f64..ad9a260 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,13 +22,30 @@ jobs:
dotnet-version: '10.0.x'
- name: Run unit tests
- run: dotnet test src/backend/tests/unit/Randall.Domain.UnitTests.csproj --logger "console;verbosity=normal"
+ run: dotnet test src/backend/tests/unit/Randall.Domain.UnitTests/Randall.Domain.UnitTests.csproj --logger "console;verbosity=normal"
+
+ # ── Integration Tests ──────────────────────────────────────────────────────
+ integration-tests:
+ name: Integration 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 integration tests
+ run: dotnet test src/backend/tests/integration/Randall.Api.IntegrationTests/Randall.Api.IntegrationTests.csproj --logger "console;verbosity=normal"
# ── E2E Tests ──────────────────────────────────────────────────────────────
e2e:
name: E2E Tests
runs-on: ubuntu-latest
- needs: unit-tests
+ needs: [unit-tests, integration-tests]
steps:
- name: Checkout
diff --git a/src/backend/Randall.slnx b/src/backend/Randall.slnx
index f1c9ff7..925baac 100644
--- a/src/backend/Randall.slnx
+++ b/src/backend/Randall.slnx
@@ -9,4 +9,7 @@
+
+
+
diff --git a/src/backend/src/Randall.Api/Program.cs b/src/backend/src/Randall.Api/Program.cs
index 8e0e4de..3e92c81 100644
--- a/src/backend/src/Randall.Api/Program.cs
+++ b/src/backend/src/Randall.Api/Program.cs
@@ -66,3 +66,5 @@ app.UseAuthorization();
app.MapControllers();
app.Run();
+
+public partial class Program { }
diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs
new file mode 100644
index 0000000..ee66665
--- /dev/null
+++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs
@@ -0,0 +1,149 @@
+using System.Net;
+using System.Net.Http.Json;
+using Randall.Api.IntegrationTests.Helpers;
+
+namespace Randall.Api.IntegrationTests.Admin;
+
+public class AdminTests(CustomWebApplicationFactory factory) : IClassFixture
+{
+ [Fact]
+ public async Task GetAllUsers_WithoutAuth_Returns401()
+ {
+ var client = factory.CreateClient();
+
+ var response = await client.GetAsync("/api/admin/users");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetAllUsers_WithNonAdmin_Returns403()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ var token = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
+
+ var client = factory.CreateClient();
+ AuthHelper.SetBearerToken(client, token);
+
+ var response = await client.GetAsync("/api/admin/users");
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetAllUsers_WithAdmin_ReturnsUserList()
+ {
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var users = await client.GetFromJsonAsync>(
+ "/api/admin/users", AuthHelper.JsonOptions);
+
+ Assert.NotNull(users);
+ Assert.Contains(users, u => u.Email == AuthHelper.AdminEmail && u.IsAdmin);
+ }
+
+ [Fact]
+ public async Task GetPendingUsers_ShowsNewlyRegisteredUser()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ var registerClient = factory.CreateClient();
+ await registerClient.PostAsJsonAsync("/api/auth/register",
+ new { Email = email, Password = "Test@1234", Name = "Pending User" });
+
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var pending = await client.GetFromJsonAsync>(
+ "/api/admin/users/pending", AuthHelper.JsonOptions);
+
+ Assert.NotNull(pending);
+ Assert.Contains(pending, u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task ApproveUser_AllowsSubsequentLogin()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ var registerClient = factory.CreateClient();
+ await registerClient.PostAsJsonAsync("/api/auth/register",
+ new { Email = email, Password = "Test@1234", Name = "Pending User" });
+
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var pending = await client.GetFromJsonAsync>(
+ "/api/admin/users/pending", AuthHelper.JsonOptions);
+ var userId = pending!.First(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase)).Id;
+
+ var approveResponse = await client.PostAsync($"/api/admin/users/{userId}/approve", null);
+ Assert.Equal(HttpStatusCode.NoContent, approveResponse.StatusCode);
+
+ // User can now login
+ var loginClient = factory.CreateClient();
+ var login = await AuthHelper.LoginAsync(loginClient, email, "Test@1234");
+ Assert.NotEmpty(login.Token);
+ }
+
+ [Fact]
+ public async Task MakeAdmin_ElevatesUserToAdmin()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
+
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var users = await client.GetFromJsonAsync>(
+ "/api/admin/users", AuthHelper.JsonOptions);
+ var userId = users!.First(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase)).Id;
+
+ var response = await client.PostAsync($"/api/admin/users/{userId}/make-admin", null);
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+
+ // Verify the user is now admin
+ var updated = await client.GetFromJsonAsync>(
+ "/api/admin/users", AuthHelper.JsonOptions);
+ Assert.True(updated!.First(u => u.Id == userId).IsAdmin);
+ }
+
+ [Fact]
+ public async Task DeleteUser_NonAdminUser_Returns204()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
+
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var users = await client.GetFromJsonAsync>(
+ "/api/admin/users", AuthHelper.JsonOptions);
+ var userId = users!.First(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase)).Id;
+
+ var response = await client.DeleteAsync($"/api/admin/users/{userId}");
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task DeleteUser_Self_ReturnsBadRequest()
+ {
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var users = await client.GetFromJsonAsync>(
+ "/api/admin/users", AuthHelper.JsonOptions);
+ var adminId = users!.First(u => u.Email == AuthHelper.AdminEmail).Id;
+
+ var response = await client.DeleteAsync($"/api/admin/users/{adminId}");
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+}
diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/Auth/AuthTests.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/Auth/AuthTests.cs
new file mode 100644
index 0000000..ae4473f
--- /dev/null
+++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/Auth/AuthTests.cs
@@ -0,0 +1,92 @@
+using System.Net;
+using System.Net.Http.Json;
+using Randall.Api.IntegrationTests.Helpers;
+
+namespace Randall.Api.IntegrationTests.Auth;
+
+public class AuthTests(CustomWebApplicationFactory factory) : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+
+ [Fact]
+ public async Task Register_WithValidData_ReturnsOk()
+ {
+ var response = await _client.PostAsJsonAsync("/api/auth/register",
+ new { Email = $"{Guid.NewGuid():N}@test.com", Password = "Test@1234", Name = "New User" });
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync(AuthHelper.JsonOptions);
+ Assert.NotNull(body?.Message);
+ }
+
+ [Fact]
+ public async Task Register_WithDuplicateEmail_ReturnsBadRequest()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ await _client.PostAsJsonAsync("/api/auth/register",
+ new { Email = email, Password = "Test@1234", Name = "First" });
+
+ var response = await _client.PostAsJsonAsync("/api/auth/register",
+ new { Email = email, Password = "Test@1234", Name = "Second" });
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Login_WithNonExistentUser_ReturnsBadRequest()
+ {
+ var response = await _client.PostAsJsonAsync("/api/auth/login",
+ new { Email = "nobody@test.com", Password = "Test@1234" });
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Login_WithWrongPassword_ReturnsBadRequest()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ await _client.PostAsJsonAsync("/api/auth/register",
+ new { Email = email, Password = "Test@1234", Name = "Test User" });
+
+ var response = await _client.PostAsJsonAsync("/api/auth/login",
+ new { Email = email, Password = "WrongPassword" });
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Login_WithUnapprovedUser_ReturnsBadRequest()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ await _client.PostAsJsonAsync("/api/auth/register",
+ new { Email = email, Password = "Test@1234", Name = "Test User" });
+
+ // Attempt login without approval
+ var response = await _client.PostAsJsonAsync("/api/auth/login",
+ new { Email = email, Password = "Test@1234" });
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Login_AsAdmin_ReturnsTokenWithAdminFlag()
+ {
+ var response = await AuthHelper.LoginAsAdminAsync(_client);
+
+ Assert.NotEmpty(response.Token);
+ Assert.True(response.IsAdmin);
+ Assert.Equal(AuthHelper.AdminEmail, response.Email);
+ }
+
+ [Fact]
+ public async Task Login_AsApprovedNonAdminUser_ReturnsTokenWithoutAdminFlag()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
+
+ var login = await AuthHelper.LoginAsync(_client, email, "Test@1234");
+
+ Assert.NotEmpty(login.Token);
+ Assert.False(login.IsAdmin);
+ }
+}
diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs
new file mode 100644
index 0000000..b20ad3e
--- /dev/null
+++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs
@@ -0,0 +1,33 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+
+namespace Randall.Api.IntegrationTests;
+
+public class CustomWebApplicationFactory : WebApplicationFactory
+{
+ private readonly string _dbPath = Path.Combine(
+ Path.GetTempPath(), $"randall_test_{Guid.NewGuid():N}.db");
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Testing");
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbPath}",
+ ["Jwt:Key"] = "test-jwt-secret-key-that-is-at-least-32-chars-long!",
+ ["Jwt:Issuer"] = "randall-api",
+ ["Jwt:Audience"] = "randall-app",
+ });
+ });
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing && File.Exists(_dbPath))
+ File.Delete(_dbPath);
+ }
+}
diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/Helpers/AuthHelper.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/Helpers/AuthHelper.cs
new file mode 100644
index 0000000..434d5be
--- /dev/null
+++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/Helpers/AuthHelper.cs
@@ -0,0 +1,67 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace Randall.Api.IntegrationTests.Helpers;
+
+// Local response DTOs — mirror the API's shape without coupling to production types
+public record LoginResponse(string Token, string Name, string Email, bool IsAdmin);
+public record RegisterResponse(string Message);
+public record PendingUserResponse(Guid Id, string Name, string Email);
+public record AdminUserResponse(Guid Id, string Name, string Email, bool IsApproved, bool IsAdmin);
+public record WorkplaceResponse(Guid Id, string Name, string Location);
+public record WorkplaceScheduleResponse(Guid Id, string Name, string Location, bool IsAvailable, string? ReservedBy);
+public record CreatedReservationResponse(Guid Id, Guid WorkplaceId, string EmployeeName, string Date);
+public record ReservationResponse(Guid Id, Guid WorkplaceId, string WorkplaceName, string WorkplaceLocation, string Date, string Status, DateTime CreatedAt);
+public record ReservationDetailResponse(Guid Id, Guid WorkplaceId, string WorkplaceName, string WorkplaceLocation, string EmployeeName, string EmployeeEmail, string Date, string Status, DateTime CreatedAt);
+
+public static class AuthHelper
+{
+ public const string AdminEmail = "admin@randall.local";
+ public const string AdminPassword = "Admin@123";
+
+ public static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public static async Task LoginAsync(HttpClient client, string email, string password)
+ {
+ var response = await client.PostAsJsonAsync("/api/auth/login",
+ new { Email = email, Password = password });
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync(JsonOptions))!;
+ }
+
+ public static Task LoginAsAdminAsync(HttpClient client) =>
+ LoginAsync(client, AdminEmail, AdminPassword);
+
+ public static void SetBearerToken(HttpClient client, string token) =>
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ ///
+ /// Registers a new user, approves them via the admin account, and returns their JWT token.
+ ///
+ public static async Task CreateApprovedUserAndLoginAsync(
+ CustomWebApplicationFactory factory,
+ string email,
+ string password = "Test@1234",
+ string name = "Test User")
+ {
+ var client = factory.CreateClient();
+ await client.PostAsJsonAsync("/api/auth/register", new { Email = email, Password = password, Name = name });
+
+ var adminClient = factory.CreateClient();
+ var adminLogin = await LoginAsAdminAsync(adminClient);
+ SetBearerToken(adminClient, adminLogin.Token);
+
+ var pending = await adminClient.GetFromJsonAsync>(
+ "/api/admin/users/pending", JsonOptions);
+ var user = pending!.First(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
+ await adminClient.PostAsync($"/api/admin/users/{user.Id}/approve", null);
+
+ var loginClient = factory.CreateClient();
+ var login = await LoginAsync(loginClient, email, password);
+ return login.Token;
+ }
+}
diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/Randall.Api.IntegrationTests.csproj b/src/backend/tests/integration/Randall.Api.IntegrationTests/Randall.Api.IntegrationTests.csproj
new file mode 100644
index 0000000..ed512f3
--- /dev/null
+++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/Randall.Api.IntegrationTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/Reservations/ReservationTests.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/Reservations/ReservationTests.cs
new file mode 100644
index 0000000..c50a21e
--- /dev/null
+++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/Reservations/ReservationTests.cs
@@ -0,0 +1,186 @@
+using System.Net;
+using System.Net.Http.Json;
+using Randall.Api.IntegrationTests.Helpers;
+
+namespace Randall.Api.IntegrationTests.Reservations;
+
+public class ReservationTests(CustomWebApplicationFactory factory) : IClassFixture
+{
+ [Fact]
+ public async Task Create_WithoutAuth_Returns401()
+ {
+ var client = factory.CreateClient();
+
+ var response = await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = Guid.NewGuid(), Date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1) });
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Create_ValidReservation_Returns201WithDetails()
+ {
+ var (client, workplaceId, date) = await SetupUserWithWorkplaceAsync(daysOffset: 1);
+
+ var response = await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaceId, Date = date });
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ var created = await response.Content.ReadFromJsonAsync(AuthHelper.JsonOptions);
+ Assert.NotEqual(Guid.Empty, created!.Id);
+ Assert.Equal(workplaceId, created.WorkplaceId);
+ Assert.Equal(date.ToString("yyyy-MM-dd"), created.Date);
+ }
+
+ [Fact]
+ public async Task GetById_ForExistingReservation_ReturnsDetails()
+ {
+ var (client, workplaceId, date) = await SetupUserWithWorkplaceAsync(daysOffset: 2);
+
+ var createResponse = await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaceId, Date = date });
+ var created = await createResponse.Content.ReadFromJsonAsync(AuthHelper.JsonOptions);
+
+ var response = await client.GetAsync($"/api/reservations/{created!.Id}");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var detail = await response.Content.ReadFromJsonAsync(AuthHelper.JsonOptions);
+ Assert.Equal(created.Id, detail!.Id);
+ Assert.Equal(workplaceId, detail.WorkplaceId);
+ Assert.Equal("Active", detail.Status);
+ }
+
+ [Fact]
+ public async Task GetById_ForNonExistentReservation_Returns404()
+ {
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var response = await client.GetAsync($"/api/reservations/{Guid.NewGuid()}");
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetMy_ReturnsOnlyOwnReservations()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ var token = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
+ var client = factory.CreateClient();
+ AuthHelper.SetBearerToken(client, token);
+
+ var workplaces = await client.GetFromJsonAsync>(
+ "/api/workplaces", AuthHelper.JsonOptions);
+ var date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(3);
+ await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaces![1].Id, Date = date });
+
+ var response = await client.GetAsync("/api/reservations/my");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var reservations = await response.Content.ReadFromJsonAsync>(AuthHelper.JsonOptions);
+ Assert.NotNull(reservations);
+ Assert.NotEmpty(reservations);
+ Assert.All(reservations, r => Assert.Equal("Active", r.Status));
+ }
+
+ [Fact]
+ public async Task Create_SameWorkplaceSameDay_ReturnsBadRequest()
+ {
+ // Two different users try to reserve the same workplace on the same day
+ var email1 = $"{Guid.NewGuid():N}@test.com";
+ var email2 = $"{Guid.NewGuid():N}@test.com";
+ var token1 = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email1);
+ var token2 = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email2);
+
+ var client = factory.CreateClient();
+ AuthHelper.SetBearerToken(client, token1);
+ var workplaces = await client.GetFromJsonAsync>(
+ "/api/workplaces", AuthHelper.JsonOptions);
+ var workplaceId = workplaces![2].Id;
+ var date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(4);
+
+ await client.PostAsJsonAsync("/api/reservations", new { WorkplaceId = workplaceId, Date = date });
+
+ var client2 = factory.CreateClient();
+ AuthHelper.SetBearerToken(client2, token2);
+ var response = await client2.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaceId, Date = date });
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Create_SameEmployeeSameDay_ReturnsBadRequest()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ var token = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
+ var client = factory.CreateClient();
+ AuthHelper.SetBearerToken(client, token);
+
+ var workplaces = await client.GetFromJsonAsync>(
+ "/api/workplaces", AuthHelper.JsonOptions);
+ var date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(5);
+
+ // First reservation succeeds
+ await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaces![3].Id, Date = date });
+
+ // Same employee, different workplace, same day
+ var response = await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaces[4].Id, Date = date });
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Cancel_OwnReservation_Returns204()
+ {
+ var (client, workplaceId, date) = await SetupUserWithWorkplaceAsync(daysOffset: 6);
+
+ var createResponse = await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaceId, Date = date });
+ var created = await createResponse.Content.ReadFromJsonAsync(AuthHelper.JsonOptions);
+
+ var response = await client.DeleteAsync($"/api/reservations/{created!.Id}");
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Cancel_AnotherUsersReservation_ReturnsBadRequest()
+ {
+ // User 1 creates a reservation
+ var (client1, workplaceId, date) = await SetupUserWithWorkplaceAsync(daysOffset: 8);
+ var createResponse = await client1.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaceId, Date = date });
+ var created = await createResponse.Content.ReadFromJsonAsync(AuthHelper.JsonOptions);
+
+ // User 2 tries to cancel it
+ var email2 = $"{Guid.NewGuid():N}@test.com";
+ var token2 = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email2);
+ var client2 = factory.CreateClient();
+ AuthHelper.SetBearerToken(client2, token2);
+
+ var response = await client2.DeleteAsync($"/api/reservations/{created!.Id}");
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ // Helper: creates a fresh approved user and returns their authenticated client,
+ // the first available workplace ID, and a future date unique to the test.
+ private async Task<(HttpClient client, Guid workplaceId, DateOnly date)> SetupUserWithWorkplaceAsync(int daysOffset)
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ var token = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
+
+ var client = factory.CreateClient();
+ AuthHelper.SetBearerToken(client, token);
+
+ var workplaces = await client.GetFromJsonAsync>(
+ "/api/workplaces", AuthHelper.JsonOptions);
+
+ return (client, workplaces![0].Id, DateOnly.FromDateTime(DateTime.UtcNow).AddDays(daysOffset));
+ }
+}
diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/Workplaces/WorkplaceTests.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/Workplaces/WorkplaceTests.cs
new file mode 100644
index 0000000..38fdcbb
--- /dev/null
+++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/Workplaces/WorkplaceTests.cs
@@ -0,0 +1,124 @@
+using System.Net;
+using System.Net.Http.Json;
+using Randall.Api.IntegrationTests.Helpers;
+
+namespace Randall.Api.IntegrationTests.Workplaces;
+
+public class WorkplaceTests(CustomWebApplicationFactory factory) : IClassFixture
+{
+ private readonly string _today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd");
+ private readonly string _yesterday = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1).ToString("yyyy-MM-dd");
+
+ [Fact]
+ public async Task GetAll_WithoutAuth_Returns401()
+ {
+ var client = factory.CreateClient();
+
+ var response = await client.GetAsync("/api/workplaces");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetAll_WithAuth_ReturnsAllSeededWorkplaces()
+ {
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var workplaces = await client.GetFromJsonAsync>(
+ "/api/workplaces", AuthHelper.JsonOptions);
+
+ Assert.NotNull(workplaces);
+ Assert.Equal(16, workplaces.Count);
+ Assert.All(workplaces, w =>
+ {
+ Assert.NotEqual(Guid.Empty, w.Id);
+ Assert.NotEmpty(w.Name);
+ Assert.NotEmpty(w.Location);
+ });
+ }
+
+ [Fact]
+ public async Task GetAvailable_WithPastDate_ReturnsBadRequest()
+ {
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var response = await client.GetAsync($"/api/workplaces/available?date={_yesterday}");
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetAvailable_ForTodayWithNoReservations_ReturnsAllWorkplaces()
+ {
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var workplaces = await client.GetFromJsonAsync>(
+ $"/api/workplaces/available?date={_today}", AuthHelper.JsonOptions);
+
+ Assert.NotNull(workplaces);
+ Assert.Equal(16, workplaces.Count);
+ }
+
+ [Fact]
+ public async Task GetSchedule_WithoutAuth_Returns401()
+ {
+ var client = factory.CreateClient();
+
+ var response = await client.GetAsync($"/api/workplaces/schedule?date={_today}");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetSchedule_ForTodayWithNoReservations_ReturnsAllAsAvailable()
+ {
+ var client = factory.CreateClient();
+ var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
+ AuthHelper.SetBearerToken(client, adminLogin.Token);
+
+ var schedule = await client.GetFromJsonAsync>(
+ $"/api/workplaces/schedule?date={_today}", AuthHelper.JsonOptions);
+
+ Assert.NotNull(schedule);
+ Assert.Equal(16, schedule.Count);
+ Assert.All(schedule, s =>
+ {
+ Assert.True(s.IsAvailable);
+ Assert.Null(s.ReservedBy);
+ });
+ }
+
+ [Fact]
+ public async Task GetSchedule_AfterReservation_ShowsWorkplaceAsUnavailable()
+ {
+ var email = $"{Guid.NewGuid():N}@test.com";
+ var userToken = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email, name: "Schedule Tester");
+
+ var client = factory.CreateClient();
+ AuthHelper.SetBearerToken(client, userToken);
+
+ var workplaces = await client.GetFromJsonAsync>(
+ "/api/workplaces", AuthHelper.JsonOptions);
+ var workplaceId = workplaces![0].Id;
+
+ // Reserve for a date unique to this test to avoid conflicts
+ var date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(7);
+ var dateStr = date.ToString("yyyy-MM-dd");
+
+ await client.PostAsJsonAsync("/api/reservations",
+ new { WorkplaceId = workplaceId, Date = date });
+
+ var schedule = await client.GetFromJsonAsync>(
+ $"/api/workplaces/schedule?date={dateStr}", AuthHelper.JsonOptions);
+
+ var entry = schedule!.First(s => s.Id == workplaceId);
+ Assert.False(entry.IsAvailable);
+ Assert.Equal("Schedule Tester", entry.ReservedBy);
+ }
+}