From 4fcd0d60f4a198d3d7834d5e23af8c0eac654fdf Mon Sep 17 00:00:00 2001 From: Robert van Diest Date: Wed, 25 Mar 2026 19:44:53 +0100 Subject: [PATCH] Add integration tests --- .github/workflows/ci.yml | 21 +- src/backend/Randall.slnx | 3 + src/backend/src/Randall.Api/Program.cs | 2 + .../Admin/AdminTests.cs | 149 ++++++++++++++ .../Auth/AuthTests.cs | 92 +++++++++ .../CustomWebApplicationFactory.cs | 33 ++++ .../Helpers/AuthHelper.cs | 67 +++++++ .../Randall.Api.IntegrationTests.csproj | 30 +++ .../Reservations/ReservationTests.cs | 186 ++++++++++++++++++ .../Workplaces/WorkplaceTests.cs | 124 ++++++++++++ 10 files changed, 705 insertions(+), 2 deletions(-) create mode 100644 src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs create mode 100644 src/backend/tests/integration/Randall.Api.IntegrationTests/Auth/AuthTests.cs create mode 100644 src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs create mode 100644 src/backend/tests/integration/Randall.Api.IntegrationTests/Helpers/AuthHelper.cs create mode 100644 src/backend/tests/integration/Randall.Api.IntegrationTests/Randall.Api.IntegrationTests.csproj create mode 100644 src/backend/tests/integration/Randall.Api.IntegrationTests/Reservations/ReservationTests.cs create mode 100644 src/backend/tests/integration/Randall.Api.IntegrationTests/Workplaces/WorkplaceTests.cs 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); + } +}