using System.Net; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Reviews; using UniVerse.Application.Interfaces; using UniVerse.Application.Prompts; using UniVerse.Domain.Exceptions; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.ExternalServices; using UniVerse.Infrastructure.Services; using Xunit; namespace UniVerse.Api.Tests.Reviews; public class ReviewPromptServiceTests { [Fact] public async Task GetAsync_ReturnsDefaultPrompt_WhenSettingDoesNotExist() { await using var db = CreateDbContext(); var service = new ReviewPromptService(db); var result = await service.GetAsync(); Assert.Equal(ReviewPromptTemplate.Default, result.Prompt); Assert.Null(result.UpdatedAt); } [Fact] public async Task UpdateAsync_UpsertsSingletonPrompt() { await using var db = CreateDbContext(); var service = new ReviewPromptService(db); await service.UpdateAsync(new UpdateReviewPromptRequest("First {lectureContext} {reviewText}")); var result = await service.UpdateAsync(new UpdateReviewPromptRequest("Second {lectureContext} {reviewText}")); Assert.Equal("Second {lectureContext} {reviewText}", result.Prompt); Assert.NotNull(result.UpdatedAt); Assert.Equal(1, await db.ReviewPromptSettings.CountAsync()); Assert.Equal("Second {lectureContext} {reviewText}", (await service.GetAsync()).Prompt); } [Theory] [InlineData("")] [InlineData(" ")] [InlineData("Prompt without placeholders")] [InlineData("Only lecture {lectureContext}")] [InlineData("Only review {reviewText}")] public async Task UpdateAsync_RejectsInvalidPrompt(string prompt) { await using var db = CreateDbContext(); var service = new ReviewPromptService(db); await Assert.ThrowsAsync(() => service.UpdateAsync(new UpdateReviewPromptRequest(prompt))); } [Fact] public async Task AnalyzeReviewAsync_RendersCustomPrompt() { var handler = new CapturingHandler(); var http = new HttpClient(handler) { BaseAddress = new Uri("https://llm.test/") }; var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Llm:Model"] = "test-model", ["Llm:ApiKey"] = "test-key" }) .Build(); var promptService = Substitute.For(); promptService.GetAsync().Returns(new ReviewPromptDto( "Custom prompt. Context: {lectureContext}. Text: {reviewText}", DateTime.UtcNow)); var client = new LlmClient(http, config, promptService, NullLogger.Instance); await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra"); Assert.NotNull(handler.RequestBody); using var requestJson = JsonDocument.Parse(handler.RequestBody!); var content = requestJson.RootElement .GetProperty("messages")[0] .GetProperty("content") .GetString(); Assert.Contains("Custom prompt", content); Assert.Contains("Lecture: Algebra", content); Assert.Contains("Very useful review", content); Assert.DoesNotContain(ReviewPromptTemplate.LectureContextPlaceholder, content); Assert.DoesNotContain(ReviewPromptTemplate.ReviewTextPlaceholder, content); } [Fact] public async Task AnalyzeReviewAsync_ParsesSnakeCaseJsonFromFencedResponse() { var handler = new CapturingHandler(""" ```json {"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true} ``` """); var http = new HttpClient(handler) { BaseAddress = new Uri("https://llm.test/") }; var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Llm:Model"] = "test-model", ["Llm:ApiKey"] = "test-key" }) .Build(); var promptService = Substitute.For(); promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null)); var client = new LlmClient(http, config, promptService, NullLogger.Instance); var result = await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra"); Assert.Equal(0.82, result.QualityScore); Assert.Equal("Положительный", result.Sentiment); Assert.Equal(["lecture structure", "practical examples"], result.Tags); Assert.True(result.IsInformative); Assert.Equal(""" ```json {"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true} ``` """, result.RawOutput); } private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}") .Options; return new AppDbContext(options); } private sealed class CapturingHandler : HttpMessageHandler { private readonly string _analysisContent; public CapturingHandler(string? analysisContent = null) { _analysisContent = analysisContent ?? "{\"quality_score\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"is_informative\":true}"; } public string? RequestBody { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { RequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); var responsePayload = JsonSerializer.Serialize(new { choices = new[] { new { message = new { content = _analysisContent } } } }); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responsePayload, Encoding.UTF8, "application/json") }; } } }