using Domain.Entities.Stocks; using Domain.Entities.Stocks.ValueObject; using Infrastructure.Persistence; 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 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); } }