diff --git a/api/CourseRegistration.API/Controllers/DownloadController.cs b/api/CourseRegistration.API/Controllers/DownloadController.cs new file mode 100644 index 0000000..3e944d5 --- /dev/null +++ b/api/CourseRegistration.API/Controllers/DownloadController.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Mvc; +using CourseRegistration.Application.Interfaces; +using System.Text; + +namespace CourseRegistration.API.Controllers; + +/// +/// Controller for downloading course, student, and registration data as CSV files +/// +[ApiController] +[Route("api/[controller]")] +public class DownloadController : ControllerBase +{ + private readonly ICourseService _courseService; + private readonly IStudentService _studentService; + private readonly IRegistrationService _registrationService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the DownloadController + /// + public DownloadController( + ICourseService courseService, + IStudentService studentService, + IRegistrationService registrationService, + ILogger logger) + { + _courseService = courseService ?? throw new ArgumentNullException(nameof(courseService)); + _studentService = studentService ?? throw new ArgumentNullException(nameof(studentService)); + _registrationService = registrationService ?? throw new ArgumentNullException(nameof(registrationService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads all courses as a CSV file + /// + /// CSV file containing course data + [HttpGet("courses")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + public async Task DownloadCourses() + { + _logger.LogInformation("Downloading courses as CSV"); + + var result = await _courseService.GetCoursesAsync(page: 1, pageSize: int.MaxValue); + var courses = result.Items; + + var csv = new StringBuilder(); + csv.AppendLine("CourseId,CourseName,Description,InstructorName,StartDate,EndDate,Schedule,CurrentEnrollment,IsActive,CreatedAt"); + + foreach (var course in courses) + { + csv.AppendLine(string.Join(",", + course.CourseId, + EscapeCsvField(course.CourseName), + EscapeCsvField(course.Description ?? string.Empty), + EscapeCsvField(course.InstructorName), + course.StartDate.ToString("yyyy-MM-dd"), + course.EndDate.ToString("yyyy-MM-dd"), + EscapeCsvField(course.Schedule), + course.CurrentEnrollment, + course.IsActive, + course.CreatedAt.ToString("yyyy-MM-dd"))); + } + + var bytes = Encoding.UTF8.GetBytes(csv.ToString()); + return File(bytes, "text/csv", $"courses_{DateTime.UtcNow:yyyyMMdd}.csv"); + } + + /// + /// Downloads all students as a CSV file + /// + /// CSV file containing student data + [HttpGet("students")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + public async Task DownloadStudents() + { + _logger.LogInformation("Downloading students as CSV"); + + var result = await _studentService.GetStudentsAsync(page: 1, pageSize: int.MaxValue); + var students = result.Items; + + var csv = new StringBuilder(); + csv.AppendLine("StudentId,FirstName,LastName,Email,PhoneNumber,DateOfBirth,CreatedAt"); + + foreach (var student in students) + { + csv.AppendLine(string.Join(",", + student.StudentId, + EscapeCsvField(student.FirstName), + EscapeCsvField(student.LastName), + EscapeCsvField(student.Email), + EscapeCsvField(student.PhoneNumber ?? string.Empty), + student.DateOfBirth.ToString("yyyy-MM-dd"), + student.CreatedAt.ToString("yyyy-MM-dd"))); + } + + var bytes = Encoding.UTF8.GetBytes(csv.ToString()); + return File(bytes, "text/csv", $"students_{DateTime.UtcNow:yyyyMMdd}.csv"); + } + + /// + /// Downloads all registrations as a CSV file + /// + /// CSV file containing registration data + [HttpGet("registrations")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + public async Task DownloadRegistrations() + { + _logger.LogInformation("Downloading registrations as CSV"); + + var result = await _registrationService.GetRegistrationsAsync(page: 1, pageSize: int.MaxValue); + var registrations = result.Items; + + var csv = new StringBuilder(); + csv.AppendLine("RegistrationId,StudentId,StudentName,CourseId,CourseName,RegistrationDate,Status,Grade,Notes"); + + foreach (var reg in registrations) + { + csv.AppendLine(string.Join(",", + reg.RegistrationId, + reg.StudentId, + EscapeCsvField(reg.Student != null ? $"{reg.Student.FirstName} {reg.Student.LastName}" : string.Empty), + reg.CourseId, + EscapeCsvField(reg.Course?.CourseName ?? string.Empty), + reg.RegistrationDate.ToString("yyyy-MM-dd"), + reg.Status, + reg.Grade?.ToString() ?? string.Empty, + EscapeCsvField(reg.Notes ?? string.Empty))); + } + + var bytes = Encoding.UTF8.GetBytes(csv.ToString()); + return File(bytes, "text/csv", $"registrations_{DateTime.UtcNow:yyyyMMdd}.csv"); + } + + private static string EscapeCsvField(string field) + { + if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')) + { + return $"\"{field.Replace("\"", "\"\"")}\""; + } + return field; + } +} diff --git a/api/CourseRegistration.Tests/Controllers/DownloadControllerTests.cs b/api/CourseRegistration.Tests/Controllers/DownloadControllerTests.cs new file mode 100644 index 0000000..f08067b --- /dev/null +++ b/api/CourseRegistration.Tests/Controllers/DownloadControllerTests.cs @@ -0,0 +1,408 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using CourseRegistration.API.Controllers; +using CourseRegistration.Application.DTOs; +using CourseRegistration.Application.Interfaces; +using CourseRegistration.Domain.Enums; + +namespace CourseRegistration.Tests.Controllers; + +/// +/// Unit tests for DownloadController CSV download endpoints +/// +public class DownloadControllerTests +{ + private readonly Mock _mockCourseService; + private readonly Mock _mockStudentService; + private readonly Mock _mockRegistrationService; + private readonly Mock> _mockLogger; + private readonly DownloadController _controller; + + public DownloadControllerTests() + { + _mockCourseService = new Mock(); + _mockStudentService = new Mock(); + _mockRegistrationService = new Mock(); + _mockLogger = new Mock>(); + + _controller = new DownloadController( + _mockCourseService.Object, + _mockStudentService.Object, + _mockRegistrationService.Object, + _mockLogger.Object); + } + + [Fact] + public async Task DownloadCourses_ReturnsFileResult_WithCsvContentType() + { + // Arrange + var courses = new PagedResponseDto + { + Items = new List + { + new CourseDto + { + CourseId = Guid.NewGuid(), + CourseName = "Introduction to CS", + Description = "Basics of CS", + InstructorName = "Dr. Smith", + StartDate = new DateTime(2024, 1, 15), + EndDate = new DateTime(2024, 5, 15), + Schedule = "MWF 9:00-10:30", + CurrentEnrollment = 25, + IsActive = true, + CreatedAt = new DateTime(2024, 1, 1) + } + }, + TotalItems = 1 + }; + + _mockCourseService + .Setup(s => s.GetCoursesAsync(1, int.MaxValue, null, null)) + .ReturnsAsync(courses); + + // Act + var result = await _controller.DownloadCourses(); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("text/csv", fileResult.ContentType); + Assert.StartsWith("courses_", fileResult.FileDownloadName); + Assert.EndsWith(".csv", fileResult.FileDownloadName); + } + + [Fact] + public async Task DownloadCourses_CsvContent_ContainsHeaderRow() + { + // Arrange + var courses = new PagedResponseDto + { + Items = Enumerable.Empty(), + TotalItems = 0 + }; + + _mockCourseService + .Setup(s => s.GetCoursesAsync(1, int.MaxValue, null, null)) + .ReturnsAsync(courses); + + // Act + var result = await _controller.DownloadCourses(); + + // Assert + var fileResult = Assert.IsType(result); + var content = System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + Assert.Contains("CourseId,CourseName", content); + Assert.Contains("InstructorName", content); + } + + [Fact] + public async Task DownloadCourses_CsvContent_ContainsCourseData() + { + // Arrange + var courseId = Guid.NewGuid(); + var courses = new PagedResponseDto + { + Items = new List + { + new CourseDto + { + CourseId = courseId, + CourseName = "Web Development", + InstructorName = "Prof. Jones", + StartDate = new DateTime(2024, 2, 1), + EndDate = new DateTime(2024, 6, 1), + Schedule = "TTh 14:00-16:00", + CurrentEnrollment = 30, + IsActive = true, + CreatedAt = new DateTime(2024, 1, 10) + } + }, + TotalItems = 1 + }; + + _mockCourseService + .Setup(s => s.GetCoursesAsync(1, int.MaxValue, null, null)) + .ReturnsAsync(courses); + + // Act + var result = await _controller.DownloadCourses(); + + // Assert + var fileResult = Assert.IsType(result); + var content = System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + Assert.Contains("Web Development", content); + Assert.Contains("Prof. Jones", content); + Assert.Contains(courseId.ToString(), content); + } + + [Fact] + public async Task DownloadStudents_ReturnsFileResult_WithCsvContentType() + { + // Arrange + var students = new PagedResponseDto + { + Items = new List + { + new StudentDto + { + StudentId = Guid.NewGuid(), + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + PhoneNumber = "+1-555-0101", + DateOfBirth = new DateTime(1995, 5, 15), + CreatedAt = new DateTime(2024, 1, 1) + } + }, + TotalItems = 1 + }; + + _mockStudentService + .Setup(s => s.GetStudentsAsync(1, int.MaxValue)) + .ReturnsAsync(students); + + // Act + var result = await _controller.DownloadStudents(); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("text/csv", fileResult.ContentType); + Assert.StartsWith("students_", fileResult.FileDownloadName); + Assert.EndsWith(".csv", fileResult.FileDownloadName); + } + + [Fact] + public async Task DownloadStudents_CsvContent_ContainsHeaderRow() + { + // Arrange + var students = new PagedResponseDto + { + Items = Enumerable.Empty(), + TotalItems = 0 + }; + + _mockStudentService + .Setup(s => s.GetStudentsAsync(1, int.MaxValue)) + .ReturnsAsync(students); + + // Act + var result = await _controller.DownloadStudents(); + + // Assert + var fileResult = Assert.IsType(result); + var content = System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + Assert.Contains("StudentId,FirstName,LastName,Email", content); + Assert.Contains("DateOfBirth", content); + } + + [Fact] + public async Task DownloadStudents_CsvContent_ContainsStudentData() + { + // Arrange + var studentId = Guid.NewGuid(); + var students = new PagedResponseDto + { + Items = new List + { + new StudentDto + { + StudentId = studentId, + FirstName = "Jane", + LastName = "Smith", + Email = "jane.smith@example.com", + DateOfBirth = new DateTime(1996, 8, 20), + CreatedAt = new DateTime(2024, 1, 5) + } + }, + TotalItems = 1 + }; + + _mockStudentService + .Setup(s => s.GetStudentsAsync(1, int.MaxValue)) + .ReturnsAsync(students); + + // Act + var result = await _controller.DownloadStudents(); + + // Assert + var fileResult = Assert.IsType(result); + var content = System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + Assert.Contains("Jane", content); + Assert.Contains("Smith", content); + Assert.Contains("jane.smith@example.com", content); + Assert.Contains(studentId.ToString(), content); + } + + [Fact] + public async Task DownloadRegistrations_ReturnsFileResult_WithCsvContentType() + { + // Arrange + var registrations = new PagedResponseDto + { + Items = new List + { + new RegistrationDto + { + RegistrationId = Guid.NewGuid(), + StudentId = Guid.NewGuid(), + CourseId = Guid.NewGuid(), + RegistrationDate = DateTime.UtcNow.AddDays(-5), + Status = RegistrationStatus.Confirmed, + Student = new StudentDto { FirstName = "John", LastName = "Doe", Email = "john@example.com" }, + Course = new CourseDto { CourseName = "CS101", InstructorName = "Dr. A" } + } + }, + TotalItems = 1 + }; + + _mockRegistrationService + .Setup(s => s.GetRegistrationsAsync(1, int.MaxValue, null, null, null)) + .ReturnsAsync(registrations); + + // Act + var result = await _controller.DownloadRegistrations(); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("text/csv", fileResult.ContentType); + Assert.StartsWith("registrations_", fileResult.FileDownloadName); + Assert.EndsWith(".csv", fileResult.FileDownloadName); + } + + [Fact] + public async Task DownloadRegistrations_CsvContent_ContainsHeaderRow() + { + // Arrange + var registrations = new PagedResponseDto + { + Items = Enumerable.Empty(), + TotalItems = 0 + }; + + _mockRegistrationService + .Setup(s => s.GetRegistrationsAsync(1, int.MaxValue, null, null, null)) + .ReturnsAsync(registrations); + + // Act + var result = await _controller.DownloadRegistrations(); + + // Assert + var fileResult = Assert.IsType(result); + var content = System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + Assert.Contains("RegistrationId", content); + Assert.Contains("StudentName", content); + Assert.Contains("CourseName", content); + Assert.Contains("Status", content); + } + + [Fact] + public async Task DownloadRegistrations_CsvContent_ContainsRegistrationData() + { + // Arrange + var registrationId = Guid.NewGuid(); + var registrations = new PagedResponseDto + { + Items = new List + { + new RegistrationDto + { + RegistrationId = registrationId, + StudentId = Guid.NewGuid(), + CourseId = Guid.NewGuid(), + RegistrationDate = new DateTime(2024, 3, 1), + Status = RegistrationStatus.Confirmed, + Student = new StudentDto { FirstName = "Alice", LastName = "Brown", Email = "alice@example.com" }, + Course = new CourseDto { CourseName = "Advanced Math", InstructorName = "Prof. Davis" } + } + }, + TotalItems = 1 + }; + + _mockRegistrationService + .Setup(s => s.GetRegistrationsAsync(1, int.MaxValue, null, null, null)) + .ReturnsAsync(registrations); + + // Act + var result = await _controller.DownloadRegistrations(); + + // Assert + var fileResult = Assert.IsType(result); + var content = System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + Assert.Contains(registrationId.ToString(), content); + Assert.Contains("Alice Brown", content); + Assert.Contains("Advanced Math", content); + Assert.Contains("Confirmed", content); + } + + [Fact] + public async Task DownloadCourses_CsvField_EscapesCommasInFields() + { + // Arrange + var courses = new PagedResponseDto + { + Items = new List + { + new CourseDto + { + CourseId = Guid.NewGuid(), + CourseName = "Math, Science, and Art", + InstructorName = "Dr. Smith", + Description = "A course covering math, science", + StartDate = new DateTime(2024, 1, 1), + EndDate = new DateTime(2024, 6, 1), + Schedule = "MWF", + CreatedAt = new DateTime(2024, 1, 1) + } + }, + TotalItems = 1 + }; + + _mockCourseService + .Setup(s => s.GetCoursesAsync(1, int.MaxValue, null, null)) + .ReturnsAsync(courses); + + // Act + var result = await _controller.DownloadCourses(); + + // Assert + var fileResult = Assert.IsType(result); + var content = System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + // Fields with commas should be quoted + Assert.Contains("\"Math, Science, and Art\"", content); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenCourseServiceIsNull() + { + // Act & Assert + Assert.Throws(() => new DownloadController( + null!, + _mockStudentService.Object, + _mockRegistrationService.Object, + _mockLogger.Object)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenStudentServiceIsNull() + { + // Act & Assert + Assert.Throws(() => new DownloadController( + _mockCourseService.Object, + null!, + _mockRegistrationService.Object, + _mockLogger.Object)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenRegistrationServiceIsNull() + { + // Act & Assert + Assert.Throws(() => new DownloadController( + _mockCourseService.Object, + _mockStudentService.Object, + null!, + _mockLogger.Object)); + } +} diff --git a/api/CourseRegistration.Tests/CourseRegistration.Tests.csproj b/api/CourseRegistration.Tests/CourseRegistration.Tests.csproj index 17b82fa..f7a658d 100644 --- a/api/CourseRegistration.Tests/CourseRegistration.Tests.csproj +++ b/api/CourseRegistration.Tests/CourseRegistration.Tests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/frontend/index.html b/frontend/index.html index 9773824..bd3903d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -181,6 +181,10 @@

Refresh + +