feat: complete SaaS commercial delivery workflows
This commit is contained in:
@@ -228,7 +228,6 @@ public sealed class PlatformAdminEndpointTests
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Status = TenantStatus.Suspended,
|
||||
BillingStatus = BillingStatus.PastDue,
|
||||
Reason = "integration test suspension"
|
||||
});
|
||||
|
||||
@@ -441,6 +440,9 @@ public sealed class PlatformAdminEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var invoiceId = Guid.NewGuid();
|
||||
var reminderId = Guid.NewGuid();
|
||||
var ignoredReminderId = Guid.NewGuid();
|
||||
var eventId = Guid.NewGuid();
|
||||
var ignoredEventId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -468,6 +470,17 @@ public sealed class PlatformAdminEndpointTests
|
||||
Status = PlatformBillingInvoiceReminderStatus.Failed,
|
||||
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
|
||||
BalanceCentsSnapshot = 10_000
|
||||
},
|
||||
new PlatformBillingInvoiceReminder
|
||||
{
|
||||
Id = ignoredReminderId,
|
||||
TenantId = tenantId,
|
||||
InvoiceId = invoiceId,
|
||||
ReminderType = PlatformBillingInvoiceReminderType.FinalNotice,
|
||||
Channel = PlatformBillingInvoiceReminderChannel.Wechat,
|
||||
Status = PlatformBillingInvoiceReminderStatus.Failed,
|
||||
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
|
||||
BalanceCentsSnapshot = 10_000
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
@@ -489,25 +502,38 @@ public sealed class PlatformAdminEndpointTests
|
||||
var upsertBody = await upsertResponse.Content.ReadAsStringAsync();
|
||||
var channelJson = JsonDocument.Parse(upsertBody);
|
||||
var channelId = channelJson.RootElement.GetProperty("id").GetGuid();
|
||||
await factory.SeedAsync(new PlatformBillingDunningNotificationEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ChannelId = channelId,
|
||||
ReminderId = reminderId,
|
||||
InvoiceId = invoiceId,
|
||||
Provider = PlatformBillingDunningProvider.Wecom,
|
||||
Status = PlatformBillingDunningNotificationStatus.Failed,
|
||||
Attempts = 2,
|
||||
LastError = "timeout",
|
||||
LastHttpCode = 500,
|
||||
LastResponseSummary = "server error",
|
||||
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
|
||||
});
|
||||
await factory.SeedAsync(
|
||||
new PlatformBillingDunningNotificationEvent
|
||||
{
|
||||
Id = eventId,
|
||||
TenantId = tenantId,
|
||||
ChannelId = channelId,
|
||||
ReminderId = ignoredReminderId,
|
||||
InvoiceId = invoiceId,
|
||||
Provider = PlatformBillingDunningProvider.Wecom,
|
||||
Status = PlatformBillingDunningNotificationStatus.Failed,
|
||||
Attempts = 2,
|
||||
LastError = "timeout",
|
||||
LastHttpCode = 500,
|
||||
LastResponseSummary = "server error",
|
||||
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
|
||||
},
|
||||
new PlatformBillingDunningNotificationEvent
|
||||
{
|
||||
Id = ignoredEventId,
|
||||
TenantId = tenantId,
|
||||
ChannelId = channelId,
|
||||
ReminderId = reminderId,
|
||||
InvoiceId = invoiceId,
|
||||
Provider = PlatformBillingDunningProvider.Wecom,
|
||||
Status = PlatformBillingDunningNotificationStatus.Failed,
|
||||
Attempts = 5,
|
||||
LastError = "permanent failure",
|
||||
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
|
||||
});
|
||||
|
||||
var channelsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/channels?search=wecom");
|
||||
var eventsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/events?status=failed");
|
||||
var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync());
|
||||
var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||
var detailResponse = await client.GetAsync($"/api/platform-admin/saas/dunning/events/detail?eventId={eventId}");
|
||||
var retryResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/saas/dunning/events/retry",
|
||||
@@ -516,6 +542,12 @@ public sealed class PlatformAdminEndpointTests
|
||||
EventId = eventId,
|
||||
Reason = "manual retry"
|
||||
});
|
||||
var acknowledgeResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/saas/dunning/events/acknowledge",
|
||||
new ResolvePlatformBillingDunningEventDto { EventId = eventId, Reason = "delivery confirmed manually" });
|
||||
var ignoreResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/saas/dunning/events/ignore",
|
||||
new ResolvePlatformBillingDunningEventDto { EventId = ignoredEventId, Reason = "tenant requested no further delivery" });
|
||||
var disableResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/saas/dunning/channels/disable",
|
||||
new DisablePlatformBillingDunningChannelDto
|
||||
@@ -531,14 +563,20 @@ public sealed class PlatformAdminEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, acknowledgeResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, ignoreResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify platform dunning side effects");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedEvent = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
|
||||
var storedChannel = await dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
|
||||
Assert.Equal(PlatformBillingDunningNotificationStatus.Pending, storedEvent.Status);
|
||||
Assert.Equal(PlatformBillingDunningNotificationStatus.Acknowledged, storedEvent.Status);
|
||||
Assert.Null(storedEvent.LastError);
|
||||
Assert.Equal(PlatformBillingDunningNotificationStatus.Ignored, await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking()
|
||||
.Where(item => item.Id == ignoredEventId)
|
||||
.Select(item => item.Status)
|
||||
.SingleAsync());
|
||||
Assert.False(storedChannel.Enabled);
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
@@ -546,6 +584,113 @@ public sealed class PlatformAdminEndpointTests
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.billing_dunning_channel.disabled"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.billing_dunning_event.acknowledged"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.billing_dunning_event.ignored"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_provisioning_is_concurrently_idempotent_and_owner_activation_is_single_use()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
});
|
||||
var platform = await SeedPlatformAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
var key = $"tenant-create-{Guid.NewGuid():N}";
|
||||
client.DefaultRequestHeaders.Add("Idempotency-Key", key);
|
||||
var slug = $"activation-{Guid.NewGuid():N}";
|
||||
var phone = $"137{Random.Shared.Next(10_000_000, 99_999_999)}";
|
||||
var request = new CreatePlatformTenantDto
|
||||
{
|
||||
Slug = slug,
|
||||
Name = "Activation Tenant",
|
||||
OwnerPhone = phone,
|
||||
OwnerName = "Activation Owner",
|
||||
DefaultPaymentProvider = "manual",
|
||||
CollectionMode = TenantBillingCollectionMode.Manual,
|
||||
RenewalLeadDays = 21
|
||||
};
|
||||
|
||||
var responses = await Task.WhenAll(
|
||||
client.PostAsJsonAsync("/api/platform-admin/tenants", request),
|
||||
client.PostAsJsonAsync("/api/platform-admin/tenants", request));
|
||||
var failedProvisioning = await Task.WhenAll(responses
|
||||
.Where(response => response.StatusCode != HttpStatusCode.OK)
|
||||
.Select(async response => $"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}"));
|
||||
Assert.True(failedProvisioning.Length == 0, string.Join(Environment.NewLine, failedProvisioning));
|
||||
var payloads = await Task.WhenAll(responses.Select(async response =>
|
||||
await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync())));
|
||||
var tenantIds = payloads.Select(payload => payload.RootElement.GetProperty("tenant").GetProperty("id").GetGuid()).Distinct().ToArray();
|
||||
Assert.Single(tenantIds);
|
||||
Assert.Single(payloads, payload => payload.RootElement.GetProperty("activationToken").ValueKind == JsonValueKind.String);
|
||||
Assert.Single(payloads, payload => payload.RootElement.GetProperty("isReplay").GetBoolean());
|
||||
|
||||
var conflictingRequest = new CreatePlatformTenantDto
|
||||
{
|
||||
Slug = slug,
|
||||
Name = "Different Tenant Name",
|
||||
OwnerPhone = phone,
|
||||
OwnerName = request.OwnerName,
|
||||
DefaultPaymentProvider = request.DefaultPaymentProvider,
|
||||
CollectionMode = request.CollectionMode,
|
||||
RenewalLeadDays = request.RenewalLeadDays
|
||||
};
|
||||
var conflict = await client.PostAsJsonAsync("/api/platform-admin/tenants", conflictingRequest);
|
||||
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||
Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict));
|
||||
|
||||
var firstPayload = payloads.Single(payload => payload.RootElement.GetProperty("activationToken").ValueKind == JsonValueKind.String);
|
||||
var activationId = firstPayload.RootElement.GetProperty("activationId").GetGuid();
|
||||
var activationToken = firstPayload.RootElement.GetProperty("activationToken").GetString()!;
|
||||
var wrong = await client.PostAsJsonAsync("/api/auth/activation/complete", new CompleteOwnerActivationDto
|
||||
{
|
||||
ActivationId = activationId,
|
||||
Token = new string('x', activationToken.Length),
|
||||
NewPassword = "ActivatedOwner2026"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
|
||||
Assert.Equal("owner_activation_invalid", await ReadProblemCodeAsync(wrong));
|
||||
|
||||
var activationRequest = new CompleteOwnerActivationDto
|
||||
{
|
||||
ActivationId = activationId,
|
||||
Token = activationToken,
|
||||
NewPassword = "ActivatedOwner2026"
|
||||
};
|
||||
var activated = await client.PostAsJsonAsync("/api/auth/activation/complete", activationRequest);
|
||||
var replay = await client.PostAsJsonAsync("/api/auth/activation/complete", activationRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, activated.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode);
|
||||
Assert.Equal("owner_activation_consumed", await ReadProblemCodeAsync(replay));
|
||||
|
||||
var policy = await client.GetAsync($"/api/platform-admin/tenants/{tenantIds[0]}/billing-policy");
|
||||
Assert.Equal(HttpStatusCode.OK, policy.StatusCode);
|
||||
using var policyPayload = await JsonDocument.ParseAsync(await policy.Content.ReadAsStreamAsync());
|
||||
Assert.Equal(21, policyPayload.RootElement.GetProperty("renewalLeadDays").GetInt32());
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify tenant provisioning and owner activation");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking().SingleAsync(value => value.Id == activationId);
|
||||
Assert.NotNull(grant.ConsumedAt);
|
||||
Assert.DoesNotContain(activationToken, grant.TokenHash, StringComparison.Ordinal);
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(value =>
|
||||
value.TenantId == tenantIds[0] && value.Action == "tenant.owner.activated"));
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
payload.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
|
||||
{
|
||||
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return payload.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
|
||||
|
||||
Reference in New Issue
Block a user