using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class ProfileEndpointTests { [Fact] public async Task Logged_in_student_can_get_and_update_profile() { await using var factory = new ApiTestFactory(); var seed = await SeedStudentAsync(factory); var regionId = Guid.NewGuid(); var schoolId = Guid.NewGuid(); var majorId = Guid.NewGuid(); await factory.SeedAsync( new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" }, new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" }, new Major { Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" }); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var getResponse = await client.GetAsync("/api/profile/me"); var getJson = await ReadJsonAsync(getResponse); using var patchResponse = await client.PatchAsJsonAsync( "/api/profile/me", new UpdateProfileDto { Name = "张三", AvatarPreset = "female", RegionId = regionId, SelectedSchoolId = schoolId, SelectedMajorId = majorId, Stats = JsonSerializer.SerializeToElement(new { level = 3 }), RecentActivities = JsonSerializer.SerializeToElement(new[] { new { type = "login" } }) }); var patchJson = await ReadJsonAsync(patchResponse); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); Assert.NotEqual(Guid.Empty, getJson.RootElement.GetProperty("profileId").GetGuid()); Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode); Assert.Equal("张三", patchJson.RootElement.GetProperty("name").GetString()); Assert.Equal("female", patchJson.RootElement.GetProperty("avatarPreset").GetString()); Assert.Equal(regionId, patchJson.RootElement.GetProperty("regionId").GetGuid()); } [Fact] public async Task Exam_countdowns_use_current_profile_target() { await using var factory = new ApiTestFactory(); var seed = await SeedStudentAsync(factory); var regionId = Guid.NewGuid(); var schoolId = Guid.NewGuid(); await factory.SeedAsync( new StudentProfile { TenantId = seed.TenantId, UserId = seed.UserId, RegionId = regionId, SelectedSchoolId = schoolId }, new ExamDate { TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, ExamName = "校考", ExamAt = DateTimeOffset.UtcNow.AddDays(10) }, new ExamDate { TenantId = seed.TenantId, RegionId = Guid.NewGuid(), ExamName = "其他地区考试", ExamAt = DateTimeOffset.UtcNow.AddDays(1) }); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var response = await client.GetAsync("/api/profile/exam-countdowns"); var json = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var item = json.RootElement.GetProperty("items").EnumerateArray().Single(); Assert.Equal("校考", item.GetProperty("examName").GetString()); Assert.True(item.GetProperty("daysLeft").GetInt32() >= 9); } [Fact] public async Task Profile_requires_authentication() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); using var response = await client.GetAsync("/api/profile/me"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Student_can_manage_notifications_badges_and_feedbacks() { await using var factory = new ApiTestFactory(); var seed = await SeedStudentAsync(factory); var notificationId = Guid.NewGuid(); var badgeId = Guid.NewGuid(); await factory.SeedAsync( new StudentProfile { TenantId = seed.TenantId, UserId = seed.UserId }, new UserNotification { Id = notificationId, TenantId = seed.TenantId, UserId = seed.UserId, NotificationType = "system", Title = "欢迎", Message = "欢迎回来", Status = NotificationStatus.Unread }, new Badge { Id = badgeId, TenantId = seed.TenantId, Name = "首练", Category = "learning", IsActive = true }, new UserBadge { TenantId = seed.TenantId, UserId = seed.UserId, BadgeId = badgeId, GrantedAt = DateTimeOffset.UtcNow }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var notificationsResponse = await client.GetAsync("/api/profile/notifications"); var notificationsJson = await ReadJsonAsync(notificationsResponse); var statusResponse = await client.PostAsJsonAsync( "/api/profile/notifications/status", new NotificationStatusDto { NotificationIds = [notificationId], Status = "read" }); var badgesResponse = await client.GetAsync("/api/profile/badges?includeLocked=true"); var badgesJson = await ReadJsonAsync(badgesResponse); var feedbackResponse = await client.PostAsJsonAsync( "/api/profile/feedbacks", new SubmitFeedbackDto { Type = "suggestion", Title = "建议", Description = "希望增加练习提醒", Priority = "normal" }); var feedbackJson = await ReadJsonAsync(feedbackResponse); var feedbacksResponse = await client.GetAsync("/api/profile/feedbacks?type=suggestion"); var feedbacksJson = await ReadJsonAsync(feedbacksResponse); Assert.Equal(HttpStatusCode.OK, notificationsResponse.StatusCode); Assert.Equal("unread", notificationsJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("status").GetString()); Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, badgesResponse.StatusCode); Assert.True(badgesJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("isUnlocked").GetBoolean()); Assert.Equal(HttpStatusCode.OK, feedbackResponse.StatusCode); Assert.Equal("suggestion", feedbackJson.RootElement.GetProperty("type").GetString()); Assert.Equal(HttpStatusCode.OK, feedbacksResponse.StatusCode); Assert.Single(feedbacksJson.RootElement.EnumerateArray()); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Equal(NotificationStatus.Read, dbContext.UserNotifications.Single(item => item.Id == notificationId).Status); Assert.Single(dbContext.ReportStatusEvents); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = $"136{Random.Shared.Next(10000000, 99999999)}"; 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 = "Student" }, new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, 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 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(); } }