| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- 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;
- /// <summary>
- /// D2 M2 종목 가상보드 통합 테스트 — 글 작성(StockCode 세팅 + PostStockTag + StockBoardStats 증가 +
- /// 예측 Pending 생성), 시세 없을 때 예측 거부, 목록 필터를 검증한다.
- /// 공유 antooza_test DB 이므로 종목코드는 970xxx 고범위 + GUID 회원으로 충돌을 피한다.
- /// </summary>
- [TestClass]
- public sealed class StockBoardTests
- {
- // 캐시는 항상 miss → DB Config 폴백. RewardService 가 요구.
- private sealed class NullCacheService : ICacheService
- {
- public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
- public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
- public Task SetAsync<T>(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<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
- }
- // 트렌딩 캐시는 Redis 의존 — 무효화 no-op, 조회 미사용.
- private sealed class NullTrendingCache : IStockBoardTrendingCache
- {
- public Task<IReadOnlyList<StockTrendingEntry>> GetTopAsync(int count, IAppDbContext db, CancellationToken ct = default)
- => Task.FromResult<IReadOnlyList<StockTrendingEntry>>([]);
- public Task InvalidateAsync(CancellationToken ct = default) => Task.CompletedTask;
- }
- private static Handler NewHandler(AppDbContext db) => new(db, new NullCacheService(), new NullTrendingCache());
- /// <summary>회원 + MemberStats + Wallet 생성 후 ID 반환.</summary>
- private static async Task<int> 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;
- }
- /// <summary>종목 시드 후 코드 반환. price > 0 이면 일별 종가 1건 시드.</summary>
- private static async Task<string> 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;
- }
- /// <summary>테스트 간 고유한 970xxx 종목코드 생성.</summary>
- 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);
- }
- }
- }
|