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); } private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}") .Options; return new AppDbContext(options); } private sealed class CapturingHandler : HttpMessageHandler { 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); const string responsePayload = """ { "choices": [ { "message": { "content": "{\"qualityScore\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"isInformative\":true}" } } ] } """; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responsePayload, Encoding.UTF8, "application/json") }; } } }