StockBoardTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Stocks;
  4. using Application.Features.Api.Forum.StockBoard.CreatePost;
  5. using Domain.Entities.Common;
  6. using Domain.Entities.Common.ValueObject;
  7. using Domain.Entities.Forum.Posts.ValueObject;
  8. using Domain.Entities.Members;
  9. using Domain.Entities.Stocks;
  10. using Domain.Entities.Stocks.ValueObject;
  11. using Domain.Entities.Wallets;
  12. using Infrastructure.Persistence;
  13. using Microsoft.EntityFrameworkCore;
  14. using CreateCommand = Application.Features.Api.Forum.StockBoard.CreatePost.Command;
  15. using ListHandler = Application.Features.Api.Forum.StockBoard.ListPosts.Handler;
  16. using ListQuery = Application.Features.Api.Forum.StockBoard.ListPosts.Query;
  17. using PredictionHandler = Application.Features.Api.Forum.StockBoard.GetPrediction.Handler;
  18. using PredictionQuery = Application.Features.Api.Forum.StockBoard.GetPrediction.Query;
  19. namespace Application.Tests;
  20. /// <summary>
  21. /// D2 M2 종목 가상보드 통합 테스트 — 글 작성(StockCode 세팅 + PostStockTag + StockBoardStats 증가 +
  22. /// 예측 Pending 생성), 시세 없을 때 예측 거부, 목록 필터를 검증한다.
  23. /// 공유 antooza_test DB 이므로 종목코드는 970xxx 고범위 + GUID 회원으로 충돌을 피한다.
  24. /// </summary>
  25. [TestClass]
  26. public sealed class StockBoardTests
  27. {
  28. // 캐시는 항상 miss → DB Config 폴백. RewardService 가 요구.
  29. private sealed class NullCacheService : ICacheService
  30. {
  31. public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
  32. public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
  33. public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
  34. public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
  35. public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
  36. public Task<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
  37. }
  38. // 트렌딩 캐시는 Redis 의존 — 무효화 no-op, 조회 미사용.
  39. private sealed class NullTrendingCache : IStockBoardTrendingCache
  40. {
  41. public Task<IReadOnlyList<StockTrendingEntry>> GetTopAsync(int count, IAppDbContext db, CancellationToken ct = default)
  42. => Task.FromResult<IReadOnlyList<StockTrendingEntry>>([]);
  43. public Task InvalidateAsync(CancellationToken ct = default) => Task.CompletedTask;
  44. }
  45. private static Handler NewHandler(AppDbContext db) => new(db, new NullCacheService(), new NullTrendingCache());
  46. /// <summary>회원 + MemberStats + Wallet 생성 후 ID 반환.</summary>
  47. private static async Task<int> CreateMemberAsync(AppDbContext db)
  48. {
  49. var member = Member.Create($"stockboard-{Guid.NewGuid():N}@antooza.test");
  50. await db.Member.AddAsync(member);
  51. await db.SaveChangesAsync(default);
  52. await db.MemberStats.AddAsync(MemberStats.Create(member.ID));
  53. await db.Wallet.AddAsync(Wallet.Create(member.ID));
  54. await db.SaveChangesAsync(default);
  55. return member.ID;
  56. }
  57. /// <summary>종목 시드 후 코드 반환. price > 0 이면 일별 종가 1건 시드.</summary>
  58. private static async Task<string> CreateStockAsync(AppDbContext db, string code, int price = 0)
  59. {
  60. var stock = Stock.Create(code, $"테스트종목{code}", StockMarket.KOSPI, new DateOnly(2020, 1, 2));
  61. await db.Stock.AddAsync(stock);
  62. await db.SaveChangesAsync(default);
  63. if (price > 0)
  64. {
  65. var daily = StockDailyPrice.Create(stock.ID, new DateOnly(2026, 7, 3), price, price, price, price, 1000, 1000000, 0, 0m);
  66. await db.StockDailyPrice.AddAsync(daily);
  67. await db.SaveChangesAsync(default);
  68. }
  69. return code;
  70. }
  71. /// <summary>테스트 간 고유한 970xxx 종목코드 생성.</summary>
  72. private static string NextCode()
  73. {
  74. // 970000~979999 범위에서 무작위 6자리
  75. var n = 970000 + Random.Shared.Next(0, 10000);
  76. return n.ToString("D6");
  77. }
  78. private static async Task SeedRewardConfigAsync(AppDbContext db)
  79. {
  80. var config = Config.Create();
  81. config.UpdateReward(new RewardConfig { PostExp = 5, PostPoint = 10, MinPostLength = 0 });
  82. await db.Config.AddAsync(config);
  83. await db.SaveChangesAsync(default);
  84. }
  85. [TestMethod]
  86. public async Task CreatePost_SetsStockCode_AndPostStockTag_AndStats()
  87. {
  88. var code = NextCode();
  89. int memberID;
  90. using (var db = TestDb.Create())
  91. {
  92. await SeedRewardConfigAsync(db);
  93. memberID = await CreateMemberAsync(db);
  94. await CreateStockAsync(db, code);
  95. }
  96. int postID;
  97. using (var db = TestDb.Create())
  98. {
  99. var result = await NewHandler(db).Handle(
  100. new CreateCommand(memberID, code, "분석글 제목", $"본문 $ {code} 은 예시. 실제 토큰 ${code}(테스트종목) 참고.", Prediction: null),
  101. default);
  102. Assert.IsTrue(result.IsSuccess, "정상 종목보드 글은 성공해야 한다");
  103. postID = result.Value;
  104. }
  105. using (var verify = TestDb.Create())
  106. {
  107. var post = await verify.Post.AsNoTracking().SingleAsync(c => c.ID == postID);
  108. Assert.AreEqual(code, post.StockCode, "Post.StockCode 가 설정된다");
  109. // primary 코드 포함 + 중복 제거 → 1건
  110. var tags = await verify.PostStockTag.AsNoTracking().Where(c => c.PostID == postID).ToListAsync();
  111. Assert.AreEqual(1, tags.Count, "primary 코드 1건 (본문 $태그와 dedupe)");
  112. Assert.AreEqual(code, tags[0].StockCode);
  113. var stats = await verify.StockBoardStats.AsNoTracking().SingleAsync(c => c.StockCode == code);
  114. Assert.AreEqual(1, stats.Posts, "StockBoardStats.Posts 가 1 증가");
  115. Assert.IsNotNull(stats.LastPostAt);
  116. }
  117. }
  118. [TestMethod]
  119. public async Task CreatePost_SecondPost_IncrementsStats()
  120. {
  121. var code = NextCode();
  122. int memberID;
  123. using (var db = TestDb.Create())
  124. {
  125. await SeedRewardConfigAsync(db);
  126. memberID = await CreateMemberAsync(db);
  127. await CreateStockAsync(db, code);
  128. }
  129. using (var db = TestDb.Create())
  130. {
  131. await NewHandler(db).Handle(new CreateCommand(memberID, code, "첫 글", "본문1", null), default);
  132. }
  133. using (var db = TestDb.Create())
  134. {
  135. await NewHandler(db).Handle(new CreateCommand(memberID, code, "둘째 글", "본문2", null), default);
  136. }
  137. using (var verify = TestDb.Create())
  138. {
  139. var stats = await verify.StockBoardStats.AsNoTracking().SingleAsync(c => c.StockCode == code);
  140. Assert.AreEqual(2, stats.Posts, "글 2건 → Posts=2");
  141. }
  142. }
  143. [TestMethod]
  144. public async Task CreatePost_WithPrediction_CreatesPending_WithBasePriceAndDueDate()
  145. {
  146. var code = NextCode();
  147. int memberID;
  148. using (var db = TestDb.Create())
  149. {
  150. await SeedRewardConfigAsync(db);
  151. memberID = await CreateMemberAsync(db);
  152. await CreateStockAsync(db, code, price: 70000);
  153. }
  154. int postID;
  155. using (var db = TestDb.Create())
  156. {
  157. var result = await NewHandler(db).Handle(
  158. new CreateCommand(memberID, code, "상승 예측", "오릅니다",
  159. new PredictionInput(Direction: (byte)PredictionDirection.Up, HorizonDays: 5, TargetPrice: 80000m)),
  160. default);
  161. Assert.IsTrue(result.IsSuccess, "시세가 있으면 예측 글은 성공");
  162. postID = result.Value;
  163. }
  164. using (var verify = TestDb.Create())
  165. {
  166. var prediction = await verify.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID);
  167. Assert.AreEqual(PredictionStatus.Pending, prediction.Status, "생성 시 Pending");
  168. Assert.AreEqual(70000m, prediction.BasePrice, "BasePrice = 최신 종가");
  169. Assert.AreEqual(80000m, prediction.TargetPrice);
  170. Assert.AreEqual(PredictionDirection.Up, prediction.Direction);
  171. Assert.AreEqual((short)5, prediction.HorizonDays);
  172. Assert.IsTrue(prediction.DueDate > new DateOnly(2026, 1, 1), "DueDate 가 산정된다");
  173. Assert.IsNull(prediction.SettledPrice, "M2 는 미채점");
  174. Assert.IsNull(prediction.SettledAt);
  175. }
  176. }
  177. [TestMethod]
  178. public async Task CreatePost_PredictionButNoPrice_RejectsWholeRequest()
  179. {
  180. var code = NextCode();
  181. int memberID;
  182. using (var db = TestDb.Create())
  183. {
  184. await SeedRewardConfigAsync(db);
  185. memberID = await CreateMemberAsync(db);
  186. await CreateStockAsync(db, code, price: 0); // 시세 없음
  187. }
  188. using (var db = TestDb.Create())
  189. {
  190. var result = await NewHandler(db).Handle(
  191. new CreateCommand(memberID, code, "예측 시도", "본문",
  192. new PredictionInput((byte)PredictionDirection.Down, 20, null)),
  193. default);
  194. Assert.IsTrue(result.IsFailure, "시세 없으면 예측 요청 전체 실패");
  195. Assert.AreEqual("StockBoard.NoPrice", result.Error.Code);
  196. }
  197. using (var verify = TestDb.Create())
  198. {
  199. Assert.AreEqual(0, await verify.Post.CountAsync(c => c.StockCode == code), "실패 시 글도 생성되지 않는다");
  200. Assert.AreEqual(0, await verify.StockBoardStats.CountAsync(c => c.StockCode == code), "실패 시 통계 미증가");
  201. }
  202. }
  203. [TestMethod]
  204. public async Task CreatePost_UnknownStock_Rejected()
  205. {
  206. var code = NextCode();
  207. int memberID;
  208. using (var db = TestDb.Create())
  209. {
  210. await SeedRewardConfigAsync(db);
  211. memberID = await CreateMemberAsync(db);
  212. // 종목 시드하지 않음
  213. }
  214. using (var db = TestDb.Create())
  215. {
  216. var result = await NewHandler(db).Handle(new CreateCommand(memberID, code, "제목", "본문", null), default);
  217. Assert.IsTrue(result.IsFailure, "존재하지 않는 종목은 거부");
  218. Assert.AreEqual("StockBoard.StockNotFound", result.Error.Code);
  219. }
  220. }
  221. [TestMethod]
  222. public async Task ListPosts_FiltersByStockCode_WithStatsHeader()
  223. {
  224. var codeA = NextCode();
  225. var codeB = NextCode();
  226. int memberID;
  227. using (var db = TestDb.Create())
  228. {
  229. await SeedRewardConfigAsync(db);
  230. memberID = await CreateMemberAsync(db);
  231. await CreateStockAsync(db, codeA);
  232. await CreateStockAsync(db, codeB);
  233. }
  234. using (var db = TestDb.Create())
  235. {
  236. await NewHandler(db).Handle(new CreateCommand(memberID, codeA, "A글1", "본문", null), default);
  237. await NewHandler(db).Handle(new CreateCommand(memberID, codeA, "A글2", "본문", null), default);
  238. await NewHandler(db).Handle(new CreateCommand(memberID, codeB, "B글1", "본문", null), default);
  239. }
  240. using (var verify = TestDb.Create())
  241. {
  242. var result = await new ListHandler(verify).Handle(new ListQuery(codeA, Page: 1, PerPage: 20), default);
  243. Assert.AreEqual(2, result.Total, "codeA 파티션은 2건만");
  244. Assert.IsTrue(result.List.All(c => c.StockCode == codeA), "다른 종목 글은 섞이지 않는다");
  245. Assert.AreEqual(2, result.Stats.Posts, "헤더 통계 = 2");
  246. Assert.AreEqual(codeA, result.Stats.StockCode);
  247. Assert.IsNotNull(result.Stats.StockName, "종목명 조인");
  248. }
  249. }
  250. [TestMethod]
  251. public async Task GetPrediction_ReturnsMeta_OrNotFound()
  252. {
  253. var code = NextCode();
  254. int memberID;
  255. using (var db = TestDb.Create())
  256. {
  257. await SeedRewardConfigAsync(db);
  258. memberID = await CreateMemberAsync(db);
  259. await CreateStockAsync(db, code, price: 50000);
  260. }
  261. int predPostID, plainPostID;
  262. using (var db = TestDb.Create())
  263. {
  264. var r1 = await NewHandler(db).Handle(
  265. new CreateCommand(memberID, code, "예측글", "본문",
  266. new PredictionInput((byte)PredictionDirection.Up, 5, null)),
  267. default);
  268. predPostID = r1.Value;
  269. var r2 = await NewHandler(db).Handle(new CreateCommand(memberID, code, "일반글", "본문", null), default);
  270. plainPostID = r2.Value;
  271. }
  272. using (var verify = TestDb.Create())
  273. {
  274. var hasPred = await new PredictionHandler(verify).Handle(new PredictionQuery(predPostID), default);
  275. Assert.IsTrue(hasPred.IsSuccess, "예측 있는 글은 메타 반환");
  276. Assert.AreEqual(PredictionStatus.Pending, (PredictionStatus)hasPred.Value.Status);
  277. Assert.AreEqual("상승", hasPred.Value.DirectionLabel);
  278. Assert.AreEqual(50000m, hasPred.Value.BasePrice);
  279. var noPred = await new PredictionHandler(verify).Handle(new PredictionQuery(plainPostID), default);
  280. Assert.IsTrue(noPred.IsFailure, "예측 없는 글은 NotFound");
  281. Assert.AreEqual("Prediction.NotFound", noPred.Error.Code);
  282. }
  283. }
  284. }