using System.Net; using System.Net.Http.Json; using System.Text.Json; using EARSystem.Core; using EARSystem.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using static EARSystem.Tests.BrowserHelpers; namespace EARSystem.Tests; public class IntegrationTests(AppFactory factory) : IClassFixture { private static object Mood(Guid? version=null) => new {mood=4,energy=3,stress=2,thoughts="测试日记 ", tags=new[]{"平静"},recordedAt=DateTimeOffset.UtcNow.AddMinutes(-2),version}; [Fact] public async Task AnonymousAndCsrfAreProtected() { using var client=factory.Browser(); Assert.Equal(HttpStatusCode.Unauthorized,(await client.GetAsync("/api/moods")).StatusCode); await Register(client); Assert.Equal(HttpStatusCode.BadRequest,(await client.PostAsJsonAsync("/api/moods",Mood())).StatusCode); var admin=await client.GetAsync("/Admin"); Assert.Equal(HttpStatusCode.Redirect,admin.StatusCode); Assert.Contains("/Account/Denied",admin.Headers.Location!.ToString()); Assert.Equal(HttpStatusCode.Forbidden,(await client.GetAsync(admin.Headers.Location)).StatusCode); } [Fact] public async Task MoodOwnerIsolationAndStaleVersion() { using var first=factory.Browser();using var second=factory.Browser(); var a=await Register(first);var b=await Register(second); var saved=await Json(await Send(first,a,"/api/moods",HttpMethod.Post,Mood())); var id=saved.GetProperty("id").GetGuid();var version=saved.GetProperty("version").GetGuid(); Assert.Equal(HttpStatusCode.NotFound,(await Send(second,b,"/api/moods/"+id,HttpMethod.Put,Mood(version))).StatusCode); var changed=await Json(await Send(first,a,"/api/moods/"+id,HttpMethod.Put,Mood(version))); var rendered=await (await first.GetAsync("/")).Content.ReadAsStringAsync(); Assert.Contains("<script>alert(1)</script>",rendered); Assert.DoesNotContain("",rendered); Assert.NotEqual(version,changed.GetProperty("version").GetGuid()); Assert.Equal(HttpStatusCode.Conflict,(await Send(first,a,"/api/moods/"+id,HttpMethod.Put,Mood(version))).StatusCode); Assert.Equal(HttpStatusCode.NotFound,(await Send(second,b,"/api/moods/"+id+"?version="+version,HttpMethod.Delete)).StatusCode); Assert.Equal(HttpStatusCode.NoContent,(await Send(first,a,"/api/moods/"+id+"?version="+changed.GetProperty("version").GetGuid(),HttpMethod.Delete)).StatusCode); } [Fact] public async Task ValidationRejectsFutureAndOutOfRangeMood() { using var client=factory.Browser();var token=await Register(client); Assert.Equal(HttpStatusCode.BadRequest,(await Send(client,token,"/api/moods",HttpMethod.Post,new{mood=7,energy=3,stress=3,thoughts="",tags=Array.Empty(),recordedAt=DateTimeOffset.UtcNow})).StatusCode); Assert.Equal(HttpStatusCode.BadRequest,(await Send(client,token,"/api/moods",HttpMethod.Post,new{mood=3,energy=3,stress=3,thoughts="",tags=Array.Empty(),recordedAt=DateTimeOffset.UtcNow.AddDays(1)})).StatusCode); } [Fact] public async Task RecommendationWorksWithoutMoodAndOnlineFailureIsExplicit() { using var client=factory.Browser();var token=await Register(client); var input=new{longitude=120.155,latitude=30.274,coordinateSystem="GCJ02",radiusMeters=3000,mode="Demo"}; var result=await Json(await Send(client,token,"/api/recommendations",HttpMethod.Post,input)); Assert.Equal("Explore",result.GetProperty("goal").GetString());Assert.Equal("Demo",result.GetProperty("mode").GetString()); Assert.Equal(10,result.GetProperty("items").GetArrayLength()); Assert.All(result.GetProperty("items").EnumerateArray(),x=>Assert.Equal("Demo",x.GetProperty("place").GetProperty("source").GetString())); using (var scope=factory.Services.CreateScope()) { var db=scope.ServiceProvider.GetRequiredService(); var record=await db.Recommendations.OrderByDescending(x=>x.CreatedAt).FirstAsync(); Assert.DoesNotContain("Longitude",record.ResultsJson);Assert.DoesNotContain("Latitude",record.ResultsJson); Assert.DoesNotContain("Parts",record.ResultsJson);Assert.DoesNotContain("Distance",record.ResultsJson); } var online=await Send(client,token,"/api/recommendations",HttpMethod.Post,new{longitude=120.155,latitude=30.274,coordinateSystem="GCJ02",radiusMeters=3000,mode="Online"}); Assert.Equal(HttpStatusCode.ServiceUnavailable,online.StatusCode); Assert.Contains("密钥",await online.Content.ReadAsStringAsync()); var rows=await Json(await client.GetAsync("/api/moods"));Assert.Equal(0,rows.GetArrayLength()); } [Fact] public async Task ExperienceDiaryTransactionFavoritesAndFeedbackRecalculation() { using var client=factory.Browser();var token=await Register(client); using var scope=factory.Services.CreateScope();var db=scope.ServiceProvider.GetRequiredService(); var place=await db.Places.FirstAsync(); var before=await scope.ServiceProvider.GetRequiredService().GetAsync(place.Id); var favorite=await Send(client,token,"/api/favorites",HttpMethod.Post,new{placeId=place.Id}); favorite.EnsureSuccessStatusCode(); var payload=new { placeId=place.Id,visitedAt=DateTimeOffset.UtcNow.AddMinutes(-5),satisfaction=5,moodBefore=2,moodAfter=4, quiet=5,comfort=5,nature=4,liveliness=1,social=1,focus=3,thoughts="体验同步测试",contribute=true,syncDiary=true,energy=3,stress=2 }; var exp=await Json(await Send(client,token,"/api/experiences",HttpMethod.Post,payload)); var id=exp.GetProperty("id").GetGuid();var version=exp.GetProperty("version").GetGuid(); var moods=await Json(await client.GetAsync("/api/moods")); Assert.Contains(moods.EnumerateArray(),x=>x.GetProperty("experienceId").GetGuid()==id && x.GetProperty("thoughts").GetString()=="体验同步测试"); var favorites=await Json(await client.GetAsync("/api/favorites"));Assert.True(favorites[0].GetProperty("visited").GetBoolean()); var after=await scope.ServiceProvider.GetRequiredService().GetAsync(place.Id); Assert.Equal(before!.FeedbackCount+1,after!.FeedbackCount); Assert.Equal(HttpStatusCode.BadRequest,(await Send(client,token,"/api/experiences",HttpMethod.Post,payload)).StatusCode); Assert.Equal(HttpStatusCode.NoContent,(await Send(client,token,"/api/experiences/"+id+"?version="+version,HttpMethod.Delete)).StatusCode); var removed=await scope.ServiceProvider.GetRequiredService().GetAsync(place.Id);Assert.Equal(before.FeedbackCount,removed!.FeedbackCount); var preserved=await Json(await client.GetAsync("/api/moods"));Assert.Equal(1,preserved.GetArrayLength());Assert.Equal(JsonValueKind.Null,preserved[0].GetProperty("experienceId").ValueKind); } [Fact] public async Task WeeklyUsesLastOfDayAndMissingTrendRemainsNull() { using var client=factory.Browser();var token=await Register(client); await Json(await Send(client,token,"/api/moods",HttpMethod.Post,Mood())); var weekly=await Json(await client.GetAsync("/api/reports/weekly"));Assert.Equal(1,weekly.GetProperty("recordedDays").GetInt32()); Assert.Equal(4,weekly.GetProperty("mood").GetDouble()); var trend=await Json(await client.GetAsync("/api/reports/trend?days=7"));Assert.Equal(7,trend.GetArrayLength()); Assert.Equal(6,trend.EnumerateArray().Count(x=>x.GetProperty("mood").ValueKind==JsonValueKind.Null)); } [Fact] public async Task PagesRenderAndPreferencesRejectConflictingCategories() { using var client=factory.Browser();var token=await Register(client); foreach(var page in new[]{"/","/Diary","/Nearby","/Review","/Favorites","/Experiences","/Settings"}) Assert.Equal(HttpStatusCode.OK,(await client.GetAsync(page)).StatusCode); var bad=await Send(client,token,"/api/preferences",HttpMethod.Put,new{favoriteCategories=new[]{"Cafe"},blockedCategories=new[]{"Cafe"},blockedPlaces=Array.Empty(),radiusMeters=3000}); Assert.Equal(HttpStatusCode.BadRequest,bad.StatusCode); var good=await Send(client,token,"/api/preferences",HttpMethod.Put,new{favoriteCategories=new[]{"Cafe"},blockedCategories=new[]{"Dining"},blockedPlaces=Array.Empty(),radiusMeters=1000,budget=20}); good.EnsureSuccessStatusCode(); } [Fact] public async Task ExperienceEditsCheckOwnerAndVersionAndUpdateFeedback() { using var client=factory.Browser();using var other=factory.Browser(); var token=await Register(client);var otherToken=await Register(other); using var scope=factory.Services.CreateScope(); var db=scope.ServiceProvider.GetRequiredService(); var place=await db.Places.FirstAsync(); var provider=scope.ServiceProvider.GetRequiredService(); var baseline=await provider.GetAsync(place.Id); object Payload(int satisfaction,int quiet,bool contribute,Guid? version=null)=>new{ placeId=place.Id,visitedAt=DateTimeOffset.UtcNow.AddMinutes(-10),satisfaction,quiet,comfort=3,nature=3, liveliness=3,social=3,focus=3,thoughts="私密体验正文",contribute,version }; var saved=await Json(await Send(client,token,"/api/experiences",HttpMethod.Post,Payload(1,1,true))); var id=saved.GetProperty("id").GetGuid();var version=saved.GetProperty("version").GetGuid(); Assert.Equal(HttpStatusCode.NotFound,(await Send(other,otherToken,"/api/experiences/"+id,HttpMethod.Put,Payload(5,5,true,version))).StatusCode); var changed=await Json(await Send(client,token,"/api/experiences/"+id,HttpMethod.Put,Payload(5,5,true,version))); var newVersion=changed.GetProperty("version").GetGuid(); Assert.NotEqual(version,newVersion); Assert.Equal(HttpStatusCode.Conflict,(await Send(client,token,"/api/experiences/"+id,HttpMethod.Put,Payload(2,2,true,version))).StatusCode); Assert.Equal(HttpStatusCode.Conflict,(await Send(client,token,"/api/experiences/"+id+"?version="+version,HttpMethod.Delete)).StatusCode); var after=await provider.GetAsync(place.Id); Assert.Equal(baseline!.FeedbackCount+1,after!.FeedbackCount); Assert.True(after.Atmosphere![0]>=baseline.Atmosphere![0]); Assert.DoesNotContain("私密体验正文",System.Text.Json.JsonSerializer.Serialize(after)); var revoked=await Json(await Send(client,token,"/api/experiences/"+id,HttpMethod.Put,Payload(5,5,false,newVersion))); Assert.Equal(baseline.FeedbackCount,(await provider.GetAsync(place.Id))!.FeedbackCount); var foreign=await Json(await other.GetAsync("/api/experiences"));Assert.Equal(0,foreign.GetArrayLength()); } [Fact] public async Task RealAtmosphereExcludesSyntheticAccountsAndCountsDistinctPeople() { using var scope=factory.Services.CreateScope(); var db=scope.ServiceProvider.GetRequiredService(); var manager=scope.ServiceProvider.GetRequiredService>(); var user=new ApplicationUser {UserName=Guid.NewGuid()+"@test.local",Email=Guid.NewGuid()+"@test.local"}; Assert.True((await manager.CreateAsync(user,"TestPassword!42")).Succeeded); var demo=await db.Users.FirstAsync(x=>x.IsDemo && x.UserName=="demo02@ear.local"); var place=new Place{Name="独立采集测试地点",Source=PlaceSource.Collected,Category=PlaceCategory.Park,Longitude=120.155,Latitude=30.274}; db.Places.Add(place);await db.SaveChangesAsync(); var service=scope.ServiceProvider.GetRequiredService(); Experience Entry(string id,int quiet,int days)=>new(){UserId=id,PlaceId=place.Id,VisitedAt=DateTimeOffset.UtcNow.AddDays(-days),Satisfaction=4,Quiet=quiet,Contribute=true}; await service.SaveAsync(demo.Id,Entry(demo.Id,1,2),false,3,3); var provider=scope.ServiceProvider.GetRequiredService(); Assert.Equal(0,(await provider.GetAsync(place.Id))!.FeedbackCount); await service.SaveAsync(user.Id,Entry(user.Id,2,2),false,3,3); await service.SaveAsync(user.Id,Entry(user.Id,5,1),false,3,3); var real=await provider.GetAsync(place.Id); Assert.Equal(1,real!.FeedbackCount);Assert.Equal(5,real.Atmosphere![0]); } [Fact] public async Task CompletedDemoSeedDoesNotRecreateDeletedRecords() { using var scope=factory.Services.CreateScope(); var db=scope.ServiceProvider.GetRequiredService(); var demo=await db.Users.SingleAsync(x=>x.Email=="demo20@ear.local"); var entries=await db.Moods.Where(x=>x.UserId==demo.Id).ToArrayAsync(); db.Moods.RemoveRange(entries);await db.SaveChangesAsync(); await scope.ServiceProvider.GetRequiredService().InitializeAsync(true); Assert.False(await db.Moods.AnyAsync(x=>x.UserId==demo.Id)); Assert.NotNull((await db.Rules.SingleAsync()).DemoSeededAt); } [Fact] public async Task AdminCanMaintainWeightsWithoutDiaryAccessOrChangingSources() { using var client=factory.Browser(); var token=await Register(client); // Grant the test-only account its role through the isolated fixture, then renew its cookie. using var scope=factory.Services.CreateScope(); var db=scope.ServiceProvider.GetRequiredService(); var manager=scope.ServiceProvider.GetRequiredService>(); var row=await Json(await Send(client,token,"/api/moods",HttpMethod.Post,Mood())); var userId=(await db.Moods.FindAsync(row.GetProperty("id").GetGuid()))!.UserId; var user=(await manager.FindByIdAsync(userId))!; Assert.True((await manager.AddToRoleAsync(user,"Admin")).Succeeded); await Send(client,token,"/Account/Logout",HttpMethod.Post); token=await Token(client); var login=await client.PostAsync("/Account/Login",new FormUrlEncodedContent(new Dictionary{ ["__RequestVerificationToken"]=token,["Email"]=user.Email!,["Password"]="TestPassword!42" })); Assert.Equal(HttpStatusCode.Redirect,login.StatusCode); var response=await client.GetAsync("/Admin"); Assert.Equal(HttpStatusCode.OK,response.StatusCode); var html=await response.Content.ReadAsStringAsync();Assert.DoesNotContain("测试日记",html); token=await Token(client,"/Admin"); var rule=await db.Rules.SingleAsync(); var stamp=rule.ConcurrencyStamp; var payload=new Dictionary{ ["__RequestVerificationToken"]=token,["ConcurrencyStamp"]=stamp.ToString(), ["Weights.Atmosphere"]="0.35",["Weights.Distance"]="0.20",["Weights.Quality"]="0.10", ["Weights.Budget"]="0.10",["Weights.Preference"]="0.10",["Weights.History"]="0.15" }; Assert.Equal(HttpStatusCode.Redirect,(await client.PostAsync("/Admin/Rules",new FormUrlEncodedContent(payload))).StatusCode); Assert.Equal(HttpStatusCode.Conflict,(await client.PostAsync("/Admin/Rules",new FormUrlEncodedContent(payload))).StatusCode); payload["Weights.History"]="0.9"; Assert.Equal(HttpStatusCode.BadRequest,(await client.PostAsync("/Admin/Rules",new FormUrlEncodedContent(payload))).StatusCode); var place=await db.Places.FirstAsync(x=>x.Source==PlaceSource.Demo); var edit=new Dictionary{["__RequestVerificationToken"]=token,["Id"]=place.Id.ToString(), ["Name"]=place.Name,["Address"]=place.Address,["Source"]="Collected",["Category"]="Cafe", ["Longitude"]="120.155",["Latitude"]="30.274"}; Assert.Equal(HttpStatusCode.OK,(await client.PostAsync("/Admin/Edit",new FormUrlEncodedContent(edit))).StatusCode); db.ChangeTracker.Clear(); Assert.Equal(PlaceSource.Demo,(await db.Places.FindAsync(place.Id))!.Source); } [Fact] public async Task SyncedDiaryRetainsAssessmentsAndCanBeDetached() { using var client=factory.Browser();var token=await Register(client); using var scope=factory.Services.CreateScope(); var db=scope.ServiceProvider.GetRequiredService();var place=await db.Places.FirstAsync(); object Payload(bool sync,Guid? version=null)=>new{placeId=place.Id,visitedAt=DateTimeOffset.UtcNow.AddMinutes(-15), satisfaction=4,moodAfter=4,energy=5,stress=1,thoughts="同步后独立保留",syncDiary=sync,version}; var saved=await Json(await Send(client,token,"/api/experiences",HttpMethod.Post,Payload(true))); var entries=await Json(await client.GetAsync("/api/experiences")); Assert.Equal(5,entries[0].GetProperty("energy").GetInt32());Assert.Equal(1,entries[0].GetProperty("stress").GetInt32()); await Json(await Send(client,token,"/api/experiences/"+saved.GetProperty("id").GetGuid(),HttpMethod.Put, Payload(false,saved.GetProperty("version").GetGuid()))); var moods=await Json(await client.GetAsync("/api/moods")); Assert.Equal(1,moods.GetArrayLength());Assert.Equal(JsonValueKind.Null,moods[0].GetProperty("experienceId").ValueKind); Assert.Equal("同步后独立保留",moods[0].GetProperty("thoughts").GetString()); } [Fact] public async Task EmptyRequestBudgetCanOverrideSavedDefault() { using var client=factory.Browser();var token=await Register(client); await Json(await Send(client,token,"/api/preferences",HttpMethod.Put,new{ favoriteCategories=Array.Empty(),blockedCategories=Array.Empty(), blockedPlaces=Array.Empty(),radiusMeters=3000,budget=1 })); var result=await Json(await Send(client,token,"/api/recommendations",HttpMethod.Post,new{ longitude=120.155,latitude=30.274,mode="Demo",budget=(decimal?)null })); Assert.All(result.GetProperty("items").EnumerateArray(),item=>Assert.Equal(.5,item.GetProperty("parts").GetProperty("budget").GetDouble())); } [Theory] [InlineData("moods")] [InlineData("recommendations")] [InlineData("preferences")] public async Task NullArraysAreRejectedAsValidationErrors(string route) { using var client=factory.Browser();var token=await Register(client); object input=route switch { "moods"=>new{recordedAt=DateTimeOffset.UtcNow,tags=(string[]?)null}, "recommendations"=>new{longitude=120.155,latitude=30.274,categories=(string[]?)null}, _=>new{favoriteCategories=(string[]?)null} }; var method=route=="preferences"?HttpMethod.Put:HttpMethod.Post; Assert.Equal(HttpStatusCode.BadRequest,(await Send(client,token,"/api/"+route,method,input)).StatusCode); } [Fact] public async Task SameMinuteEntriesUseLatestCreationAcrossReportsAndRecommendations() { using var client=factory.Browser();var token=await Register(client); var time=DateTimeOffset.UtcNow.AddMinutes(-2); await Json(await Send(client,token,"/api/moods",HttpMethod.Post,new{mood=1,energy=1,stress=5,recordedAt=time,tags=Array.Empty()})); await Json(await Send(client,token,"/api/moods",HttpMethod.Post,new{mood=5,energy=5,stress=1,recordedAt=time,tags=new[]{"准备学习"}})); var list=await Json(await client.GetAsync("/api/moods"));Assert.Equal(5,list[0].GetProperty("mood").GetInt32()); var trend=await Json(await client.GetAsync("/api/reports/trend?days=7"));Assert.Equal(5,trend[6].GetProperty("mood").GetInt32()); var weekly=await Json(await client.GetAsync("/api/reports/weekly"));Assert.Equal(5,weekly.GetProperty("mood").GetDouble()); var recommendation=await Json(await Send(client,token,"/api/recommendations",HttpMethod.Post,new{longitude=120.155,latitude=30.274,mode="Demo"})); Assert.Equal("Focus",recommendation.GetProperty("goal").GetString());Assert.Equal(5,recommendation.GetProperty("profile").GetProperty("mood").GetDouble()); } }