feat: integration cloudfile

This commit is contained in:
2026-09-06 12:33:38 +07:00
parent c2088f42dc
commit 888dd41dcd
24 changed files with 1148 additions and 28 deletions
+1
View File
@@ -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`, `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>` 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.
+1
View File
@@ -25,6 +25,7 @@ public class ApiExceptionMiddleware(RequestDelegate next, ILogger<ApiExceptionMi
UnauthorizedException => (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"),
};
+7 -1
View File
@@ -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": {
+7 -1
View File
@@ -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",
+3 -1
View File
@@ -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);
public class BadRequestException(string message) : Exception(message);
public class StorageUnavailableException(string message, Exception? inner = null) : Exception(message, inner);
@@ -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<DocumentDto>;
public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateDocumentCommand, DocumentDto>
public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore)
: IRequestHandler<CreateDocumentCommand, DocumentDto>
{
public async Task<DocumentDto> 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<DocumentDto>(doc);
}
}
@@ -6,15 +6,37 @@ namespace mws.backend.dotnet.application.Documents;
public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest;
public class DeleteDocumentHandler(IUnitOfWork uow) : IRequestHandler<DeleteDocumentCommand>
public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore contentStore)
: IRequestHandler<DeleteDocumentCommand>
{
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);
}
}
}
@@ -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<DocumentDto>;
public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateDocumentCommand, DocumentDto>
public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore)
: IRequestHandler<UpdateDocumentCommand, DocumentDto>
{
public async Task<DocumentDto> 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<DocumentDto>(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<DocumentDto>(doc);
if (doc.Type == DocumentType.Document)
{
dto.Content = request.Content;
}
return dto;
}
}
@@ -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<Stream> DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct);
Task<bool> ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct);
Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct);
}
+22 -2
View File
@@ -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<DocumentDto>;
public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetDocumentQuery, DocumentDto>
public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore)
: IRequestHandler<GetDocumentQuery, DocumentDto>
{
public async Task<DocumentDto> Handle(GetDocumentQuery query, CancellationToken ct)
{
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct);
return mapper.Map<DocumentDto>(doc);
var dto = mapper.Map<DocumentDto>(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;
}
}
+3 -1
View File
@@ -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; }
@@ -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<CloudFileDocumentContentStore> 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<Stream> 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<bool> 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);
}
}
}
@@ -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<CloudFileMetadata?> UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct);
Task<Stream> DownloadAsync(string bucket, string key, CancellationToken ct);
Task<bool> 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<CloudFileOptions> opt,
ILogger<CloudFileHttpClient> 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<CloudFileMetadata?> 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<CloudFileUploadResponse>(responseText, Json);
return dto?.Metadata;
}
public async Task<Stream> 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<bool> 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; }
}
@@ -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;
}
+13
View File
@@ -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<IPermissionService, PermissionService>();
services.Configure<CloudFileOptions>(configuration.GetSection("CloudFile"));
services.AddHttpClient<ICloudFileHttpClient, CloudFileHttpClient>((sp, http) =>
{
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<CloudFileOptions>>().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<IDocumentContentStore, CloudFileDocumentContentStore>();
return services;
}
}
@@ -0,0 +1,492 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long?>("ContentSize")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid");
b.Property<DateTime?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Group")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("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<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("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<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("RoleId", "Screen");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("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
}
}
}
@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace mws.backend.dotnet.infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropDocumentContent_AddStorageFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Content",
table: "documents");
migrationBuilder.AddColumn<string>(
name: "ContentHash",
table: "documents",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ContentSize",
table: "documents",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedContentAt",
table: "documents",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
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<string>(
name: "Content",
table: "documents",
type: "text",
nullable: true);
}
}
}
@@ -28,8 +28,12 @@ namespace mws.backend.dotnet.infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long?>("ContentSize")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -59,6 +63,9 @@ namespace mws.backend.dotnet.infrastructure.Migrations
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid");
b.Property<DateTime?>("UpdatedContentAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ParentId");
@@ -12,7 +12,9 @@ public class DocumentConfiguration : IEntityTypeConfiguration<Document>
e.HasKey(d => d.Id);
e.Property(d => d.Title).HasMaxLength(300).IsRequired();
e.Property(d => d.Type).HasConversion<string>().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);
-3
View File
@@ -82,21 +82,18 @@ public static class DbSeeder
{
Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = requirements.Id,
Title = "Authentication", Type = DocumentType.Document,
Content = "<h2>Authentication</h2><p>Phase 1 uses simple username/password with JWT.</p>",
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 = "<h2>User Management</h2><p>Users are created via seeding in Phase 1.</p>",
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 = "<h2>API Design</h2><p>REST API with ASP.NET Core Web API.</p>",
CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now,
});
await db.SaveChangesAsync();
+53
View File
@@ -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
+134
View File
@@ -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<ICloudFileHttpClient> _cfMock = new();
private readonly Mock<ILogger<CloudFileDocumentContentStore>> _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<CancellationToken>()), 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<Stream>(),
It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Connection refused"));
var ex = await Assert.ThrowsAsync<mws.backend.dotnet.application.Common.StorageUnavailableException>(
() => _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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Connection refused"));
await Assert.ThrowsAsync<mws.backend.dotnet.application.Common.StorageUnavailableException>(
() => _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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ExistsAsync_ReturnsTrueWhenFileExists()
{
var projectId = Guid.NewGuid();
var documentId = Guid.NewGuid();
_cfMock.Setup(c => c.ExistsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Connection refused"));
var result = await _sut.ExistsAsync(projectId, documentId, CancellationToken.None);
Assert.False(result);
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\infrastructure\infrastructure.csproj" />
</ItemGroup>
</Project>