using Application.Helpers; using Domain.Entities.Stocks; using Infrastructure.StockData; using Microsoft.EntityFrameworkCore; namespace Application.Tests; /// /// SeibroCorpActionSyncService 의 DB 반영 로직(SeibroCorpActionImport) + 이벤트 구동 트리거 체인 통합 검증 — /// HTTP 는 파서 입력을 fixture 로 대체(SeibroSupplySyncTests 방식 미러링). 실제 SQL Server(LocalDB)로 확인. /// 총회 upsert / 안건 upsert / 상호변경 upsert / 대금 window / 단주 upsert / CB행사 window / CB대상 발견체인 / 예산 상한. /// [TestClass] public sealed class SeibroCorpActionSyncTests { [TestMethod] public async Task UpsertGeneralMeetings_FromSampleXml_ThenIdempotent() { // 기대 행위: getGmeetInfo(1행) upsert — UQ (custno, RgtStdDt, GmetKacd). 재실행 무변경. var rows = SeibroGmeetParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getGmeetInfo.xml"))); using (var db = TestDb.Create()) { var (inserted, _) = await SeibroCorpActionImport.UpsertGeneralMeetingsAsync(db, rows, CancellationToken.None); Assert.AreEqual(1, inserted); } using (var db = TestDb.Create()) { var (inserted, updated) = await SeibroCorpActionImport.UpsertGeneralMeetingsAsync(db, rows, CancellationToken.None); Assert.AreEqual(0, inserted, "재실행 idempotent"); Assert.AreEqual(0, updated, "무변경 no-op"); var saved = await db.GeneralMeeting.AsNoTracking().Where(c => c.IssucoCustno == 593 && c.RgtStdDt == new DateOnly(2017, 12, 31) && c.GmetKacd == "001").SingleAsync(); Assert.AreEqual(new DateOnly(2018, 3, 23), saved.GmetDt); Assert.IsFalse(saved.EvoteYn); } } [TestMethod] public async Task UpsertMeetingAgendas_FromSampleXml_KeepsAllElevenAgendas() { // 기대 행위: getGmeetMeasureInfo(11행) upsert — AGENDA_SEQ 로 구분 11행 모두 저장. 재실행 idempotent. var rows = SeibroGmeetMeasureParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getGmeetMeasureInfo.xml"))); using (var db = TestDb.Create()) { var (inserted, _) = await SeibroCorpActionImport.UpsertMeetingAgendasAsync(db, rows, CancellationToken.None); Assert.AreEqual(11, inserted); } using (var db = TestDb.Create()) { var (inserted, updated) = await SeibroCorpActionImport.UpsertMeetingAgendasAsync(db, rows, CancellationToken.None); Assert.AreEqual(0, inserted, "재실행 idempotent"); Assert.AreEqual(0, updated); var agendas = await db.MeetingAgenda.AsNoTracking().Where(c => c.IssucoCustno == 593 && c.RgtStdDt == new DateOnly(2017, 12, 31)).ToListAsync(); Assert.AreEqual(11, agendas.Count); } } [TestMethod] public async Task UpsertNameChanges_FromSampleXml_ThenIdempotent() { // 기대 행위: getFmnmAltInfo(1행) upsert — UQ (custno, RgtStdDt). 재실행 무변경. var rows = SeibroFmnmAltParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getFmnmAltInfo.xml"))); using (var db = TestDb.Create()) { var (inserted, _) = await SeibroCorpActionImport.UpsertNameChangesAsync(db, rows, CancellationToken.None); Assert.AreEqual(1, inserted); } using (var db = TestDb.Create()) { var (inserted, updated) = await SeibroCorpActionImport.UpsertNameChangesAsync(db, rows, CancellationToken.None); Assert.AreEqual(0, inserted, "재실행 idempotent"); Assert.AreEqual(0, updated); var saved = await db.CorpNameChange.AsNoTracking().Where(c => c.IssucoCustno == 3572).SingleAsync(); Assert.AreEqual("(주)카카오", saved.FmnmAltKorNm); Assert.AreEqual(new DateOnly(2015, 10, 20), saved.UniCirclDt); } } [TestMethod] public async Task ReplacePaymentScheduleWindow_FromSampleXml_StampsDayAndTpcd_ThenReconstructs() { // 기대 행위: getCostPaySchedul — 지급일+구분 window delete+insert. 응답에 없는 지급일/구분을 요청값으로 스탬핑. 재실행 창 재구성. var rows = SeibroCostPaySchedulParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getCostPaySchedul.xml"))); var payDate = new DateOnly(2018, 7, 20); int firstInserted; using (var db = TestDb.Create()) { var (inserted, deleted) = await SeibroCorpActionImport.ReplacePaymentScheduleWindowAsync(db, payDate, "3", rows, CancellationToken.None); Assert.IsTrue(inserted > 0, "단주대금 다수 insert"); Assert.AreEqual(0, deleted); firstInserted = inserted; } using (var db = TestDb.Create()) { var (inserted, deleted) = await SeibroCorpActionImport.ReplacePaymentScheduleWindowAsync(db, payDate, "3", rows, CancellationToken.None); Assert.AreEqual(firstInserted, inserted, "동일 창 재구성"); Assert.AreEqual(firstInserted, deleted, "기존 창 삭제 후 재삽입"); var stamped = await db.PaymentSchedule.AsNoTracking().Where(c => c.Isin == "KR7066980004" && c.Th1PayTermBeginDt == payDate).SingleAsync(); Assert.AreEqual("3", stamped.PayCostTpcd, "단주 구분 스탬핑"); Assert.AreEqual(payDate, stamped.Th1PayTermBeginDt, "지급일 스탬핑"); Assert.AreEqual("국내CB행사", stamped.RgtRacdNm); } } [TestMethod] public async Task UpsertOddLots_FromSampleXml_StampsCustnoAndPayDate() { // 기대 행위: getOddLotInfo(1행) — 응답에 없는 회사번호·지급일을 요청값으로 스탬핑 upsert (UQ custno+PayDate+RgtStdDt). var rows = SeibroOddLotParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getOddLotInfo.xml"))); const int custno = 10364; var payDate = new DateOnly(2018, 7, 9); using (var db = TestDb.Create()) { var (inserted, _) = await SeibroCorpActionImport.UpsertOddLotsAsync(db, custno, payDate, rows, CancellationToken.None); Assert.AreEqual(1, inserted); } using (var db = TestDb.Create()) { var (inserted, updated) = await SeibroCorpActionImport.UpsertOddLotsAsync(db, custno, payDate, rows, CancellationToken.None); Assert.AreEqual(0, inserted, "재실행 idempotent"); Assert.AreEqual(0, updated); var saved = await db.OddLotPayment.AsNoTracking().Where(c => c.IssucoCustno == custno && c.PayDate == payDate).SingleAsync(); Assert.AreEqual(new DateOnly(2018, 6, 11), saved.RgtStdDt); Assert.AreEqual("무상증자", saved.RgtRacdNm); Assert.AreEqual(3175m, saved.EstmStdprc); } } [TestMethod] public async Task ReplaceExerciseWindow_SyntheticXml_ThenReconstructs_AndSeedsBondTarget() { // 빈 fixture 대응 — 합성 XML 로 CB행사 window delete+insert 및 (f) BondTarget 발견체인 검증. const string exerciseXml = """ """; var exRows = SeibroXrcStkOptionXrcParser.Parse(SeibroXml.Parse(exerciseXml)); var day = new DateOnly(2018, 3, 21); using (var db = TestDb.Create()) { var (inserted, deleted) = await SeibroCorpActionImport.ReplaceExerciseWindowAsync(db, day, day, exRows, CancellationToken.None); Assert.AreEqual(1, inserted); Assert.AreEqual(0, deleted); } using (var db = TestDb.Create()) { var (inserted, deleted) = await SeibroCorpActionImport.ReplaceExerciseWindowAsync(db, day, day, exRows, CancellationToken.None); Assert.AreEqual(1, inserted, "창 재구성"); Assert.AreEqual(1, deleted, "기존 창 삭제"); } // (f) 발견체인: ConvertibleExercise 의 DISTINCT BondIsin 이 BondTarget 수집 대상 시드가 된다 using (var db = TestDb.Create()) { var bondIsins = await db.ConvertibleExercise.AsNoTracking().Select(c => c.BondIsin).Distinct().ToListAsync(); CollectionAssert.Contains(bondIsins, "KR6019591561", "행사내역이 CB대상 발견 소스"); const string statXml = """ """; var statRows = SeibroXrcStkStatParser.Parse(SeibroXml.Parse(statXml)); var (inserted, _) = await SeibroCorpActionImport.UpsertBondTargetsAsync(db, statRows, CancellationToken.None); Assert.AreEqual(1, inserted); var target = await db.ConvertibleBondTarget.AsNoTracking().Where(c => c.BondIsin == "KR6019591561").SingleAsync(); Assert.AreEqual("KR7019590007", target.XrcStkIsin); Assert.AreEqual(5000m, target.XrcPrice); } } [TestMethod] public async Task MeetingTrigger_FromRightsBaseDate_SelectsMeetingRacdsOnly() { // 기대 행위(이벤트 구동): RightsBaseDate RGT_RACD∈{001,002,003,009} 만 총회 트리거, 204(상호)·기타는 제외. string[] meetingRacds = ["001", "002", "003", "009"]; var custno = 800000 + Random.Shared.Next(1, 99999); var stdDt = new DateOnly(2019, 3, 31); using (var db = TestDb.Create()) { await db.RightsBaseDate.AddAsync(RightsBaseDate.Create(custno, "테스트정기", stdDt, "001", "정기총회", "99"), CancellationToken.None); await db.RightsBaseDate.AddAsync(RightsBaseDate.Create(custno, "테스트상호", stdDt, "204", "상호변경", "99"), CancellationToken.None); await db.RightsBaseDate.AddAsync(RightsBaseDate.Create(custno, "테스트배당", stdDt, "103", "배당분배", "02"), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); } using (var db = TestDb.Create()) { var meetingTriggers = await db.RightsBaseDate.AsNoTracking() .Where(c => c.IssucoCustno == custno && meetingRacds.Contains(c.RgtRacd)) .Select(c => new { c.IssucoCustno, c.RgtStdDt }) .Distinct() .ToListAsync(); Assert.AreEqual(1, meetingTriggers.Count, "총회계열(001) 1건만"); var nameTriggers = await db.RightsBaseDate.AsNoTracking() .Where(c => c.IssucoCustno == custno && c.RgtRacd == "204") .Select(c => new { c.IssucoCustno, c.RgtStdDt }) .Distinct() .ToListAsync(); Assert.AreEqual(1, nameTriggers.Count, "상호변경(204) 1건"); } } [TestMethod] public async Task OddLotTrigger_FromPaymentSchedule_SelectsTpcd3Only() { // 기대 행위(이벤트 구동): PaymentSchedule PAY_COST_TPCD=3(단주)만 단주대금 트리거, 1(배당금)은 제외. var custno = 900000 + Random.Shared.Next(1, 99999); var payDate = new DateOnly(2018, 7, 20); var isinA = $"KR7{Guid.NewGuid().ToString("N")[..9]}"; var isinB = $"KR7{Guid.NewGuid().ToString("N")[..9]}"; using (var db = TestDb.Create()) { await db.PaymentSchedule.AddAsync(PaymentSchedule.Create(custno, isinA, "배당건", null, null, null, null, "1", payDate), CancellationToken.None); await db.PaymentSchedule.AddAsync(PaymentSchedule.Create(custno, isinB, "단주건", null, null, null, null, "3", payDate), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); } using (var db = TestDb.Create()) { var triggers = await db.PaymentSchedule.AsNoTracking() .Where(c => c.IssucoCustno == custno && c.PayCostTpcd == "3") .Select(c => new { c.IssucoCustno, c.Th1PayTermBeginDt }) .Distinct() .ToListAsync(); Assert.AreEqual(1, triggers.Count, "단주(구분3) 1건만 트리거"); } } [TestMethod] public void Quota_CorpAndStockBudgets_AreSeparateBuckets() { // 기대 행위: 기업·주식 예산이 독립 버킷 — 한쪽 소진이 다른 쪽에 영향 없음. var quota = new SeibroQuota(() => new DateOnly(2026, 7, 7)); for (var i = 0; i < 5; i++) { Assert.IsTrue(quota.TryConsume(SeibroCategory.Corp, 1, 5)); } Assert.IsFalse(quota.TryConsume(SeibroCategory.Corp, 1, 5), "기업 예산(5) 소진"); Assert.AreEqual(0, quota.Remaining(SeibroCategory.Corp, 5)); Assert.AreEqual(8, quota.Remaining(SeibroCategory.Stock, 8), "주식 예산은 온전"); Assert.IsTrue(quota.TryConsume(SeibroCategory.Stock, 8, 8), "주식은 별도로 소비 가능"); } }