StockQueryHandlerTests.cs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. using Domain.Entities.Stocks;
  2. using Domain.Entities.Stocks.ValueObject;
  3. using Infrastructure.Persistence;
  4. using ListHandler = Application.Features.Api.Stocks.GetList.Handler;
  5. using ListQuery = Application.Features.Api.Stocks.GetList.Query;
  6. using QuotesHandler = Application.Features.Api.Stocks.GetQuotes.Handler;
  7. using QuotesQuery = Application.Features.Api.Stocks.GetQuotes.Query;
  8. using SuggestHandler = Application.Features.Api.Stocks.Suggest.Handler;
  9. using SuggestQuery = Application.Features.Api.Stocks.Suggest.Query;
  10. namespace Application.Tests;
  11. [TestClass]
  12. public sealed class StockQueryHandlerTests
  13. {
  14. private static async Task<Stock> CreateStockAsync(AppDbContext db, string code, string name, StockMarket market = StockMarket.KOSPI, bool delisted = false)
  15. {
  16. var stock = Stock.Create(code, name, market, new DateOnly(2020, 1, 2));
  17. if (delisted)
  18. {
  19. stock.MarkDelisted(new DateOnly(2025, 12, 30));
  20. }
  21. await db.Stock.AddAsync(stock);
  22. await db.SaveChangesAsync(default);
  23. return stock;
  24. }
  25. [TestMethod]
  26. public async Task Suggest_NamePrefix_ReturnsUpTo10()
  27. {
  28. // 기대 행위: 이름 prefix 매칭은 최대 10건까지만 반환한다.
  29. using var db = TestDb.Create();
  30. for (var i = 1; i <= 12; i++)
  31. {
  32. await CreateStockAsync(db, $"9100{i:D2}", $"ZQA자동완성{i:D2}");
  33. }
  34. var handler = new SuggestHandler(db);
  35. var result = await handler.Handle(new SuggestQuery("ZQA자동완성"), default);
  36. Assert.AreEqual(10, result.List.Count, "prefix 매칭은 10건으로 제한된다");
  37. Assert.IsTrue(result.List.All(c => c.Name.StartsWith("ZQA자동완성")), "이름 prefix 만 매칭되어야 한다");
  38. }
  39. [TestMethod]
  40. public async Task Suggest_CodePrefix_ExcludesDelisted()
  41. {
  42. // 기대 행위: 코드 prefix 로도 매칭하되 상폐(soft-off) 종목은 제외한다.
  43. using var db = TestDb.Create();
  44. await CreateStockAsync(db, "920001", "QXB활성종목");
  45. await CreateStockAsync(db, "920002", "QXB상폐종목", delisted: true);
  46. var handler = new SuggestHandler(db);
  47. var result = await handler.Handle(new SuggestQuery("92000"), default);
  48. Assert.AreEqual(1, result.List.Count, "상폐 종목은 자동완성에서 제외된다");
  49. Assert.AreEqual("920001", result.List[0].Code);
  50. Assert.AreEqual("KOSPI", result.List[0].Market);
  51. }
  52. [TestMethod]
  53. public async Task Suggest_EmptyQuery_ReturnsEmpty()
  54. {
  55. // 기대 행위: 빈 검색어는 DB 조회 없이 빈 목록을 반환한다.
  56. using var db = TestDb.Create();
  57. var handler = new SuggestHandler(db);
  58. var result = await handler.Handle(new SuggestQuery(" "), default);
  59. Assert.AreEqual(0, result.List.Count);
  60. }
  61. [TestMethod]
  62. public async Task Quotes_ReturnsDenormClosePrice_IgnoresUnknownCodes()
  63. {
  64. // 기대 행위: Stock denorm(종가/등락률)만 읽어 반환하고, 미존재 코드는 조용히 제외한다.
  65. using var db = TestDb.Create();
  66. var stock = await CreateStockAsync(db, "930001", "QZC시세종목", StockMarket.KOSDAQ);
  67. stock.UpdateLastPrice(new DateOnly(2026, 7, 2), 70500, 1.25m, 420_000_000_000L);
  68. await CreateStockAsync(db, "930002", "QZC무시세종목");
  69. await db.SaveChangesAsync(default);
  70. var handler = new QuotesHandler(db);
  71. var result = await handler.Handle(new QuotesQuery(["930001", "930002", "939999"]), default);
  72. Assert.IsTrue(result.IsSuccess);
  73. Assert.AreEqual(2, result.Value.List.Count, "미존재 코드는 제외된다");
  74. var quoted = result.Value.List.Single(c => c.Code == "930001");
  75. Assert.AreEqual(70500, quoted.ClosePrice);
  76. Assert.AreEqual(1.25m, quoted.ChangeRate);
  77. var noPrice = result.Value.List.Single(c => c.Code == "930002");
  78. Assert.IsNull(noPrice.ClosePrice, "시세 미수집 종목은 null 로 반환된다");
  79. }
  80. [TestMethod]
  81. public async Task List_ReturnsPrevDayVolume_FromLatestDailyPrice()
  82. {
  83. // 기대 행위: 목록의 Volume 은 최근 거래일(LastTradingDate) StockDailyPrice 의 거래량이고, 시세 미수집 종목은 null 이다.
  84. using var db = TestDb.Create();
  85. var stock = await CreateStockAsync(db, "940001", "YVL거래량종목");
  86. stock.UpdateLastPrice(new DateOnly(2026, 7, 2), 70500, 1.25m, 420_000_000_000L);
  87. await db.StockDailyPrice.AddAsync(StockDailyPrice.Create(stock.ID, new DateOnly(2026, 7, 1), 69000, 70000, 68500, 69500, 11_000_000L, 760_000_000_000L, 500, 0.72m));
  88. await db.StockDailyPrice.AddAsync(StockDailyPrice.Create(stock.ID, new DateOnly(2026, 7, 2), 70000, 71000, 69800, 70500, 12_345_678L, 870_000_000_000L, 1000, 1.25m));
  89. await CreateStockAsync(db, "940002", "YVL무시세종목");
  90. await db.SaveChangesAsync(default);
  91. var handler = new ListHandler(db);
  92. var result = await handler.Handle(new ListQuery("YVL", null, 1, 20), default);
  93. Assert.AreEqual(2, result.Total);
  94. var withVolume = result.List.Single(c => c.Code == "940001");
  95. Assert.AreEqual(12_345_678L, withVolume.Volume, "최근 거래일의 거래량이 반환되어야 한다");
  96. var noPrice = result.List.Single(c => c.Code == "940002");
  97. Assert.IsNull(noPrice.Volume, "시세 미수집 종목은 null 로 반환된다");
  98. }
  99. [TestMethod]
  100. public async Task Quotes_Over50Codes_Fails()
  101. {
  102. // 기대 행위: 50개 초과 요청은 DB 조회 전에 거절한다.
  103. using var db = TestDb.Create();
  104. var codes = Enumerable.Range(100000, 51).Select(c => c.ToString()).ToList();
  105. var handler = new QuotesHandler(db);
  106. var result = await handler.Handle(new QuotesQuery(codes), default);
  107. Assert.IsFalse(result.IsSuccess, "최대 50개 제한을 넘으면 실패해야 한다");
  108. Assert.AreEqual("Stock.TooManyCodes", result.Error.Code);
  109. }
  110. }