diff --git a/CLAUDE.md b/CLAUDE.md index d2e6f7a..7a40dab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ Two independent mechanisms — don't cross-wire them: ### Conventions +- No comments in code. Code must be self-documenting through clear naming and structure. - `net10.0`, `enable`, `enable` on every project. - DTOs live next to the module that returns them (e.g. `ProjectDto` in `Projects/Contracts.cs`). Use `Contracts.cs` per folder; don't sprawl. - All async service methods take `CancellationToken ct = default` and forward it. diff --git a/api/Middleware/ApiExceptionMiddleware.cs b/api/Middleware/ApiExceptionMiddleware.cs index 1368ba5..6a05c4d 100644 --- a/api/Middleware/ApiExceptionMiddleware.cs +++ b/api/Middleware/ApiExceptionMiddleware.cs @@ -25,6 +25,7 @@ public class ApiExceptionMiddleware(RequestDelegate next, ILogger (StatusCodes.Status401Unauthorized, ex.Message), ForbiddenException => (StatusCodes.Status403Forbidden, ex.Message), NotFoundException => (StatusCodes.Status404NotFound, ex.Message), + StorageUnavailableException => (StatusCodes.Status502BadGateway, "Storage backend is unavailable. Please try again."), _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred"), }; diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json index 16ff39e..70954ac 100644 --- a/api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "Default": "Host=192.168.2.100;Port=5432;Database=mws;Username=postgres;Password=Pa55w0rd" + "Default": "Host=192.168.1.100;Port=5432;Database=mws;Username=postgres;Password=xxxx" }, "Jwt": { "Secret": "mws-development-secret-key-change-me-in-production-123456", @@ -10,6 +10,12 @@ }, "Cors": { "Origins": "*" + }, + "CloudFile": { + "BaseUrl": "https://s3.koda.id.vn", + "ApiKey": "xxxx", + "BucketPrefix": "mws-p-", + "HttpTimeoutSeconds": 30 }, "Logging": { "LogLevel": { diff --git a/api/appsettings.json b/api/appsettings.json index 16ff39e..57610ae 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "Default": "Host=192.168.2.100;Port=5432;Database=mws;Username=postgres;Password=Pa55w0rd" + "Default": "Host=pgsql.koda.id.vn;Port=5432;Database=mws;Username=postgres;Password=xxxx" }, "Jwt": { "Secret": "mws-development-secret-key-change-me-in-production-123456", @@ -11,6 +11,12 @@ "Cors": { "Origins": "*" }, + "CloudFile": { + "BaseUrl": "https://s3.koda.id.vn", + "ApiKey": "xxxx", + "BucketPrefix": "mws-p-", + "HttpTimeoutSeconds": 30 + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/application/Common/Exceptions.cs b/application/Common/Exceptions.cs index e7de005..bc12a30 100644 --- a/application/Common/Exceptions.cs +++ b/application/Common/Exceptions.cs @@ -6,4 +6,6 @@ public class ForbiddenException(string message) : Exception(message); public class UnauthorizedException(string message) : Exception(message); -public class BadRequestException(string message) : Exception(message); \ No newline at end of file +public class BadRequestException(string message) : Exception(message); + +public class StorageUnavailableException(string message, Exception? inner = null) : Exception(message, inner); \ No newline at end of file diff --git a/application/Documents/Commands/CreateDocument.cs b/application/Documents/Commands/CreateDocument.cs index b01a33a..58a6c06 100644 --- a/application/Documents/Commands/CreateDocument.cs +++ b/application/Documents/Commands/CreateDocument.cs @@ -1,3 +1,4 @@ +using System.Text; using AutoMapper; using MediatR; using mws.backend.dotnet.application.Common; @@ -8,7 +9,8 @@ namespace mws.backend.dotnet.application.Documents; public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest; -public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore) + : IRequestHandler { public async Task Handle(CreateDocumentCommand command, CancellationToken ct) { @@ -43,7 +45,6 @@ public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHa ParentId = request.ParentId, Title = request.Title.Trim(), Type = request.Type, - Content = request.Type == DocumentType.Document ? request.Content : null, CreatedBy = command.UserId, CreatedAt = now, UpdatedBy = command.UserId, @@ -52,6 +53,21 @@ public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHa uow.Documents.Add(doc); await uow.SaveChangesAsync(ct); + + if (request.Type == DocumentType.Document && !string.IsNullOrEmpty(request.Content)) + { + await contentStore.EnsureBucketAsync(doc.ProjectId, ct); + var bytes = Encoding.UTF8.GetBytes(request.Content); + using (var ms = new MemoryStream(bytes)) + { + await contentStore.UploadAsync(doc.ProjectId, doc.Id, "text/html; charset=utf-8", ms, ct); + } + + doc.ContentSize = bytes.Length; + doc.UpdatedContentAt = now; + await uow.SaveChangesAsync(ct); + } + return mapper.Map(doc); } } diff --git a/application/Documents/Commands/DeleteDocument.cs b/application/Documents/Commands/DeleteDocument.cs index 6fe9c98..626a01c 100644 --- a/application/Documents/Commands/DeleteDocument.cs +++ b/application/Documents/Commands/DeleteDocument.cs @@ -6,15 +6,37 @@ namespace mws.backend.dotnet.application.Documents; public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest; -public class DeleteDocumentHandler(IUnitOfWork uow) : IRequestHandler +public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore contentStore) + : IRequestHandler { public async Task Handle(DeleteDocumentCommand command, CancellationToken ct) { var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct); await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Delete, ct); + var allIds = new List<(Guid ProjectId, Guid DocumentId)>(); + await CollectDescendants(uow, command.DocumentId, doc.ProjectId, allIds, ct); + allIds.Add((doc.ProjectId, doc.Id)); + await DocumentAccess.DeleteDescendantsAsync(uow, command.DocumentId, ct); uow.Documents.Remove(doc); await uow.SaveChangesAsync(ct); + + foreach (var (projectId, documentId) in allIds) + { + await contentStore.DeleteAsync(projectId, documentId, ct); + } + } + + private static async Task CollectDescendants( + IUnitOfWork uow, Guid parentId, Guid projectId, + List<(Guid ProjectId, Guid DocumentId)> ids, CancellationToken ct) + { + var children = await uow.Documents.GetChildrenAsync(parentId, ct); + foreach (var child in children) + { + ids.Add((projectId, child.Id)); + await CollectDescendants(uow, child.Id, projectId, ids, ct); + } } } diff --git a/application/Documents/Commands/UpdateDocument.cs b/application/Documents/Commands/UpdateDocument.cs index 81ee8c7..5f87e12 100644 --- a/application/Documents/Commands/UpdateDocument.cs +++ b/application/Documents/Commands/UpdateDocument.cs @@ -1,3 +1,4 @@ +using System.Text; using AutoMapper; using MediatR; using mws.backend.dotnet.application.Common; @@ -8,7 +9,8 @@ namespace mws.backend.dotnet.application.Documents; public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest; -public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore) + : IRequestHandler { public async Task Handle(UpdateDocumentCommand command, CancellationToken ct) { @@ -22,19 +24,31 @@ public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHa } doc.Title = request.Title.Trim(); - if (doc.Type == DocumentType.Document) - { - doc.Content = request.Content; - doc.UpdatedAt = DateTime.UtcNow; - doc.UpdatedBy = command.UserId; - } - else - { - doc.UpdatedAt = DateTime.UtcNow; - doc.UpdatedBy = command.UserId; - } + doc.UpdatedAt = DateTime.UtcNow; + doc.UpdatedBy = command.UserId; await uow.SaveChangesAsync(ct); - return mapper.Map(doc); + + if (doc.Type == DocumentType.Document) + { + await contentStore.EnsureBucketAsync(doc.ProjectId, ct); + var bytes = Encoding.UTF8.GetBytes(request.Content ?? ""); + using (var ms = new MemoryStream(bytes)) + { + await contentStore.UploadAsync(doc.ProjectId, doc.Id, "text/html; charset=utf-8", ms, ct); + } + + doc.ContentSize = bytes.Length; + doc.UpdatedContentAt = DateTime.UtcNow; + doc.ContentHash = null; + await uow.SaveChangesAsync(ct); + } + + var dto = mapper.Map(doc); + if (doc.Type == DocumentType.Document) + { + dto.Content = request.Content; + } + return dto; } } diff --git a/application/Documents/IDocumentContentStore.cs b/application/Documents/IDocumentContentStore.cs new file mode 100644 index 0000000..770d2fd --- /dev/null +++ b/application/Documents/IDocumentContentStore.cs @@ -0,0 +1,10 @@ +namespace mws.backend.dotnet.application.Documents; + +public interface IDocumentContentStore +{ + Task EnsureBucketAsync(Guid projectId, CancellationToken ct); + Task UploadAsync(Guid projectId, Guid documentId, string contentType, Stream body, CancellationToken ct); + Task DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct); + Task ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct); + Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct); +} diff --git a/application/Documents/Queries/GetDocument.cs b/application/Documents/Queries/GetDocument.cs index 0534e50..dd0c5d7 100644 --- a/application/Documents/Queries/GetDocument.cs +++ b/application/Documents/Queries/GetDocument.cs @@ -1,16 +1,36 @@ +using System.Text; using AutoMapper; using MediatR; using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.domain.Documents; namespace mws.backend.dotnet.application.Documents; public record GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest; -public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore) + : IRequestHandler { public async Task Handle(GetDocumentQuery query, CancellationToken ct) { var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct); - return mapper.Map(doc); + var dto = mapper.Map(doc); + + if (doc.Type == DocumentType.Document) + { + await using var stream = await contentStore.DownloadAsync(doc.ProjectId, doc.Id, ct); + if (stream != Stream.Null) + { + using var reader = new StreamReader(stream, Encoding.UTF8); + var content = await reader.ReadToEndAsync(ct); + dto.Content = string.IsNullOrEmpty(content) ? null : content; + } + else + { + dto.Content = null; + } + } + + return dto; } } diff --git a/domain/Documents/Document.cs b/domain/Documents/Document.cs index f8e2b81..840de29 100644 --- a/domain/Documents/Document.cs +++ b/domain/Documents/Document.cs @@ -12,8 +12,10 @@ public class Document public Guid ProjectId { get; set; } public Guid? ParentId { get; set; } public string Title { get; set; } = string.Empty; - public string? Content { get; set; } public DocumentType Type { get; set; } = DocumentType.Document; + public string? ContentHash { get; set; } + public long? ContentSize { get; set; } + public DateTime? UpdatedContentAt { get; set; } public Guid CreatedBy { get; set; } public DateTime CreatedAt { get; set; } public Guid? UpdatedBy { get; set; } diff --git a/infrastructure/CloudFile/CloudFileDocumentContentStore.cs b/infrastructure/CloudFile/CloudFileDocumentContentStore.cs new file mode 100644 index 0000000..4288a0d --- /dev/null +++ b/infrastructure/CloudFile/CloudFileDocumentContentStore.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Logging; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Documents; + +namespace mws.backend.dotnet.infrastructure.CloudFile; + +public class CloudFileDocumentContentStore( + ICloudFileHttpClient cf, + ILogger log) : IDocumentContentStore +{ + private static string Bucket(Guid projectId) => $"mws-p-{projectId:N}"; + private static string Key(Guid documentId) => $"{documentId:N}.html"; + + public Task EnsureBucketAsync(Guid projectId, CancellationToken ct) => + cf.EnsureBucketAsync(Bucket(projectId), ct); + + public async Task UploadAsync(Guid projectId, Guid documentId, string contentType, Stream body, CancellationToken ct) + { + try + { + var meta = await cf.UploadAsync(Bucket(projectId), Key(documentId), contentType, body, ct); + log.LogDebug("Uploaded document {DocumentId} to cloudfile: {Hash}", documentId, meta?.ContentHash); + } + catch (HttpRequestException ex) + { + log.LogError(ex, "CloudFile upload failed for document {DocumentId}", documentId); + throw new StorageUnavailableException("Cloud file upload failed", ex); + } + } + + public async Task DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct) + { + try + { + return await cf.DownloadAsync(Bucket(projectId), Key(documentId), ct); + } + catch (HttpRequestException ex) + { + log.LogError(ex, "CloudFile download failed for document {DocumentId}", documentId); + throw new StorageUnavailableException("Cloud file download failed", ex); + } + } + + public async Task ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct) + { + try + { + return await cf.ExistsAsync(Bucket(projectId), Key(documentId), ct); + } + catch (HttpRequestException) + { + return false; + } + } + + public async Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct) + { + var bucket = Bucket(projectId); + var key = Key(documentId); + log.LogInformation("CloudFile delete: bucket={Bucket}, key={Key}", bucket, key); + try + { + await cf.DeleteAsync(bucket, key, ct); + log.LogInformation("CloudFile delete OK for document {DocumentId}", documentId); + } + catch (HttpRequestException ex) + { + log.LogWarning(ex, "CloudFile delete failed for document {DocumentId} (bucket={Bucket}, key={Key})", documentId, bucket, key); + } + } +} diff --git a/infrastructure/CloudFile/CloudFileHttpClient.cs b/infrastructure/CloudFile/CloudFileHttpClient.cs new file mode 100644 index 0000000..3c9c48e --- /dev/null +++ b/infrastructure/CloudFile/CloudFileHttpClient.cs @@ -0,0 +1,146 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace mws.backend.dotnet.infrastructure.CloudFile; + +public interface ICloudFileHttpClient +{ + Task UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct); + Task DownloadAsync(string bucket, string key, CancellationToken ct); + Task ExistsAsync(string bucket, string key, CancellationToken ct); + Task DeleteAsync(string bucket, string key, CancellationToken ct); + Task EnsureBucketAsync(string bucket, CancellationToken ct); +} + +public class CloudFileHttpClient( + HttpClient http, + IOptions opt, + ILogger log) : ICloudFileHttpClient +{ + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private HttpRequestMessage Req(HttpMethod method, string url) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Add("X-API-Key", opt.Value.ApiKey); + return request; + } + + public async Task UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct) + { + using var form = new MultipartFormDataContent + { + { new StreamContent(body), "file", key }, + { new StringContent(bucket), "bucket" }, + { new StringContent(key), "key" } + }; + + var request = Req(HttpMethod.Post, "/api/files/upload"); + request.Content = form; + + var resp = await http.SendAsync(request, ct); + var responseText = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode) + { + log.LogWarning("CloudFile upload failed {Status}: {Body}", resp.StatusCode, responseText); + resp.EnsureSuccessStatusCode(); + } + + var dto = JsonSerializer.Deserialize(responseText, Json); + return dto?.Metadata; + } + + public async Task DownloadAsync(string bucket, string key, CancellationToken ct) + { + var request = Req(HttpMethod.Get, $"/api/files/download/{bucket}/{Uri.EscapeDataString(key)}"); + var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + + if (resp.StatusCode == HttpStatusCode.NotFound) + { + return Stream.Null; + } + + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsStreamAsync(ct); + } + + public async Task ExistsAsync(string bucket, string key, CancellationToken ct) + { + var request = Req(HttpMethod.Get, $"/api/files/{bucket}/metadata/{Uri.EscapeDataString(key)}"); + var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + return resp.IsSuccessStatusCode; + } + + public async Task DeleteAsync(string bucket, string key, CancellationToken ct) + { + var request = Req(HttpMethod.Delete, $"/api/files/{bucket}/{Uri.EscapeDataString(key)}"); + var resp = await http.SendAsync(request, ct); + + if (resp.StatusCode != HttpStatusCode.NotFound) + { + resp.EnsureSuccessStatusCode(); + } + } + + public async Task EnsureBucketAsync(string bucket, CancellationToken ct) + { + var request = Req(HttpMethod.Post, "/api/buckets"); + var json = JsonSerializer.Serialize(new { name = bucket }, Json); + request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + var resp = await http.SendAsync(request, ct); + var responseText = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode && resp.StatusCode != HttpStatusCode.BadRequest) + { + log.LogWarning("CloudFile ensureBucket failed {Status}: {Body}", resp.StatusCode, responseText); + resp.EnsureSuccessStatusCode(); + } + } +} + +public class CloudFileUploadResponse +{ + public bool Success { get; set; } + public CloudFileMetadata? Metadata { get; set; } +} + +public class CloudFileMetadata +{ + [JsonPropertyName("fileId")] + public string FileId { get; set; } = string.Empty; + + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + [JsonPropertyName("extension")] + public string Extension { get; set; } = string.Empty; + + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = string.Empty; + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("bucket")] + public string Bucket { get; set; } = string.Empty; + + [JsonPropertyName("key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("contentHash")] + public string ContentHash { get; set; } = string.Empty; + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("modifiedAt")] + public DateTime ModifiedAt { get; set; } +} diff --git a/infrastructure/CloudFile/CloudFileOptions.cs b/infrastructure/CloudFile/CloudFileOptions.cs new file mode 100644 index 0000000..ae303f7 --- /dev/null +++ b/infrastructure/CloudFile/CloudFileOptions.cs @@ -0,0 +1,9 @@ +namespace mws.backend.dotnet.infrastructure.CloudFile; + +public class CloudFileOptions +{ + public string BaseUrl { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public string BucketPrefix { get; set; } = "mws-p-"; + public int HttpTimeoutSeconds { get; set; } = 30; +} diff --git a/infrastructure/DependencyInjection.cs b/infrastructure/DependencyInjection.cs index e4c82f0..a8fa17f 100644 --- a/infrastructure/DependencyInjection.cs +++ b/infrastructure/DependencyInjection.cs @@ -3,8 +3,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using mws.backend.dotnet.application.Auth; using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Documents; using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.infrastructure.Authentication; +using mws.backend.dotnet.infrastructure.CloudFile; using mws.backend.dotnet.infrastructure.Persistence; namespace mws.backend.dotnet.infrastructure; @@ -37,6 +39,17 @@ public static class DependencyInjection services.AddScoped(); + services.Configure(configuration.GetSection("CloudFile")); + services.AddHttpClient((sp, http) => + { + var options = sp.GetRequiredService>().Value; +#pragma warning disable S1075 // hardcoded URL — this is a configurable base address from appsettings + http.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/"); +#pragma warning restore S1075 + http.Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds); + }); + services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/infrastructure/Migrations/20260906043840_DropDocumentContent_AddStorageFields.Designer.cs b/infrastructure/Migrations/20260906043840_DropDocumentContent_AddStorageFields.Designer.cs new file mode 100644 index 0000000..fecdb8a --- /dev/null +++ b/infrastructure/Migrations/20260906043840_DropDocumentContent_AddStorageFields.Designer.cs @@ -0,0 +1,492 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using mws.backend.dotnet.infrastructure.Persistence; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260906043840_DropDocumentContent_AddStorageFields")] + partial class DropDocumentContent_AddStorageFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentSize") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UpdatedContentAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId", "ParentId"); + + b.ToTable("documents", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.MasterData.MasterDataEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Group", "Value") + .IsUnique(); + + b.ToTable("master_data", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("ProjectId", "UserId", "Screen"); + + b.ToTable("project_member_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("RoleId", "Screen"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Status"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedByUser"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null) + .WithMany() + .HasForeignKey("ProjectId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Navigation("Permissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/infrastructure/Migrations/20260906043840_DropDocumentContent_AddStorageFields.cs b/infrastructure/Migrations/20260906043840_DropDocumentContent_AddStorageFields.cs new file mode 100644 index 0000000..698fb81 --- /dev/null +++ b/infrastructure/Migrations/20260906043840_DropDocumentContent_AddStorageFields.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + /// + public partial class DropDocumentContent_AddStorageFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Content", + table: "documents"); + + migrationBuilder.AddColumn( + name: "ContentHash", + table: "documents", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "ContentSize", + table: "documents", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "UpdatedContentAt", + table: "documents", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContentHash", + table: "documents"); + + migrationBuilder.DropColumn( + name: "ContentSize", + table: "documents"); + + migrationBuilder.DropColumn( + name: "UpdatedContentAt", + table: "documents"); + + migrationBuilder.AddColumn( + name: "Content", + table: "documents", + type: "text", + nullable: true); + } + } +} diff --git a/infrastructure/Migrations/AppDbContextModelSnapshot.cs b/infrastructure/Migrations/AppDbContextModelSnapshot.cs index d611d38..197e9a6 100644 --- a/infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -28,8 +28,12 @@ namespace mws.backend.dotnet.infrastructure.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("Content") - .HasColumnType("text"); + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentSize") + .HasColumnType("bigint"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -59,6 +63,9 @@ namespace mws.backend.dotnet.infrastructure.Migrations b.Property("UpdatedBy") .HasColumnType("uuid"); + b.Property("UpdatedContentAt") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("ParentId"); diff --git a/infrastructure/Persistence/Configuration/DocumentConfiguration.cs b/infrastructure/Persistence/Configuration/DocumentConfiguration.cs index 97486d0..cc56581 100644 --- a/infrastructure/Persistence/Configuration/DocumentConfiguration.cs +++ b/infrastructure/Persistence/Configuration/DocumentConfiguration.cs @@ -12,7 +12,9 @@ public class DocumentConfiguration : IEntityTypeConfiguration e.HasKey(d => d.Id); e.Property(d => d.Title).HasMaxLength(300).IsRequired(); e.Property(d => d.Type).HasConversion().HasMaxLength(20); - e.Property(d => d.Content).HasColumnType("text"); + e.Property(d => d.ContentHash).HasMaxLength(64); + e.Property(d => d.ContentSize); + e.Property(d => d.UpdatedContentAt); e.HasIndex(d => new { d.ProjectId, d.ParentId }); e.HasIndex(d => d.ParentId); diff --git a/infrastructure/Persistence/DbSeeder.cs b/infrastructure/Persistence/DbSeeder.cs index 214e9ea..d4623d1 100644 --- a/infrastructure/Persistence/DbSeeder.cs +++ b/infrastructure/Persistence/DbSeeder.cs @@ -82,21 +82,18 @@ public static class DbSeeder { Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = requirements.Id, Title = "Authentication", Type = DocumentType.Document, - Content = "

Authentication

Phase 1 uses simple username/password with JWT.

", CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, }); db.Documents.Add(new Document { Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = requirements.Id, Title = "User Management", Type = DocumentType.Document, - Content = "

User Management

Users are created via seeding in Phase 1.

", CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, }); db.Documents.Add(new Document { Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = design.Id, Title = "API Design", Type = DocumentType.Document, - Content = "

API Design

REST API with ASP.NET Core Web API.

", CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, }); await db.SaveChangesAsync(); diff --git a/mws.backend.dotnet.sln b/mws.backend.dotnet.sln index 06a717b..2cc69c7 100644 --- a/mws.backend.dotnet.sln +++ b/mws.backend.dotnet.sln @@ -8,27 +8,80 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "domain", "domain\domain.csp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "infrastructure", "infrastructure\infrastructure.csproj", "{FEFFBC10-B99E-4A56-9F32-6D925D192606}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tests", "tests\tests.csproj", "{F69F8389-492D-4227-A6EC-2F41FC7FEBBE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|x64.ActiveCfg = Debug|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|x64.Build.0 = Debug|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|x86.ActiveCfg = Debug|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|x86.Build.0 = Debug|Any CPU {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|Any CPU.ActiveCfg = Release|Any CPU {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|Any CPU.Build.0 = Release|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|x64.ActiveCfg = Release|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|x64.Build.0 = Release|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|x86.ActiveCfg = Release|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|x86.Build.0 = Release|Any CPU {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|x64.ActiveCfg = Debug|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|x64.Build.0 = Debug|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|x86.ActiveCfg = Debug|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|x86.Build.0 = Debug|Any CPU {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|Any CPU.ActiveCfg = Release|Any CPU {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|Any CPU.Build.0 = Release|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|x64.ActiveCfg = Release|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|x64.Build.0 = Release|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|x86.ActiveCfg = Release|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|x86.Build.0 = Release|Any CPU {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|x64.Build.0 = Debug|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|x86.Build.0 = Debug|Any CPU {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|Any CPU.ActiveCfg = Release|Any CPU {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|Any CPU.Build.0 = Release|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|x64.ActiveCfg = Release|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|x64.Build.0 = Release|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|x86.ActiveCfg = Release|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|x86.Build.0 = Release|Any CPU {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|x64.ActiveCfg = Debug|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|x64.Build.0 = Debug|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|x86.ActiveCfg = Debug|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|x86.Build.0 = Debug|Any CPU {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|Any CPU.ActiveCfg = Release|Any CPU {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|Any CPU.Build.0 = Release|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|x64.ActiveCfg = Release|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|x64.Build.0 = Release|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|x86.ActiveCfg = Release|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|x86.Build.0 = Release|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Debug|x64.ActiveCfg = Debug|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Debug|x64.Build.0 = Debug|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Debug|x86.ActiveCfg = Debug|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Debug|x86.Build.0 = Debug|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Release|Any CPU.Build.0 = Release|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Release|x64.ActiveCfg = Release|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Release|x64.Build.0 = Release|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Release|x86.ActiveCfg = Release|Any CPU + {F69F8389-492D-4227-A6EC-2F41FC7FEBBE}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/tests/CloudFileDocumentContentStoreTests.cs b/tests/CloudFileDocumentContentStoreTests.cs new file mode 100644 index 0000000..415e5d9 --- /dev/null +++ b/tests/CloudFileDocumentContentStoreTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Moq; +using mws.backend.dotnet.infrastructure.CloudFile; + +namespace tests; + +public class CloudFileDocumentContentStoreTests +{ + private readonly Mock _cfMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly CloudFileDocumentContentStore _sut; + + public CloudFileDocumentContentStoreTests() + { + _sut = new CloudFileDocumentContentStore(_cfMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task EnsureBucketAsync_CallsCorrectBucket() + { + var projectId = Guid.NewGuid(); + + await _sut.EnsureBucketAsync(projectId, CancellationToken.None); + + _cfMock.Verify(c => c.EnsureBucketAsync($"mws-p-{projectId:N}", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UploadAsync_BuildsCorrectBucketAndKey() + { + var projectId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + using var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + + await _sut.UploadAsync(projectId, documentId, "text/html; charset=utf-8", stream, CancellationToken.None); + + _cfMock.Verify(c => c.UploadAsync( + $"mws-p-{projectId:N}", + $"{documentId:N}.html", + "text/html; charset=utf-8", + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task UploadAsync_ThrowsStorageUnavailableOnHttpRequestException() + { + var projectId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + using var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + + _cfMock.Setup(c => c.UploadAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + var ex = await Assert.ThrowsAsync( + () => _sut.UploadAsync(projectId, documentId, "text/html", stream, CancellationToken.None)); + + Assert.Contains("Cloud file upload failed", ex.Message); + } + + [Fact] + public async Task DownloadAsync_ReturnsStreamNullOnNotFound() + { + var projectId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + + _cfMock.Setup(c => c.DownloadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Stream.Null); + + var result = await _sut.DownloadAsync(projectId, documentId, CancellationToken.None); + + Assert.Equal(Stream.Null, result); + } + + [Fact] + public async Task DownloadAsync_ThrowsStorageUnavailableOnHttpRequestException() + { + var projectId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + + _cfMock.Setup(c => c.DownloadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + await Assert.ThrowsAsync( + () => _sut.DownloadAsync(projectId, documentId, CancellationToken.None)); + } + + [Fact] + public async Task DeleteAsync_Swallows404Errors() + { + var projectId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + + // Should not throw + await _sut.DeleteAsync(projectId, documentId, CancellationToken.None); + + _cfMock.Verify(c => c.DeleteAsync( + $"mws-p-{projectId:N}", + $"{documentId:N}.html", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExistsAsync_ReturnsTrueWhenFileExists() + { + var projectId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + + _cfMock.Setup(c => c.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _sut.ExistsAsync(projectId, documentId, CancellationToken.None); + + Assert.True(result); + } + + [Fact] + public async Task ExistsAsync_ReturnsFalseOnException() + { + var projectId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + + _cfMock.Setup(c => c.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + var result = await _sut.ExistsAsync(projectId, documentId, CancellationToken.None); + + Assert.False(result); + } +} diff --git a/tests/UnitTest1.cs b/tests/UnitTest1.cs new file mode 100644 index 0000000..3b98de5 --- /dev/null +++ b/tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} diff --git a/tests/tests.csproj b/tests/tests.csproj new file mode 100644 index 0000000..a2f4501 --- /dev/null +++ b/tests/tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file