using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; using Tiku.Application.Growth; using Tiku.Domain.Commerce; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class ReferralEndpointTests { [Fact] public async Task Anonymous_invite_code_request_returns_401() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto()); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Member_can_create_invite_code_idempotently() { await using var factory = new ApiTestFactory(); var seed = await SeedReferralAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed.Referrer); var first = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto { Channel = "h5" }); var second = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto()); var firstItem = await first.Content.ReadFromJsonAsync(); var secondItem = await second.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); Assert.Equal(firstItem!.InviteCode, secondItem!.InviteCode); } [Fact] public async Task Anonymous_can_resolve_and_track_referral_code() { await using var factory = new ApiTestFactory(); var seed = await SeedReferralAsync(factory, includeCode: true); using var client = factory.CreateClient(); var resolve = await client.PostAsJsonAsync( "/api/referral/resolve", new ResolveReferralDto { TenantCode = seed.TenantCode, Code = seed.ReferralCode }); var track = await client.PostAsJsonAsync( "/api/referral/track-event", new TrackReferralEventDto { TenantCode = seed.TenantCode, RefCode = seed.ReferralCode, EventType = "enter", Source = "qrcode", TargetUserId = seed.Student.UserId, Metadata = JsonSerializer.SerializeToElement(new { page = "landing" }) }); var resolution = await resolve.Content.ReadFromJsonAsync(); var tracked = await track.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, resolve.StatusCode); Assert.True(resolution!.Valid); Assert.Equal(seed.Referrer.UserId, resolution.InviterId); Assert.Equal(HttpStatusCode.OK, track.StatusCode); Assert.Equal(seed.Referrer.UserId, tracked!.Item.ReferrerUserId); Assert.Equal(seed.Student.UserId, tracked.Item.TargetUserId); Assert.NotNull(tracked.Lead); } [Fact] public async Task Member_can_bind_referral_and_create_qrcode() { var qrcodeGenerator = new FakeReferralQrcodeGenerator(); await using var factory = new ApiTestFactory(referralQrcodeGenerator: qrcodeGenerator); var seed = await SeedReferralAsync(factory, includeCode: true); using var client = factory.CreateClient(); await LoginAsync(client, seed.Student); var bind = await client.PostAsJsonAsync( "/api/referral/bind", new BindReferralDto { RefCode = seed.ReferralCode, Source = "miniapp" }); var repeatBind = await client.PostAsJsonAsync( "/api/referral/bind", new BindReferralDto { RefCode = seed.ReferralCode, Source = "miniapp" }); var qrcode = await client.PostAsJsonAsync( "/api/referral/qrcode", new ReferralQrcodeDto { Page = "pages/home/index", Provider = "wechat-miniapp" }); var bindItem = await bind.Content.ReadFromJsonAsync(); var repeatItem = await repeatBind.Content.ReadFromJsonAsync(); var qrcodeItem = await qrcode.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, bind.StatusCode); Assert.Equal(HttpStatusCode.OK, repeatBind.StatusCode); Assert.True(bindItem!.Lead.Changed); Assert.False(repeatItem!.Lead.Changed); Assert.Equal(seed.Referrer.UserId, bindItem.Lead.ReferrerUserId); Assert.Equal(HttpStatusCode.OK, qrcode.StatusCode); Assert.Equal("Ready", qrcodeItem!.Status); Assert.Equal("https://cdn.example.test/referral/qrcode.png", qrcodeItem.QrcodeUrl); Assert.Equal(1, qrcodeGenerator.CallCount); Assert.Equal("wechat-miniapp", qrcodeGenerator.LastRequest!.Provider); Assert.Equal("pages/home/index", qrcodeGenerator.LastRequest.Page); Assert.Equal($"ref={qrcodeItem.RefCode}", qrcodeGenerator.LastRequest.Scene); Assert.Equal("fake-referral-qrcode-generator", qrcodeItem.Metadata.GetProperty("generatedBy").GetString()); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Contains(dbContext.ReferralLeads, item => item.TenantId == seed.TenantId && item.StudentUserId == seed.Student.UserId && item.ReferrerUserId == seed.Referrer.UserId); Assert.Contains(dbContext.ReferralQrcodes, item => item.TenantId == seed.TenantId && item.UserId == seed.Student.UserId); } [Fact] public async Task Anonymous_referral_without_tenant_returns_404() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync( "/api/referral/resolve", new ResolveReferralDto { Code = "ABC12345" }); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task Non_admin_cannot_access_referral_management() { await using var factory = new ApiTestFactory(); var seed = await SeedReferralAsync(factory, includeCode: true); using var client = factory.CreateClient(); await LoginAsync(client, seed.Student); var response = await client.GetAsync("/api/referral/stats"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Admin_can_manage_referral_team_and_query_stats() { await using var factory = new ApiTestFactory(); var seed = await SeedReferralAsync(factory, includeCode: true); using var client = factory.CreateClient(); await LoginAsync(client, seed.Admin); var manualBind = await client.PostAsJsonAsync( "/api/referral/manual-bind", new ManualBindReferralDto { StudentUserId = seed.Student.UserId, ReferrerUserId = seed.Referrer.UserId, Source = "manual", Force = true }); var team = await client.PutAsJsonAsync( "/api/referral/team", new UpsertReferralTeamDto { MemberUserId = seed.Referrer.UserId, LeaderUserId = seed.Admin.UserId, RelationType = "sales_team", Status = "active" }); await factory.SeedAsync(new Order { TenantId = seed.TenantId, UserId = seed.Student.UserId, OrderNo = "R202607260001", Status = OrderStatus.Paid, ProductType = "svip", ProductName = "SVIP", AmountCents = 9900, PaidAt = DateTimeOffset.UtcNow }); var stats = await client.GetAsync($"/api/referral/stats?referrerUserId={seed.Referrer.UserId}"); var salesStats = await client.GetAsync("/api/referral/sales-stats"); var conversion = await client.GetAsync("/api/referral/conversion-report?days=30"); var clients = await client.GetAsync($"/api/referral/sales-clients?referrerUserId={seed.Referrer.UserId}"); var teamList = await client.GetAsync($"/api/referral/team?leaderUserId={seed.Admin.UserId}"); var statsItem = await stats.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, manualBind.StatusCode); Assert.Equal(HttpStatusCode.OK, team.StatusCode); Assert.Equal(HttpStatusCode.OK, stats.StatusCode); Assert.Equal(1, statsItem!.LeadCount); Assert.Equal(1, statsItem.PaidLeadCount); Assert.Equal(9900, statsItem.PaidAmountCents); Assert.Equal(HttpStatusCode.OK, salesStats.StatusCode); Assert.Equal(HttpStatusCode.OK, conversion.StatusCode); Assert.Equal(HttpStatusCode.OK, clients.StatusCode); Assert.Equal(HttpStatusCode.OK, teamList.StatusCode); Assert.Contains(seed.Referrer.UserId.ToString(), await teamList.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); } private static async Task SeedReferralAsync( ApiTestFactory factory, bool includeCode = false) { var tenantId = Guid.NewGuid(); var tenantCode = tenantId.ToString("N"); var referrer = new LoginSeed(tenantId, Guid.NewGuid(), "13800001001"); var student = new LoginSeed(tenantId, Guid.NewGuid(), "13800001002"); var admin = new LoginSeed(tenantId, Guid.NewGuid(), "13800001003"); const string referralCode = "ABC12345"; var entities = new List { new Tenant { Id = tenantId, Slug = tenantCode, Name = "Referral Tenant" }, User(referrer.UserId, referrer.Phone, "Referral Teacher", "teacher"), User(student.UserId, student.Phone, "Referral Student", "student"), User(admin.UserId, admin.Phone, "Referral Admin", "tenant_admin"), new TenantMembership { TenantId = tenantId, UserId = referrer.UserId, Role = TenantRole.Sales, Status = MembershipStatus.Active }, new TenantMembership { TenantId = tenantId, UserId = student.UserId, Role = TenantRole.Student, Status = MembershipStatus.Active }, new TenantMembership { TenantId = tenantId, UserId = admin.UserId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active } }; if (includeCode) { entities.Add(new ReferralCode { TenantId = tenantId, UserId = referrer.UserId, Code = referralCode, Status = ReferralCodeStatus.Active }); } await factory.SeedAsync(entities.ToArray()); return new ReferralSeed(tenantId, tenantCode, referrer, student, admin, referralCode); } private static User User(Guid userId, string phone, string name, string role) { return new User { Id = userId, Phone = phone, Name = name, PrimaryRole = role }.WithTestPassword(); } private static async Task LoginAsync(HttpClient client, LoginSeed seed) { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private sealed class FakeReferralQrcodeGenerator : IReferralQrcodeGenerator { public int CallCount { get; private set; } public ReferralQrcodeGenerateRequest? LastRequest { get; private set; } public Task GenerateAsync( ReferralQrcodeGenerateRequest request, CancellationToken cancellationToken = default) { CallCount++; LastRequest = request; return Task.FromResult(new ReferralQrcodeGenerateResult( "https://cdn.example.test/referral/qrcode.png", request.Provider, JsonSerializer.SerializeToElement(new { generatedBy = "fake-referral-qrcode-generator" }))); } } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); private sealed record ReferralSeed( Guid TenantId, string TenantCode, LoginSeed Referrer, LoginSeed Student, LoginSeed Admin, string ReferralCode); }