using Application.Helpers; using Domain.Entities.Stocks; using Domain.Entities.Stocks.ValueObject; using Infrastructure.StockData; using Microsoft.EntityFrameworkCore; namespace Application.Tests; /// /// SeibroBondSyncService 의 DB 반영 로직(SeibroBondImport) + rolling 대상 선정 통합 검증 — /// HTTP 는 파서 입력을 fixture/합성 XML 로 대체(SeibroCorpActionSyncTests 방식 미러링). 실제 SQL Server(LocalDB). /// 발행 upsert / 마스터 rolling(KRX BondDailyTrade ISIN 합집합 대상) / 이자 / 조기상환 window / 단기발행 스탬핑 / CD·CP·ESTB 매핑(SECN_TPCD 분기) / Bond 예산 상한. /// [TestClass] public sealed class SeibroBondSyncTests { [TestMethod] public async Task UpsertBondIssuances_FromSampleXml_ThenIdempotent() { // 기대 행위: getBondIssuInfo(1행) upsert — UQ Isin. 재실행 무변경. var rows = SeibroBondIssuParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getBondIssuInfo.xml"))); using (var db = TestDb.Create()) { var (inserted, _) = await SeibroBondImport.UpsertBondIssuancesAsync(db, rows, CancellationToken.None); Assert.AreEqual(1, inserted); } using (var db = TestDb.Create()) { var (inserted, updated) = await SeibroBondImport.UpsertBondIssuancesAsync(db, rows, CancellationToken.None); Assert.AreEqual(0, inserted, "재실행 idempotent"); Assert.AreEqual(0, updated, "무변경 no-op"); var saved = await db.BondIssuance.AsNoTracking().Where(c => c.Isin == "KR6268761881").SingleAsync(); Assert.AreEqual(new DateOnly(2018, 8, 3), saved.IssuDt); Assert.AreEqual(29998, saved.IssucoCustno); } } [TestMethod] public async Task UpsertBondMaster_FromSampleXml_StampsIsin_AndCreditGrades() { // 기대 행위: getBondStatInfo — 응답에 ISIN 없어 요청 ISIN 스탬핑, PK Isin upsert, 신용등급 4사 저장. var rows = SeibroBondStatParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getBondStatInfo.xml"))); const string isin = "KR6268761881"; using (var db = TestDb.Create()) { var ok = await SeibroBondImport.UpsertBondMasterAsync(db, isin, rows, CancellationToken.None); Assert.IsTrue(ok); } using (var db = TestDb.Create()) { var saved = await db.BondMaster.AsNoTracking().Where(c => c.Isin == isin).SingleAsync(); Assert.AreEqual("SK해운 61", saved.KorSecnNm); Assert.AreEqual(new DateOnly(2020, 2, 3), saved.XpirDt, "만기"); Assert.AreEqual(4.485m, saved.CouponRate, "표면이자율"); Assert.AreEqual("2", saved.GrtyTpcd, "무보증"); Assert.AreEqual("133", saved.KisValatGrdCd); Assert.AreEqual("133", saved.KrValatGrdCd); Assert.IsNull(saved.NiceValatGrdCd, "빈칸 → null"); } } [TestMethod] public async Task BondMasterTarget_UnionsBondIssuanceAndKrxBondDailyTrade() { // 기대 행위(rolling 대상 선정): BondMaster 대상 = BondIssuance.Isin ∪ KRX BondDailyTrade.Code 중복 제거. var issuedOnly = $"KRA{Guid.NewGuid().ToString("N")[..9]}"; var krxOnly = $"KRB{Guid.NewGuid().ToString("N")[..9]}"; var both = $"KRC{Guid.NewGuid().ToString("N")[..9]}"; using (var db = TestDb.Create()) { await db.BondIssuance.AddAsync(BondIssuance.Create(new DateOnly(2018, 8, 3), 1, "발행사", issuedOnly, "발행전용"), CancellationToken.None); await db.BondIssuance.AddAsync(BondIssuance.Create(new DateOnly(2018, 8, 3), 1, "발행사", both, "양쪽"), CancellationToken.None); await db.BondDailyTrade.AddAsync(NewTrade(krxOnly), CancellationToken.None); await db.BondDailyTrade.AddAsync(NewTrade(both), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); } using (var db = TestDb.Create()) { var issued = await db.BondIssuance.AsNoTracking().Select(c => c.Isin).Distinct().ToListAsync(); var krx = await db.BondDailyTrade.AsNoTracking().Select(c => c.Code).Distinct().ToListAsync(); var universe = issued.Union(krx, StringComparer.Ordinal).ToHashSet(); Assert.IsTrue(universe.Contains(issuedOnly), "발행만"); Assert.IsTrue(universe.Contains(krxOnly), "KRX만"); Assert.IsTrue(universe.Contains(both), "양쪽 포함"); Assert.AreEqual(1, universe.Count(c => c == both), "중복 제거로 both 는 1회"); } } [TestMethod] public async Task UpsertInterestPayment_SyntheticXml_StampsIsin_SnapshotUpdate() { // 빈 fixture 대응 — 합성 XML 로 PK Isin 스냅샷 upsert + 재실행 갱신 검증. const string xml = """ """; var rows = SeibroIntPayParser.Parse(SeibroXml.Parse(xml)); const string isin = "KR6268761881"; using (var db = TestDb.Create()) { Assert.IsTrue(await SeibroBondImport.UpsertInterestPaymentAsync(db, isin, rows, CancellationToken.None)); } using (var db = TestDb.Create()) { Assert.IsTrue(await SeibroBondImport.UpsertInterestPaymentAsync(db, isin, rows, CancellationToken.None), "재수집 idempotent upsert"); var saved = await db.BondInterestPayment.AsNoTracking().Where(c => c.Isin == isin).SingleAsync(); Assert.AreEqual("23", saved.IntPayCycleTpcd, "개월 주기"); Assert.AreEqual(new DateOnly(2018, 11, 3), saved.AfterDate); } } [TestMethod] public async Task ReplaceEarlyRedemptionWindow_FromSampleXml_ThenReconstructs() { // 기대 행위: getBondOptionXrcInfo — 조기상환일 window delete+insert (UQ Isin+ErlyRedDt). 재실행 창 재구성. var rows = SeibroBondOptionXrcParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getBondOptionXrcInfo.xml"))); var day = new DateOnly(2018, 8, 24); using (var db = TestDb.Create()) { var (inserted, deleted) = await SeibroBondImport.ReplaceEarlyRedemptionWindowAsync(db, day, day, rows, CancellationToken.None); Assert.AreEqual(1, inserted); Assert.AreEqual(0, deleted); } using (var db = TestDb.Create()) { var (inserted, deleted) = await SeibroBondImport.ReplaceEarlyRedemptionWindowAsync(db, day, day, rows, CancellationToken.None); Assert.AreEqual(1, inserted, "창 재구성"); Assert.AreEqual(1, deleted, "기존 창 삭제"); var saved = await db.BondEarlyRedemption.AsNoTracking().Where(c => c.Isin == "KR6225693573" && c.ErlyRedDt == day).SingleAsync(); Assert.AreEqual("9401", saved.OptionTpcd, "CALL"); Assert.AreEqual(2000000000m, saved.ErlyRedamtVal); } } [TestMethod] public async Task UpsertShortTermIssuances_FromSampleXml_StampsSecnTpcdAndIssuDt() { // 기대 행위: getShortmIssuInfo(다수행) — 응답에 없는 SECN_TPCD·ISSU_DT 를 요청값 스탬핑 upsert (UQ Isin). var rows = SeibroShortmIssuParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getShortmIssuInfo.xml"))); var issuDt = new DateOnly(2018, 8, 1); using (var db = TestDb.Create()) { var (inserted, _) = await SeibroBondImport.UpsertShortTermIssuancesAsync(db, "12", issuDt, rows, CancellationToken.None); Assert.IsTrue(inserted > 1, "다수 종목 insert"); } using (var db = TestDb.Create()) { var (inserted, updated) = await SeibroBondImport.UpsertShortTermIssuancesAsync(db, "12", issuDt, rows, CancellationToken.None); Assert.AreEqual(0, inserted, "재실행 idempotent"); Assert.AreEqual(0, updated); var saved = await db.ShortTermIssuance.AsNoTracking().Where(c => c.Isin == "KRZF00405288").SingleAsync(); Assert.AreEqual("12", saved.SecnTpcd, "구분 스탬핑"); Assert.AreEqual(issuDt, saved.IssuDt, "발행일 스탬핑"); Assert.AreEqual(405, saved.IssucoCustno); } } [TestMethod] public async Task ShortTermMasterTarget_BranchesBySecnTpcd() { // 기대 행위(SECN_TPCD 분기): 13→CD, 12→CP, 14→ESTB 대상 분리. 각 마스터 매핑 검증. var cdIsin = $"KRD{Guid.NewGuid().ToString("N")[..9]}"; var cpIsin = $"KRE{Guid.NewGuid().ToString("N")[..9]}"; var estbIsin = $"KRF{Guid.NewGuid().ToString("N")[..9]}"; var issuDt = new DateOnly(2018, 8, 1); using (var db = TestDb.Create()) { await db.ShortTermIssuance.AddAsync(ShortTermIssuance.Create(cdIsin, "CD종목", 1, "발행", "13", issuDt), CancellationToken.None); await db.ShortTermIssuance.AddAsync(ShortTermIssuance.Create(cpIsin, "CP종목", 2, "발행", "12", issuDt), CancellationToken.None); await db.ShortTermIssuance.AddAsync(ShortTermIssuance.Create(estbIsin, "전단채", 3, "발행", "14", issuDt), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); } using (var db = TestDb.Create()) { var cdTargets = await db.ShortTermIssuance.AsNoTracking().Where(c => c.SecnTpcd == "13" && !db.CdMaster.Any(m => m.Isin == c.Isin)).Select(c => c.Isin).ToListAsync(); var cpTargets = await db.ShortTermIssuance.AsNoTracking().Where(c => c.SecnTpcd == "12" && !db.CpMaster.Any(m => m.Isin == c.Isin)).Select(c => c.Isin).ToListAsync(); var estbTargets = await db.ShortTermIssuance.AsNoTracking().Where(c => c.SecnTpcd == "14" && !db.EstbMaster.Any(m => m.Isin == c.Isin)).Select(c => c.Isin).ToListAsync(); CollectionAssert.Contains(cdTargets, cdIsin); CollectionAssert.DoesNotContain(cdTargets, cpIsin, "CP 는 CD 대상 아님"); CollectionAssert.Contains(cpTargets, cpIsin); CollectionAssert.Contains(estbTargets, estbIsin); // CP 마스터 매핑 (fixture) var cpRows = SeibroCPParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getCPInfo.xml"))); Assert.IsTrue(await SeibroBondImport.UpsertCpMasterAsync(db, cpIsin, cpRows, CancellationToken.None)); var cp = await db.CpMaster.AsNoTracking().Where(c => c.Isin == cpIsin).SingleAsync(); Assert.AreEqual("112", cp.KisValatGrdCd); Assert.AreEqual("교보증권", cp.DiscnOrdNm); // ESTB 마스터 매핑 (fixture) var estbRows = SeibroESTBParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getESTBInfo.xml"))); Assert.IsTrue(await SeibroBondImport.UpsertEstbMasterAsync(db, estbIsin, estbRows, CancellationToken.None)); var estb = await db.EstbMaster.AsNoTracking().Where(c => c.Isin == estbIsin).SingleAsync(); Assert.AreEqual("3", estb.EstbKacd, "PF AB"); Assert.AreEqual("112", estb.NiceValatGrdCd); // CD 마스터 매핑 (합성 — 빈 fixture) const string cdXml = """ """; var cdRows = SeibroCDParser.Parse(SeibroXml.Parse(cdXml)); Assert.IsTrue(await SeibroBondImport.UpsertCdMasterAsync(db, cdIsin, cdRows, CancellationToken.None)); var cd = await db.CdMaster.AsNoTracking().Where(c => c.Isin == cdIsin).SingleAsync(); Assert.AreEqual(1.85m, cd.DiscnRate); Assert.AreEqual("1", cd.IntPayMannTpcd); } } [TestMethod] public void Quota_BondBudget_CapsConsumption() { // 기대 행위: 채권 예산 상한 — 소진 후 추가 소비 거부, 다른 카테고리 무영향. var quota = new SeibroQuota(() => new DateOnly(2026, 7, 7)); Assert.IsTrue(quota.TryConsume(SeibroCategory.Bond, 8000, 8000), "예산 전량 소비"); Assert.IsFalse(quota.TryConsume(SeibroCategory.Bond, 1, 8000), "채권 예산 소진"); Assert.AreEqual(0, quota.Remaining(SeibroCategory.Bond, 8000)); Assert.AreEqual(40000, quota.Remaining(SeibroCategory.Corp, 40000), "기업 예산 무영향"); } private static BondDailyTrade NewTrade(string isin) { return BondDailyTrade.Create(BondMarket.General, isin, "채권매매", new DateOnly(2024, 1, 2), 10000m, 10000m, 10010m, 9990m, 5m, 3.5m, null, null, null, 100, 1000000); } }