fix(data): harden portfolio publication concurrency
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<PortfolioMigrationPostgresFixture>
|
||||
{
|
||||
public const string Name = "Portfolio migration PostgreSQL";
|
||||
}
|
||||
|
||||
public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return container.StartAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public async Task<MigratedPortfolioDatabase> CreateMigratedDatabaseAsync()
|
||||
{
|
||||
var databaseName = $"portfolio_{Guid.NewGuid():N}";
|
||||
|
||||
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
|
||||
{
|
||||
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
|
||||
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
|
||||
{
|
||||
Database = databaseName,
|
||||
Timeout = 10,
|
||||
CommandTimeout = 10
|
||||
}.ConnectionString;
|
||||
|
||||
var migrations = GetMigrationPaths();
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
|
||||
foreach (var migration in migrations)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
|
||||
{
|
||||
CommandTimeout = 30
|
||||
};
|
||||
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
return new MigratedPortfolioDatabase(connectionString, migrations.Count);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetMigrationPaths()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
|
||||
if (Directory.Exists(migrationsDirectory))
|
||||
{
|
||||
return Directory.GetFiles(migrationsDirectory, "V*.sql")
|
||||
.Where(path => string.CompareOrdinal(Path.GetFileName(path), "V030__") < 0)
|
||||
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MigratedPortfolioDatabase(string ConnectionString, int AppliedMigrationCount)
|
||||
{
|
||||
public async Task<NpgsqlConnection> OpenConnectionAsync()
|
||||
{
|
||||
var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
[Collection(PortfolioMigrationPostgresCollection.Name)]
|
||||
public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFixture fixture)
|
||||
{
|
||||
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
|
||||
private static long nextLegacyId = 1000;
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationsV001ThroughV029_ShouldApplyToPostgres17()
|
||||
{
|
||||
var database = await fixture.CreateMigratedDatabaseAsync();
|
||||
|
||||
Assert.Equal(29, database.AppliedMigrationCount);
|
||||
|
||||
await using var connection = await database.OpenConnectionAsync();
|
||||
Assert.Equal(4, await ExecuteScalarAsync<long>(
|
||||
connection,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'portfolio_games',
|
||||
'portfolio_game_sessions',
|
||||
'portfolio_game_masters',
|
||||
'portfolio_game_reviews')
|
||||
"""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("portfolio_game_sessions")]
|
||||
[InlineData("portfolio_game_masters")]
|
||||
public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(string linkTable)
|
||||
{
|
||||
var database = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var connection = await database.OpenConnectionAsync();
|
||||
var seed = await SeedCardAsync(connection, isPublic: true);
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
$"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId",
|
||||
transaction,
|
||||
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<PostgresException>(
|
||||
() => transaction.CommitAsync().WaitAsync(CommandTimeout));
|
||||
|
||||
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt()
|
||||
{
|
||||
var database = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var connection = await database.OpenConnectionAsync();
|
||||
var seed = await SeedCardAsync(connection, isPublic: true);
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
"""
|
||||
UPDATE portfolio_games
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
WHERE id = @portfolioGameId
|
||||
""",
|
||||
transaction,
|
||||
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
"DELETE FROM sessions WHERE id = @sessionId",
|
||||
transaction,
|
||||
new NpgsqlParameter("sessionId", seed.SessionId));
|
||||
|
||||
await transaction.CommitAsync().WaitAsync(CommandTimeout);
|
||||
|
||||
Assert.False(await ExecuteScalarAsync<bool>(
|
||||
connection,
|
||||
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
|
||||
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
|
||||
Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync<DateTime>(
|
||||
connection,
|
||||
"SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId",
|
||||
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
|
||||
Assert.Equal(0, await ExecuteScalarAsync<long>(
|
||||
connection,
|
||||
"SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId",
|
||||
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard()
|
||||
{
|
||||
var database = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var publishConnection = await database.OpenConnectionAsync();
|
||||
await using var deleteConnection = await database.OpenConnectionAsync();
|
||||
var seed = await SeedCardAsync(publishConnection, isPublic: false);
|
||||
await using var publishTransaction = await publishConnection.BeginTransactionAsync();
|
||||
await using var deleteTransaction = await deleteConnection.BeginTransactionAsync();
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
publishConnection,
|
||||
"""
|
||||
UPDATE portfolio_games
|
||||
SET is_public = true,
|
||||
published_at = COALESCE(published_at, now()),
|
||||
updated_at = now()
|
||||
WHERE id = @portfolioGameId
|
||||
""",
|
||||
publishTransaction,
|
||||
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
|
||||
await ExecuteNonQueryAsync(deleteConnection, "SET LOCAL lock_timeout = '2s'", deleteTransaction);
|
||||
|
||||
Assert.Equal(1, await ExecuteNonQueryAsync(
|
||||
deleteConnection,
|
||||
"DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId",
|
||||
deleteTransaction,
|
||||
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
|
||||
await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<PostgresException>(
|
||||
() => publishTransaction.CommitAsync().WaitAsync(CommandTimeout));
|
||||
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
|
||||
|
||||
await using var verificationConnection = await database.OpenConnectionAsync();
|
||||
Assert.False(await ExecuteScalarAsync<bool>(
|
||||
verificationConnection,
|
||||
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
|
||||
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit()
|
||||
{
|
||||
var database = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var connection = await database.OpenConnectionAsync();
|
||||
var cardDeleteSeed = await SeedCardAsync(connection, isPublic: true);
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
"DELETE FROM portfolio_games WHERE id = @portfolioGameId",
|
||||
parameters: new NpgsqlParameter("portfolioGameId", cardDeleteSeed.PortfolioGameId));
|
||||
|
||||
var groupDeleteSeed = await SeedCardAsync(connection, isPublic: true);
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
"DELETE FROM game_groups WHERE id = @groupId",
|
||||
parameters: new NpgsqlParameter("groupId", groupDeleteSeed.GroupId));
|
||||
|
||||
Assert.Equal(0, await ExecuteScalarAsync<long>(
|
||||
connection,
|
||||
"SELECT COUNT(*) FROM portfolio_games WHERE id IN (@cardDeleteId, @groupDeleteId)",
|
||||
parameters:
|
||||
[
|
||||
new NpgsqlParameter("cardDeleteId", cardDeleteSeed.PortfolioGameId),
|
||||
new NpgsqlParameter("groupDeleteId", groupDeleteSeed.PortfolioGameId)
|
||||
]));
|
||||
}
|
||||
|
||||
private static async Task<PortfolioSeed> SeedCardAsync(NpgsqlConnection connection, bool isPublic)
|
||||
{
|
||||
var playerId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var legacyId = Interlocked.Increment(ref nextLegacyId);
|
||||
var publishedAtValue = DateTime.UtcNow.AddDays(-1);
|
||||
var publishedAt = new DateTime(publishedAtValue.Ticks / 10 * 10, DateTimeKind.Utc);
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
"""
|
||||
INSERT INTO players (id, telegram_id, display_name, platform, external_user_id)
|
||||
VALUES (@playerId, @legacyId, 'Portfolio GM', 'Telegram', @legacyIdText);
|
||||
|
||||
INSERT INTO game_groups (id, telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
|
||||
VALUES (@groupId, @legacyId, 'Portfolio Club', @legacyId, 'Telegram', @legacyIdText);
|
||||
|
||||
INSERT INTO sessions (id, group_id, title, join_link, scheduled_at)
|
||||
VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day');
|
||||
|
||||
INSERT INTO portfolio_games (
|
||||
id,
|
||||
group_id,
|
||||
public_slug,
|
||||
title,
|
||||
description,
|
||||
cover_storage_key,
|
||||
is_public,
|
||||
published_at)
|
||||
VALUES (
|
||||
@portfolioGameId,
|
||||
@groupId,
|
||||
@publicSlug,
|
||||
'Completed Adventure',
|
||||
'A completed adventure.',
|
||||
'covers/example.webp',
|
||||
@isPublic,
|
||||
CASE WHEN @isPublic THEN @publishedAt ELSE NULL END);
|
||||
|
||||
INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id)
|
||||
VALUES (@portfolioGameId, @sessionId);
|
||||
|
||||
INSERT INTO portfolio_game_masters (portfolio_game_id, player_id)
|
||||
VALUES (@portfolioGameId, @playerId);
|
||||
""",
|
||||
transaction,
|
||||
new NpgsqlParameter("playerId", playerId),
|
||||
new NpgsqlParameter("legacyId", legacyId),
|
||||
new NpgsqlParameter("legacyIdText", legacyId.ToString()),
|
||||
new NpgsqlParameter("groupId", groupId),
|
||||
new NpgsqlParameter("sessionId", sessionId),
|
||||
new NpgsqlParameter("portfolioGameId", portfolioGameId),
|
||||
new NpgsqlParameter("publicSlug", $"portfolio-{portfolioGameId:N}"),
|
||||
new NpgsqlParameter("isPublic", isPublic),
|
||||
new NpgsqlParameter("publishedAt", publishedAt));
|
||||
|
||||
await transaction.CommitAsync().WaitAsync(CommandTimeout);
|
||||
return new PortfolioSeed(portfolioGameId, groupId, sessionId, publishedAt);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteNonQueryAsync(
|
||||
NpgsqlConnection connection,
|
||||
string sql,
|
||||
NpgsqlTransaction? transaction = null,
|
||||
params NpgsqlParameter[] parameters)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
command.Parameters.AddRange(parameters);
|
||||
return await command.ExecuteNonQueryAsync().WaitAsync(CommandTimeout);
|
||||
}
|
||||
|
||||
private static async Task<T> ExecuteScalarAsync<T>(
|
||||
NpgsqlConnection connection,
|
||||
string sql,
|
||||
NpgsqlTransaction? transaction = null,
|
||||
params NpgsqlParameter[] parameters)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
command.Parameters.AddRange(parameters);
|
||||
return (T)(await command.ExecuteScalarAsync().WaitAsync(CommandTimeout))!;
|
||||
}
|
||||
|
||||
private sealed record PortfolioSeed(
|
||||
Guid PortfolioGameId,
|
||||
Guid GroupId,
|
||||
Guid SessionId,
|
||||
DateTime PublishedAt);
|
||||
}
|
||||
@@ -30,11 +30,13 @@ public sealed class PortfolioMigrationTests
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_public ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE FUNCTION unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now() WHERE id = OLD.portfolio_game_id AND is_public = true AND (NOT EXISTS (SELECT 1 FROM portfolio_game_sessions WHERE portfolio_game_id = OLD.portfolio_game_id) OR NOT EXISTS (SELECT 1 FROM portfolio_game_masters WHERE portfolio_game_id = OLD.portfolio_game_id));", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("RAISE EXCEPTION 'published portfolio game % must have at least one linked session and at least one linked master', target_portfolio_game_id USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioSessionDeletionSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SharedDeleteSessionHandler_ShouldUnpublishLinkedPortfolioCardBeforeDeletingSession()
|
||||
{
|
||||
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
||||
"src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs"));
|
||||
|
||||
const string unpublish =
|
||||
"UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = @SessionId AND pg.is_public = true";
|
||||
|
||||
Assert.Contains(unpublish, source, StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
source.IndexOf(unpublish, StringComparison.Ordinal) <
|
||||
source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal),
|
||||
"Linked public portfolio cards must be unpublished before deleting the session.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInteractionGuildBeforeDeletingSession()
|
||||
{
|
||||
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
||||
"src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs"));
|
||||
|
||||
const string unpublish =
|
||||
"UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id JOIN game_groups g ON g.id = s.group_id WHERE pgs.portfolio_game_id = pg.id AND s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId AND pg.is_public = true";
|
||||
|
||||
Assert.Contains(unpublish, source, StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
source.IndexOf(unpublish, StringComparison.Ordinal) <
|
||||
source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal),
|
||||
"Discord cards must be unpublished before deleting the session.");
|
||||
}
|
||||
|
||||
private static string NormalizeSql(string sql)
|
||||
{
|
||||
return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Replace("( ", "(", StringComparison.Ordinal)
|
||||
.Replace(" )", ")", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user