using Domain.Entities.Stocks; using Domain.Entities.Stocks.ValueObject; using Infrastructure.Persistence; using ListHandler = Application.Features.Api.Stocks.GetList.Handler; using ListQuery = Application.Features.Api.Stocks.GetList.Query; using QuotesHandler = Application.Features.Api.Stocks.GetQuotes.Handler; using QuotesQuery = Application.Features.Api.Stocks.GetQuotes.Query; using SuggestHandler = Application.Features.Api.Stocks.Suggest.Handler; using SuggestQuery = Application.Features.Api.Stocks.Suggest.Query; namespace Application.Tests; [TestClass] public sealed class StockQueryHandlerTests { private static async Task CreateStockAsync(AppDbContext db, string code, string name, StockMarket market = StockMarket.KOSPI, bool delisted = false) { var stock = Stock.Create(code, name, market, new DateOnly(2020, 1, 2)); if (delisted) { stock.MarkDelisted(new DateOnly(2025, 12, 30)); } await db.Stock.AddAsync(stock); await db.SaveChangesAsync(default); return stock; } [TestMethod] public async Task Suggest_NamePrefix_ReturnsUpTo10() { // 기대 행위: 이름 prefix 매칭은 최대 10건까지만 반환한다. using var db = TestDb.Create(); for (var i = 1; i <= 12; i++) { await CreateStockAsync(db, $"9100{i:D2}", $"ZQA자동완성{i:D2}"); } var handler = new SuggestHandler(db); var result = await handler.Handle(new SuggestQuery("ZQA자동완성"), default); Assert.AreEqual(10, result.List.Count, "prefix 매칭은 10건으로 제한된다"); Assert.IsTrue(result.List.All(c => c.Name.StartsWith("ZQA자동완성")), "이름 prefix 만 매칭되어야 한다"); } [TestMethod] public async Task Suggest_CodePrefix_ExcludesDelisted() { // 기대 행위: 코드 prefix 로도 매칭하되 상폐(soft-off) 종목은 제외한다. using var db = TestDb.Create(); await CreateStockAsync(db, "920001", "QXB활성종목"); await CreateStockAsync(db, "920002", "QXB상폐종목", delisted: true); var handler = new SuggestHandler(db); var result = await handler.Handle(new SuggestQuery("92000"), default); Assert.AreEqual(1, result.List.Count, "상폐 종목은 자동완성에서 제외된다"); Assert.AreEqual("920001", result.List[0].Code); Assert.AreEqual("KOSPI", result.List[0].Market); } [TestMethod] public async Task Suggest_EmptyQuery_ReturnsEmpty() { // 기대 행위: 빈 검색어는 DB 조회 없이 빈 목록을 반환한다. using var db = TestDb.Create(); var handler = new SuggestHandler(db); var result = await handler.Handle(new SuggestQuery(" "), default); Assert.AreEqual(0, result.List.Count); } [TestMethod] public async Task Quotes_ReturnsDenormClosePrice_IgnoresUnknownCodes() { // 기대 행위: Stock denorm(종가/등락률)만 읽어 반환하고, 미존재 코드는 조용히 제외한다. using var db = TestDb.Create(); var stock = await CreateStockAsync(db, "930001", "QZC시세종목", StockMarket.KOSDAQ); stock.UpdateLastPrice(new DateOnly(2026, 7, 2), 70500, 1.25m, 420_000_000_000L); await CreateStockAsync(db, "930002", "QZC무시세종목"); await db.SaveChangesAsync(default); var handler = new QuotesHandler(db); var result = await handler.Handle(new QuotesQuery(["930001", "930002", "939999"]), default); Assert.IsTrue(result.IsSuccess); Assert.AreEqual(2, result.Value.List.Count, "미존재 코드는 제외된다"); var quoted = result.Value.List.Single(c => c.Code == "930001"); Assert.AreEqual(70500, quoted.ClosePrice); Assert.AreEqual(1.25m, quoted.ChangeRate); var noPrice = result.Value.List.Single(c => c.Code == "930002"); Assert.IsNull(noPrice.ClosePrice, "시세 미수집 종목은 null 로 반환된다"); } [TestMethod] public async Task List_ReturnsPrevDayVolume_FromLatestDailyPrice() { // 기대 행위: 목록의 Volume 은 최근 거래일(LastTradingDate) StockDailyPrice 의 거래량이고, 시세 미수집 종목은 null 이다. using var db = TestDb.Create(); var stock = await CreateStockAsync(db, "940001", "YVL거래량종목"); stock.UpdateLastPrice(new DateOnly(2026, 7, 2), 70500, 1.25m, 420_000_000_000L); 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)); 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)); await CreateStockAsync(db, "940002", "YVL무시세종목"); await db.SaveChangesAsync(default); var handler = new ListHandler(db); var result = await handler.Handle(new ListQuery("YVL", null, 1, 20), default); Assert.AreEqual(2, result.Total); var withVolume = result.List.Single(c => c.Code == "940001"); Assert.AreEqual(12_345_678L, withVolume.Volume, "최근 거래일의 거래량이 반환되어야 한다"); var noPrice = result.List.Single(c => c.Code == "940002"); Assert.IsNull(noPrice.Volume, "시세 미수집 종목은 null 로 반환된다"); } [TestMethod] public async Task Quotes_Over50Codes_Fails() { // 기대 행위: 50개 초과 요청은 DB 조회 전에 거절한다. using var db = TestDb.Create(); var codes = Enumerable.Range(100000, 51).Select(c => c.ToString()).ToList(); var handler = new QuotesHandler(db); var result = await handler.Handle(new QuotesQuery(codes), default); Assert.IsFalse(result.IsSuccess, "최대 50개 제한을 넘으면 실패해야 한다"); Assert.AreEqual("Stock.TooManyCodes", result.Error.Code); } }