using Domain.Entities.Stocks; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; using FuturesHandler = Application.Features.Api.Stocks.GetFutures.Handler; using FuturesQuery = Application.Features.Api.Stocks.GetFutures.Query; namespace Application.Tests; /// /// 파생상품 정규·야간 세션 분리 저장 검증 — UQ 가 (Kind, IsuCode, TradeDate, Session) 으로 확장되어 /// 동일 종목코드의 정규·야간 행이 각각 저장되고(야간이 정규를 덮어쓰지 않음), 같은 세션 중복은 UQ 로 차단된다. /// [TestClass] public sealed class DerivativeSessionUpsertTests { [TestMethod] public async Task Futures_SameCode_RegularAndNight_BothPersisted() { // 기대 행위: 동일 (FuturesKind, IsuCode, TradeDate) 의 정규·야간 행이 각각 저장된다 (기존엔 야간이 정규를 덮어씀). using var db = TestDb.Create(); var day = new DateOnly(2026, 3, 4); var regular = FuturesDailyTrade.Create(FuturesKind.General, "ZQF69000", "10년국채 F 202609 (주간)", day, MarketSession.Regular, 106.29m, 106.20m, 106.35m, 106.15m, 0.07m, 106.30m, 106.29m, 82224L, 8741234560000L, 215549L, "10년국채 선물", "정규"); var night = FuturesDailyTrade.Create(FuturesKind.General, "ZQF69000", "10년국채 F 202609 (야간)", day, MarketSession.Night, 106.22m, 106.28m, 106.28m, 106.20m, -0.07m, null, 106.22m, 30L, 3186600000L, null, "10년국채 선물", "야간"); await db.FuturesDailyTrade.AddAsync(regular); await db.FuturesDailyTrade.AddAsync(night); await db.SaveChangesAsync(default); var saved = await db.FuturesDailyTrade.AsNoTracking().Where(c => c.IsuCode == "ZQF69000" && c.TradeDate == day).OrderBy(c => c.Session).ToListAsync(); Assert.AreEqual(2, saved.Count, "정규·야간이 별도 행으로 저장된다"); Assert.AreEqual(MarketSession.Regular, saved[0].Session); Assert.AreEqual(106.29m, saved[0].Close, "정규 종가가 야간 값으로 오염되지 않는다"); Assert.AreEqual(82224L, saved[0].Volume, "정규 거래량이 야간 값으로 오염되지 않는다"); Assert.AreEqual(MarketSession.Night, saved[1].Session); Assert.AreEqual(106.22m, saved[1].Close); Assert.AreEqual(30L, saved[1].Volume); } [TestMethod] public async Task Futures_SameSessionDuplicate_RejectedByUniqueIndex() { // 기대 행위: 같은 (Kind, IsuCode, TradeDate, Session) 중복 insert 는 UQ 위반으로 차단된다. var day = new DateOnly(2026, 3, 5); using (var db = TestDb.Create()) { await db.FuturesDailyTrade.AddAsync(FuturesDailyTrade.Create(FuturesKind.General, "ZQF69001", "테스트 F 202609", day, MarketSession.Regular, 1m, 1m, 1m, 1m, 0m, null, null, 0L, 0L, null)); await db.SaveChangesAsync(default); } using (var db = TestDb.Create()) { await db.FuturesDailyTrade.AddAsync(FuturesDailyTrade.Create(FuturesKind.General, "ZQF69001", "테스트 F 202609", day, MarketSession.Regular, 2m, 2m, 2m, 2m, 0m, null, null, 0L, 0L, null)); await Assert.ThrowsExactlyAsync(async () => await db.SaveChangesAsync(default), "동일 세션 중복은 UQ 로 차단"); } } [TestMethod] public async Task Options_SameCode_RegularAndNight_BothPersisted() { // 기대 행위: 옵션도 동일 (OptionsKind, IsuCode, TradeDate) 의 정규·야간 행이 각각 저장된다 (옵션 응답엔 MKT_NM 없이 종목명 표기). using var db = TestDb.Create(); var day = new DateOnly(2026, 3, 4); var regular = OptionsDailyTrade.Create(OptionsKind.General, "ZQO51232", "미니코스피 C 202607 232.5 (정규)", day, MarketSession.Regular, OptionRightType.Call, 232.5m, 1.50m, 1.40m, 1.55m, 1.38m, 0.10m, 12.00m, 1.50m, 1000L, 150000000L, 500L, "미니코스피200 옵션"); var night = OptionsDailyTrade.Create(OptionsKind.General, "ZQO51232", "미니코스피 C 202607 232.5 (야간)", day, MarketSession.Night, OptionRightType.Call, 232.5m, 1.45m, 1.50m, 1.50m, 1.44m, -0.05m, 12.10m, 1.45m, 10L, 1500000L, 500L, "미니코스피200 옵션"); await db.OptionsDailyTrade.AddAsync(regular); await db.OptionsDailyTrade.AddAsync(night); await db.SaveChangesAsync(default); var saved = await db.OptionsDailyTrade.AsNoTracking().Where(c => c.IsuCode == "ZQO51232" && c.TradeDate == day).OrderBy(c => c.Session).ToListAsync(); Assert.AreEqual(2, saved.Count, "정규·야간이 별도 행으로 저장된다"); Assert.AreEqual(1.50m, saved[0].Close, "정규 종가 보존"); Assert.AreEqual(1.45m, saved[1].Close, "야간 종가 보존"); } [TestMethod] public async Task GetFutures_DefaultRegularOnly_NightBySessionParameter() { // 기대 행위: 조회 API 는 기본 정규 세션만 반환하고, session 파라미터로 야간을 조회할 수 있다. using var db = TestDb.Create(); var day = new DateOnly(2026, 3, 6); await db.FuturesDailyTrade.AddAsync(FuturesDailyTrade.Create(FuturesKind.General, "ZQF69002", "테스트2 F 202609 (주간)", day, MarketSession.Regular, 100m, 100m, 100m, 100m, 0m, null, null, 500L, 50000L, null)); await db.FuturesDailyTrade.AddAsync(FuturesDailyTrade.Create(FuturesKind.General, "ZQF69002", "테스트2 F 202609 (야간)", day, MarketSession.Night, 99m, 99m, 99m, 99m, -1m, null, null, 5L, 495L, null)); await db.SaveChangesAsync(default); var handler = new FuturesHandler(db); var regularResult = await handler.Handle(new FuturesQuery(FuturesKind.General, day, 1, 100), default); Assert.IsTrue(regularResult.List.All(c => c.Session == MarketSession.Regular), "기본 조회는 정규 세션만 반환"); Assert.IsTrue(regularResult.List.Any(c => c.IsuCode == "ZQF69002" && c.Close == 100m), "정규 행 반환"); var nightResult = await handler.Handle(new FuturesQuery(FuturesKind.General, day, 1, 100, MarketSession.Night), default); Assert.IsTrue(nightResult.List.All(c => c.Session == MarketSession.Night), "session=2 조회는 야간 세션만 반환"); Assert.IsTrue(nightResult.List.Any(c => c.IsuCode == "ZQF69002" && c.Close == 99m), "야간 행 반환"); } }