Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
175b9a8
R001 Add guest CRUD, booking validation, and tests
pacorosa-unimedia Apr 26, 2026
4cb9381
R002 Add reservation conflict detection and handling
pacorosa-unimedia Apr 26, 2026
423fc8a
R002 Add CreateReservationTests with in-memory SQLite
pacorosa-unimedia Apr 26, 2026
96a6ddd
R003 Add staff login and reservations management UI/API
pacorosa-unimedia Apr 26, 2026
efc0668
R003 Use .NET user-secrets for staff access code config
pacorosa-unimedia Apr 26, 2026
d35d58a
R004 Add staff check-in feature for reservations
pacorosa-unimedia Apr 26, 2026
d3600d8
R006 Add housekeeping UI and room state management
pacorosa-unimedia Apr 26, 2026
de28513
R003 Update room state to Occupied on check-in
pacorosa-unimedia Apr 26, 2026
60fc314
R001 Update ReservationController to return 404 on NotFoundException
pacorosa-unimedia Apr 26, 2026
6473657
R003 Refactor staff auth logic into StaffAuth static class
pacorosa-unimedia Apr 26, 2026
dcd461c
R003 Improve SetRoomState error handling for missing rooms
pacorosa-unimedia Apr 26, 2026
885b212
R003 Add staff-only auth policy and secure API endpoints
pacorosa-unimedia Apr 26, 2026
1800c6b
R004 Prevent check-in if reservation is already checked out
pacorosa-unimedia Apr 26, 2026
5a1582f
R004 Add CheckInTests for reservation and room state logic
pacorosa-unimedia Apr 26, 2026
58b3f4b
R004 Update check-in logic to require room is 'Ready'
pacorosa-unimedia Apr 26, 2026
a89114a
R004 Update API endpoints to use absolute paths
pacorosa-unimedia Apr 26, 2026
c0c89ac
R004 Refactor room number validation in controllers
pacorosa-unimedia Apr 26, 2026
ed1d134
R004 Set cookie Path to /api in StaffController
pacorosa-unimedia Apr 26, 2026
c55bb1f
R001 Check for column before ALTER TABLE in Guests
pacorosa-unimedia Apr 26, 2026
5a2a7b8
test: add comprehensive tests for repositories, validators, and models
Copilot Apr 28, 2026
98cfd13
Merge pull request #1 from apkouk/copilot/improve-test-coverage
apkouk Apr 28, 2026
78c3fe5
Update api/Program.cs
apkouk Apr 28, 2026
74641cd
Improve reservation conflict checks and test coverage
pacorosa-unimedia Apr 28, 2026
8c83c45
Atomically check in reservations and improve DB setup
pacorosa-unimedia Apr 28, 2026
0b304b5
(feat) Add sortable table hook and improve staff reservations UI
pacorosa-unimedia Apr 28, 2026
63c96c8
Merge branch 'main' of https://github.com/apkouk/reservations-interview
pacorosa-unimedia Apr 28, 2026
d1ec5fb
(feat) Secure staff auth with data protection, add global error UI
pacorosa-unimedia Apr 28, 2026
28a365a
(refactor) Update auth, error handling, and repository logic
pacorosa-unimedia Apr 28, 2026
349eb15
(refactor) Update routing and add support for forwarded headers
pacorosa-unimedia Apr 28, 2026
566d933
(refactor) Add required field checks to BookingValidator
pacorosa-unimedia Apr 28, 2026
7745c74
(fix) Drop temp table and fix reservation date migration logic
pacorosa-unimedia Apr 28, 2026
6f39e95
(refactor) Improve Location header construction in Created response
pacorosa-unimedia Apr 28, 2026
9caf70b
(refactor) Remove security comment from AllowedHosts settings
pacorosa-unimedia Apr 28, 2026
9218f96
(refactor) Restrict trusted forwarded headers to loopback only
pacorosa-unimedia Apr 28, 2026
ac9bbb0
(refactor) Improve /error route search param validation
pacorosa-unimedia Apr 28, 2026
6961111
(refactor) Refactor ReservationDb and add check-in error handling
pacorosa-unimedia Apr 28, 2026
e58731a
(fix) Convert room number to int and validate room update
pacorosa-unimedia Apr 28, 2026
8d78a41
(refactors) Refactor API responses, error handling, and sorting logic
pacorosa-unimedia Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
.DS_Store
.DS_Store

