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);
}
}