XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

EARSystem

【ASP】耳间,网页端的情绪智能推荐系统

公开
关注 0 Fork 0 Star 0
UTF-8
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("&lt;script&gt;alert(1)&lt;/script&gt;",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());
    }
}