using Application.Abstractions.Cache; using Application.Abstractions.Data; using Application.Abstractions.Stocks; using Application.Features.Api.Forum.StockBoard.CreatePost; using Domain.Entities.Common; using Domain.Entities.Common.ValueObject; using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Members; using Domain.Entities.Stocks; using Domain.Entities.Stocks.ValueObject; using Domain.Entities.Wallets; using Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using CreateCommand = Application.Features.Api.Forum.StockBoard.CreatePost.Command; using ListHandler = Application.Features.Api.Forum.StockBoard.ListPosts.Handler; using ListQuery = Application.Features.Api.Forum.StockBoard.ListPosts.Query; using PredictionHandler = Application.Features.Api.Forum.StockBoard.GetPrediction.Handler; using PredictionQuery = Application.Features.Api.Forum.StockBoard.GetPrediction.Query; namespace Application.Tests; /// /// D2 M2 종목 가상보드 통합 테스트 — 글 작성(StockCode 세팅 + PostStockTag + StockBoardStats 증가 + /// 예측 Pending 생성), 시세 없을 때 예측 거부, 목록 필터를 검증한다. /// 공유 antooza_test DB 이므로 종목코드는 970xxx 고범위 + GUID 회원으로 충돌을 피한다. /// [TestClass] public sealed class StockBoardTests { // 캐시는 항상 miss → DB Config 폴백. RewardService 가 요구. private sealed class NullCacheService : ICacheService { public Task GetAsync(string key, CancellationToken ct = default) => Task.FromResult(default); public Task SetAsync(string key, T value, CancellationToken ct = default) => Task.CompletedTask; public Task SetAsync(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask; public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask; public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask; public Task TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true); } // 트렌딩 캐시는 Redis 의존 — 무효화 no-op, 조회 미사용. private sealed class NullTrendingCache : IStockBoardTrendingCache { public Task> GetTopAsync(int count, IAppDbContext db, CancellationToken ct = default) => Task.FromResult>([]); public Task InvalidateAsync(CancellationToken ct = default) => Task.CompletedTask; } private static Handler NewHandler(AppDbContext db) => new(db, new NullCacheService(), new NullTrendingCache()); /// 회원 + MemberStats + Wallet 생성 후 ID 반환. private static async Task CreateMemberAsync(AppDbContext db) { var member = Member.Create($"stockboard-{Guid.NewGuid():N}@antooza.test"); await db.Member.AddAsync(member); await db.SaveChangesAsync(default); await db.MemberStats.AddAsync(MemberStats.Create(member.ID)); await db.Wallet.AddAsync(Wallet.Create(member.ID)); await db.SaveChangesAsync(default); return member.ID; } /// 종목 시드 후 코드 반환. price > 0 이면 일별 종가 1건 시드. private static async Task CreateStockAsync(AppDbContext db, string code, int price = 0) { var stock = Stock.Create(code, $"테스트종목{code}", StockMarket.KOSPI, new DateOnly(2020, 1, 2)); await db.Stock.AddAsync(stock); await db.SaveChangesAsync(default); if (price > 0) { var daily = StockDailyPrice.Create(stock.ID, new DateOnly(2026, 7, 3), price, price, price, price, 1000, 1000000, 0, 0m); await db.StockDailyPrice.AddAsync(daily); await db.SaveChangesAsync(default); } return code; } /// 테스트 간 고유한 970xxx 종목코드 생성. private static string NextCode() { // 970000~979999 범위에서 무작위 6자리 var n = 970000 + Random.Shared.Next(0, 10000); return n.ToString("D6"); } private static async Task SeedRewardConfigAsync(AppDbContext db) { var config = Config.Create(); config.UpdateReward(new RewardConfig { PostExp = 5, PostPoint = 10, MinPostLength = 0 }); await db.Config.AddAsync(config); await db.SaveChangesAsync(default); } [TestMethod] public async Task CreatePost_SetsStockCode_AndPostStockTag_AndStats() { var code = NextCode(); int memberID; using (var db = TestDb.Create()) { await SeedRewardConfigAsync(db); memberID = await CreateMemberAsync(db); await CreateStockAsync(db, code); } int postID; using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle( new CreateCommand(memberID, code, "분석글 제목", $"본문 $ {code} 은 예시. 실제 토큰 ${code}(테스트종목) 참고.", Prediction: null), default); Assert.IsTrue(result.IsSuccess, "정상 종목보드 글은 성공해야 한다"); postID = result.Value; } using (var verify = TestDb.Create()) { var post = await verify.Post.AsNoTracking().SingleAsync(c => c.ID == postID); Assert.AreEqual(code, post.StockCode, "Post.StockCode 가 설정된다"); // primary 코드 포함 + 중복 제거 → 1건 var tags = await verify.PostStockTag.AsNoTracking().Where(c => c.PostID == postID).ToListAsync(); Assert.AreEqual(1, tags.Count, "primary 코드 1건 (본문 $태그와 dedupe)"); Assert.AreEqual(code, tags[0].StockCode); var stats = await verify.StockBoardStats.AsNoTracking().SingleAsync(c => c.StockCode == code); Assert.AreEqual(1, stats.Posts, "StockBoardStats.Posts 가 1 증가"); Assert.IsNotNull(stats.LastPostAt); } } [TestMethod] public async Task CreatePost_SecondPost_IncrementsStats() { var code = NextCode(); int memberID; using (var db = TestDb.Create()) { await SeedRewardConfigAsync(db); memberID = await CreateMemberAsync(db); await CreateStockAsync(db, code); } using (var db = TestDb.Create()) { await NewHandler(db).Handle(new CreateCommand(memberID, code, "첫 글", "본문1", null), default); } using (var db = TestDb.Create()) { await NewHandler(db).Handle(new CreateCommand(memberID, code, "둘째 글", "본문2", null), default); } using (var verify = TestDb.Create()) { var stats = await verify.StockBoardStats.AsNoTracking().SingleAsync(c => c.StockCode == code); Assert.AreEqual(2, stats.Posts, "글 2건 → Posts=2"); } } [TestMethod] public async Task CreatePost_WithPrediction_CreatesPending_WithBasePriceAndDueDate() { var code = NextCode(); int memberID; using (var db = TestDb.Create()) { await SeedRewardConfigAsync(db); memberID = await CreateMemberAsync(db); await CreateStockAsync(db, code, price: 70000); } int postID; using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle( new CreateCommand(memberID, code, "상승 예측", "오릅니다", new PredictionInput(Direction: (byte)PredictionDirection.Up, HorizonDays: 5, TargetPrice: 80000m)), default); Assert.IsTrue(result.IsSuccess, "시세가 있으면 예측 글은 성공"); postID = result.Value; } using (var verify = TestDb.Create()) { var prediction = await verify.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID); Assert.AreEqual(PredictionStatus.Pending, prediction.Status, "생성 시 Pending"); Assert.AreEqual(70000m, prediction.BasePrice, "BasePrice = 최신 종가"); Assert.AreEqual(80000m, prediction.TargetPrice); Assert.AreEqual(PredictionDirection.Up, prediction.Direction); Assert.AreEqual((short)5, prediction.HorizonDays); Assert.IsTrue(prediction.DueDate > new DateOnly(2026, 1, 1), "DueDate 가 산정된다"); Assert.IsNull(prediction.SettledPrice, "M2 는 미채점"); Assert.IsNull(prediction.SettledAt); } } [TestMethod] public async Task CreatePost_PredictionButNoPrice_RejectsWholeRequest() { var code = NextCode(); int memberID; using (var db = TestDb.Create()) { await SeedRewardConfigAsync(db); memberID = await CreateMemberAsync(db); await CreateStockAsync(db, code, price: 0); // 시세 없음 } using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle( new CreateCommand(memberID, code, "예측 시도", "본문", new PredictionInput((byte)PredictionDirection.Down, 20, null)), default); Assert.IsTrue(result.IsFailure, "시세 없으면 예측 요청 전체 실패"); Assert.AreEqual("StockBoard.NoPrice", result.Error.Code); } using (var verify = TestDb.Create()) { Assert.AreEqual(0, await verify.Post.CountAsync(c => c.StockCode == code), "실패 시 글도 생성되지 않는다"); Assert.AreEqual(0, await verify.StockBoardStats.CountAsync(c => c.StockCode == code), "실패 시 통계 미증가"); } } [TestMethod] public async Task CreatePost_UnknownStock_Rejected() { var code = NextCode(); int memberID; using (var db = TestDb.Create()) { await SeedRewardConfigAsync(db); memberID = await CreateMemberAsync(db); // 종목 시드하지 않음 } using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle(new CreateCommand(memberID, code, "제목", "본문", null), default); Assert.IsTrue(result.IsFailure, "존재하지 않는 종목은 거부"); Assert.AreEqual("StockBoard.StockNotFound", result.Error.Code); } } [TestMethod] public async Task ListPosts_FiltersByStockCode_WithStatsHeader() { var codeA = NextCode(); var codeB = NextCode(); int memberID; using (var db = TestDb.Create()) { await SeedRewardConfigAsync(db); memberID = await CreateMemberAsync(db); await CreateStockAsync(db, codeA); await CreateStockAsync(db, codeB); } using (var db = TestDb.Create()) { await NewHandler(db).Handle(new CreateCommand(memberID, codeA, "A글1", "본문", null), default); await NewHandler(db).Handle(new CreateCommand(memberID, codeA, "A글2", "본문", null), default); await NewHandler(db).Handle(new CreateCommand(memberID, codeB, "B글1", "본문", null), default); } using (var verify = TestDb.Create()) { var result = await new ListHandler(verify).Handle(new ListQuery(codeA, Page: 1, PerPage: 20), default); Assert.AreEqual(2, result.Total, "codeA 파티션은 2건만"); Assert.IsTrue(result.List.All(c => c.StockCode == codeA), "다른 종목 글은 섞이지 않는다"); Assert.AreEqual(2, result.Stats.Posts, "헤더 통계 = 2"); Assert.AreEqual(codeA, result.Stats.StockCode); Assert.IsNotNull(result.Stats.StockName, "종목명 조인"); } } [TestMethod] public async Task GetPrediction_ReturnsMeta_OrNotFound() { var code = NextCode(); int memberID; using (var db = TestDb.Create()) { await SeedRewardConfigAsync(db); memberID = await CreateMemberAsync(db); await CreateStockAsync(db, code, price: 50000); } int predPostID, plainPostID; using (var db = TestDb.Create()) { var r1 = await NewHandler(db).Handle( new CreateCommand(memberID, code, "예측글", "본문", new PredictionInput((byte)PredictionDirection.Up, 5, null)), default); predPostID = r1.Value; var r2 = await NewHandler(db).Handle(new CreateCommand(memberID, code, "일반글", "본문", null), default); plainPostID = r2.Value; } using (var verify = TestDb.Create()) { var hasPred = await new PredictionHandler(verify).Handle(new PredictionQuery(predPostID), default); Assert.IsTrue(hasPred.IsSuccess, "예측 있는 글은 메타 반환"); Assert.AreEqual(PredictionStatus.Pending, (PredictionStatus)hasPred.Value.Status); Assert.AreEqual("상승", hasPred.Value.DirectionLabel); Assert.AreEqual(50000m, hasPred.Value.BasePrice); var noPred = await new PredictionHandler(verify).Handle(new PredictionQuery(plainPostID), default); Assert.IsTrue(noPred.IsFailure, "예측 없는 글은 NotFound"); Assert.AreEqual("Prediction.NotFound", noPred.Error.Code); } } }