# Copilot plan snapshots
.copilot/
.vs/CopilotSnapshots

# .NET build outputs
*.dll
bin/
obj/
/.vs
120 changes: 120 additions & 0 deletions api.Tests/BookingValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Models;
using Models.Errors;
using NUnit.Framework;
using Validators;

namespace api.Tests
{
[TestFixture]
public class BookingValidatorTests
{
// ── helpers ─────────────────────────────────────────────────────────────

private static Reservation ValidBooking(
string roomNumber = "101",
string email = "guest@example.com",
DateTime? start = null,
DateTime? end = null
)
{
var s = start ?? DateTime.Today;
var e = end ?? DateTime.Today.AddDays(3);
return new Reservation
{
Id = Guid.NewGuid(),
RoomNumber = roomNumber,
GuestEmail = email,
Start = s,
End = e,
};
}

// ── valid room numbers ───────────────────────────────────────────────────

[TestCase("101")]
[TestCase("102")]
[TestCase("103")]
[TestCase("104")]
[TestCase("105")]
[TestCase("201")]
[TestCase("202")]
[TestCase("203")]
[TestCase("901")]
[TestCase("910")]
public void Validate_ValidRoomNumber_DoesNotThrow(string roomNumber)
{
var booking = ValidBooking(roomNumber: roomNumber);
Assert.DoesNotThrow(() => BookingValidator.Validate(booking));
}

// ── invalid room numbers ─────────────────────────────────────────────────

[TestCase("000", TestName = "RoomNumber_000_AllZeros")]
[TestCase("100", TestName = "RoomNumber_100_DoorIsZeroZero")]
[TestCase("200", TestName = "RoomNumber_200_DoorIsZeroZero")]
[TestCase("900", TestName = "RoomNumber_900_DoorIsZeroZero")]
[TestCase("0", TestName = "RoomNumber_SingleDigit")]
[TestCase("1", TestName = "RoomNumber_SingleDigit_1")]
[TestCase("2020",TestName = "RoomNumber_FourDigits")]
[TestCase("-101",TestName = "RoomNumber_Negative")]
[TestCase("abc", TestName = "RoomNumber_NonNumeric")]
[TestCase("", TestName = "RoomNumber_Empty")]
public void Validate_InvalidRoomNumber_ThrowsInvalidBooking(string roomNumber)
{
var booking = ValidBooking(roomNumber: roomNumber);
Assert.Throws<InvalidBooking>(() => BookingValidator.Validate(booking));
}

// ── email validation ─────────────────────────────────────────────────────

[TestCase("guest@example.com")]
[TestCase("a@b.io")]
[TestCase("first.last+tag@sub.domain.org")]
public void Validate_ValidEmail_DoesNotThrow(string email)
{
var booking = ValidBooking(email: email);
Assert.DoesNotThrow(() => BookingValidator.Validate(booking));
}

[TestCase("notanemail", TestName = "Email_NoAtSign")]
[TestCase("missing@domain", TestName = "Email_NoDotInDomain")]
[TestCase("@nodomain.com", TestName = "Email_EmptyLocalPart")]
[TestCase("", TestName = "Email_Empty")]
[TestCase("spaces @a.com", TestName = "Email_SpaceInLocal")]
public void Validate_InvalidEmail_ThrowsInvalidBooking(string email)
{
var booking = ValidBooking(email: email);
Assert.Throws<InvalidBooking>(() => BookingValidator.Validate(booking));
}

// ── date range validation ────────────────────────────────────────────────

[TestCase(1, TestName = "Duration_1Day_IsValid")]
[TestCase(15, TestName = "Duration_15Days_IsValid")]
[TestCase(30, TestName = "Duration_30Days_IsValid")]
public void Validate_ValidDuration_DoesNotThrow(int days)
{
var start = DateTime.Today;
var booking = ValidBooking(start: start, end: start.AddDays(days));
Assert.DoesNotThrow(() => BookingValidator.Validate(booking));
}

[TestCase(0, TestName = "Duration_SameDay_StartEqualsEnd")]
[TestCase(-1, TestName = "Duration_EndBeforeStart")]
public void Validate_StartNotBeforeEnd_ThrowsInvalidBooking(int daysOffset)
{
var start = DateTime.Today;
var booking = ValidBooking(start: start, end: start.AddDays(daysOffset));
Assert.Throws<InvalidBooking>(() => BookingValidator.Validate(booking));
}

[TestCase(31, TestName = "Duration_31Days_ExceedsMaximum")]
[TestCase(60, TestName = "Duration_60Days_ExceedsMaximum")]
public void Validate_DurationExceedsMaximum_ThrowsInvalidBooking(int days)
{
var start = DateTime.Today;
var booking = ValidBooking(start: start, end: start.AddDays(days));
Assert.Throws<InvalidBooking>(() => BookingValidator.Validate(booking));
}
}
}
198 changes: 198 additions & 0 deletions api.Tests/CheckForConflictTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using Dapper;
using Microsoft.Data.Sqlite;
using Models;
using Models.Errors;
using NUnit.Framework;
using Repositories;

