From b1a9e92e76d1317f31d4835ebbedc6f9dde75ea3 Mon Sep 17 00:00:00 2001 From: Zachary Greenfield Date: Sat, 24 Jan 2026 22:39:14 -0600 Subject: [PATCH] feat: Add Student CRUD Controller --- .agent/rules/rules.md | 151 ++++++++++++++++++ .../Controllers/StudentController.cs | 84 ++++++++++ DotNetExample.DataAccess/StudentRepository.cs | 40 +++++ .../Wrappers/IMongoDbWrapper.cs | 12 ++ .../Commands/CreateStudent.cs | 19 +++ .../Commands/DeleteStudent.cs | 18 +++ .../Commands/ICreateStudent.cs | 8 + .../Commands/IDeleteStudent.cs | 6 + .../Commands/IUpdateStudent.cs | 8 + .../Commands/UpdateStudent.cs | 19 +++ .../DataAccess/IStudentRepository.cs | 12 ++ DotNetExample.Domain/Entities/Student.cs | 9 ++ .../Queries/GetAllStudents.cs | 19 +++ DotNetExample.Domain/Queries/GetStudent.cs | 19 +++ .../Queries/IGetAllStudents.cs | 8 + DotNetExample.Domain/Queries/IGetStudent.cs | 8 + DotNetExample.IoC/RegisterDependencies.cs | 9 ++ .../StudentControllerTests/CreateShould.cs | 55 +++++++ .../StudentControllerTests/DeleteShould.cs | 61 +++++++ .../StudentControllerTests/GetAllShould.cs | 52 ++++++ .../Api/StudentControllerTests/GetShould.cs | 67 ++++++++ .../StudentControllerTests/UpdateShould.cs | 63 ++++++++ 22 files changed, 747 insertions(+) create mode 100644 .agent/rules/rules.md create mode 100644 DotNetExample.Api/Controllers/StudentController.cs create mode 100644 DotNetExample.DataAccess/StudentRepository.cs create mode 100644 DotNetExample.DataAccess/Wrappers/IMongoDbWrapper.cs create mode 100644 DotNetExample.Domain/Commands/CreateStudent.cs create mode 100644 DotNetExample.Domain/Commands/DeleteStudent.cs create mode 100644 DotNetExample.Domain/Commands/ICreateStudent.cs create mode 100644 DotNetExample.Domain/Commands/IDeleteStudent.cs create mode 100644 DotNetExample.Domain/Commands/IUpdateStudent.cs create mode 100644 DotNetExample.Domain/Commands/UpdateStudent.cs create mode 100644 DotNetExample.Domain/DataAccess/IStudentRepository.cs create mode 100644 DotNetExample.Domain/Entities/Student.cs create mode 100644 DotNetExample.Domain/Queries/GetAllStudents.cs create mode 100644 DotNetExample.Domain/Queries/GetStudent.cs create mode 100644 DotNetExample.Domain/Queries/IGetAllStudents.cs create mode 100644 DotNetExample.Domain/Queries/IGetStudent.cs create mode 100644 DotNetExample.Tests/Api/StudentControllerTests/CreateShould.cs create mode 100644 DotNetExample.Tests/Api/StudentControllerTests/DeleteShould.cs create mode 100644 DotNetExample.Tests/Api/StudentControllerTests/GetAllShould.cs create mode 100644 DotNetExample.Tests/Api/StudentControllerTests/GetShould.cs create mode 100644 DotNetExample.Tests/Api/StudentControllerTests/UpdateShould.cs diff --git a/.agent/rules/rules.md b/.agent/rules/rules.md new file mode 100644 index 0000000..3e3d0cf --- /dev/null +++ b/.agent/rules/rules.md @@ -0,0 +1,151 @@ +--- +trigger: always_on +--- + +# AI Coding Standards & Project Specification + +This document serves as the **Single Source of Truth** for AI Agents and Developers working on this solution. It combines Architectural Standards, Technology Choices, and strict Test Driven Development (TDD) protocols. + +## 1. High-Level Architecture + +All solutions must follow a **Clean Architecture** (Onion Architecture) style. The dependency flow is strictly unidirectional, pointing "inward" toward the Domain. + +### Dependency Graph +```mermaid +graph TD + IoC["[ProjectName].IoC"] --> Api["[ProjectName].Api"] + IoC --> DataAccess["[ProjectName].DataAccess"] + IoC --> Domain["[ProjectName].Domain"] + Api --> Domain + DataAccess --> Domain +``` + +### Component Responsibilities +1. **[ProjectName].Domain** (The Core) + * **Role**: Entities, Value Objects, Logic, Commands, Queries, Repository Interfaces. + * **Dependencies**: NONE. +2. **[ProjectName].DataAccess** (The Infrastructure) + * **Role**: Implements Repository Interfaces (Entity Framework/Dapper). + * **Dependencies**: Domain. +3. **[ProjectName].Api** (The Presentation) + * **Role**: REST/HTTP Endpoints, Controllers, DTOs. + * **Dependencies**: Domain. (NO DataAccess reference). +4. **[ProjectName].IoC** (The Composition Root) + * **Role**: `Program.cs`, Dependency Injection wiring. + * **Dependencies**: Api, DataAccess, Domain. + +## 2. Technology Stack + +* **Framework**: .NET Standard / Latest LTS (.NET 8/10+) +* **Language**: C# (Latest) +* **Testing**: MSTest +* **Assertions**: FluentAssertions + * **Rule**: Assert on Return Values or State Changes whenever possible. + * **Fallback**: Use `Received()` (interaction testing) ONLY for void methods where no state change or return value can be observed (e.g., logging, or the final boundary call). + +### Dependencies Checklist +Ensure the following are installed/available before starting work: +1. **MSTest.Sdk**: Core testing framework. +2. **FluentAssertions**: For expressive assertions. +3. **NSubstitute**: For mocking. +4. **Microsoft.AspNetCore.App**: Framework Reference (Required for API Integration Tests). + +## 3. Development Workflow: Test Driven Development (TDD) + +**CRITICAL INSTRUCTION**: AI Agents must strictly adhere to the **Red/Green/Refactor** cycle. You are FORBIDDEN from writing implementation code without a verified failing test. + +### Test Coverage Requirements +Every feature MUST cover the following scenarios (where applicable): +1. **Happy Path**: + * **200 OK**: Request succeeded with data. + * **201 Created**: Resource successfully created. + * **204 No Content**: Action succeeded (Update/Delete). +2. **Unhappy Paths (Error Handling)**: + * **400 Bad Request**: Validation failure (Invalid model, missing fields). + * **404 Not Found**: Resource does not exist (Get/Update/Delete). + * **500 Internal Server Error**: Unexpected downstream failures (Mocked Exception from Wrapper). + +### Phase 1: RED (Write Test) +1. **Goal**: Write a failing test for a specific behavior. +2. **Action**: Create a test file following the [File Organization](#5-file-organization--naming-conventions) rules. +3. **Best Practice**: Use **Randomized Data** via the `RandomTestValues` package instead of static constants or manual random calls. + * *Why?* Prevents "hardcoding" solutions (e.g., `return 0`) that accidentally pass static tests. It forces the implementation to handle dynamic input. +4. **Run**: `dotnet test --filter `. +5. **Validation**: + * MUST fail. + * If logic is missing, write *stubs* (throw `NotImplementedException`) to make it compile, then confirm failure. + +### Phase 2: GREEN (Simplest Implementation) +1. **Goal**: Pass the test with the *minimum code necessary*. +2. **Rule**: Do not implement full algorithms yet. If `Add(0,0)` expects `0`, return `0`. + * *Why?* To force the creation of the next test case. +3. **Validation**: Run test. It must pass. + +### Phase 3: REFACTOR +1. **Goal**: Clean up code/tests without changing behavior. +2. **Action**: + * **Clean Code Principles**: Use expressive variable names. + * **Comments**: + * **REQUIRED**: Use `// Arrange`, `// Act`, `// Assert` comments to clearly delineate test phases. + * **FORBIDDEN**: Avoid inline comments explaining *WHAT* or *WHY* (e.g., `// Using real instance...`). The code structure should be self-explanatory. Extract logic to private methods with descriptive names instead. + * **Structure**: Remove magic strings, extract methods. Use `TestBase` classes to reduce setup boilerplate. + +### Phase 4: ITERATE +1. **Goal**: Evolve logic by adding new test cases. +2. **Action**: Add the next test case (e.g., `Add(1,1)` returns `2`). + * **Technique**: Use `[DataRow]`, `[TestCase]`, or similar attributes to modify existing tests or add new permutations efficiently. + +### Mocking Rules & Isolation +* **Architectural Rule**: The DataAccess layer must implement the **Repository Pattern** by using a **Wrapper Pattern** for the underlying store (e.g., `IMongoDbWrapper`, `ISqlWrapper`). + * *Reason*: Allows testing business logic with real Repository implementations but mocked low-level drivers. +* **ALLOWED**: Mocking **Infrastructure Wrappers** (e.g., `IMongoDbWrapper`, `IHttpClientWrapper`, `IDateTimeWrapper`). +* **FORBIDDEN**: Mocking **Domain Repository Interfaces** (e.g., `IStudentRepository`) in logical tests. + * *Correction*: When testing Commands/Queries, use the **REAL** Repository implementation injected with a **MOCKED** Wrapper. +* **FORBIDDEN**: Mocking internal domain entities or value objects. Use real instances. + +## 4. Design Patterns & Conventions + +### CQRS (Command Query Responsibility Segregation) +* **Commands (Write)**: `[ProjectName].Domain/Commands`. Return `Task`. +* **Queries (Read)**: `[ProjectName].Domain/Queries`. Return `Task`. +* **Handlers**: Implement specific interfaces, inject Repository Interfaces. + +### Centralized Dependency Injection +* **No Magic in API**: `Program.cs` resides in **IoC** project. +* **Registration**: `IoC` project coordinates `services.Add[LayerName]()` calls. + +## 5. File Organization & Naming Conventions + +### Directory Structure +```text +[SolutionName]/ +├── [ProjectName].Api/ # Controllers, ApiConfig +├── [ProjectName].DataAccess/ # Repositories +├── [ProjectName].Domain/ # Commands, Queries, Entities, Interfaces +├── [ProjectName].IoC/ # Program.cs, RegisterDependencies +└── [ProjectName].Tests/ # MSTest Project +``` + +### Test File Granularity / Naming +To maintain context and clarity, use granular test files: +* **Pattern**: `[ProjectName].Tests/[Namespace]/[ClassName]Tests/[MethodName]_[Scenario].cs` +* **Example**: + * `Api/FooControllerTests/GetShould_ReturnOk.cs` + * `Api/FooControllerTests/GetShould_Throw_WhenInvalid.cs` +* **Tip**: Use `partial class` for shared setup if splitting tests for the same method. + +## 6. Instructions for AI Code Generation + +**CRITICAL: ITERATIVE WORKFLOW REQUIRED** +* **Small Steps**: Do NOT generate large amounts of code at once. +* **Stop & Check**: After *every* file change or test run, pause and evaluate next steps. +* **No "Overshooting"**: Do not anticipate future requirements. Solve ONLY the current failing test. +* **Maintainability**: Enterprise-grade code requires careful adherence to patterns. Rushing breaks design. + +When asked to implement a feature: +1. **Check Context**: Do I understand the requirement? +2. **Plan TDD**: What is the FIRST failing test? +3. **Execute Cycle**: + * Create Test -> Run (Fail) -> Implement (Pass) -> Refactor. + * Repeat until feature is complete. +4. **Conformity**: Ensure all new files land in the correct folders (Clean Architecture) and use specified namespaces. diff --git a/DotNetExample.Api/Controllers/StudentController.cs b/DotNetExample.Api/Controllers/StudentController.cs new file mode 100644 index 0000000..cca2177 --- /dev/null +++ b/DotNetExample.Api/Controllers/StudentController.cs @@ -0,0 +1,84 @@ +using DotNetExample.Domain.Commands; +using DotNetExample.Domain.Entities; +using DotNetExample.Domain.Queries; + +using Microsoft.AspNetCore.Mvc; + +namespace DotNetExample.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class StudentController : ControllerBase +{ + private readonly ICreateStudent _createStudent; + private readonly IGetStudent _getStudent; + private readonly IGetAllStudents _getAllStudents; + private readonly IUpdateStudent _updateStudent; + private readonly IDeleteStudent _deleteStudent; + + public StudentController( + ICreateStudent createStudent, + IGetStudent getStudent, + IGetAllStudents getAllStudents, + IUpdateStudent updateStudent, + IDeleteStudent deleteStudent) + { + _createStudent = createStudent; + _getStudent = getStudent; + _getAllStudents = getAllStudents; + _updateStudent = updateStudent; + _deleteStudent = deleteStudent; + } + + [HttpPost] + public async Task Create([FromBody] Student student) + { + await _createStudent.ExecuteAsync(student); + return CreatedAtAction(nameof(Get), new { id = student.Id }, student); + } + + [HttpGet("{id}")] + public async Task Get(string id) + { + var student = await _getStudent.ExecuteAsync(id); + if (student == null) + { + return NotFound(); + } + return Ok(student); + } + + [HttpGet] + public async Task GetAll() + { + var students = await _getAllStudents.ExecuteAsync(); + return Ok(students); + } + + [HttpPut("{id}")] + public async Task Update(string id, [FromBody] Student student) + { + var existingStudent = await _getStudent.ExecuteAsync(id); + if (existingStudent == null) + { + return NotFound(); + } + + student.Id = id; + await _updateStudent.ExecuteAsync(student); + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task Delete(string id) + { + var existingStudent = await _getStudent.ExecuteAsync(id); + if (existingStudent == null) + { + return NotFound(); + } + + await _deleteStudent.ExecuteAsync(id); + return NoContent(); + } +} diff --git a/DotNetExample.DataAccess/StudentRepository.cs b/DotNetExample.DataAccess/StudentRepository.cs new file mode 100644 index 0000000..7a89e6d --- /dev/null +++ b/DotNetExample.DataAccess/StudentRepository.cs @@ -0,0 +1,40 @@ +using DotNetExample.DataAccess.Wrappers; +using DotNetExample.Domain.DataAccess; +using DotNetExample.Domain.Entities; + +namespace DotNetExample.DataAccess; + +public class StudentRepository : IStudentRepository +{ + private readonly IMongoDbWrapper _mongoDbWrapper; + + public StudentRepository(IMongoDbWrapper mongoDbWrapper) + { + _mongoDbWrapper = mongoDbWrapper; + } + + public Task GetByIdAsync(string id) + { + return _mongoDbWrapper.GetStudentByIdAsync(id); + } + + public Task> GetAllAsync() + { + return _mongoDbWrapper.GetAllStudentsAsync(); + } + + public Task CreateAsync(Student student) + { + return _mongoDbWrapper.InsertStudentAsync(student); + } + + public Task UpdateAsync(Student student) + { + return _mongoDbWrapper.UpdateStudentAsync(student); + } + + public Task DeleteAsync(string id) + { + return _mongoDbWrapper.DeleteStudentAsync(id); + } +} diff --git a/DotNetExample.DataAccess/Wrappers/IMongoDbWrapper.cs b/DotNetExample.DataAccess/Wrappers/IMongoDbWrapper.cs new file mode 100644 index 0000000..1b091b6 --- /dev/null +++ b/DotNetExample.DataAccess/Wrappers/IMongoDbWrapper.cs @@ -0,0 +1,12 @@ +using DotNetExample.Domain.Entities; + +namespace DotNetExample.DataAccess.Wrappers; + +public interface IMongoDbWrapper +{ + Task GetStudentByIdAsync(string id); + Task> GetAllStudentsAsync(); + Task InsertStudentAsync(Student student); + Task UpdateStudentAsync(Student student); + Task DeleteStudentAsync(string id); +} diff --git a/DotNetExample.Domain/Commands/CreateStudent.cs b/DotNetExample.Domain/Commands/CreateStudent.cs new file mode 100644 index 0000000..d0a0adf --- /dev/null +++ b/DotNetExample.Domain/Commands/CreateStudent.cs @@ -0,0 +1,19 @@ +using DotNetExample.Domain.DataAccess; +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Commands; + +public class CreateStudent : ICreateStudent +{ + private readonly IStudentRepository _studentRepository; + + public CreateStudent(IStudentRepository studentRepository) + { + _studentRepository = studentRepository; + } + + public Task ExecuteAsync(Student student) + { + return _studentRepository.CreateAsync(student); + } +} diff --git a/DotNetExample.Domain/Commands/DeleteStudent.cs b/DotNetExample.Domain/Commands/DeleteStudent.cs new file mode 100644 index 0000000..b9bc239 --- /dev/null +++ b/DotNetExample.Domain/Commands/DeleteStudent.cs @@ -0,0 +1,18 @@ +using DotNetExample.Domain.DataAccess; + +namespace DotNetExample.Domain.Commands; + +public class DeleteStudent : IDeleteStudent +{ + private readonly IStudentRepository _studentRepository; + + public DeleteStudent(IStudentRepository studentRepository) + { + _studentRepository = studentRepository; + } + + public Task ExecuteAsync(string id) + { + return _studentRepository.DeleteAsync(id); + } +} diff --git a/DotNetExample.Domain/Commands/ICreateStudent.cs b/DotNetExample.Domain/Commands/ICreateStudent.cs new file mode 100644 index 0000000..9c0d518 --- /dev/null +++ b/DotNetExample.Domain/Commands/ICreateStudent.cs @@ -0,0 +1,8 @@ +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Commands; + +public interface ICreateStudent +{ + Task ExecuteAsync(Student student); +} diff --git a/DotNetExample.Domain/Commands/IDeleteStudent.cs b/DotNetExample.Domain/Commands/IDeleteStudent.cs new file mode 100644 index 0000000..ba21d4d --- /dev/null +++ b/DotNetExample.Domain/Commands/IDeleteStudent.cs @@ -0,0 +1,6 @@ +namespace DotNetExample.Domain.Commands; + +public interface IDeleteStudent +{ + Task ExecuteAsync(string id); +} diff --git a/DotNetExample.Domain/Commands/IUpdateStudent.cs b/DotNetExample.Domain/Commands/IUpdateStudent.cs new file mode 100644 index 0000000..1376204 --- /dev/null +++ b/DotNetExample.Domain/Commands/IUpdateStudent.cs @@ -0,0 +1,8 @@ +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Commands; + +public interface IUpdateStudent +{ + Task ExecuteAsync(Student student); +} diff --git a/DotNetExample.Domain/Commands/UpdateStudent.cs b/DotNetExample.Domain/Commands/UpdateStudent.cs new file mode 100644 index 0000000..99f6f94 --- /dev/null +++ b/DotNetExample.Domain/Commands/UpdateStudent.cs @@ -0,0 +1,19 @@ +using DotNetExample.Domain.DataAccess; +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Commands; + +public class UpdateStudent : IUpdateStudent +{ + private readonly IStudentRepository _studentRepository; + + public UpdateStudent(IStudentRepository studentRepository) + { + _studentRepository = studentRepository; + } + + public Task ExecuteAsync(Student student) + { + return _studentRepository.UpdateAsync(student); + } +} diff --git a/DotNetExample.Domain/DataAccess/IStudentRepository.cs b/DotNetExample.Domain/DataAccess/IStudentRepository.cs new file mode 100644 index 0000000..e88c6b5 --- /dev/null +++ b/DotNetExample.Domain/DataAccess/IStudentRepository.cs @@ -0,0 +1,12 @@ +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.DataAccess; + +public interface IStudentRepository +{ + Task GetByIdAsync(string id); + Task> GetAllAsync(); + Task CreateAsync(Student student); + Task UpdateAsync(Student student); + Task DeleteAsync(string id); +} diff --git a/DotNetExample.Domain/Entities/Student.cs b/DotNetExample.Domain/Entities/Student.cs new file mode 100644 index 0000000..4c45713 --- /dev/null +++ b/DotNetExample.Domain/Entities/Student.cs @@ -0,0 +1,9 @@ +namespace DotNetExample.Domain.Entities; + +public class Student +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public double Gpa { get; set; } + public string Grade { get; set; } = string.Empty; +} diff --git a/DotNetExample.Domain/Queries/GetAllStudents.cs b/DotNetExample.Domain/Queries/GetAllStudents.cs new file mode 100644 index 0000000..24317cb --- /dev/null +++ b/DotNetExample.Domain/Queries/GetAllStudents.cs @@ -0,0 +1,19 @@ +using DotNetExample.Domain.DataAccess; +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Queries; + +public class GetAllStudents : IGetAllStudents +{ + private readonly IStudentRepository _studentRepository; + + public GetAllStudents(IStudentRepository studentRepository) + { + _studentRepository = studentRepository; + } + + public Task> ExecuteAsync() + { + return _studentRepository.GetAllAsync(); + } +} diff --git a/DotNetExample.Domain/Queries/GetStudent.cs b/DotNetExample.Domain/Queries/GetStudent.cs new file mode 100644 index 0000000..242a62a --- /dev/null +++ b/DotNetExample.Domain/Queries/GetStudent.cs @@ -0,0 +1,19 @@ +using DotNetExample.Domain.DataAccess; +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Queries; + +public class GetStudent : IGetStudent +{ + private readonly IStudentRepository _studentRepository; + + public GetStudent(IStudentRepository studentRepository) + { + _studentRepository = studentRepository; + } + + public Task ExecuteAsync(string id) + { + return _studentRepository.GetByIdAsync(id); + } +} diff --git a/DotNetExample.Domain/Queries/IGetAllStudents.cs b/DotNetExample.Domain/Queries/IGetAllStudents.cs new file mode 100644 index 0000000..4ad96d6 --- /dev/null +++ b/DotNetExample.Domain/Queries/IGetAllStudents.cs @@ -0,0 +1,8 @@ +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Queries; + +public interface IGetAllStudents +{ + Task> ExecuteAsync(); +} diff --git a/DotNetExample.Domain/Queries/IGetStudent.cs b/DotNetExample.Domain/Queries/IGetStudent.cs new file mode 100644 index 0000000..9a37ede --- /dev/null +++ b/DotNetExample.Domain/Queries/IGetStudent.cs @@ -0,0 +1,8 @@ +using DotNetExample.Domain.Entities; + +namespace DotNetExample.Domain.Queries; + +public interface IGetStudent +{ + Task ExecuteAsync(string id); +} diff --git a/DotNetExample.IoC/RegisterDependencies.cs b/DotNetExample.IoC/RegisterDependencies.cs index 6d5a421..e95000b 100644 --- a/DotNetExample.IoC/RegisterDependencies.cs +++ b/DotNetExample.IoC/RegisterDependencies.cs @@ -15,5 +15,14 @@ public static void Register(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddSingleton(); + + // Student services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } + } diff --git a/DotNetExample.Tests/Api/StudentControllerTests/CreateShould.cs b/DotNetExample.Tests/Api/StudentControllerTests/CreateShould.cs new file mode 100644 index 0000000..1ca239f --- /dev/null +++ b/DotNetExample.Tests/Api/StudentControllerTests/CreateShould.cs @@ -0,0 +1,55 @@ +using DotNetExample.Api.Controllers; +using DotNetExample.Domain.Commands; +using DotNetExample.Domain.Entities; +using DotNetExample.Domain.Queries; + +using Microsoft.AspNetCore.Mvc; + +using NSubstitute; + +namespace DotNetExample.Tests.Api.StudentControllerTests; + +[TestClass] +public partial class CreateShould +{ + private ICreateStudent _createStudent; + private IGetStudent _getStudent; + private IGetAllStudents _getAllStudents; + private IUpdateStudent _updateStudent; + private IDeleteStudent _deleteStudent; + private StudentController _sut; + + [TestInitialize] + public void TestInitialize() + { + _createStudent = Substitute.For(); + _getStudent = Substitute.For(); + _getAllStudents = Substitute.For(); + _updateStudent = Substitute.For(); + _deleteStudent = Substitute.For(); + _sut = new StudentController(_createStudent, _getStudent, _getAllStudents, _updateStudent, _deleteStudent); + } + + [TestMethod] + public async Task ReturnCreated() + { + // Arrange + var student = new Student + { + Id = "123", + Name = "John Doe", + Gpa = 3.5, + Grade = "A" + }; + + // Act + var result = await _sut.Create(student); + + // Assert + Assert.IsInstanceOfType(result, typeof(CreatedAtActionResult)); + var createdResult = (CreatedAtActionResult)result; + Assert.AreEqual(nameof(StudentController.Get), createdResult.ActionName); + Assert.AreEqual(student, createdResult.Value); + await _createStudent.Received(1).ExecuteAsync(student); + } +} diff --git a/DotNetExample.Tests/Api/StudentControllerTests/DeleteShould.cs b/DotNetExample.Tests/Api/StudentControllerTests/DeleteShould.cs new file mode 100644 index 0000000..af8642c --- /dev/null +++ b/DotNetExample.Tests/Api/StudentControllerTests/DeleteShould.cs @@ -0,0 +1,61 @@ +using DotNetExample.Api.Controllers; +using DotNetExample.Domain.Commands; +using DotNetExample.Domain.Entities; +using DotNetExample.Domain.Queries; + +using Microsoft.AspNetCore.Mvc; + +using NSubstitute; + +namespace DotNetExample.Tests.Api.StudentControllerTests; + +[TestClass] +public partial class DeleteShould +{ + private ICreateStudent _createStudent; + private IGetStudent _getStudent; + private IGetAllStudents _getAllStudents; + private IUpdateStudent _updateStudent; + private IDeleteStudent _deleteStudent; + private StudentController _sut; + + [TestInitialize] + public void TestInitialize() + { + _createStudent = Substitute.For(); + _getStudent = Substitute.For(); + _getAllStudents = Substitute.For(); + _updateStudent = Substitute.For(); + _deleteStudent = Substitute.For(); + _sut = new StudentController(_createStudent, _getStudent, _getAllStudents, _updateStudent, _deleteStudent); + } + + [TestMethod] + public async Task ReturnNoContent() + { + // Arrange + var existingStudent = new Student { Id = "123", Name = "John Doe", Gpa = 3.5, Grade = "A" }; + _getStudent.ExecuteAsync("123").Returns(existingStudent); + + // Act + var result = await _sut.Delete("123"); + + // Assert + Assert.IsInstanceOfType(result, typeof(NoContentResult)); + await _deleteStudent.Received(1).ExecuteAsync("123"); + } + + [TestMethod] + public async Task ReturnNotFound_WhenStudentDoesNotExist() + { + // Arrange + _getStudent.ExecuteAsync("999").Returns((Student?)null); + + // Act + var result = await _sut.Delete("999"); + + // Assert + Assert.IsInstanceOfType(result, typeof(NotFoundResult)); + await _deleteStudent.DidNotReceive().ExecuteAsync(Arg.Any()); + } +} diff --git a/DotNetExample.Tests/Api/StudentControllerTests/GetAllShould.cs b/DotNetExample.Tests/Api/StudentControllerTests/GetAllShould.cs new file mode 100644 index 0000000..973afcb --- /dev/null +++ b/DotNetExample.Tests/Api/StudentControllerTests/GetAllShould.cs @@ -0,0 +1,52 @@ +using DotNetExample.Api.Controllers; +using DotNetExample.Domain.Commands; +using DotNetExample.Domain.Entities; +using DotNetExample.Domain.Queries; + +using Microsoft.AspNetCore.Mvc; + +using NSubstitute; + +namespace DotNetExample.Tests.Api.StudentControllerTests; + +[TestClass] +public partial class GetAllShould +{ + private ICreateStudent _createStudent; + private IGetStudent _getStudent; + private IGetAllStudents _getAllStudents; + private IUpdateStudent _updateStudent; + private IDeleteStudent _deleteStudent; + private StudentController _sut; + + [TestInitialize] + public void TestInitialize() + { + _createStudent = Substitute.For(); + _getStudent = Substitute.For(); + _getAllStudents = Substitute.For(); + _updateStudent = Substitute.For(); + _deleteStudent = Substitute.For(); + _sut = new StudentController(_createStudent, _getStudent, _getAllStudents, _updateStudent, _deleteStudent); + } + + [TestMethod] + public async Task ReturnOk() + { + // Arrange + var students = new List + { + new Student { Id = "1", Name = "John Doe", Gpa = 3.5, Grade = "A" }, + new Student { Id = "2", Name = "Jane Smith", Gpa = 3.8, Grade = "A+" } + }; + _getAllStudents.ExecuteAsync().Returns(students); + + // Act + var result = await _sut.GetAll(); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + Assert.AreEqual(students, okResult.Value); + } +} diff --git a/DotNetExample.Tests/Api/StudentControllerTests/GetShould.cs b/DotNetExample.Tests/Api/StudentControllerTests/GetShould.cs new file mode 100644 index 0000000..da9a414 --- /dev/null +++ b/DotNetExample.Tests/Api/StudentControllerTests/GetShould.cs @@ -0,0 +1,67 @@ +using DotNetExample.Api.Controllers; +using DotNetExample.Domain.Commands; +using DotNetExample.Domain.Entities; +using DotNetExample.Domain.Queries; + +using Microsoft.AspNetCore.Mvc; + +using NSubstitute; + +namespace DotNetExample.Tests.Api.StudentControllerTests; + +[TestClass] +public partial class GetShould +{ + private ICreateStudent _createStudent; + private IGetStudent _getStudent; + private IGetAllStudents _getAllStudents; + private IUpdateStudent _updateStudent; + private IDeleteStudent _deleteStudent; + private StudentController _sut; + + [TestInitialize] + public void TestInitialize() + { + _createStudent = Substitute.For(); + _getStudent = Substitute.For(); + _getAllStudents = Substitute.For(); + _updateStudent = Substitute.For(); + _deleteStudent = Substitute.For(); + _sut = new StudentController(_createStudent, _getStudent, _getAllStudents, _updateStudent, _deleteStudent); + } + + [TestMethod] + public async Task ReturnOk() + { + // Arrange + var student = new Student + { + Id = "123", + Name = "John Doe", + Gpa = 3.5, + Grade = "A" + }; + _getStudent.ExecuteAsync("123").Returns(student); + + // Act + var result = await _sut.Get("123"); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + Assert.AreEqual(student, okResult.Value); + } + + [TestMethod] + public async Task ReturnNotFound_WhenStudentDoesNotExist() + { + // Arrange + _getStudent.ExecuteAsync("999").Returns((Student?)null); + + // Act + var result = await _sut.Get("999"); + + // Assert + Assert.IsInstanceOfType(result, typeof(NotFoundResult)); + } +} diff --git a/DotNetExample.Tests/Api/StudentControllerTests/UpdateShould.cs b/DotNetExample.Tests/Api/StudentControllerTests/UpdateShould.cs new file mode 100644 index 0000000..7be44c2 --- /dev/null +++ b/DotNetExample.Tests/Api/StudentControllerTests/UpdateShould.cs @@ -0,0 +1,63 @@ +using DotNetExample.Api.Controllers; +using DotNetExample.Domain.Commands; +using DotNetExample.Domain.Entities; +using DotNetExample.Domain.Queries; + +using Microsoft.AspNetCore.Mvc; + +using NSubstitute; + +namespace DotNetExample.Tests.Api.StudentControllerTests; + +[TestClass] +public partial class UpdateShould +{ + private ICreateStudent _createStudent; + private IGetStudent _getStudent; + private IGetAllStudents _getAllStudents; + private IUpdateStudent _updateStudent; + private IDeleteStudent _deleteStudent; + private StudentController _sut; + + [TestInitialize] + public void TestInitialize() + { + _createStudent = Substitute.For(); + _getStudent = Substitute.For(); + _getAllStudents = Substitute.For(); + _updateStudent = Substitute.For(); + _deleteStudent = Substitute.For(); + _sut = new StudentController(_createStudent, _getStudent, _getAllStudents, _updateStudent, _deleteStudent); + } + + [TestMethod] + public async Task ReturnNoContent() + { + // Arrange + var existingStudent = new Student { Id = "123", Name = "John Doe", Gpa = 3.5, Grade = "A" }; + var updatedStudent = new Student { Id = "123", Name = "John Updated", Gpa = 3.9, Grade = "A+" }; + _getStudent.ExecuteAsync("123").Returns(existingStudent); + + // Act + var result = await _sut.Update("123", updatedStudent); + + // Assert + Assert.IsInstanceOfType(result, typeof(NoContentResult)); + await _updateStudent.Received(1).ExecuteAsync(Arg.Is(s => s.Id == "123")); + } + + [TestMethod] + public async Task ReturnNotFound_WhenStudentDoesNotExist() + { + // Arrange + var student = new Student { Id = "999", Name = "Non Existent", Gpa = 3.0, Grade = "B" }; + _getStudent.ExecuteAsync("999").Returns((Student?)null); + + // Act + var result = await _sut.Update("999", student); + + // Assert + Assert.IsInstanceOfType(result, typeof(NotFoundResult)); + await _updateStudent.DidNotReceive().ExecuteAsync(Arg.Any()); + } +}