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), "주식은 별도로 소비 가능");
}
}