namespace api.Tests
{
/// <summary>
/// Tests for the conflict-detection logic inside ReservationRepository.CheckForConflict,
/// exercised indirectly via CreateReservation (the only public entry point).
///
/// Interval model: [Start, End) — the standard half-open hotel night convention.
/// Two reservations on the same room conflict when: existingStart < newEnd AND existingEnd > newStart
/// </summary>
[TestFixture]
public class CheckForConflictTests
{
private SqliteConnection _db = null!;
private ReservationRepository _repo = null!;

[SetUp]
public async Task SetUp()
{
_db = new SqliteConnection("Data Source=:memory:");
await _db.OpenAsync();

await _db.ExecuteAsync(@"
CREATE TABLE Guests (
Email TEXT PRIMARY KEY NOT NULL,
Name TEXT NOT NULL,
Surname TEXT
);
CREATE TABLE Rooms (
Number INT PRIMARY KEY NOT NULL,
State INT NOT NULL
);
CREATE TABLE Reservations (
Id TEXT PRIMARY KEY NOT NULL,
GuestEmail TEXT NOT NULL,
RoomNumber INT NOT NULL,
Start TEXT NOT NULL,
End TEXT NOT NULL,
CheckedIn INT NOT NULL DEFAULT 0,
CheckedOut INT NOT NULL DEFAULT 0
);
INSERT INTO Guests (Email, Name) VALUES ('guest@example.com', 'Test Guest');
INSERT INTO Rooms (Number, State) VALUES (101, 0);
INSERT INTO Rooms (Number, State) VALUES (102, 0);
");

_repo = new ReservationRepository(_db);
}

[TearDown]
public void TearDown() => _db.Dispose();

private static Reservation Make(string room, string start, string end) => new()
{
Id = Guid.NewGuid(),
RoomNumber = room,
GuestEmail = "guest@example.com",
Start = DateTime.Parse(start),
End = DateTime.Parse(end),
};

// ── Overlapping — must throw ConflictException ────────────────────────────

// existing: |-------|
// new: |-------|
[TestCase("101", "2025-06-01", "2025-06-10", "2025-06-07", "2025-06-15",
TestName = "Conflict_NewStartsInsideExisting_Throws")]

// existing: |-------|
// new: |-------|
[TestCase("101", "2025-06-07", "2025-06-15", "2025-06-01", "2025-06-10",
TestName = "Conflict_NewEndsInsideExisting_Throws")]

// existing: |---------------|
// new: |-------|
[TestCase("101", "2025-06-01", "2025-06-20", "2025-06-05", "2025-06-15",
TestName = "Conflict_NewContainedByExisting_Throws")]

// existing: |-------|
// new: |---------------|
[TestCase("101", "2025-06-05", "2025-06-15", "2025-06-01", "2025-06-20",
TestName = "Conflict_NewContainsExisting_Throws")]

// existing: |-------|
// new: |-------|
[TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-10",
TestName = "Conflict_ExactSameDates_Throws")]

// existing: |-------|
// new: |---|
[TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-05",
TestName = "Conflict_NewSameStartShorter_Throws")]

// existing: |-------|
// new: |---|
[TestCase("101", "2025-06-01", "2025-06-10", "2025-06-05", "2025-06-10",
TestName = "Conflict_NewSameEndLonger_Throws")]

// existing: |-|
// new: |-------|
[TestCase("101", "2025-06-03", "2025-06-05", "2025-06-01", "2025-06-10",
TestName = "Conflict_ExistingShorterInsideNew_Throws")]
public async Task CheckForConflict_OverlappingRanges_ThrowsConflictException(
string room,
string existStart, string existEnd,
string newStart, string newEnd)
{
await _repo.CreateReservation(Make(room, existStart, existEnd));

Assert.ThrowsAsync<ConflictException>(
() => _repo.CreateReservation(Make(room, newStart, newEnd)));
}

// ── Non-overlapping — must NOT throw ─────────────────────────────────────

// existing: |-------|
// new: |-------| (new starts exactly when existing ends)
[TestCase("101", "2025-06-01", "2025-06-10", "2025-06-10", "2025-06-20",
TestName = "NoConflict_AdjacentNewAfter_DoesNotThrow")]

// existing: |-------|
// new: |-------| (new ends exactly when existing starts)
[TestCase("101", "2025-06-10", "2025-06-20", "2025-06-01", "2025-06-10",
TestName = "NoConflict_AdjacentNewBefore_DoesNotThrow")]

// existing: |-------|
// new: |--| (gap between)
[TestCase("101", "2025-06-01", "2025-06-05", "2025-06-10", "2025-06-15",
TestName = "NoConflict_NewStartsAfterGap_DoesNotThrow")]

// existing: |-------|
// new: |--| (gap between)
[TestCase("101", "2025-06-10", "2025-06-15", "2025-06-01", "2025-06-05",
TestName = "NoConflict_NewEndsBeforeGap_DoesNotThrow")]

// same dates but different room — never a conflict
[TestCase("101", "2025-06-01", "2025-06-10", "2025-06-01", "2025-06-10",
TestName = "NoConflict_SameDatesButDifferentRoom_DoesNotThrow")]
public async Task CheckForConflict_NonOverlappingRanges_DoesNotThrow(
string room,
string existStart, string existEnd,
string newStart, string newEnd)
{
await _repo.CreateReservation(Make(room, existStart, existEnd));

// For the different-room case, point the new reservation at room 102
var newRoom = (room == "101" && newStart == existStart && newEnd == existEnd) ? "102" : room;

Assert.DoesNotThrowAsync(
() => _repo.CreateReservation(Make(newRoom, newStart, newEnd)));
}

// ── Multiple existing reservations ───────────────────────────────────────

// Ensures the query finds a conflict even when there are multiple rows
// and the conflict is not with the first-inserted one.
[TestCase("101", "2025-06-01", "2025-06-05",
"2025-06-10", "2025-06-15",
"2025-06-12", "2025-06-18",
TestName = "Conflict_SecondOfTwoExistingReservations_Throws")]
public async Task CheckForConflict_ConflictsWithSecondExistingReservation_Throws(
string room,
string exist1Start, string exist1End,
string exist2Start, string exist2End,
string newStart, string newEnd)
{
await _repo.CreateReservation(Make(room, exist1Start, exist1End));
await _repo.CreateReservation(Make(room, exist2Start, exist2End));

Assert.ThrowsAsync<ConflictException>(
() => _repo.CreateReservation(Make(room, newStart, newEnd)));
}

// A reservation that fits cleanly in a gap between two existing ones must succeed.
[TestCase("101", "2025-06-01", "2025-06-05",
"2025-06-10", "2025-06-15",
"2025-06-05", "2025-06-10",
TestName = "NoConflict_FitsInGapBetweenTwoExisting_DoesNotThrow")]
public async Task CheckForConflict_FitsInGapBetweenTwoReservations_DoesNotThrow(
string room,
string exist1Start, string exist1End,
string exist2Start, string exist2End,
string newStart, string newEnd)
{
await _repo.CreateReservation(Make(room, exist1Start, exist1End));
await _repo.CreateReservation(Make(room, exist2Start, exist2End));

Assert.DoesNotThrowAsync(
() => _repo.CreateReservation(Make(room, newStart, newEnd)));
}
}
}
Loading