forked from gongxuegit/tiku-backend.net
feat: add student video and profile experience
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
@@ -16,6 +19,11 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class DirectContentEndpointTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_create_question_and_sync_primary_collection()
|
||||
{
|
||||
@@ -175,6 +183,44 @@ public sealed class DirectContentEndpointTests
|
||||
Assert.True(dbContext.Questions.Single(item => item.Id == questionId).HasVideoExplanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_queue_content_import_and_worker_writes_import_detail()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var queueResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/imports/questions",
|
||||
new DirectImportDto
|
||||
{
|
||||
Async = true,
|
||||
Items =
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "异步导入题" })
|
||||
]
|
||||
});
|
||||
var queuedJob = await queueResponse.Content.ReadFromJsonAsync<BackgroundJobItem>(JsonOptions);
|
||||
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var processed = await jobService.ProcessPendingAsync("content-import-test-worker", 10);
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedJob = dbContext.BackgroundJobs.Single(item => item.Id == queuedJob!.Id);
|
||||
var importJobId = storedJob.Result.GetProperty("importJobId").GetGuid();
|
||||
|
||||
var detailResponse = await client.GetAsync($"/api/tenant-content/imports/detail?jobId={importJobId}");
|
||||
var detail = await ReadJsonAsync(detailResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, queueResponse.StatusCode);
|
||||
Assert.Equal(1, processed);
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, storedJob.Status);
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
Assert.Equal(1, detail.RootElement.GetProperty("job").GetProperty("insertedCount").GetInt32());
|
||||
Assert.Single(detail.RootElement.GetProperty("items").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_upsert_scoreline_fields_and_records()
|
||||
{
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
@@ -16,6 +19,11 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class ProfileEndpointTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Logged_in_student_can_get_and_update_profile()
|
||||
{
|
||||
@@ -189,6 +197,59 @@ public sealed class ProfileEndpointTests
|
||||
Assert.Single(dbContext.ReportStatusEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Student_can_check_in_once_per_day_and_query_score_events()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedStudentAsync(factory);
|
||||
await factory.SeedAsync(
|
||||
new StudentProfile
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
UserId = seed.UserId
|
||||
},
|
||||
new PointActivityTask
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TaskKey = "daily_check_in",
|
||||
Title = "每日签到",
|
||||
TaskType = PointActivityTaskType.DailyLogin,
|
||||
Points = 5,
|
||||
MaxClaimsPerUser = 3650
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var firstResponse = await client.PostAsync("/api/profile/check-in", null);
|
||||
var first = await firstResponse.Content.ReadFromJsonAsync<CheckInResult>(JsonOptions);
|
||||
var secondResponse = await client.PostAsync("/api/profile/check-in", null);
|
||||
var second = await secondResponse.Content.ReadFromJsonAsync<CheckInResult>(JsonOptions);
|
||||
var eventsResponse = await client.GetAsync("/api/profile/score-events?sourceType=daily_check_in");
|
||||
var events = await eventsResponse.Content.ReadFromJsonAsync<ProfileScoreEventList>(JsonOptions);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.False(first!.AlreadyCheckedIn);
|
||||
Assert.Equal(5, first.Points);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
Assert.True(second!.AlreadyCheckedIn);
|
||||
Assert.Equal(first.ScoreEventId, second.ScoreEventId);
|
||||
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
|
||||
var item = Assert.Single(events!.Items);
|
||||
Assert.Equal("check_in", item.EventType);
|
||||
Assert.Equal(5, item.Points);
|
||||
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Single(dbContext.PointActivityClaims.Where(claim =>
|
||||
claim.TenantId == seed.TenantId &&
|
||||
claim.UserId == seed.UserId &&
|
||||
claim.SourceType == "daily_check_in"));
|
||||
Assert.Single(dbContext.UserScoreEvents.Where(scoreEvent =>
|
||||
scoreEvent.TenantId == seed.TenantId &&
|
||||
scoreEvent.UserId == seed.UserId &&
|
||||
scoreEvent.SourceType == "daily_check_in"));
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
161
Tiku.IntegrationTests/Api/VideoEndpointTests.cs
Normal file
161
Tiku.IntegrationTests/Api/VideoEndpointTests.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class VideoEndpointTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Student_can_search_play_report_progress_and_query_question_videos()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedStudentAsync(factory);
|
||||
var questionId = Guid.NewGuid();
|
||||
var videoId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Question { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published },
|
||||
new VideoExplanation
|
||||
{
|
||||
Id = videoId,
|
||||
TenantId = seed.TenantId,
|
||||
Title = "透视解析",
|
||||
Description = "素描透视",
|
||||
VideoUrl = "https://cdn.example.test/video.mp4",
|
||||
DurationSeconds = 120,
|
||||
IsActive = true
|
||||
},
|
||||
new QuestionVideo
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
QuestionId = questionId,
|
||||
VideoId = videoId
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var search = await client.GetFromJsonAsync<CatalogList<VideoExplanationCatalogItem>>("/api/videos/search?keyword=透视", JsonOptions);
|
||||
var questionVideos = await client.GetFromJsonAsync<CatalogList<QuestionVideoCatalogItem>>($"/api/questions/videos?questionId={questionId}", JsonOptions);
|
||||
var batch = await (await client.PostAsJsonAsync(
|
||||
"/api/questions/videos/batch",
|
||||
new QuestionVideoQueryDto { QuestionIds = [questionId] }))
|
||||
.Content
|
||||
.ReadFromJsonAsync<CatalogList<QuestionVideoCatalogItem>>(JsonOptions);
|
||||
var playResponse = await client.PostAsJsonAsync(
|
||||
"/api/videos/play",
|
||||
new VideoPlayDto { VideoId = videoId, QuestionId = questionId });
|
||||
var play = await playResponse.Content.ReadFromJsonAsync<VideoPlaybackItem>(JsonOptions);
|
||||
var progressResponse = await client.PostAsJsonAsync(
|
||||
"/api/videos/progress",
|
||||
new VideoProgressDto
|
||||
{
|
||||
VideoId = videoId,
|
||||
QuestionId = questionId,
|
||||
PositionSeconds = 120,
|
||||
DurationSeconds = 120,
|
||||
WatchedSeconds = 120
|
||||
});
|
||||
var progress = await progressResponse.Content.ReadFromJsonAsync<VideoProgressItem>(JsonOptions);
|
||||
|
||||
Assert.Single(search!.Items);
|
||||
Assert.Single(questionVideos!.Items);
|
||||
Assert.Single(batch!.Items);
|
||||
Assert.Equal(HttpStatusCode.OK, playResponse.StatusCode);
|
||||
Assert.Equal("https://cdn.example.test/video.mp4", play!.PlayUrl);
|
||||
Assert.Equal(HttpStatusCode.OK, progressResponse.StatusCode);
|
||||
Assert.True(progress!.IsCompleted);
|
||||
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Single(dbContext.VideoPlaybackProgress.Where(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.UserId == seed.UserId &&
|
||||
item.VideoId == videoId &&
|
||||
item.QuestionId == questionId));
|
||||
Assert.Single(dbContext.ContentAssetAccessEvents.Where(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.UserId == seed.UserId &&
|
||||
item.AssetType == "Video"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Student_cannot_play_another_tenant_video()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantA = await SeedStudentAsync(factory, "13900001001");
|
||||
var tenantB = await SeedStudentAsync(factory, "13900001002");
|
||||
var videoId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new VideoExplanation
|
||||
{
|
||||
Id = videoId,
|
||||
TenantId = tenantA.TenantId,
|
||||
Title = "A tenant video",
|
||||
VideoUrl = "https://cdn.example.test/a.mp4",
|
||||
IsActive = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, tenantB);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/videos/play",
|
||||
new VideoPlayDto { VideoId = videoId });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(
|
||||
ApiTestFactory factory,
|
||||
string? phone = null)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var resolvedPhone = phone ?? $"135{Random.Shared.Next(10000000, 99999999)}";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Video Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = resolvedPhone,
|
||||
Name = "Video Student"
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return (tenantId, userId, resolvedPhone);
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,8 @@ public sealed class ArchitectureBoundaryTests
|
||||
"Aliyun.OSS",
|
||||
"Senparc.Weixin",
|
||||
"AlipaySDKNet",
|
||||
"Aop.Api"
|
||||
"Aop.Api",
|
||||
"Microsoft.SemanticKernel"
|
||||
};
|
||||
|
||||
var violations = sourceRoots
|
||||
|
||||
Reference in New Issue
Block a user