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<AppFactory>
{
private static object Mood(Guid? version=null) => new {mood=4,energy=3,stress=2,thoughts="测试日记 <script>alert(1)</script>",
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("<script>alert(1)</script>",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<string>(),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<string>(),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<AppDbContext>();
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<AppDbContext>();
var place=await db.Places.FirstAsync();
var before=await scope.ServiceProvider.GetRequiredService<CatalogPlaceProvider>().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<CatalogPlaceProvider>().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<CatalogPlaceProvider>().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<Guid>(),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<Guid>(),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<AppDbContext>();
var place=await db.Places.FirstAsync();
var provider=scope.ServiceProvider.GetRequiredService<CatalogPlaceProvider>();
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<AppDbContext>();
var manager=scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
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<IExperienceService>();
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<CatalogPlaceProvider>();
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<AppDbContext>();
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<DemoSeeder>().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<AppDbContext>();
var manager=scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
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<string,string>{
["__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<string,string>{
["__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<string,string>{["__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<AppDbContext>();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<string>(),blockedCategories=Array.Empty<string>(),
blockedPlaces=Array.Empty<Guid>(),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<string>()}));
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());
}
}
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<AppFactory>
{
private static object Mood(Guid? version=null) => new {mood=4,energy=3,stress=2,thoughts="测试日记 <script>alert(1)</script>",
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("<script>alert(1)</script>",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<string>(),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<string>(),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<AppDbContext>();
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<AppDbContext>();
var place=await db.Places.FirstAsync();
var before=await scope.ServiceProvider.GetRequiredService<CatalogPlaceProvider>().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<CatalogPlaceProvider>().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<CatalogPlaceProvider>().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<Guid>(),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<Guid>(),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<AppDbContext>();
var place=await db.Places.FirstAsync();
var provider=scope.ServiceProvider.GetRequiredService<CatalogPlaceProvider>();
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<AppDbContext>();
var manager=scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
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<IExperienceService>();
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<CatalogPlaceProvider>();
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<AppDbContext>();
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<DemoSeeder>().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<AppDbContext>();
var manager=scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
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<string,string>{
["__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<string,string>{
["__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<string,string>{["__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<AppDbContext>();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<string>(),blockedCategories=Array.Empty<string>(),
blockedPlaces=Array.Empty<Guid>(),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<string>()}));
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());
}
}