From 66da1ccf7557216fb3536fc47e336d53b61ae348 Mon Sep 17 00:00:00 2001 From: Mateusz Dalke Date: Sun, 23 Nov 2025 09:42:00 +0100 Subject: [PATCH 1/2] Add XML comments for improved code documentation - Added detailed XML comments across services (`AuthService`, `ProjectService`, `JwtProvider`, `UserService`) and repositories (`ProjectRepository`). - Introduced helper and test methods in `UserTest` and `ProjectTest` with comprehensive XML comments for clarity. - Refactored and standardized XML comments for consistent structure and readability. --- sparkly-server.csproj | 1 - sparkly-server.test/HealthzTest.cs | 30 +-- sparkly-server.test/ProjectTest.cs | 100 ++++++++++ .../TestWebAppliactionFactory.cs | 63 +++---- sparkly-server.test/UserTest.cs | 15 +- src/Domain/Auth/RefreshToken.cs | 5 + src/Domain/Projects/Project.cs | 1 - src/Services/Auth/AuthService.cs | 19 ++ src/Services/Auth/JwtProvider.cs | 9 + src/Services/Projects/ProjectRepository.cs | 45 ++++- src/Services/Projects/ProjectService.cs | 171 ++++++++++++++++-- src/Services/Users/UserService.cs | 25 ++- 12 files changed, 412 insertions(+), 72 deletions(-) create mode 100644 sparkly-server.test/ProjectTest.cs diff --git a/sparkly-server.csproj b/sparkly-server.csproj index 1e43bac..b1e9091 100644 --- a/sparkly-server.csproj +++ b/sparkly-server.csproj @@ -49,7 +49,6 @@ - diff --git a/sparkly-server.test/HealthzTest.cs b/sparkly-server.test/HealthzTest.cs index 3f45f2e..02926de 100644 --- a/sparkly-server.test/HealthzTest.cs +++ b/sparkly-server.test/HealthzTest.cs @@ -1,22 +1,22 @@ -using Sparkly.Tests.Infrastructure; -using System.Net; +using System.Net; -namespace sparkly_server.Services.Users.test; - -public class HealthzTest : IClassFixture +namespace sparkly_server.test { - private readonly HttpClient _client; - - public HealthzTest(TestWebApplicationFactory factory) + public class HealthzTest : IClassFixture { - _client = factory.CreateClient(); - } + private readonly HttpClient _client; - [Fact] - public async Task Healthz_ReturnsOk() - { - var response = await _client.GetAsync("/healthz"); + public HealthzTest(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task Healthz_ReturnsOk() + { + var response = await _client.GetAsync("/healthz"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } } } diff --git a/sparkly-server.test/ProjectTest.cs b/sparkly-server.test/ProjectTest.cs new file mode 100644 index 0000000..917b912 --- /dev/null +++ b/sparkly-server.test/ProjectTest.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.DependencyInjection; +using sparkly_server.DTO.Auth; +using sparkly_server.DTO.Projects; +using sparkly_server.Enum; +using sparkly_server.Infrastructure; +using System.Net.Http.Headers; +using System.Net.Http.Json; + +namespace sparkly_server.test +{ + public class ProjectTest : IClassFixture, IAsyncLifetime + { + private readonly HttpClient _client; + private readonly TestWebApplicationFactory _factory; + + public ProjectTest(TestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.Users.RemoveRange(db.Users); + db.Projects.RemoveRange(db.Projects); + await db.SaveChangesAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // Helpers + + /// + /// Creates a new project asynchronously with the specified project name and returns the created project's details. + /// + /// The name of the project to be created. + /// A task representing the asynchronous operation. The result contains the details of the created project, or null if deserialization fails. + private async Task CreateProjectAsync(string projectName) + { + var payload = new CreateProjectRequest( + ProjectName: projectName, + Description: "Test project", + Visibility: ProjectVisibility.Public + ); + + var response = await _client.PostAsJsonAsync("/api/v1/projects/create", payload); + response.EnsureSuccessStatusCode(); + + var created = await response.Content.ReadFromJsonAsync(); + return created; + } + + /// + /// Registers a new user and logs them in, setting the authentication token in the HTTP client header for subsequent requests. + /// + /// Thrown when the authentication response does not contain an access token. + /// A task that represents the asynchronous operation of registering and logging in a user. + private async Task RegisterAndLoginUser() + { + var email = "test@sparkly.local"; + var password = "Test1234!"; + var userName = "testuser"; + + var registerPayload = new RegisterRequest(Username:userName, Email: email, Password: password); + var registerResponse = await _client.PostAsJsonAsync("/api/v1/auth/register", registerPayload); + registerResponse.EnsureSuccessStatusCode(); + + var loginPayload = new LoginRequest(Identifier: email, Password: password); + var loginResponse = await _client.PostAsJsonAsync("/api/v1/auth/login", loginPayload); + + loginResponse.EnsureSuccessStatusCode(); + + var loginContent = await loginResponse.Content.ReadFromJsonAsync(); + + if (loginContent?.AccessToken is null) + { + throw new InvalidOperationException("Auth response did not contain access token"); + } + + _client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", loginContent.AccessToken); + } + + // Tests + [Fact] + public async Task CreateProject_Should_Create_Project_For_Authenticated_User() + { + await RegisterAndLoginUser(); + var projectName = "MyTestProject"; + + var created = await CreateProjectAsync(projectName); + + Assert.NotNull(created); + Assert.Equal(projectName, created.ProjectName); + } + } +} diff --git a/sparkly-server.test/TestWebAppliactionFactory.cs b/sparkly-server.test/TestWebAppliactionFactory.cs index 4d5def0..b5fed6d 100644 --- a/sparkly-server.test/TestWebAppliactionFactory.cs +++ b/sparkly-server.test/TestWebAppliactionFactory.cs @@ -5,46 +5,47 @@ using Microsoft.Extensions.DependencyInjection; using sparkly_server.Infrastructure; -namespace Sparkly.Tests.Infrastructure; - -public class TestWebApplicationFactory : WebApplicationFactory +namespace sparkly_server.test { - protected override void ConfigureWebHost(IWebHostBuilder builder) + public class TestWebApplicationFactory : WebApplicationFactory { - builder.UseEnvironment("Testing"); - - builder.ConfigureAppConfiguration((config) => + protected override void ConfigureWebHost(IWebHostBuilder builder) { - var settings = new Dictionary + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration((config) => { - ["SPARKLY_JWT_KEY"] = "this-is-very-long-test-jwt-key-123456", - ["SPARKLY_JWT_ISSUER"] = "sparkly-test-issuer" - }; + var settings = new Dictionary + { + ["SPARKLY_JWT_KEY"] = "this-is-very-long-test-jwt-key-123456", + ["SPARKLY_JWT_ISSUER"] = "sparkly-test-issuer" + }; - config.AddInMemoryCollection(settings); - }); - - builder.ConfigureServices(services => - { - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); + config.AddInMemoryCollection(settings); + }); - if (descriptor is not null) + builder.ConfigureServices(services => { - services.Remove(descriptor); - } + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); - services.AddDbContext(options => - { - options.UseInMemoryDatabase("sparkly-tests"); - }); + if (descriptor is not null) + { + services.Remove(descriptor); + } - var sp = services.BuildServiceProvider(); + services.AddDbContext(options => + { + options.UseInMemoryDatabase("sparkly-tests"); + }); - using var scope = sp.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - }); + var sp = services.BuildServiceProvider(); + + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + }); + } } } diff --git a/sparkly-server.test/UserTest.cs b/sparkly-server.test/UserTest.cs index e3c2664..10c5829 100644 --- a/sparkly-server.test/UserTest.cs +++ b/sparkly-server.test/UserTest.cs @@ -1,11 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using sparkly_server.DTO.Auth; using sparkly_server.Infrastructure; -using Sparkly.Tests.Infrastructure; using System.Text; using System.Text.Json; -namespace sparkly_server.Services.Users.test +namespace sparkly_server.test { public class UserTest : IClassFixture, IAsyncLifetime { @@ -29,7 +28,16 @@ public async Task InitializeAsync() } public Task DisposeAsync() => Task.CompletedTask; - + + /// + /// Registers a test user in the system by sending a registration request to the API. + /// + /// The username of the user to register. + /// The email address of the user to register. + /// The password of the user to register. + /// A task that represents the asynchronous operation. + + // Helper private async Task RegisterTestUserAsync(string userName, string email, string password) { var payload = new RegisterRequest(Username: userName, Email: email, Password: password); @@ -44,6 +52,7 @@ private async Task RegisterTestUserAsync(string userName, string email, string p response.EnsureSuccessStatusCode(); } + // Tests [Fact] public async Task User_CanBeCreated() { diff --git a/src/Domain/Auth/RefreshToken.cs b/src/Domain/Auth/RefreshToken.cs index 3a818e0..532a386 100644 --- a/src/Domain/Auth/RefreshToken.cs +++ b/src/Domain/Auth/RefreshToken.cs @@ -27,6 +27,11 @@ public RefreshToken(Guid userId, string token, DateTime expiresAt) ExpiresAt = expiresAt; } + /// + /// Revokes the refresh token by marking it as no longer active. + /// + /// The IP address from which the revoke operation is made. + /// An optional new token that replaces the current token. public void Revoke(string? ip = null, string? replacedByToken = null) { if (RevokedAt is not null) diff --git a/src/Domain/Projects/Project.cs b/src/Domain/Projects/Project.cs index 6b696e3..abdc9ee 100644 --- a/src/Domain/Projects/Project.cs +++ b/src/Domain/Projects/Project.cs @@ -93,7 +93,6 @@ private static string NormalizeSlug(string value) public void Rename(string newName) { SetNameInternal(newName); - // Jeśli chcesz, aby slug szedł za nazwą: Slug = NormalizeSlug(newName); Touch(); } diff --git a/src/Services/Auth/AuthService.cs b/src/Services/Auth/AuthService.cs index d1a640f..eedad74 100644 --- a/src/Services/Auth/AuthService.cs +++ b/src/Services/Auth/AuthService.cs @@ -20,6 +20,13 @@ public AuthService(IUserService userService, _db = db; } + /// + /// Authenticates a user based on the provided credentials and generates an authentication result containing tokens. + /// + /// The user's identifier, such as username or email address. + /// The user's password. + /// A cancellation token to observe while awaiting the task. + /// An AuthResult object containing the access token, refresh token, and their expiry times, or null if authentication fails. public async Task LoginAsync(string identifier, string password, CancellationToken ct = default) { var user = await _userService.ValidateUserAsync(identifier, password, ct); @@ -51,6 +58,12 @@ public AuthService(IUserService userService, ); } + /// + /// Refreshes the user's authentication tokens by validating the provided refresh token and generating a new access token. + /// + /// The existing refresh token issued to the user for renewing authentication. + /// A cancellation token to observe while performing the refresh operation. + /// An AuthResult object containing the new access token, the provided refresh token, and their respective expiry times. Returns null if the refresh token is invalid or inactive. public async Task RefreshAsync(string refreshToken, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(refreshToken)) @@ -81,6 +94,12 @@ public async Task RefreshAsync(string refreshToken, CancellationToke ); } + /// + /// Revokes a refresh token to log the user out by marking the token as revoked in the database. + /// + /// The token to be revoked, which identifies the user session. + /// A cancellation token to observe while awaiting the task. + /// A Task representing the asynchronous operation. public async Task LogoutAsync(string refreshToken, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(refreshToken)) diff --git a/src/Services/Auth/JwtProvider.cs b/src/Services/Auth/JwtProvider.cs index d5cab24..f3cbdcf 100644 --- a/src/Services/Auth/JwtProvider.cs +++ b/src/Services/Auth/JwtProvider.cs @@ -20,6 +20,11 @@ public JwtProvider(IConfiguration config) _audience = config["SPARKLY_JWT_AUDIENCE"] ?? "sparkly-api"; } + /// + /// Generates a JWT access token for the given user. + /// + /// The user for whom the access token is being generated. The user object should contain details like Id, Email, UserName, and Role. + /// A string representing the generated JWT access token. public string GenerateAccessToken(User user) { var claims = new List @@ -46,6 +51,10 @@ public string GenerateAccessToken(User user) return new JwtSecurityTokenHandler().WriteToken(token); } + /// + /// Generates a secure refresh token to be used for renewing access tokens. + /// + /// A string representing the generated refresh token. public string GenerateRefreshToken() { // na razie prosty generator; później można dodać zapisywanie do bazy diff --git a/src/Services/Projects/ProjectRepository.cs b/src/Services/Projects/ProjectRepository.cs index 10d44ff..89342c5 100644 --- a/src/Services/Projects/ProjectRepository.cs +++ b/src/Services/Projects/ProjectRepository.cs @@ -13,13 +13,25 @@ public ProjectRepository(AppDbContext db) { _db = db; } - + + /// + /// Adds a new project to the database asynchronously. + /// + /// The project entity to be added. + /// A CancellationToken to observe while waiting for the task to complete. + /// A Task representing the asynchronous operation. public async Task AddAsync(Project project, CancellationToken cancellationToken = default) { await _db.Projects.AddAsync(project, cancellationToken); await _db.SaveChangesAsync(cancellationToken); } + /// + /// Retrieves a project by its unique identifier asynchronously. + /// + /// The unique identifier of the project to be retrieved. + /// A CancellationToken to observe while waiting for the task to complete. + /// A Task that represents the asynchronous operation. The task result contains the project if found; otherwise, null. public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { return _db.Projects @@ -27,22 +39,47 @@ public async Task AddAsync(Project project, CancellationToken cancellationToken .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); } + /// + /// Retrieves a list of projects associated with a specific user asynchronously. + /// + /// The unique identifier of the user whose projects are to be retrieved. + /// A CancellationToken to observe while waiting for the task to complete. + /// A Task that represents the asynchronous operation. The task result contains a read-only list of projects associated with the specified user. public async Task> GetForUserAsync(Guid userId, CancellationToken cancellationToken = default) { return await _db.Projects .Where(p => p.OwnerId == userId || p.Members.Any(m => m.Id == userId)) .ToListAsync(cancellationToken); } + + /// + /// Checks asynchronously whether a project name is already taken. + /// + /// The name of the project to check for existence. + /// A CancellationToken to observe while waiting for the task to complete. + /// A Task that represents the asynchronous operation. The task result contains a boolean value indicating whether the project name is already taken. public async Task IsProjectNameTakenAsync(string projectName, CancellationToken cn) { return await _db.Projects .AnyAsync(pn => pn.ProjectName == projectName, cn); } + /// + /// Saves all changes made in the current context to the database asynchronously. + /// + /// A CancellationToken to observe while waiting for the task to complete. + /// A Task representing the asynchronous save operation. public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return _db.SaveChangesAsync(cancellationToken); } + + /// + /// Deletes a project from the database asynchronously. + /// + /// The unique identifier of the project to be deleted. + /// A CancellationToken to observe while waiting for the task to complete. + /// A Task representing the asynchronous operation. public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { await _db.Projects @@ -50,6 +87,12 @@ await _db.Projects .ExecuteDeleteAsync(cancellationToken); } + /// + /// Retrieves a random list of public projects asynchronously. + /// + /// The number of public projects to retrieve. + /// A CancellationToken to observe while waiting for the task to complete. + /// A Task that represents the asynchronous operation, containing a read-only list of public projects. public async Task> GetRandomPublicAsync(int take, CancellationToken ct = default) { return await _db.Projects diff --git a/src/Services/Projects/ProjectService.cs b/src/Services/Projects/ProjectService.cs index 5571db0..f229a0d 100644 --- a/src/Services/Projects/ProjectService.cs +++ b/src/Services/Projects/ProjectService.cs @@ -21,13 +21,26 @@ public ProjectService( _projects = projects; } - public async Task CreateProjectAsync(string name, string description, ProjectVisibility visibility, CancellationToken cancellationToken = default) + /// + /// Creates a new project with the specified details and assigns it to the authenticated user. + /// + /// The name of the project to be created. + /// A brief description of the project. + /// Specifies whether the project is public or private. + /// A token to cancel the operation if needed. + /// Returns the newly created project. + /// + /// Thrown if the user is not authenticated, the owner is not found, + /// or the project name is already taken. + /// + public async Task CreateProjectAsync(string name, string description, ProjectVisibility visibility, + CancellationToken cancellationToken = default) { var userId = _currentUser.UserId - ?? throw new InvalidOperationException("User is not authenticated"); - + ?? throw new InvalidOperationException("User is not authenticated"); + var owner = await _users.GetByIdAsync(userId, cancellationToken) - ?? throw new InvalidOperationException("Owner not found"); + ?? throw new InvalidOperationException("Owner not found"); if (await _projects.IsProjectNameTakenAsync(name, cancellationToken)) { @@ -40,7 +53,16 @@ public async Task CreateProjectAsync(string name, string description, P return project; } - + + /// + /// Retrieves a project by its unique identifier. + /// + /// The unique identifier of the project to retrieve. + /// A token to cancel the operation if needed. + /// Returns the project associated with the specified identifier. + /// + /// Thrown if the project with the specified identifier is not found. + /// public async Task GetProjectByIdAsync(Guid projectId, CancellationToken cancellationToken = default) { var project = await _projects.GetByIdAsync(projectId, cancellationToken) @@ -48,14 +70,34 @@ public async Task GetProjectByIdAsync(Guid projectId, CancellationToken return project; } - + + /// + /// Retrieves the list of projects associated with the currently authenticated user. + /// + /// A token to cancel the operation if needed. + /// Returns a read-only list of projects owned or shared with the current user. + /// Thrown if the user is not authenticated. public Task> GetProjectsForCurrentUserAsync(CancellationToken cancellationToken = default) { var userId = _currentUser.UserId - ?? throw new InvalidOperationException("User is not authenticated"); + ?? throw new InvalidOperationException("User is not authenticated"); return _projects.GetForUserAsync(userId, cancellationToken); } - + + /// + /// Renames an existing project using the specified new name. + /// + /// The unique identifier of the project to be renamed. + /// The new name to assign to the project. + /// A token to cancel the operation if needed. + /// Returns a task that represents the asynchronous operation of renaming the project. + /// + /// Thrown if the user is not authenticated, the project is not found, or the new name is invalid + /// (e.g., null, empty, or already taken). + /// + /// + /// Thrown if the authenticated user is not the owner of the project. + /// public async Task RenameAsync(Guid projectId, string newName, CancellationToken cancellationToken = default) { var userId = _currentUser.UserId @@ -77,7 +119,20 @@ public async Task RenameAsync(Guid projectId, string newName, CancellationToken await _projects.SaveChangesAsync(cancellationToken); } - + + /// + /// Changes the description of the specified project. + /// + /// The unique identifier of the project whose description will be changed. + /// The new description to assign to the project. + /// A token to cancel the operation if needed. + /// An asynchronous operation. + /// + /// Thrown if the user is not authenticated or if the project is not found. + /// + /// + /// Thrown if the user is not the owner of the project. + /// public async Task ChangeDescriptionAsync(Guid projectId, string newDescription, CancellationToken cancellationToken = default) { var userId = _currentUser.UserId @@ -94,7 +149,20 @@ public async Task ChangeDescriptionAsync(Guid projectId, string newDescription, await _projects.SaveChangesAsync(cancellationToken); } - + + /// + /// Sets the visibility of a specified project to either public or private. + /// + /// The unique identifier of the project whose visibility is to be changed. + /// The new visibility setting for the project. + /// A token to cancel the operation if needed. + /// Returns a task representing the asynchronous operation. + /// + /// Thrown if the current user is not authenticated or if the project cannot be found. + /// + /// + /// Thrown if the user does not own the project and is not an admin. + /// public async Task SetVisibilityAsync(Guid projectId, ProjectVisibility visibility, CancellationToken cancellationToken = default) { var userId = _currentUser.UserId @@ -112,14 +180,28 @@ public async Task SetVisibilityAsync(Guid projectId, ProjectVisibility visibilit await _projects.SaveChangesAsync(cancellationToken); } - + + /// + /// Adds a new member to a project, ensuring the authenticated user has the necessary permissions. + /// + /// The unique identifier of the project to which the member will be added. + /// The unique identifier of the user to add as a member of the project. + /// A token to cancel the operation if necessary. + /// Returns a task representing the asynchronous operation. + /// + /// Thrown if the authenticated user is not valid, the project is not found, + /// or the user to be added does not exist. + /// + /// + /// Thrown if the authenticated user does not have permission to add members to the project. + /// public async Task AddMemberAsync(Guid projectId, Guid userId, CancellationToken cancellationToken = default) { var currentUser = _currentUser.UserId - ?? throw new InvalidOperationException("User is not authenticated"); - + ?? throw new InvalidOperationException("User is not authenticated"); + var project = await _projects.GetByIdAsync(projectId, cancellationToken) - ?? throw new InvalidOperationException("ProjectName not found"); + ?? throw new InvalidOperationException("ProjectName not found"); var isAdmin = _currentUser.IsInRole(Roles.Admin); var isOwner = project.IsOwner(currentUser); @@ -134,12 +216,25 @@ public async Task AddMemberAsync(Guid projectId, Guid userId, CancellationToken await _projects.SaveChangesAsync(cancellationToken); } - + + /// + /// Removes a member from a specified project. + /// + /// The unique identifier of the project from which the member will be removed. + /// The unique identifier of the user to be removed from the project. + /// A token to cancel the operation if needed. + /// Returns a task representing the asynchronous operation. + /// + /// Thrown if the current user is not authenticated, the project is not found, or the user to be removed is not found. + /// + /// + /// Thrown if the current user does not have sufficient permissions to remove members from the project. + /// public async Task RemoveMemberAsync(Guid projectId, Guid userId, CancellationToken cancellationToken = default) { var currentUser = _currentUser.UserId ?? throw new InvalidOperationException("User is not authenticated"); - + var project = await _projects.GetByIdAsync(projectId, cancellationToken) ?? throw new InvalidOperationException("ProjectName not found"); @@ -155,8 +250,20 @@ public async Task RemoveMemberAsync(Guid projectId, Guid userId, CancellationTok project.RemoveMember(userToRemove); await _projects.SaveChangesAsync(cancellationToken); - } - + } + + /// + /// Retrieves a random selection of public projects. + /// + /// The number of public projects to retrieve. + /// A token to cancel the operation if needed. + /// Returns a list of randomly selected public projects. + /// + /// Thrown if the value of is less than or equal to zero. + /// + /// + /// Thrown if the operation is canceled via the token. + /// public async Task> GetRandomPublicAsync(int take, CancellationToken ct = default) { var projects = await _projects.GetRandomPublicAsync(take, ct); @@ -165,7 +272,20 @@ public async Task> GetRandomPublicAsync(int take, .Select(p => new ProjectResponse(p)) .ToList(); } - + + /// + /// Updates the details of an existing project with the provided information. + /// + /// The unique identifier of the project to update. + /// The updated project details including name, description, and visibility. + /// A token to cancel the operation if required. + /// Returns a task that represents the asynchronous operation. + /// + /// Thrown if the user is not authenticated or the project is not found. + /// + /// + /// Thrown if the user does not have sufficient permissions to update the project. + /// public async Task UpdateProjectAsync(Guid projectId, UpdateProjectRequest request, CancellationToken cancellationToken = default) { var userId = _currentUser.UserId @@ -202,6 +322,19 @@ public async Task UpdateProjectAsync(Guid projectId, UpdateProjectRequest reques await _projects.SaveChangesAsync(cancellationToken); } + + /// + /// Deletes an existing project specified by its ID. + /// + /// The unique identifier of the project to delete. + /// A token to cancel the operation if necessary. + /// A task that represents the asynchronous delete operation. + /// + /// Thrown if the authenticated user is not found or the project does not exist. + /// + /// + /// Thrown if the user is neither the owner of the project nor an administrator. + /// public async Task DeleteProjectAsync(Guid projectId, CancellationToken cancellationToken = default) { var userId = _currentUser.UserId diff --git a/src/Services/Users/UserService.cs b/src/Services/Users/UserService.cs index 880f3d2..38d7c37 100644 --- a/src/Services/Users/UserService.cs +++ b/src/Services/Users/UserService.cs @@ -13,11 +13,25 @@ public UserService(IUserRepository users) _users = users; } + /// + /// Registers a new user with the specified username, email, and password. + /// + /// The desired username for the new user. + /// The email address of the new user. + /// The password for the new user. Must be at least 6 characters long. + /// Token to monitor for cancellation requests. + /// + /// Returns an instance of representing the newly created user. + /// + /// + /// Thrown when the username or email is already registered, + /// invalid input is provided, or the password does not meet the required criteria. + /// public async Task RegisterAsync(string userName, string email, string password, CancellationToken ct = default) { var userByEmail = await _users.GetByEmailAsync(email, ct); var userByName = await _users.GetByEmailAsync(userName, ct); - + if (userByEmail is not null || userByName is not null) { throw new InvalidOperationException("User with this email already exists."); @@ -44,6 +58,15 @@ public async Task RegisterAsync(string userName, string email, string pass return user; } + /// + /// Validates the credentials of a user using an identifier (email or username) and password. + /// + /// The unique identifier for a user, which can be either the username or email address. + /// The password to be validated for the specified user. + /// Token to monitor for cancellation requests. + /// + /// Returns an instance of if the credentials are valid; otherwise, returns null. + /// public async Task ValidateUserAsync(string identifier, string password, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(identifier)) From 5831056862353cae64fab6edf9863d12d86a8a06 Mon Sep 17 00:00:00 2001 From: SculptTechProject <150788324+SculptTechProject@users.noreply.github.com> Date: Sun, 23 Nov 2025 20:46:17 +0100 Subject: [PATCH 2/2] Add database migration for user and project updates, enforce field length constraints, refine JWT setup, enhance test setup, and update project API and test structure. --- ...1123194301_ProjectAuditAndSlug.Designer.cs | 192 ++++++++++++++++++ .../20251123194301_ProjectAuditAndSlug.cs | 72 +++++++ Migrations/AppDbContextModelSnapshot.cs | 17 +- Program.cs | 39 +++- README.md | 184 +++++++++-------- sparkly-server.csproj | 2 + sparkly-server.sln.DotSettings.user | 7 +- sparkly-server.test/ProjectTest.cs | 115 ++++++----- .../TestWebAppliactionFactory.cs | 29 +-- src/DTO/Projects/ProjectResponse.cs | 16 +- src/Domain/Users/User.cs | 4 + 11 files changed, 491 insertions(+), 186 deletions(-) create mode 100644 Migrations/20251123194301_ProjectAuditAndSlug.Designer.cs create mode 100644 Migrations/20251123194301_ProjectAuditAndSlug.cs diff --git a/Migrations/20251123194301_ProjectAuditAndSlug.Designer.cs b/Migrations/20251123194301_ProjectAuditAndSlug.Designer.cs new file mode 100644 index 0000000..90dfc88 --- /dev/null +++ b/Migrations/20251123194301_ProjectAuditAndSlug.Designer.cs @@ -0,0 +1,192 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using sparkly_server.Infrastructure; + +#nullable disable + +namespace sparkly_server.Services.Users.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251123194301_ProjectAuditAndSlug")] + partial class ProjectAuditAndSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProjectUser", b => + { + b.Property("MembersId") + .HasColumnType("uuid"); + + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.HasKey("MembersId", "ProjectsId"); + + b.HasIndex("ProjectsId"); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("sparkly_server.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByToken") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByIp") + .HasColumnType("text"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("sparkly_server.Domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProjectName") + .IsUnique(); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("sparkly_server.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ProjectUser", b => + { + b.HasOne("sparkly_server.Domain.Users.User", null) + .WithMany() + .HasForeignKey("MembersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("sparkly_server.Domain.Projects.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("sparkly_server.Domain.Auth.RefreshToken", b => + { + b.HasOne("sparkly_server.Domain.Users.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("sparkly_server.Domain.Users.User", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20251123194301_ProjectAuditAndSlug.cs b/Migrations/20251123194301_ProjectAuditAndSlug.cs new file mode 100644 index 0000000..54e4b4e --- /dev/null +++ b/Migrations/20251123194301_ProjectAuditAndSlug.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace sparkly_server.Services.Users.Migrations +{ + /// + public partial class ProjectAuditAndSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UserName", + table: "users", + type: "character varying(20)", + maxLength: 20, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Role", + table: "users", + type: "character varying(20)", + maxLength: 20, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "users", + type: "character varying(300)", + maxLength: 300, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UserName", + table: "users", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(20)", + oldMaxLength: 20); + + migrationBuilder.AlterColumn( + name: "Role", + table: "users", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(20)", + oldMaxLength: 20); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "users", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(300)", + oldMaxLength: 300); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index d7db13d..a1daabb 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -116,7 +116,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("projects", (string)null); }); - modelBuilder.Entity("sparkly_server.Domain.User", b => + modelBuilder.Entity("sparkly_server.Domain.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -132,15 +132,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PasswordHash") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(300) + .HasColumnType("character varying(300)"); b.Property("Role") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(20) + .HasColumnType("character varying(20)"); b.Property("UserName") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(20) + .HasColumnType("character varying(20)"); b.HasKey("Id"); @@ -152,7 +155,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("ProjectUser", b => { - b.HasOne("sparkly_server.Domain.User", null) + b.HasOne("sparkly_server.Domain.Users.User", null) .WithMany() .HasForeignKey("MembersId") .OnDelete(DeleteBehavior.Cascade) @@ -167,7 +170,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("sparkly_server.Domain.Auth.RefreshToken", b => { - b.HasOne("sparkly_server.Domain.User", "User") + b.HasOne("sparkly_server.Domain.Users.User", "User") .WithMany("RefreshTokens") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -176,7 +179,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("sparkly_server.Domain.User", b => + modelBuilder.Entity("sparkly_server.Domain.Users.User", b => { b.Navigation("RefreshTokens"); }); diff --git a/Program.cs b/Program.cs index 9965890..b6b01ee 100644 --- a/Program.cs +++ b/Program.cs @@ -19,8 +19,7 @@ { if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Testing")) { - // Dev / Testing fallback key only for local usage - jwtKey = "dev-only-jwt-key-change-me"; + jwtKey = "this-is-very-long-test-jwt-key-123456"; } else { @@ -55,7 +54,14 @@ builder.Services.AddScoped(); // Database -if (!builder.Environment.IsEnvironment("Testing")) +if (builder.Environment.IsEnvironment("Testing")) +{ + builder.Services.AddDbContext(options => + { + options.UseInMemoryDatabase("sparkly-tests"); + }); +} +else { var connectionString = builder.Configuration.GetConnectionString("Default") ?? Environment.GetEnvironmentVariable("ConnectionStrings__Default") @@ -90,15 +96,34 @@ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { + var keyBytes = Encoding.UTF8.GetBytes(jwtKey); + options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + + IssuerSigningKey = new SymmetricSecurityKey(keyBytes), ValidateIssuerSigningKey = true, ClockSkew = TimeSpan.FromMinutes(1), }; + + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + Console.WriteLine("[JWT] Authentication failed:"); + Console.WriteLine(context.Exception.ToString()); + return Task.CompletedTask; + }, + OnChallenge = context => + { + Console.WriteLine("[JWT] Challenge fired:"); + Console.WriteLine($"Error: {context.Error}, Desc: {context.ErrorDescription}"); + return Task.CompletedTask; + } + }; }); var app = builder.Build(); @@ -118,7 +143,10 @@ db.Database.Migrate(); } -app.UseHttpsRedirection(); +if (!app.Environment.IsEnvironment("Testing")) +{ + app.UseHttpsRedirection(); +} app.UseAuthentication(); app.UseAuthorization(); @@ -127,7 +155,6 @@ app.MapControllers(); -// Simple health endpoint for tests and monitoring app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); app.Run(); diff --git a/README.md b/README.md index db4bf24..6e7d35d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Sparkly Server -Backend API for **Sparkly**, a build‑in‑public social platform. This repository contains the C# / .NET backend that powers authentication, user accounts and the core application features used by the Sparkly web client. +Backend API for **Sparkly**, a build‑in‑public social platform. This repository contains the C# / .NET backend that powers authentication, user accounts, project management and the core application features used by the Sparkly web client. -> Status: early development – API surface and architecture are evolving. +> Status: early development – API surface, architecture and testing pipeline are actively evolving. --- @@ -10,28 +10,30 @@ Backend API for **Sparkly**, a build‑in‑public social platform. This reposit * **.NET**: .NET 9 (ASP.NET Core Web API) * **Data access**: Entity Framework Core (code‑first migrations) -* **Database**: PostgreSQL (via Docker) or local dev database +* **Database**: PostgreSQL (local or via Docker) * **Containerization**: Docker + Docker Compose +* **Tests**: xUnit integration tests using TestServer +* **CI/CD**: GitHub Actions (build + tests + optional Docker build) * **Tooling**: `dotnet` CLI, EF Core CLI --- ## Project structure -High‑level layout of the repository: - ```text sparkly-server/ +├─ src/ # API, domain, infrastructure ├─ Migrations/ # EF Core migrations -├─ src/ # Application source code (API, domain, infrastructure) -├─ Program.cs # Application bootstrap / entrypoint -├─ compose.yaml # Docker Compose stack (API + database) -├─ Dockerfile # Image for the API service -├─ appsettings.json # Base configuration (non‑secret) -└─ appsettings.Development.json # Local overrides (DO NOT commit secrets) +├─ sparkly-server.test/ # Integration tests +├─ Program.cs # Entrypoint +├─ compose.yaml # Docker Compose stack (API + DB) +├─ Dockerfile # API image +├─ .github/workflows/ # GitHub Actions CI +├─ appsettings.json # Base config (non-secret) +└─ appsettings.Development.json # Local overrides ``` -The `src` folder is where the actual application code lives (controllers, domain models, services, etc.). As the project grows, this will be organized into clear layers (e.g. `Api`, `Application`, `Domain`, `Infrastructure`). +The `src` directory contains controllers, domain models, services, repositories and configuration. Tests live in a separate project with isolated database state. --- @@ -39,103 +41,119 @@ The `src` folder is where the actual application code lives (controllers, domain ### Prerequisites -* .NET 9 SDK installed -* Docker + Docker Compose installed (for running the full stack) +* .NET 9 SDK +* Docker + Docker Compose * Git -### 1. Clone the repository +### Clone ```bash git clone https://github.com/SculptTechProject/sparkly-server.git cd sparkly-server ``` -### 2. Configure environment +### Environment configuration -The backend expects configuration from `appsettings.json` and environment variables. **Secrets must never be committed to the repo.** +The backend reads configuration from `appsettings.json` and environment variables. Do not commit secrets. -Create a local environment file (for your own use) or set environment variables via your shell / Docker: +Example variables: ```bash -# Example – adjust names/values to match the codebase export ASPNETCORE_ENVIRONMENT=Development export ConnectionStrings__DefaultConnection="Host=localhost;Port=5432;Database=sparkly;Username=sparkly;Password=changeme" - -# Example if you add auth / JWT later export Jwt__Issuer="https://sparkly.local" export Jwt__Audience="sparkly-app" export Jwt__Secret="super-long-random-secret-key-change-me" ``` -Keep a non‑secret example in the repo as `appsettings.Development.example.json` or `.env.example` (recommended), and use it to document required keys. +A `.env.example` or `appsettings.Development.example.json` is recommended to document required keys. -### 3. Apply database migrations (optional but recommended) - -If EF Core migrations are used, apply them before running the API: +### Database migrations ```bash dotnet restore - dotnet ef database update ``` -If you use Docker with a database container, you can also let the application apply migrations on startup (depending on how the bootstrapping is implemented). +Or let Docker apply migrations at startup, depending on configuration. -### 4. Run the API locally +### Run locally -**Option A – `dotnet run`** +**Dotnet CLI:** ```bash dotnet restore - dotnet run ``` -By default the API will listen on the ports defined in `appsettings.json` / `launchSettings` / environment variables (commonly `http://localhost:5000` or `http://localhost:8080`). - -**Option B – Docker Compose (API + DB)** -> Recommended +**Docker Compose:** ```bash docker compose up --build ``` -This will: +This builds the API image and launches both the API and PostgreSQL. + +--- + +## Tests + +The project includes integration tests that run the API using an in-memory test server. Tests reset database state for every run. + +Run tests locally: + +```bash +dotnet test +``` -* build the backend image using `Dockerfile`, -* start the API container, -* start the database container defined in `compose.yaml`. +Run tests using Docker Compose: -Check the logs to confirm that the API is healthy and connected to the database. +```bash +docker compose run --rm api dotnet test +``` --- -## API surface (high‑level) +## GitHub Actions (CI) -This backend is responsible for the core Sparkly features, for example: +This repository contains a CI pipeline that runs on every push and pull request: -* user registration and login, -* user profile data and settings, -* Sparkly dashboard / feed backend endpoints, -* future billing / subscriptions integration (Stripe), -* admin / internal endpoints for moderation and analytics. +* restore and build +* run tests +* optionally build Docker image -As the project grows, consider documenting endpoints using: +This ensures that the API and tests stay green across contributions. -* **OpenAPI / Swagger** (Swashbuckle), -* or minimal API documentation in `README` (auth endpoints, example requests/responses). +--- + +## API overview + +The backend currently covers: + +* user registration and login +* user profile and authentication +* project creation and management + +Planned additions: + +* feed system for build-in-public updates +* real‑time notifications +* billing and subscription logic +* moderation and admin endpoints + +Documentation will be available through OpenAPI/Swagger. --- -## Docker +## Docker commands -### Build image manually +Build image manually: ```bash docker build -t sparkly-server . ``` -### Run container manually +Run image manually: ```bash docker run \ @@ -145,66 +163,46 @@ docker run \ sparkly-server ``` -In practice you will usually prefer `docker compose up` because it brings up the database and the API together. - --- -## Configuration & secrets +## Secrets -**Important:** +Secrets must always be supplied using environment variables or secret management tools. Never commit real credentials. -* API keys, JWT secrets, Stripe keys, and real database credentials **must never** live in the Git repository. -* Use environment variables / secret managers in development and production. +Likely future keys: -Recommended pattern: - -* commit a `.env.example` (or `appsettings.Development.example.json`) with all required keys but without real secrets, -* document each key in a short comment / table so contributors know what to set. - -Example keys you are likely to add as Sparkly evolves: - -* `Stripe__SecretKey` -* `Stripe__WebhookSecret` -* `Jwt__Secret` -* `Jwt__Issuer` -* `Jwt__Audience` +* Stripe secrets +* JWT settings +* OAuth providers (GitHub, Google) --- ## Development workflow -Suggested workflow while the project is young: - -1. Create a small issue / task (feature, refactor, bugfix). -2. Work on a feature branch. -3. Add or update tests (unit/integration) around new behaviour. -4. Run tests and `dotnet build` locally. -5. Open a PR (even if you are the only contributor – PR history becomes project documentation). +1. Create a small issue or task. +2. Implement changes on a feature branch. +3. Add or update tests. +4. Run local build and tests. +5. Push and open a PR. -This keeps the history clean and makes it easier to reason about changes later. +This keeps the project clean and easy to maintain. --- -## Roadmap ideas +## Roadmap (short‑term) -Some directions for the Sparkly backend: - -* Authentication & authorization layer (JWT, refresh tokens, roles/permissions). -* First version of the "build in public" feed (posts, comments, reactions). -* Real‑time communication (SignalR or WebSockets) for live rooms / chats. -* Stripe integration for paid plans (billing, webhooks, subscription status synced to users). -* Observability (logging, metrics, health checks, readiness / liveness probes for Docker / Kubernetes). +* Full authentication and refresh tokens +* Public project pages +* Build-in-public feed +* Email notifications +* Admin panel foundations +* Observability (structured logs, metrics, probes) --- ## Contributing -This project is currently developed by the SculptTech / Sparkly team. External contributions are welcome once the core architecture stabilises. - -If you want to propose a change: - -* open an issue with a short description and motivation, -* or open a draft PR with your idea. +The project is actively developed by the SculptTech / Sparkly team. External contributions will be welcomed once core systems stabilise. --- @@ -212,4 +210,4 @@ If you want to propose a change: License: **TBD** -Until a license is added, treat this repository as source‑available but not licensed for unrestricted commercial reuse. +Until then, treat the repository as source‑available only. diff --git a/sparkly-server.csproj b/sparkly-server.csproj index b1e9091..b713321 100644 --- a/sparkly-server.csproj +++ b/sparkly-server.csproj @@ -50,6 +50,8 @@ + + diff --git a/sparkly-server.sln.DotSettings.user b/sparkly-server.sln.DotSettings.user index 87423b6..c91e3a4 100644 --- a/sparkly-server.sln.DotSettings.user +++ b/sparkly-server.sln.DotSettings.user @@ -1,2 +1,7 @@  - ForceIncluded \ No newline at end of file + ForceIncluded + <SessionState ContinuousTestingMode="0" Name="CreateProject_Should_Create_Project_For_Authenticated_User" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::E26AB9F3-8A34-4AB8-A503-F0B851823527::net9.0::sparkly_server.test.ProjectTest.CreateProject_Should_Create_Project_For_Authenticated_User</TestId> + </TestAncestor> +</SessionState> \ No newline at end of file diff --git a/sparkly-server.test/ProjectTest.cs b/sparkly-server.test/ProjectTest.cs index 917b912..ea1a740 100644 --- a/sparkly-server.test/ProjectTest.cs +++ b/sparkly-server.test/ProjectTest.cs @@ -1,10 +1,13 @@ -using Microsoft.Extensions.DependencyInjection; -using sparkly_server.DTO.Auth; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using sparkly_server.Domain.Users; using sparkly_server.DTO.Projects; using sparkly_server.Enum; using sparkly_server.Infrastructure; -using System.Net.Http.Headers; -using System.Net.Http.Json; +using sparkly_server.Services.Auth; +using Xunit.Abstractions; namespace sparkly_server.test { @@ -12,32 +15,67 @@ public class ProjectTest : IClassFixture, IAsyncLifet { private readonly HttpClient _client; private readonly TestWebApplicationFactory _factory; + private readonly ITestOutputHelper _output; - public ProjectTest(TestWebApplicationFactory factory) + public ProjectTest(TestWebApplicationFactory factory, ITestOutputHelper output) { _factory = factory; _client = factory.CreateClient(); + _output = output; } public async Task InitializeAsync() { using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - + db.Users.RemoveRange(db.Users); db.Projects.RemoveRange(db.Projects); await db.SaveChangesAsync(); } public Task DisposeAsync() => Task.CompletedTask; - + // Helpers /// - /// Creates a new project asynchronously with the specified project name and returns the created project's details. + /// Authenticates a test user by creating a new user in the database, + /// generating a JWT access token for the user, and assigning the token to the HTTP client. + /// Returns the unique identifier of the created test user. /// - /// The name of the project to be created. - /// A task representing the asynchronous operation. The result contains the details of the created project, or null if deserialization fails. + /// A representing the ID of the test user. + private async Task AuthenticateAsTestUserAsync() + { + using var scope = _factory.Services.CreateScope(); + + var db = scope.ServiceProvider.GetRequiredService(); + var jwtProvider = scope.ServiceProvider.GetRequiredService(); + + var user = new User( + userName: "testuser", + email: "test@sparkly.local" + ); + + user.SetPasswordHash("TEST_HASH"); + + db.Users.Add(user); + await db.SaveChangesAsync(); + + var token = jwtProvider.GenerateAccessToken(user); + + _client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + return user.Id; + } + + /// + /// Creates a new project with the specified name by sending a request to the server. + /// The project is created with a default description and public visibility. + /// Returns the details of the newly created project if the operation is successful. + /// + /// The name of the project to create. + /// A containing the details of the created project, or null if creation fails. private async Task CreateProjectAsync(string projectName) { var payload = new CreateProjectRequest( @@ -47,54 +85,39 @@ public async Task InitializeAsync() ); var response = await _client.PostAsJsonAsync("/api/v1/projects/create", payload); + + var rawBody = await response.Content.ReadAsStringAsync(); + _output.WriteLine($"[CreateProjectAsync] Status: {(int)response.StatusCode} {response.StatusCode}"); + _output.WriteLine($"[CreateProjectAsync] Body: {rawBody}"); + response.EnsureSuccessStatusCode(); var created = await response.Content.ReadFromJsonAsync(); return created; } - /// - /// Registers a new user and logs them in, setting the authentication token in the HTTP client header for subsequent requests. - /// - /// Thrown when the authentication response does not contain an access token. - /// A task that represents the asynchronous operation of registering and logging in a user. - private async Task RegisterAndLoginUser() - { - var email = "test@sparkly.local"; - var password = "Test1234!"; - var userName = "testuser"; - - var registerPayload = new RegisterRequest(Username:userName, Email: email, Password: password); - var registerResponse = await _client.PostAsJsonAsync("/api/v1/auth/register", registerPayload); - registerResponse.EnsureSuccessStatusCode(); - - var loginPayload = new LoginRequest(Identifier: email, Password: password); - var loginResponse = await _client.PostAsJsonAsync("/api/v1/auth/login", loginPayload); - - loginResponse.EnsureSuccessStatusCode(); - - var loginContent = await loginResponse.Content.ReadFromJsonAsync(); - - if (loginContent?.AccessToken is null) - { - throw new InvalidOperationException("Auth response did not contain access token"); - } - - _client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", loginContent.AccessToken); - } - // Tests + [Fact] public async Task CreateProject_Should_Create_Project_For_Authenticated_User() { - await RegisterAndLoginUser(); + var userId = await AuthenticateAsTestUserAsync(); var projectName = "MyTestProject"; - + var created = await CreateProjectAsync(projectName); - + Assert.NotNull(created); - Assert.Equal(projectName, created.ProjectName); + Assert.Equal(projectName, created!.ProjectName); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var project = await db.Projects + .AsNoTracking() + .SingleAsync(p => p.Id == created.Id); + + Assert.Equal(projectName, project.ProjectName); + Assert.Equal(userId, project.OwnerId); } } -} +} \ No newline at end of file diff --git a/sparkly-server.test/TestWebAppliactionFactory.cs b/sparkly-server.test/TestWebAppliactionFactory.cs index b5fed6d..b391291 100644 --- a/sparkly-server.test/TestWebAppliactionFactory.cs +++ b/sparkly-server.test/TestWebAppliactionFactory.cs @@ -1,9 +1,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using sparkly_server.Infrastructure; namespace sparkly_server.test { @@ -18,34 +15,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var settings = new Dictionary { ["SPARKLY_JWT_KEY"] = "this-is-very-long-test-jwt-key-123456", - ["SPARKLY_JWT_ISSUER"] = "sparkly-test-issuer" + ["SPARKLY_JWT_ISSUER"] = "sparkly", + ["SPARKLY_JWT_AUDIENCE"] = "sparkly-api" }; config.AddInMemoryCollection(settings); }); - - builder.ConfigureServices(services => - { - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); - - if (descriptor is not null) - { - services.Remove(descriptor); - } - - services.AddDbContext(options => - { - options.UseInMemoryDatabase("sparkly-tests"); - }); - - var sp = services.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - }); } } } diff --git a/src/DTO/Projects/ProjectResponse.cs b/src/DTO/Projects/ProjectResponse.cs index 42481e1..595fa36 100644 --- a/src/DTO/Projects/ProjectResponse.cs +++ b/src/DTO/Projects/ProjectResponse.cs @@ -5,12 +5,16 @@ namespace sparkly_server.DTO.Projects { public sealed record ProjectResponse { - public Guid Id { get; } - public string ProjectName { get; } - public string Description { get; } - public ProjectVisibility Visibility { get; } - public Guid OwnerId { get; } - + public Guid Id { get; init; } + public string ProjectName { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public ProjectVisibility Visibility { get; init; } + public Guid OwnerId { get; init; } + + public ProjectResponse() + { + } + public ProjectResponse(Project project) { Id = project.Id; diff --git a/src/Domain/Users/User.cs b/src/Domain/Users/User.cs index 5648acf..f819d3e 100644 --- a/src/Domain/Users/User.cs +++ b/src/Domain/Users/User.cs @@ -1,5 +1,6 @@ using sparkly_server.Domain.Auth; using sparkly_server.Domain.Projects; +using System.ComponentModel.DataAnnotations; namespace sparkly_server.Domain.Users { @@ -7,8 +8,11 @@ public class User { public Guid Id { get; private set; } public string Email { get; private set; } = default!; + [MaxLength(20)] public string UserName { get; set; } = default!; + [MaxLength(300)] public string PasswordHash { get; private set; } = default!; + [MaxLength(20)] public string Role { get; private set; } = "User"; public ICollection RefreshTokens { get; set; } = new List(); public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;