From 3cf7ae1c3e9320405e765aa3ac8b5ec6e431dae9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Mar 2026 07:10:30 +0000
Subject: [PATCH 1/2] Initial plan
From fb751119a7a96f60120f64a8cf30a613c8139503 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Mar 2026 07:21:03 +0000
Subject: [PATCH 2/2] Add CSV download feature for courses and registrations
Co-authored-by: Hemavathi15sg <224925058+Hemavathi15sg@users.noreply.github.com>
---
.../Controllers/DownloadsController.cs | 135 ++++++++
.../Interfaces/IRegistrationService.cs | 5 +
.../Services/RegistrationService.cs | 9 +
.../Controllers/DownloadsControllerTests.cs | 311 ++++++++++++++++++
frontend/index.html | 8 +
frontend/script.js | 108 +++++-
6 files changed, 575 insertions(+), 1 deletion(-)
create mode 100644 api/CourseRegistration.API/Controllers/DownloadsController.cs
create mode 100644 api/CourseRegistration.Tests/Controllers/DownloadsControllerTests.cs
diff --git a/api/CourseRegistration.API/Controllers/DownloadsController.cs b/api/CourseRegistration.API/Controllers/DownloadsController.cs
new file mode 100644
index 0000000..3993154
--- /dev/null
+++ b/api/CourseRegistration.API/Controllers/DownloadsController.cs
@@ -0,0 +1,135 @@
+using Microsoft.AspNetCore.Mvc;
+using CourseRegistration.Application.Interfaces;
+using System.Text;
+
+namespace CourseRegistration.API.Controllers;
+
+///
+/// Controller for downloading course and registration data as CSV files
+///
+[ApiController]
+[Route("api/[controller]")]
+public class DownloadsController : ControllerBase
+{
+ private readonly ICourseService _courseService;
+ private readonly IRegistrationService _registrationService;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the DownloadsController
+ ///
+ public DownloadsController(
+ ICourseService courseService,
+ IRegistrationService registrationService,
+ ILogger logger)
+ {
+ _courseService = courseService ?? throw new ArgumentNullException(nameof(courseService));
+ _registrationService = registrationService ?? throw new ArgumentNullException(nameof(registrationService));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Downloads all courses as a CSV file
+ ///
+ /// CSV file containing all courses
+ [HttpGet("courses")]
+ [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task DownloadCourses()
+ {
+ _logger.LogInformation("Downloading all courses as CSV");
+
+ var courses = await _courseService.SearchCoursesAsync(null, null);
+
+ var csv = new StringBuilder();
+ csv.AppendLine("CourseId,CourseName,InstructorName,Description,StartDate,EndDate,Schedule,CurrentEnrollment,IsActive");
+
+ foreach (var course in courses)
+ {
+ csv.AppendLine(string.Join(",",
+ EscapeCsvField(course.CourseId.ToString()),
+ EscapeCsvField(course.CourseName),
+ EscapeCsvField(course.InstructorName),
+ EscapeCsvField(course.Description ?? string.Empty),
+ EscapeCsvField(course.StartDate.ToString("yyyy-MM-dd")),
+ EscapeCsvField(course.EndDate.ToString("yyyy-MM-dd")),
+ EscapeCsvField(course.Schedule),
+ EscapeCsvField(course.CurrentEnrollment.ToString()),
+ EscapeCsvField(course.IsActive.ToString())));
+ }
+
+ var fileName = $"courses_{DateTime.UtcNow:yyyyMMdd}.csv";
+ var bytes = Encoding.UTF8.GetBytes(csv.ToString());
+ return File(bytes, "text/csv", fileName);
+ }
+
+ ///
+ /// Downloads all registrations as a CSV file
+ ///
+ /// CSV file containing all registrations
+ [HttpGet("registrations")]
+ [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task DownloadRegistrations()
+ {
+ _logger.LogInformation("Downloading all registrations as CSV");
+
+ var registrations = await _registrationService.GetAllRegistrationsAsync();
+
+ var csv = new StringBuilder();
+ csv.AppendLine("RegistrationId,StudentId,StudentName,StudentEmail,CourseId,CourseName,InstructorName,RegistrationDate,Status,Grade,Notes");
+
+ foreach (var reg in registrations)
+ {
+ csv.AppendLine(string.Join(",",
+ EscapeCsvField(reg.RegistrationId.ToString()),
+ EscapeCsvField(reg.StudentId.ToString()),
+ EscapeCsvField(reg.Student?.FullName ?? string.Empty),
+ EscapeCsvField(reg.Student?.Email ?? string.Empty),
+ EscapeCsvField(reg.CourseId.ToString()),
+ EscapeCsvField(reg.Course?.CourseName ?? string.Empty),
+ EscapeCsvField(reg.Course?.InstructorName ?? string.Empty),
+ EscapeCsvField(reg.RegistrationDate.ToString("yyyy-MM-dd")),
+ EscapeCsvField(reg.Status.ToString()),
+ EscapeCsvField(reg.Grade?.ToString() ?? string.Empty),
+ EscapeCsvField(reg.Notes ?? string.Empty)));
+ }
+
+ var fileName = $"registrations_{DateTime.UtcNow:yyyyMMdd}.csv";
+ var bytes = Encoding.UTF8.GetBytes(csv.ToString());
+ return File(bytes, "text/csv", fileName);
+ }
+
+ ///
+ /// Escapes a CSV field value to prevent injection and handle special characters.
+ /// Fields containing commas, double quotes, or newlines are wrapped in double quotes.
+ /// Double quotes within the value are escaped by doubling them.
+ /// Leading = + - @ characters are prefixed with a tab to prevent formula injection.
+ /// The tab prefix is applied before quoting so it is preserved correctly in all cases.
+ ///
+ private static string EscapeCsvField(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return string.Empty;
+ }
+
+ // Prevent CSV formula injection by neutralizing dangerous leading characters.
+ // This covers the primary injection vectors: = (formula), + (formula), - (formula/unary),
+ // @ (DDE/macro trigger). Tab-prefix is the recommended approach as it is invisible to end
+ // users while preventing spreadsheet apps from interpreting the cell as a formula.
+ if (value[0] is '=' or '+' or '-' or '@')
+ {
+ value = "\t" + value;
+ }
+
+ // Quote the field if it contains characters that need escaping.
+ // This is applied after the optional tab-prefix, so the tab is preserved inside quotes.
+ if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'))
+ {
+ return "\"" + value.Replace("\"", "\"\"") + "\"";
+ }
+
+ return value;
+ }
+}
diff --git a/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs b/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs
index a563f4c..80f2c44 100644
--- a/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs
+++ b/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs
@@ -57,4 +57,9 @@ Task> GetRegistrationsAsync(
/// Checks if a student is already registered for a course
///
Task IsStudentRegisteredForCourseAsync(Guid studentId, Guid courseId);
+
+ ///
+ /// Gets all registrations without pagination
+ ///
+ Task> GetAllRegistrationsAsync();
}
\ No newline at end of file
diff --git a/api/CourseRegistration.Application/Services/RegistrationService.cs b/api/CourseRegistration.Application/Services/RegistrationService.cs
index eaf3e6b..68b4a19 100644
--- a/api/CourseRegistration.Application/Services/RegistrationService.cs
+++ b/api/CourseRegistration.Application/Services/RegistrationService.cs
@@ -212,6 +212,15 @@ public async Task IsStudentRegisteredForCourseAsync(Guid studentId, Guid c
return await _unitOfWork.Registrations.IsStudentRegisteredForCourseAsync(studentId, courseId);
}
+ ///
+ /// Gets all registrations without pagination
+ ///
+ public async Task> GetAllRegistrationsAsync()
+ {
+ var registrations = await _unitOfWork.Registrations.GetRegistrationsWithFiltersAsync(null, null, null);
+ return _mapper.Map>(registrations);
+ }
+
///
/// Validates if a status transition is allowed
///
diff --git a/api/CourseRegistration.Tests/Controllers/DownloadsControllerTests.cs b/api/CourseRegistration.Tests/Controllers/DownloadsControllerTests.cs
new file mode 100644
index 0000000..6fd725f
--- /dev/null
+++ b/api/CourseRegistration.Tests/Controllers/DownloadsControllerTests.cs
@@ -0,0 +1,311 @@
+using Xunit;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+using CourseRegistration.API.Controllers;
+using CourseRegistration.Application.DTOs;
+using CourseRegistration.Application.Interfaces;
+using CourseRegistration.Domain.Enums;
+
+namespace CourseRegistration.Tests.Controllers;
+
+///
+/// Unit tests for the DownloadsController
+///
+public class DownloadsControllerTests
+{
+ // -------------------------------------------------------------------------
+ // Hand-written test fakes
+ // -------------------------------------------------------------------------
+
+ private sealed class FakeCourseService : ICourseService
+ {
+ private readonly IEnumerable _courses;
+
+ public FakeCourseService(IEnumerable courses)
+ {
+ _courses = courses;
+ }
+
+ public Task> SearchCoursesAsync(string? searchTerm, string? instructor)
+ => Task.FromResult(_courses);
+
+ // Unused members
+ public Task> GetCoursesAsync(int page, int pageSize, string? searchTerm, string? instructor) => throw new NotImplementedException();
+ public Task GetCourseByIdAsync(Guid id) => throw new NotImplementedException();
+ public Task CreateCourseAsync(CreateCourseDto dto) => throw new NotImplementedException();
+ public Task UpdateCourseAsync(Guid id, UpdateCourseDto dto) => throw new NotImplementedException();
+ public Task DeleteCourseAsync(Guid id) => throw new NotImplementedException();
+ public Task> GetAvailableCoursesAsync() => throw new NotImplementedException();
+ public Task> GetCoursesByInstructorAsync(string instructorName) => throw new NotImplementedException();
+ public Task> GetCourseRegistrationsAsync(Guid courseId) => throw new NotImplementedException();
+ }
+
+ private sealed class FakeRegistrationService : IRegistrationService
+ {
+ private readonly IEnumerable _registrations;
+
+ public FakeRegistrationService(IEnumerable registrations)
+ {
+ _registrations = registrations;
+ }
+
+ public Task> GetAllRegistrationsAsync()
+ => Task.FromResult(_registrations);
+
+ // Unused members
+ public Task> GetRegistrationsAsync(int page, int pageSize, Guid? studentId, Guid? courseId, RegistrationStatus? status) => throw new NotImplementedException();
+ public Task GetRegistrationByIdAsync(Guid id) => throw new NotImplementedException();
+ public Task CreateRegistrationAsync(CreateRegistrationDto dto) => throw new NotImplementedException();
+ public Task UpdateRegistrationStatusAsync(Guid id, UpdateRegistrationStatusDto dto) => throw new NotImplementedException();
+ public Task CancelRegistrationAsync(Guid id) => throw new NotImplementedException();
+ public Task> GetRegistrationsByStudentAsync(Guid studentId) => throw new NotImplementedException();
+ public Task> GetRegistrationsByCourseAsync(Guid courseId) => throw new NotImplementedException();
+ public Task> GetRegistrationsByStatusAsync(RegistrationStatus status) => throw new NotImplementedException();
+ public Task IsStudentRegisteredForCourseAsync(Guid studentId, Guid courseId) => throw new NotImplementedException();
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static DownloadsController CreateController(
+ IEnumerable? courses = null,
+ IEnumerable? registrations = null)
+ {
+ var courseService = new FakeCourseService(courses ?? Enumerable.Empty());
+ var registrationService = new FakeRegistrationService(registrations ?? Enumerable.Empty());
+ return new DownloadsController(courseService, registrationService, NullLogger.Instance);
+ }
+
+ private static string ReadFileResult(IActionResult result)
+ {
+ var fileResult = Assert.IsType(result);
+ Assert.Equal("text/csv", fileResult.ContentType);
+ return System.Text.Encoding.UTF8.GetString(fileResult.FileContents);
+ }
+
+ // -------------------------------------------------------------------------
+ // DownloadCourses tests
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task DownloadCourses_EmptyList_ReturnsCsvWithHeaderOnly()
+ {
+ // Arrange
+ var controller = CreateController(courses: Enumerable.Empty());
+
+ // Act
+ var result = await controller.DownloadCourses();
+
+ // Assert
+ var csv = ReadFileResult(result);
+ var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Single(lines); // only header
+ Assert.Contains("CourseName", lines[0]);
+ Assert.Contains("InstructorName", lines[0]);
+ }
+
+ [Fact]
+ public async Task DownloadCourses_WithCourses_ReturnsCsvWithHeaderAndDataRows()
+ {
+ // Arrange
+ var courseId = Guid.NewGuid();
+ var courses = new[]
+ {
+ new CourseDto
+ {
+ CourseId = courseId,
+ CourseName = "Introduction to C#",
+ InstructorName = "Dr. Smith",
+ Description = "A beginner course",
+ StartDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ EndDate = new DateTime(2026, 6, 30, 0, 0, 0, DateTimeKind.Utc),
+ Schedule = "MWF 9:00-10:30 AM",
+ CurrentEnrollment = 5,
+ IsActive = true
+ }
+ };
+ var controller = CreateController(courses: courses);
+
+ // Act
+ var result = await controller.DownloadCourses();
+
+ // Assert
+ var csv = ReadFileResult(result);
+ var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length); // header + 1 data row
+ Assert.Contains("Introduction to C#", lines[1]);
+ Assert.Contains("Dr. Smith", lines[1]);
+ Assert.Contains(courseId.ToString(), lines[1]);
+ }
+
+ [Fact]
+ public async Task DownloadCourses_FieldWithComma_IsQuotedInCsv()
+ {
+ // Arrange
+ var courses = new[]
+ {
+ new CourseDto
+ {
+ CourseId = Guid.NewGuid(),
+ CourseName = "Science, Technology, Engineering",
+ InstructorName = "Dr. Jones",
+ StartDate = DateTime.UtcNow.AddDays(1),
+ EndDate = DateTime.UtcNow.AddMonths(6),
+ Schedule = "TTh 2:00-3:30 PM"
+ }
+ };
+ var controller = CreateController(courses: courses);
+
+ // Act
+ var result = await controller.DownloadCourses();
+
+ // Assert
+ var csv = ReadFileResult(result);
+ Assert.Contains("\"Science, Technology, Engineering\"", csv);
+ }
+
+ [Fact]
+ public async Task DownloadCourses_ReturnsFileWithCsvContentType()
+ {
+ // Arrange
+ var controller = CreateController();
+
+ // 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);
+ }
+
+ // -------------------------------------------------------------------------
+ // DownloadRegistrations tests
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task DownloadRegistrations_EmptyList_ReturnsCsvWithHeaderOnly()
+ {
+ // Arrange
+ var controller = CreateController(registrations: Enumerable.Empty());
+
+ // Act
+ var result = await controller.DownloadRegistrations();
+
+ // Assert
+ var csv = ReadFileResult(result);
+ var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Single(lines); // only header
+ Assert.Contains("RegistrationId", lines[0]);
+ Assert.Contains("StudentName", lines[0]);
+ Assert.Contains("CourseName", lines[0]);
+ }
+
+ [Fact]
+ public async Task DownloadRegistrations_WithRegistrations_ReturnsCsvWithHeaderAndDataRows()
+ {
+ // Arrange
+ var registrationId = Guid.NewGuid();
+ var studentId = Guid.NewGuid();
+ var courseId = Guid.NewGuid();
+ var registrations = new[]
+ {
+ new RegistrationDto
+ {
+ RegistrationId = registrationId,
+ StudentId = studentId,
+ CourseId = courseId,
+ Student = new StudentDto { FullName = "Jane Doe", Email = "jane@example.com" },
+ Course = new CourseDto { CourseName = "Advanced Math", InstructorName = "Prof. Brown" },
+ RegistrationDate = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc),
+ Status = RegistrationStatus.Confirmed,
+ Notes = "Priority student"
+ }
+ };
+ var controller = CreateController(registrations: registrations);
+
+ // Act
+ var result = await controller.DownloadRegistrations();
+
+ // Assert
+ var csv = ReadFileResult(result);
+ var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length); // header + 1 data row
+ Assert.Contains("Jane Doe", lines[1]);
+ Assert.Contains("Advanced Math", lines[1]);
+ Assert.Contains(registrationId.ToString(), lines[1]);
+ Assert.Contains("Confirmed", lines[1]);
+ }
+
+ [Fact]
+ public async Task DownloadRegistrations_ReturnsFileWithCsvContentType()
+ {
+ // Arrange
+ var controller = CreateController();
+
+ // 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_FieldWithDoubleQuote_IsEscapedInCsv()
+ {
+ // Arrange
+ var registrations = new[]
+ {
+ new RegistrationDto
+ {
+ RegistrationId = Guid.NewGuid(),
+ StudentId = Guid.NewGuid(),
+ CourseId = Guid.NewGuid(),
+ RegistrationDate = DateTime.UtcNow,
+ Status = RegistrationStatus.Pending,
+ Notes = "She said \"hello\""
+ }
+ };
+ var controller = CreateController(registrations: registrations);
+
+ // Act
+ var result = await controller.DownloadRegistrations();
+
+ // Assert
+ var csv = ReadFileResult(result);
+ // Double quotes are escaped by doubling: "She said ""hello"""
+ Assert.Contains("She said \"\"hello\"\"", csv);
+ }
+
+ [Fact]
+ public async Task DownloadCourses_FormulaInjectionField_IsPrefixedWithTab()
+ {
+ // Arrange
+ var courses = new[]
+ {
+ new CourseDto
+ {
+ CourseId = Guid.NewGuid(),
+ CourseName = "=SUM(1+1)",
+ InstructorName = "Dr. Evil",
+ StartDate = DateTime.UtcNow.AddDays(1),
+ EndDate = DateTime.UtcNow.AddMonths(6),
+ Schedule = "MWF"
+ }
+ };
+ var controller = CreateController(courses: courses);
+
+ // Act
+ var result = await controller.DownloadCourses();
+
+ // Assert
+ var csv = ReadFileResult(result);
+ // The formula-prefixed field should be tab-prefixed to neutralize injection
+ Assert.Contains("\t=SUM(1+1)", csv);
+ }
+}
diff --git a/frontend/index.html b/frontend/index.html
index 9773824..4a40b9e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -181,6 +181,10 @@
Refresh
+
+