560 lines
22 KiB
C#
560 lines
22 KiB
C#
#nullable enable
|
|
|
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
|
using MMoneyCoderSharp.Data;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
|
|
|
internal static class ProductionDevelopmentDatabaseSmokePlan
|
|
{
|
|
internal static IReadOnlyList<DevelopmentDatabaseSmokeScenario> Create(
|
|
string? configurationPath)
|
|
{
|
|
var options = new DatabaseOptionsLoader().Load(configurationPath);
|
|
|
|
// This executable never inherits the production read retry count. A
|
|
// timeout is surfaced once so the harness cannot conceal an uncertain
|
|
// state transition behind another attempt.
|
|
options.Resilience.MaximumRetryCount = 0;
|
|
options.Resilience.InitialRetryDelayMilliseconds = 0;
|
|
var runtime = DatabaseRuntime.Create(options);
|
|
|
|
var manualMutation = new OracleManualFinancialMutationExecutor(
|
|
runtime.ConnectionFactory,
|
|
options.Resilience);
|
|
var namedMutation = new OracleNamedPlaylistMutationExecutor(
|
|
runtime.ConnectionFactory,
|
|
options.Resilience);
|
|
var catalogMutation = new OperatorCatalogMutationExecutor(
|
|
runtime.ConnectionFactory,
|
|
options.Resilience);
|
|
|
|
var manual = new LegacyManualFinancialScreenService(
|
|
runtime.Executor,
|
|
manualMutation);
|
|
var named = new LegacyNamedPlaylistPersistenceService(
|
|
runtime.Executor,
|
|
namedMutation);
|
|
var themePersistence = new LegacyThemeCatalogPersistenceService(
|
|
runtime.Executor,
|
|
catalogMutation);
|
|
var themeSelection = new LegacyThemeSelectionService(runtime.Executor);
|
|
var expertPersistence = new LegacyExpertCatalogPersistenceService(
|
|
runtime.Executor,
|
|
catalogMutation);
|
|
var expertSelection = new LegacyExpertSelectionService(runtime.Executor);
|
|
var childGuard = new ExistingChildRowGuard(runtime.Executor);
|
|
|
|
var run = SmokeRunIdentity.Create(DateTimeOffset.UtcNow, Guid.NewGuid());
|
|
return
|
|
[
|
|
CreateManualScenario(
|
|
manual,
|
|
CreateManualRecord(ManualFinancialScreenKind.RevenueComposition, run.For("G_PIE"))),
|
|
CreateManualScenario(
|
|
manual,
|
|
CreateManualRecord(ManualFinancialScreenKind.GrowthMetrics, run.For("G_GROW"))),
|
|
CreateManualScenario(
|
|
manual,
|
|
CreateManualRecord(ManualFinancialScreenKind.Sales, run.For("G_SELL"))),
|
|
CreateManualScenario(
|
|
manual,
|
|
CreateManualRecord(ManualFinancialScreenKind.OperatingProfit, run.For("G_PROFIT"))),
|
|
CreateNamedPlaylistScenario(named, childGuard, run.For("PLAYLIST")),
|
|
CreateThemeScenario(
|
|
themePersistence,
|
|
themeSelection,
|
|
childGuard,
|
|
ThemeMarket.Krx,
|
|
run.For("THEME_KRX")),
|
|
CreateThemeScenario(
|
|
themePersistence,
|
|
themeSelection,
|
|
childGuard,
|
|
ThemeMarket.Nxt,
|
|
run.For("THEME_NXT")),
|
|
CreateExpertScenario(
|
|
expertPersistence,
|
|
expertSelection,
|
|
childGuard,
|
|
run.For("EXPERT"))
|
|
];
|
|
}
|
|
|
|
private static DevelopmentDatabaseSmokeScenario CreateManualScenario(
|
|
IManualFinancialScreenService service,
|
|
ManualFinancialRecord expectedRecord)
|
|
{
|
|
var identity = expectedRecord.Identity;
|
|
ManualFinancialSnapshot? freshSnapshot = null;
|
|
return new DevelopmentDatabaseSmokeScenario(
|
|
"GraphE/" + identity.Screen,
|
|
async cancellationToken =>
|
|
{
|
|
var search = await service.SearchAsync(
|
|
identity.Screen,
|
|
identity.StockName,
|
|
10,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (search.IsTruncated || search.Items.Any(item =>
|
|
string.Equals(
|
|
item.Record.Identity.StockName,
|
|
identity.StockName,
|
|
StringComparison.Ordinal)))
|
|
{
|
|
throw Collision("GraphE preflight did not prove absence.");
|
|
}
|
|
},
|
|
cancellationToken => IgnoreResultAsync(
|
|
service.CreateAsync(expectedRecord, cancellationToken)),
|
|
async cancellationToken =>
|
|
{
|
|
freshSnapshot = await service.GetAsync(identity, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
VerifyManualRecord(expectedRecord, freshSnapshot);
|
|
},
|
|
save: null,
|
|
verifySave: null,
|
|
async cancellationToken =>
|
|
{
|
|
freshSnapshot ??= await service.GetAsync(identity, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await service.DeleteAsync(freshSnapshot, cancellationToken).ConfigureAwait(false);
|
|
},
|
|
cancellationToken => EnsureManualAbsentAsync(service, identity, cancellationToken),
|
|
IsOutcomeUnknown);
|
|
}
|
|
|
|
private static DevelopmentDatabaseSmokeScenario CreateNamedPlaylistScenario(
|
|
INamedPlaylistPersistenceService service,
|
|
ExistingChildRowGuard childGuard,
|
|
string title)
|
|
{
|
|
string? programCode = null;
|
|
var savedItem = new NamedPlaylistStoredItem(
|
|
0,
|
|
true,
|
|
new LegacySceneSelection(
|
|
"KOSPI",
|
|
"SMOKE",
|
|
"PLATE",
|
|
"CURRENT",
|
|
string.Empty),
|
|
new NamedPlaylistPageState(1, 1));
|
|
|
|
return new DevelopmentDatabaseSmokeScenario(
|
|
"AList/PList named playlist",
|
|
async cancellationToken =>
|
|
{
|
|
programCode = await service.SuggestNextProgramCodeAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
var list = await service.ListAsync(
|
|
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (list.IsTruncated || list.Items.Any(item =>
|
|
string.Equals(item.Title, title, StringComparison.Ordinal)))
|
|
{
|
|
throw Collision("Named-playlist title absence was not proven.");
|
|
}
|
|
|
|
await EnsureNamedPlaylistAbsentAsync(
|
|
service,
|
|
programCode,
|
|
cancellationToken).ConfigureAwait(false);
|
|
await childGuard.EnsureNamedPlaylistChildrenAbsentAsync(
|
|
programCode,
|
|
cancellationToken).ConfigureAwait(false);
|
|
},
|
|
cancellationToken => service.CreateDefinitionAsync(
|
|
Required(programCode),
|
|
title,
|
|
cancellationToken),
|
|
async cancellationToken =>
|
|
{
|
|
var document = await service.LoadAsync(
|
|
Required(programCode),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (!string.Equals(
|
|
document.Definition.Title,
|
|
title,
|
|
StringComparison.Ordinal) ||
|
|
document.Items.Count != 0)
|
|
{
|
|
throw Verification("Named-playlist create readback did not match.");
|
|
}
|
|
},
|
|
cancellationToken => service.ReplaceItemsAsync(
|
|
Required(programCode),
|
|
[savedItem],
|
|
cancellationToken),
|
|
async cancellationToken =>
|
|
{
|
|
var document = await service.LoadAsync(
|
|
Required(programCode),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (!string.Equals(
|
|
document.Definition.Title,
|
|
title,
|
|
StringComparison.Ordinal) ||
|
|
document.Items.Count != 1 ||
|
|
document.Items[0] != savedItem)
|
|
{
|
|
throw Verification("Named-playlist save readback did not match.");
|
|
}
|
|
},
|
|
cancellationToken => service.DeleteAsync(
|
|
Required(programCode),
|
|
cancellationToken),
|
|
async cancellationToken =>
|
|
{
|
|
await EnsureNamedPlaylistAbsentAsync(
|
|
service,
|
|
Required(programCode),
|
|
cancellationToken).ConfigureAwait(false);
|
|
var list = await service.ListAsync(
|
|
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (list.IsTruncated || list.Items.Any(item =>
|
|
string.Equals(item.Title, title, StringComparison.Ordinal)))
|
|
{
|
|
throw Verification("Named-playlist cleanup absence was not proven.");
|
|
}
|
|
},
|
|
IsOutcomeUnknown);
|
|
}
|
|
|
|
private static DevelopmentDatabaseSmokeScenario CreateThemeScenario(
|
|
IThemeCatalogPersistenceService persistence,
|
|
IThemeSelectionService selection,
|
|
ExistingChildRowGuard childGuard,
|
|
ThemeMarket market,
|
|
string title)
|
|
{
|
|
string? themeCode = null;
|
|
ThemeSelectionIdentity? freshIdentity = null;
|
|
var session = market == ThemeMarket.Krx
|
|
? ThemeSession.NotApplicable
|
|
: ThemeSession.PreMarket;
|
|
|
|
return new DevelopmentDatabaseSmokeScenario(
|
|
"ThemeA/" + market,
|
|
async cancellationToken =>
|
|
{
|
|
await EnsureThemeAbsentAsync(selection, title, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
themeCode = await persistence.SuggestNextCodeAsync(market, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await childGuard.EnsureThemeChildrenAbsentAsync(
|
|
market,
|
|
themeCode,
|
|
cancellationToken).ConfigureAwait(false);
|
|
},
|
|
cancellationToken => IgnoreResultAsync(
|
|
persistence.CreateAsync(
|
|
new ThemeCatalogDefinition(
|
|
market,
|
|
Required(themeCode),
|
|
title,
|
|
[]),
|
|
cancellationToken)),
|
|
async cancellationToken =>
|
|
{
|
|
var search = await selection.SearchAsync(
|
|
title,
|
|
ThemeSession.PreMarket,
|
|
LegacyThemeSelectionService.MaximumResults,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (search.IsTruncated)
|
|
{
|
|
throw Verification("Theme readback was truncated.");
|
|
}
|
|
|
|
var matches = search.Items.Where(item =>
|
|
string.Equals(
|
|
item.Identity.ThemeTitle,
|
|
title,
|
|
StringComparison.Ordinal)).ToArray();
|
|
if (matches.Length != 1 ||
|
|
matches[0].Identity.Market != market ||
|
|
!string.Equals(
|
|
matches[0].Identity.ThemeCode,
|
|
themeCode,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Verification("Theme create readback did not match.");
|
|
}
|
|
|
|
freshIdentity = matches[0].Identity with { Session = session };
|
|
var preview = await selection.PreviewAsync(
|
|
freshIdentity,
|
|
LegacyThemeSelectionService.MaximumPreviewItems,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (preview.Items.Count != 0 || preview.IsTruncated)
|
|
{
|
|
throw Verification("Theme preview readback did not match.");
|
|
}
|
|
},
|
|
save: null,
|
|
verifySave: null,
|
|
cancellationToken => IgnoreResultAsync(
|
|
persistence.DeleteAsync(
|
|
freshIdentity ?? new ThemeSelectionIdentity(
|
|
market,
|
|
session,
|
|
Required(themeCode),
|
|
title),
|
|
cancellationToken)),
|
|
cancellationToken => EnsureThemeAbsentAsync(selection, title, cancellationToken),
|
|
IsOutcomeUnknown);
|
|
}
|
|
|
|
private static DevelopmentDatabaseSmokeScenario CreateExpertScenario(
|
|
IExpertCatalogPersistenceService persistence,
|
|
IExpertSelectionService selection,
|
|
ExistingChildRowGuard childGuard,
|
|
string name)
|
|
{
|
|
string? expertCode = null;
|
|
ExpertSelectionIdentity? freshIdentity = null;
|
|
return new DevelopmentDatabaseSmokeScenario(
|
|
"EList expert catalog",
|
|
async cancellationToken =>
|
|
{
|
|
await EnsureExpertAbsentAsync(selection, name, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
expertCode = await persistence.SuggestNextCodeAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await childGuard.EnsureExpertChildrenAbsentAsync(
|
|
expertCode,
|
|
cancellationToken).ConfigureAwait(false);
|
|
},
|
|
cancellationToken => IgnoreResultAsync(
|
|
persistence.CreateAsync(
|
|
new ExpertCatalogDefinition(
|
|
new ExpertSelectionIdentity(Required(expertCode), name),
|
|
[]),
|
|
cancellationToken)),
|
|
async cancellationToken =>
|
|
{
|
|
var search = await selection.SearchAsync(
|
|
name,
|
|
LegacyExpertSelectionService.MaximumResults,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (search.IsTruncated)
|
|
{
|
|
throw Verification("Expert readback was truncated.");
|
|
}
|
|
|
|
var matches = search.Items.Where(item =>
|
|
string.Equals(
|
|
item.Identity.ExpertName,
|
|
name,
|
|
StringComparison.Ordinal)).ToArray();
|
|
if (matches.Length != 1 ||
|
|
!string.Equals(
|
|
matches[0].Identity.ExpertCode,
|
|
expertCode,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Verification("Expert create readback did not match.");
|
|
}
|
|
|
|
freshIdentity = matches[0].Identity;
|
|
var preview = await selection.PreviewAsync(
|
|
freshIdentity,
|
|
LegacyExpertSelectionService.MaximumPreviewItems,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (preview.Items.Count != 0 || preview.IsTruncated)
|
|
{
|
|
throw Verification("Expert preview readback did not match.");
|
|
}
|
|
},
|
|
save: null,
|
|
verifySave: null,
|
|
cancellationToken => IgnoreResultAsync(
|
|
persistence.DeleteAsync(
|
|
freshIdentity ?? new ExpertSelectionIdentity(
|
|
Required(expertCode),
|
|
name),
|
|
cancellationToken)),
|
|
cancellationToken => EnsureExpertAbsentAsync(selection, name, cancellationToken),
|
|
IsOutcomeUnknown);
|
|
}
|
|
|
|
private static ManualFinancialRecord CreateManualRecord(
|
|
ManualFinancialScreenKind screen,
|
|
string stockName)
|
|
{
|
|
var identity = new ManualFinancialIdentity(screen, stockName);
|
|
return screen switch
|
|
{
|
|
ManualFinancialScreenKind.RevenueComposition =>
|
|
new ManualRevenueCompositionRecord(
|
|
identity,
|
|
"2026-07",
|
|
[new("SMOKE", "100", 100), null, null, null, null]),
|
|
ManualFinancialScreenKind.GrowthMetrics =>
|
|
new ManualGrowthMetricsRecord(
|
|
identity,
|
|
new(1, 2, 3, 4),
|
|
new(5, 6, 7, 8),
|
|
new(9, 10, 11, 12),
|
|
new(13, 14, 15, 16),
|
|
new("P1", "P2", "P3", "P4")),
|
|
ManualFinancialScreenKind.Sales =>
|
|
new ManualSalesRecord(identity, SixQuarters()),
|
|
ManualFinancialScreenKind.OperatingProfit =>
|
|
new ManualOperatingProfitRecord(identity, SixQuarters()),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(screen))
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<ManualQuarterValue> SixQuarters() =>
|
|
[
|
|
new("Q1", 10),
|
|
new("Q2", -20),
|
|
new("Q3", 30),
|
|
new("Q4", 40),
|
|
new("Q5", 50),
|
|
new("Q6", 60)
|
|
];
|
|
|
|
private static void VerifyManualRecord(
|
|
ManualFinancialRecord expected,
|
|
ManualFinancialSnapshot actual)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(actual.RowVersion) ||
|
|
actual.Record.Identity != expected.Identity)
|
|
{
|
|
throw Verification("GraphE identity or row version did not match.");
|
|
}
|
|
|
|
var matches = (expected, actual.Record) switch
|
|
{
|
|
(ManualRevenueCompositionRecord left, ManualRevenueCompositionRecord right) =>
|
|
string.Equals(left.BaseDate, right.BaseDate, StringComparison.Ordinal) &&
|
|
left.Slices.SequenceEqual(right.Slices),
|
|
(ManualGrowthMetricsRecord left, ManualGrowthMetricsRecord right) =>
|
|
left.SalesGrowth == right.SalesGrowth &&
|
|
left.OperatingProfitGrowth == right.OperatingProfitGrowth &&
|
|
left.NetAssetGrowth == right.NetAssetGrowth &&
|
|
left.NetIncomeGrowth == right.NetIncomeGrowth &&
|
|
left.Periods == right.Periods,
|
|
(ManualSalesRecord left, ManualSalesRecord right) =>
|
|
left.Quarters.SequenceEqual(right.Quarters),
|
|
(ManualOperatingProfitRecord left, ManualOperatingProfitRecord right) =>
|
|
left.Quarters.SequenceEqual(right.Quarters),
|
|
_ => false
|
|
};
|
|
if (!matches)
|
|
{
|
|
throw Verification("GraphE create readback did not match.");
|
|
}
|
|
}
|
|
|
|
private static async Task EnsureManualAbsentAsync(
|
|
IManualFinancialScreenService service,
|
|
ManualFinancialIdentity identity,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
_ = await service.GetAsync(identity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (ManualFinancialNotFoundException)
|
|
{
|
|
return;
|
|
}
|
|
|
|
throw Verification("GraphE record still exists.");
|
|
}
|
|
|
|
private static async Task EnsureNamedPlaylistAbsentAsync(
|
|
INamedPlaylistPersistenceService service,
|
|
string programCode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
_ = await service.LoadAsync(programCode, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (NamedPlaylistNotFoundException)
|
|
{
|
|
return;
|
|
}
|
|
|
|
throw Verification("Named playlist still exists.");
|
|
}
|
|
|
|
private static async Task EnsureThemeAbsentAsync(
|
|
IThemeSelectionService selection,
|
|
string title,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var search = await selection.SearchAsync(
|
|
title,
|
|
ThemeSession.PreMarket,
|
|
LegacyThemeSelectionService.MaximumResults,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (search.IsTruncated || search.Items.Any(item =>
|
|
string.Equals(
|
|
item.Identity.ThemeTitle,
|
|
title,
|
|
StringComparison.Ordinal)))
|
|
{
|
|
throw Collision("Theme absence was not proven.");
|
|
}
|
|
}
|
|
|
|
private static async Task EnsureExpertAbsentAsync(
|
|
IExpertSelectionService selection,
|
|
string name,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var search = await selection.SearchAsync(
|
|
name,
|
|
LegacyExpertSelectionService.MaximumResults,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (search.IsTruncated || search.Items.Any(item =>
|
|
string.Equals(
|
|
item.Identity.ExpertName,
|
|
name,
|
|
StringComparison.Ordinal)))
|
|
{
|
|
throw Collision("Expert absence was not proven.");
|
|
}
|
|
}
|
|
|
|
private static async Task IgnoreResultAsync<T>(Task<T> operation)
|
|
{
|
|
_ = await operation.ConfigureAwait(false);
|
|
}
|
|
|
|
private static string Required(string? value) =>
|
|
value ?? throw new InvalidOperationException("Smoke preflight did not produce an identity.");
|
|
|
|
private static bool IsOutcomeUnknown(Exception exception)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(exception);
|
|
if (exception is ManualFinancialMutationException manual && manual.OutcomeUnknown ||
|
|
exception is NamedPlaylistMutationException named && named.OutcomeUnknown ||
|
|
exception is OperatorCatalogMutationException catalog && catalog.OutcomeUnknown)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (exception is AggregateException aggregate &&
|
|
aggregate.InnerExceptions.Any(IsOutcomeUnknown))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return exception.InnerException is not null && IsOutcomeUnknown(exception.InnerException);
|
|
}
|
|
|
|
private static InvalidOperationException Collision(string message) => new(message);
|
|
|
|
private static InvalidOperationException Verification(string message) => new(message);
|
|
}
|