Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

145 строки
6.0KB

  1. using System.Collections.Concurrent;
  2. using Campaign_Tracker.Server.ExtensionData;
  3. using Campaign_Tracker.Server.LegacyData;
  4. namespace Campaign_Tracker.Server.Municipalities;
  5. /// <summary>
  6. /// In-memory municipality profile store for development and integration testing.
  7. /// Implements <see cref="ILegacyLinkedRecordProvider"/> so profiles are included in
  8. /// the nightly extension-to-legacy link integrity check (Story 1.8 AC #4).
  9. /// </summary>
  10. public sealed class InMemoryMunicipalityProfileRepository
  11. : IMunicipalityProfileRepository, ILegacyLinkedRecordProvider
  12. {
  13. private readonly ConcurrentDictionary<string, MunicipalityProfile> _profiles = new(StringComparer.OrdinalIgnoreCase);
  14. private readonly object _lock = new();
  15. private readonly ILegacyLinkValidator _validator;
  16. private readonly ILegacyDataAccess _legacyData;
  17. private readonly TimeProvider _timeProvider;
  18. public InMemoryMunicipalityProfileRepository(
  19. ILegacyLinkValidator validator,
  20. ILegacyDataAccess legacyData,
  21. TimeProvider timeProvider)
  22. {
  23. _validator = validator;
  24. _legacyData = legacyData;
  25. _timeProvider = timeProvider;
  26. }
  27. // ── AC #1: create with required JCode link ────────────────────────────────
  28. public async Task<MunicipalityProfileSaveResult> CreateAsync(
  29. string jCode,
  30. string? displayName,
  31. string actorIdentity,
  32. CancellationToken cancellationToken = default)
  33. {
  34. if (string.IsNullOrWhiteSpace(jCode))
  35. return MunicipalityProfileSaveResult.Failure("JCode is required.");
  36. // P8: normalize before validation so the link validator uses the same form that gets stored
  37. var normalizedJCode = jCode.Trim().ToUpperInvariant();
  38. // AC #4: validate before saving; never write if the link is invalid
  39. var linkRef = LegacyLinkReference.ForJurisdiction(normalizedJCode);
  40. var validation = await _validator.ValidateAsync(linkRef, cancellationToken);
  41. if (!validation.IsValid)
  42. return MunicipalityProfileSaveResult.Failure(validation.Error!);
  43. var now = _timeProvider.GetUtcNow();
  44. var profile = new MunicipalityProfile(
  45. ProfileId: Guid.NewGuid().ToString("N"),
  46. JCode: normalizedJCode,
  47. DisplayName: string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
  48. CreatedAt: now,
  49. UpdatedAt: now,
  50. UpdatedBy: actorIdentity);
  51. // P2: atomic check + insert under lock to prevent TOCTOU race on duplicate JCode
  52. lock (_lock)
  53. {
  54. if (_profiles.Values.Any(p => string.Equals(p.JCode, normalizedJCode, StringComparison.OrdinalIgnoreCase)))
  55. return MunicipalityProfileSaveResult.Failure(
  56. $"A municipality profile already exists for JCode '{normalizedJCode}'.");
  57. _profiles[profile.ProfileId] = profile;
  58. }
  59. return MunicipalityProfileSaveResult.Success(profile);
  60. }
  61. // ── AC #3: update with audit trail captured by caller ────────────────────
  62. public Task<MunicipalityProfileSaveResult> UpdateAsync(
  63. string profileId,
  64. string? displayName,
  65. string actorIdentity,
  66. CancellationToken cancellationToken = default)
  67. {
  68. // P2: wrap read-modify-write in lock to prevent lost updates under concurrent PUTs
  69. lock (_lock)
  70. {
  71. if (!_profiles.TryGetValue(profileId, out var existing))
  72. return Task.FromResult(MunicipalityProfileSaveResult.ProfileNotFound(profileId));
  73. var updated = existing with
  74. {
  75. DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
  76. UpdatedAt = _timeProvider.GetUtcNow(),
  77. UpdatedBy = actorIdentity,
  78. };
  79. _profiles[profileId] = updated;
  80. return Task.FromResult(MunicipalityProfileSaveResult.Success(updated));
  81. }
  82. }
  83. // ── AC #2: resolve combined extension + legacy view ──────────────────────
  84. public async Task<MunicipalityProfileView?> GetByIdAsync(
  85. string profileId,
  86. CancellationToken cancellationToken = default)
  87. {
  88. if (!_profiles.TryGetValue(profileId, out var profile))
  89. return null;
  90. return await BuildViewAsync(profile, cancellationToken);
  91. }
  92. public async Task<IReadOnlyList<MunicipalityProfileView>> GetAllAsync(
  93. CancellationToken cancellationToken = default)
  94. {
  95. var profiles = _profiles.Values
  96. .OrderBy(p => p.JCode, StringComparer.OrdinalIgnoreCase)
  97. .ToArray();
  98. var views = new List<MunicipalityProfileView>(profiles.Length);
  99. foreach (var profile in profiles)
  100. views.Add(await BuildViewAsync(profile, cancellationToken));
  101. return views;
  102. }
  103. // ── ILegacyLinkedRecordProvider ───────────────────────────────────────────
  104. Task<IReadOnlyList<ILegacyLinkedRecord>> ILegacyLinkedRecordProvider.GetAllAsync(
  105. CancellationToken cancellationToken)
  106. => Task.FromResult<IReadOnlyList<ILegacyLinkedRecord>>(
  107. _profiles.Values.Cast<ILegacyLinkedRecord>().ToArray());
  108. // ── helpers ───────────────────────────────────────────────────────────────
  109. private async Task<MunicipalityProfileView> BuildViewAsync(
  110. MunicipalityProfile profile,
  111. CancellationToken cancellationToken)
  112. {
  113. var jurisdiction = await _legacyData.GetJurisdictionAsync(profile.JCode, cancellationToken);
  114. return new MunicipalityProfileView(
  115. Profile: profile,
  116. LegacyName: jurisdiction?.Name,
  117. LegacyMailingAddress: jurisdiction?.MailingAddress,
  118. LegacyCityStateZip: jurisdiction?.CityStateZip);
  119. }
  120. }

Powered by TurnKey Linux.