forked from xiongyuxing/tiku-backend.net
feat: add tenant content asset management endpoints
This commit is contained in:
350
Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs
Normal file
350
Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs
Normal file
@@ -0,0 +1,350 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AssetManagementEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Tenant_content_upload_sign_requires_authentication()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/assets/uploads/sign",
|
||||
new AssetUploadSignDto
|
||||
{
|
||||
FileName = "lesson.pdf",
|
||||
MimeType = "application/pdf"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_sign_upload_and_create_pending_asset()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/assets/uploads/sign",
|
||||
new AssetUploadSignDto
|
||||
{
|
||||
FileName = "lesson.pdf",
|
||||
MimeType = "application/pdf",
|
||||
FileSizeBytes = 1024,
|
||||
Title = "课程讲义",
|
||||
AssetType = "pdf",
|
||||
Visibility = "members"
|
||||
});
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("PUT", body.RootElement.GetProperty("upload").GetProperty("method").GetString());
|
||||
var item = body.RootElement.GetProperty("item");
|
||||
var assetId = item.GetProperty("id").GetGuid();
|
||||
Assert.Equal("Pending", item.GetProperty("uploadStatus").GetString());
|
||||
Assert.Equal("AliyunOss", item.GetProperty("storageProvider").GetString());
|
||||
Assert.StartsWith($"{seed.TenantId:N}/assets/", item.GetProperty("objectKey").GetString(), StringComparison.Ordinal);
|
||||
|
||||
using var listResponse = await client.GetAsync("/api/tenant-content/assets?uploadStatus=pending");
|
||||
var list = await ReadJsonAsync(listResponse);
|
||||
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal(assetId, listItem.GetProperty("id").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
||||
Assert.Equal(seed.TenantId, asset.TenantId);
|
||||
Assert.Equal(seed.UserId, asset.CreatedBy);
|
||||
Assert.Equal(AssetUploadStatus.Pending, asset.UploadStatus);
|
||||
Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_confirm_upload_and_mark_asset_verified()
|
||||
{
|
||||
var storage = new FakeObjectStorageService
|
||||
{
|
||||
MetadataSizeBytes = 2048,
|
||||
MetadataChecksumSha256 = new string('a', 64)
|
||||
};
|
||||
await using var factory = new ApiTestFactory(objectStorageService: storage);
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var assetId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new ContentAsset
|
||||
{
|
||||
Id = assetId,
|
||||
TenantId = seed.TenantId,
|
||||
Title = "课程讲义",
|
||||
FileName = "lesson.pdf",
|
||||
StorageProvider = AssetStorageProvider.AliyunOss,
|
||||
Bucket = "tenant-assets",
|
||||
ObjectKey = $"{seed.TenantId:N}/assets/lesson.pdf",
|
||||
MimeType = "application/pdf",
|
||||
AssetType = ContentAssetType.Pdf,
|
||||
UploadStatus = AssetUploadStatus.Pending,
|
||||
SecurityScanStatus = AssetSecurityScanStatus.Pending
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/assets/uploads/confirm",
|
||||
new AssetUploadConfirmDto
|
||||
{
|
||||
AssetId = assetId,
|
||||
MimeType = "application/pdf",
|
||||
FileSizeBytes = 2048,
|
||||
ChecksumSha256 = new string('a', 64)
|
||||
});
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("Verified", body.RootElement.GetProperty("item").GetProperty("uploadStatus").GetString());
|
||||
Assert.Equal(2048, body.RootElement.GetProperty("metadata").GetProperty("sizeBytes").GetInt64());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
||||
Assert.Equal(AssetUploadStatus.Verified, asset.UploadStatus);
|
||||
Assert.Equal(seed.UserId, asset.VerifiedBy);
|
||||
Assert.Equal(2048, asset.VerifiedSizeBytes);
|
||||
Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_jobs_are_scoped_to_current_tenant_and_include_detail()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var otherTenantId = Guid.NewGuid();
|
||||
var jobId = Guid.NewGuid();
|
||||
var itemId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = otherTenantId,
|
||||
Slug = otherTenantId.ToString("N"),
|
||||
Name = "Other Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
},
|
||||
new ContentImportJob
|
||||
{
|
||||
Id = jobId,
|
||||
TenantId = seed.TenantId,
|
||||
SourceName = "questions.xlsx",
|
||||
ImportType = ContentImportType.Questions,
|
||||
SourceFormat = ImportSourceFormat.Excel,
|
||||
Status = ContentImportStatus.CompletedWithErrors,
|
||||
TotalCount = 1,
|
||||
ErrorCount = 1
|
||||
},
|
||||
new ContentImportItem
|
||||
{
|
||||
Id = itemId,
|
||||
TenantId = seed.TenantId,
|
||||
JobId = jobId,
|
||||
RowNo = 1,
|
||||
Status = ContentImportItemStatus.Invalid,
|
||||
IssuesCount = 1
|
||||
},
|
||||
new ContentImportIssue
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = seed.TenantId,
|
||||
JobId = jobId,
|
||||
ItemId = itemId,
|
||||
RowNo = 1,
|
||||
Severity = ImportIssueSeverity.Error,
|
||||
Code = "missing_answer",
|
||||
Message = "答案不能为空"
|
||||
},
|
||||
new ContentImportJob
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = otherTenantId,
|
||||
SourceName = "other.xlsx"
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var listResponse = await client.GetAsync("/api/tenant-content/import-jobs?status=completedWithErrors");
|
||||
using var detailResponse = await client.GetAsync($"/api/tenant-content/import-jobs/{jobId}");
|
||||
var list = await ReadJsonAsync(listResponse);
|
||||
var detail = await ReadJsonAsync(detailResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal(jobId, listItem.GetProperty("id").GetGuid());
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
Assert.Equal(jobId, detail.RootElement.GetProperty("job").GetProperty("id").GetGuid());
|
||||
Assert.Single(detail.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Single(detail.RootElement.GetProperty("issues").EnumerateArray());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13900000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Test Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
},
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed class FakeObjectStorageService : IObjectStorageService
|
||||
{
|
||||
public long? MetadataSizeBytes { get; init; }
|
||||
|
||||
public string? MetadataChecksumSha256 { get; init; }
|
||||
|
||||
public string ConfiguredDefaultProvider() => ObjectStorageProviders.AliyunOss;
|
||||
|
||||
public string ConfiguredDefaultBucket() => "tenant-assets";
|
||||
|
||||
public string NormalizeProvider(string? value, string? fallback = null)
|
||||
{
|
||||
return value ?? fallback ?? ObjectStorageProviders.AliyunOss;
|
||||
}
|
||||
|
||||
public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey;
|
||||
|
||||
public string ValidateMimeType(string mimeType) => mimeType;
|
||||
|
||||
public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes;
|
||||
|
||||
public void AssertUploadProvider(string provider) { }
|
||||
|
||||
public void AssertWritableLocation(StorageAssetLocation location) { }
|
||||
|
||||
public Task<ObjectStorageSignedUrl> SignUploadAsync(
|
||||
ObjectStorageUploadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ObjectStorageSignedUrl(
|
||||
request.Provider,
|
||||
request.Bucket,
|
||||
request.ObjectKey,
|
||||
"PUT",
|
||||
new Uri($"https://storage.example.test/{request.ObjectKey}"),
|
||||
new Dictionary<string, string> { ["content-type"] = request.MimeType },
|
||||
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||
request.ExpiresIn,
|
||||
"fake-signed-url"));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||
ObjectStorageDownloadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ObjectStorageSignedUrl(
|
||||
request.Provider,
|
||||
request.Bucket,
|
||||
request.ObjectKey,
|
||||
"GET",
|
||||
new Uri($"https://storage.example.test/{request.ObjectKey}"),
|
||||
new Dictionary<string, string>(),
|
||||
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||
request.ExpiresIn,
|
||||
"fake-signed-url"));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ObjectStorageMetadata(
|
||||
request.Provider,
|
||||
request.Bucket,
|
||||
request.ObjectKey,
|
||||
Exists: true,
|
||||
MetadataSizeBytes ?? request.DeclaredFileSizeBytes,
|
||||
request.DeclaredMimeType,
|
||||
MetadataChecksumSha256 ?? request.DeclaredChecksumSha256,
|
||||
"etag",
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
new Dictionary<string, string>(),
|
||||
"fake-head"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user