SeibroCorpActionSyncTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. using Application.Helpers;
  2. using Domain.Entities.Stocks;
  3. using Infrastructure.StockData;
  4. using Microsoft.EntityFrameworkCore;
  5. namespace Application.Tests;
  6. /// <summary>
  7. /// SeibroCorpActionSyncService 의 DB 반영 로직(SeibroCorpActionImport) + 이벤트 구동 트리거 체인 통합 검증 —
  8. /// HTTP 는 파서 입력을 fixture 로 대체(SeibroSupplySyncTests 방식 미러링). 실제 SQL Server(LocalDB)로 확인.
  9. /// 총회 upsert / 안건 upsert / 상호변경 upsert / 대금 window / 단주 upsert / CB행사 window / CB대상 발견체인 / 예산 상한.
  10. /// </summary>
  11. [TestClass]
  12. public sealed class SeibroCorpActionSyncTests
  13. {
  14. [TestMethod]
  15. public async Task UpsertGeneralMeetings_FromSampleXml_ThenIdempotent()
  16. {
  17. // 기대 행위: getGmeetInfo(1행) upsert — UQ (custno, RgtStdDt, GmetKacd). 재실행 무변경.
  18. var rows = SeibroGmeetParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getGmeetInfo.xml")));
  19. using (var db = TestDb.Create())
  20. {
  21. var (inserted, _) = await SeibroCorpActionImport.UpsertGeneralMeetingsAsync(db, rows, CancellationToken.None);
  22. Assert.AreEqual(1, inserted);
  23. }
  24. using (var db = TestDb.Create())
  25. {
  26. var (inserted, updated) = await SeibroCorpActionImport.UpsertGeneralMeetingsAsync(db, rows, CancellationToken.None);
  27. Assert.AreEqual(0, inserted, "재실행 idempotent");
  28. Assert.AreEqual(0, updated, "무변경 no-op");
  29. var saved = await db.GeneralMeeting.AsNoTracking().Where(c => c.IssucoCustno == 593 && c.RgtStdDt == new DateOnly(2017, 12, 31) && c.GmetKacd == "001").SingleAsync();
  30. Assert.AreEqual(new DateOnly(2018, 3, 23), saved.GmetDt);
  31. Assert.IsFalse(saved.EvoteYn);
  32. }
  33. }
  34. [TestMethod]
  35. public async Task UpsertMeetingAgendas_FromSampleXml_KeepsAllElevenAgendas()
  36. {
  37. // 기대 행위: getGmeetMeasureInfo(11행) upsert — AGENDA_SEQ 로 구분 11행 모두 저장. 재실행 idempotent.
  38. var rows = SeibroGmeetMeasureParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getGmeetMeasureInfo.xml")));
  39. using (var db = TestDb.Create())
  40. {
  41. var (inserted, _) = await SeibroCorpActionImport.UpsertMeetingAgendasAsync(db, rows, CancellationToken.None);
  42. Assert.AreEqual(11, inserted);
  43. }
  44. using (var db = TestDb.Create())
  45. {
  46. var (inserted, updated) = await SeibroCorpActionImport.UpsertMeetingAgendasAsync(db, rows, CancellationToken.None);
  47. Assert.AreEqual(0, inserted, "재실행 idempotent");
  48. Assert.AreEqual(0, updated);
  49. var agendas = await db.MeetingAgenda.AsNoTracking().Where(c => c.IssucoCustno == 593 && c.RgtStdDt == new DateOnly(2017, 12, 31)).ToListAsync();
  50. Assert.AreEqual(11, agendas.Count);
  51. }
  52. }
  53. [TestMethod]
  54. public async Task UpsertNameChanges_FromSampleXml_ThenIdempotent()
  55. {
  56. // 기대 행위: getFmnmAltInfo(1행) upsert — UQ (custno, RgtStdDt). 재실행 무변경.
  57. var rows = SeibroFmnmAltParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getFmnmAltInfo.xml")));
  58. using (var db = TestDb.Create())
  59. {
  60. var (inserted, _) = await SeibroCorpActionImport.UpsertNameChangesAsync(db, rows, CancellationToken.None);
  61. Assert.AreEqual(1, inserted);
  62. }
  63. using (var db = TestDb.Create())
  64. {
  65. var (inserted, updated) = await SeibroCorpActionImport.UpsertNameChangesAsync(db, rows, CancellationToken.None);
  66. Assert.AreEqual(0, inserted, "재실행 idempotent");
  67. Assert.AreEqual(0, updated);
  68. var saved = await db.CorpNameChange.AsNoTracking().Where(c => c.IssucoCustno == 3572).SingleAsync();
  69. Assert.AreEqual("(주)카카오", saved.FmnmAltKorNm);
  70. Assert.AreEqual(new DateOnly(2015, 10, 20), saved.UniCirclDt);
  71. }
  72. }
  73. [TestMethod]
  74. public async Task ReplacePaymentScheduleWindow_FromSampleXml_StampsDayAndTpcd_ThenReconstructs()
  75. {
  76. // 기대 행위: getCostPaySchedul — 지급일+구분 window delete+insert. 응답에 없는 지급일/구분을 요청값으로 스탬핑. 재실행 창 재구성.
  77. var rows = SeibroCostPaySchedulParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getCostPaySchedul.xml")));
  78. var payDate = new DateOnly(2018, 7, 20);
  79. int firstInserted;
  80. using (var db = TestDb.Create())
  81. {
  82. var (inserted, deleted) = await SeibroCorpActionImport.ReplacePaymentScheduleWindowAsync(db, payDate, "3", rows, CancellationToken.None);
  83. Assert.IsTrue(inserted > 0, "단주대금 다수 insert");
  84. Assert.AreEqual(0, deleted);
  85. firstInserted = inserted;
  86. }
  87. using (var db = TestDb.Create())
  88. {
  89. var (inserted, deleted) = await SeibroCorpActionImport.ReplacePaymentScheduleWindowAsync(db, payDate, "3", rows, CancellationToken.None);
  90. Assert.AreEqual(firstInserted, inserted, "동일 창 재구성");
  91. Assert.AreEqual(firstInserted, deleted, "기존 창 삭제 후 재삽입");
  92. var stamped = await db.PaymentSchedule.AsNoTracking().Where(c => c.Isin == "KR7066980004" && c.Th1PayTermBeginDt == payDate).SingleAsync();
  93. Assert.AreEqual("3", stamped.PayCostTpcd, "단주 구분 스탬핑");
  94. Assert.AreEqual(payDate, stamped.Th1PayTermBeginDt, "지급일 스탬핑");
  95. Assert.AreEqual("국내CB행사", stamped.RgtRacdNm);
  96. }
  97. }
  98. [TestMethod]
  99. public async Task UpsertOddLots_FromSampleXml_StampsCustnoAndPayDate()
  100. {
  101. // 기대 행위: getOddLotInfo(1행) — 응답에 없는 회사번호·지급일을 요청값으로 스탬핑 upsert (UQ custno+PayDate+RgtStdDt).
  102. var rows = SeibroOddLotParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getOddLotInfo.xml")));
  103. const int custno = 10364;
  104. var payDate = new DateOnly(2018, 7, 9);
  105. using (var db = TestDb.Create())
  106. {
  107. var (inserted, _) = await SeibroCorpActionImport.UpsertOddLotsAsync(db, custno, payDate, rows, CancellationToken.None);
  108. Assert.AreEqual(1, inserted);
  109. }
  110. using (var db = TestDb.Create())
  111. {
  112. var (inserted, updated) = await SeibroCorpActionImport.UpsertOddLotsAsync(db, custno, payDate, rows, CancellationToken.None);
  113. Assert.AreEqual(0, inserted, "재실행 idempotent");
  114. Assert.AreEqual(0, updated);
  115. var saved = await db.OddLotPayment.AsNoTracking().Where(c => c.IssucoCustno == custno && c.PayDate == payDate).SingleAsync();
  116. Assert.AreEqual(new DateOnly(2018, 6, 11), saved.RgtStdDt);
  117. Assert.AreEqual("무상증자", saved.RgtRacdNm);
  118. Assert.AreEqual(3175m, saved.EstmStdprc);
  119. }
  120. }
  121. [TestMethod]
  122. public async Task ReplaceExerciseWindow_SyntheticXml_ThenReconstructs_AndSeedsBondTarget()
  123. {
  124. // 빈 fixture 대응 — 합성 XML 로 CB행사 window delete+insert 및 (f) BondTarget 발견체인 검증.
  125. const string exerciseXml = """
  126. <vector result="1">
  127. <data vectorkey="0" type="Document"><result>
  128. <BOND_ISIN value="KR6019591561"/>
  129. <BOND_KOR_SECN_NM value="테스트5CB"/>
  130. <BOND_KIND_NM value="CB"/>
  131. <XRC_STK_ISIN value="KR7019590007"/>
  132. <STK_KOR_SECN_NM value="테스트"/>
  133. <RGT_STD_DT value="20180321"/>
  134. <XRC_QTY value="20000"/>
  135. <XRC_PRICE value="5000"/>
  136. </result></data>
  137. </vector>
  138. """;
  139. var exRows = SeibroXrcStkOptionXrcParser.Parse(SeibroXml.Parse(exerciseXml));
  140. var day = new DateOnly(2018, 3, 21);
  141. using (var db = TestDb.Create())
  142. {
  143. var (inserted, deleted) = await SeibroCorpActionImport.ReplaceExerciseWindowAsync(db, day, day, exRows, CancellationToken.None);
  144. Assert.AreEqual(1, inserted);
  145. Assert.AreEqual(0, deleted);
  146. }
  147. using (var db = TestDb.Create())
  148. {
  149. var (inserted, deleted) = await SeibroCorpActionImport.ReplaceExerciseWindowAsync(db, day, day, exRows, CancellationToken.None);
  150. Assert.AreEqual(1, inserted, "창 재구성");
  151. Assert.AreEqual(1, deleted, "기존 창 삭제");
  152. }
  153. // (f) 발견체인: ConvertibleExercise 의 DISTINCT BondIsin 이 BondTarget 수집 대상 시드가 된다
  154. using (var db = TestDb.Create())
  155. {
  156. var bondIsins = await db.ConvertibleExercise.AsNoTracking().Select(c => c.BondIsin).Distinct().ToListAsync();
  157. CollectionAssert.Contains(bondIsins, "KR6019591561", "행사내역이 CB대상 발견 소스");
  158. const string statXml = """
  159. <vector result="1">
  160. <data vectorkey="0" type="Document"><result>
  161. <BOND_ISIN value="KR6019591561"/>
  162. <BOND_KIND_NM value="CB"/>
  163. <XRC_STK_ISIN value="KR7019590007"/>
  164. <XRC_PRICE value="5000"/>
  165. </result></data>
  166. </vector>
  167. """;
  168. var statRows = SeibroXrcStkStatParser.Parse(SeibroXml.Parse(statXml));
  169. var (inserted, _) = await SeibroCorpActionImport.UpsertBondTargetsAsync(db, statRows, CancellationToken.None);
  170. Assert.AreEqual(1, inserted);
  171. var target = await db.ConvertibleBondTarget.AsNoTracking().Where(c => c.BondIsin == "KR6019591561").SingleAsync();
  172. Assert.AreEqual("KR7019590007", target.XrcStkIsin);
  173. Assert.AreEqual(5000m, target.XrcPrice);
  174. }
  175. }
  176. [TestMethod]
  177. public async Task MeetingTrigger_FromRightsBaseDate_SelectsMeetingRacdsOnly()
  178. {
  179. // 기대 행위(이벤트 구동): RightsBaseDate RGT_RACD∈{001,002,003,009} 만 총회 트리거, 204(상호)·기타는 제외.
  180. string[] meetingRacds = ["001", "002", "003", "009"];
  181. var custno = 800000 + Random.Shared.Next(1, 99999);
  182. var stdDt = new DateOnly(2019, 3, 31);
  183. using (var db = TestDb.Create())
  184. {
  185. await db.RightsBaseDate.AddAsync(RightsBaseDate.Create(custno, "테스트정기", stdDt, "001", "정기총회", "99"), CancellationToken.None);
  186. await db.RightsBaseDate.AddAsync(RightsBaseDate.Create(custno, "테스트상호", stdDt, "204", "상호변경", "99"), CancellationToken.None);
  187. await db.RightsBaseDate.AddAsync(RightsBaseDate.Create(custno, "테스트배당", stdDt, "103", "배당분배", "02"), CancellationToken.None);
  188. await db.SaveChangesAsync(CancellationToken.None);
  189. }
  190. using (var db = TestDb.Create())
  191. {
  192. var meetingTriggers = await db.RightsBaseDate.AsNoTracking()
  193. .Where(c => c.IssucoCustno == custno && meetingRacds.Contains(c.RgtRacd))
  194. .Select(c => new { c.IssucoCustno, c.RgtStdDt })
  195. .Distinct()
  196. .ToListAsync();
  197. Assert.AreEqual(1, meetingTriggers.Count, "총회계열(001) 1건만");
  198. var nameTriggers = await db.RightsBaseDate.AsNoTracking()
  199. .Where(c => c.IssucoCustno == custno && c.RgtRacd == "204")
  200. .Select(c => new { c.IssucoCustno, c.RgtStdDt })
  201. .Distinct()
  202. .ToListAsync();
  203. Assert.AreEqual(1, nameTriggers.Count, "상호변경(204) 1건");
  204. }
  205. }
  206. [TestMethod]
  207. public async Task OddLotTrigger_FromPaymentSchedule_SelectsTpcd3Only()
  208. {
  209. // 기대 행위(이벤트 구동): PaymentSchedule PAY_COST_TPCD=3(단주)만 단주대금 트리거, 1(배당금)은 제외.
  210. var custno = 900000 + Random.Shared.Next(1, 99999);
  211. var payDate = new DateOnly(2018, 7, 20);
  212. var isinA = $"KR7{Guid.NewGuid().ToString("N")[..9]}";
  213. var isinB = $"KR7{Guid.NewGuid().ToString("N")[..9]}";
  214. using (var db = TestDb.Create())
  215. {
  216. await db.PaymentSchedule.AddAsync(PaymentSchedule.Create(custno, isinA, "배당건", null, null, null, null, "1", payDate), CancellationToken.None);
  217. await db.PaymentSchedule.AddAsync(PaymentSchedule.Create(custno, isinB, "단주건", null, null, null, null, "3", payDate), CancellationToken.None);
  218. await db.SaveChangesAsync(CancellationToken.None);
  219. }
  220. using (var db = TestDb.Create())
  221. {
  222. var triggers = await db.PaymentSchedule.AsNoTracking()
  223. .Where(c => c.IssucoCustno == custno && c.PayCostTpcd == "3")
  224. .Select(c => new { c.IssucoCustno, c.Th1PayTermBeginDt })
  225. .Distinct()
  226. .ToListAsync();
  227. Assert.AreEqual(1, triggers.Count, "단주(구분3) 1건만 트리거");
  228. }
  229. }
  230. [TestMethod]
  231. public void Quota_CorpAndStockBudgets_AreSeparateBuckets()
  232. {
  233. // 기대 행위: 기업·주식 예산이 독립 버킷 — 한쪽 소진이 다른 쪽에 영향 없음.
  234. var quota = new SeibroQuota(() => new DateOnly(2026, 7, 7));
  235. for (var i = 0; i < 5; i++)
  236. {
  237. Assert.IsTrue(quota.TryConsume(SeibroCategory.Corp, 1, 5));
  238. }
  239. Assert.IsFalse(quota.TryConsume(SeibroCategory.Corp, 1, 5), "기업 예산(5) 소진");
  240. Assert.AreEqual(0, quota.Remaining(SeibroCategory.Corp, 5));
  241. Assert.AreEqual(8, quota.Remaining(SeibroCategory.Stock, 8), "주식 예산은 온전");
  242. Assert.IsTrue(quota.TryConsume(SeibroCategory.Stock, 8, 8), "주식은 별도로 소비 가능");
  243. }
  244. }