< Summary

Information
Class: FirebaseAdapter.FirebaseMatchOutcomeRepository
Assembly: FirebaseAdapter
File(s): /home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebaseMatchOutcomeRepository.cs
Line coverage
99%
Covered lines: 109
Uncovered lines: 1
Coverable lines: 110
Total lines: 198
Line coverage: 99%
Branch coverage
86%
Covered branches: 43
Total branches: 50
Branch coverage: 86%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)83.33%66100%
UpsertMatchOutcomeAsync()100%44100%
GetIncompleteMatchdaysAsync()79.17%2424100%
GetMatchdayOutcomesAsync()100%44100%
NeedsUpdate(...)100%88100%
ToFirestoreMatchOutcome(...)100%11100%
ConvertToPersistedMatchOutcome(...)100%11100%
BuildDocumentId(...)75%44100%
ConvertToTimestamp(...)100%11100%
ConvertFromTimestamp(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebaseMatchOutcomeRepository.cs

#LineLine coverage
 1using EHonda.KicktippAi.Core;
 2using FirebaseAdapter.Models;
 3using Google.Cloud.Firestore;
 4using Microsoft.Extensions.Logging;
 5using NodaTime;
 6
 7namespace FirebaseAdapter;
 8
 9public class FirebaseMatchOutcomeRepository : IMatchOutcomeRepository
 10{
 11    private const int ExpectedMatchesPerMatchday = 9;
 12
 13    private readonly FirestoreDb _firestoreDb;
 14    private readonly ILogger<FirebaseMatchOutcomeRepository> _logger;
 15    private readonly string _matchOutcomesCollection;
 16    private readonly string _competition;
 17
 118    public FirebaseMatchOutcomeRepository(
 119        FirestoreDb firestoreDb,
 120        ILogger<FirebaseMatchOutcomeRepository> logger,
 121        string? competition = null)
 22    {
 123        _firestoreDb = firestoreDb ?? throw new ArgumentNullException(nameof(firestoreDb));
 124        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 125        _matchOutcomesCollection = "match-outcomes";
 126        _competition = string.IsNullOrWhiteSpace(competition)
 127            ? CompetitionIds.Bundesliga2025_26
 128            : competition.Trim();
 129    }
 30
 31    public async Task<MatchOutcomeUpsertResult> UpsertMatchOutcomeAsync(
 32        CollectedMatchOutcome outcome,
 33        string communityContext,
 34        CancellationToken cancellationToken = default)
 35    {
 136        var documentId = BuildDocumentId(outcome, communityContext);
 137        var docRef = _firestoreDb.Collection(_matchOutcomesCollection).Document(documentId);
 138        var snapshot = await docRef.GetSnapshotAsync(cancellationToken);
 139        var now = Timestamp.GetCurrentTimestamp();
 40
 141        if (!snapshot.Exists)
 42        {
 143            var firestoreOutcome = ToFirestoreMatchOutcome(outcome, communityContext, documentId, now, now);
 144            await docRef.SetAsync(firestoreOutcome, cancellationToken: cancellationToken);
 45
 146            return new MatchOutcomeUpsertResult(
 147                MatchOutcomeWriteDisposition.Created,
 148                ConvertToPersistedMatchOutcome(firestoreOutcome));
 49        }
 50
 151        var existing = snapshot.ConvertTo<FirestoreMatchOutcome>();
 152        if (!NeedsUpdate(existing, outcome))
 53        {
 154            return new MatchOutcomeUpsertResult(
 155                MatchOutcomeWriteDisposition.Unchanged,
 156                ConvertToPersistedMatchOutcome(existing));
 57        }
 58
 159        var updated = ToFirestoreMatchOutcome(outcome, communityContext, documentId, existing.CreatedAt, now);
 160        await docRef.SetAsync(updated, cancellationToken: cancellationToken);
 61
 162        return new MatchOutcomeUpsertResult(
 163            MatchOutcomeWriteDisposition.Updated,
 164            ConvertToPersistedMatchOutcome(updated));
 165    }
 66
 67    public async Task<IReadOnlyList<int>> GetIncompleteMatchdaysAsync(
 68        string communityContext,
 69        int currentMatchday,
 70        CancellationToken cancellationToken = default)
 71    {
 172        var query = _firestoreDb.Collection(_matchOutcomesCollection)
 173            .WhereEqualTo("communityContext", communityContext)
 174            .WhereEqualTo("competition", _competition)
 175            .WhereLessThanOrEqualTo("matchday", currentMatchday);
 76
 177        var snapshot = await query.GetSnapshotAsync(cancellationToken);
 178        var groupedOutcomes = snapshot.Documents
 179            .Select(doc => doc.ConvertTo<FirestoreMatchOutcome>())
 180            .GroupBy(outcome => outcome.Matchday)
 181            .ToDictionary(group => group.Key, group => group.ToList());
 82
 183        var incompleteMatchdays = new List<int>();
 184        for (var matchday = 1; matchday <= currentMatchday; matchday++)
 85        {
 186            if (!groupedOutcomes.TryGetValue(matchday, out var outcomes))
 87            {
 188                incompleteMatchdays.Add(matchday);
 189                continue;
 90            }
 91
 192            var isComplete = string.Equals(_competition, CompetitionIds.Bundesliga2025_26, StringComparison.OrdinalIgnor
 193                ? outcomes.Count >= ExpectedMatchesPerMatchday &&
 194                  outcomes.All(outcome => string.Equals(outcome.Availability, nameof(MatchOutcomeAvailability.Completed)
 195                : outcomes.Count > 0 &&
 096                  outcomes.All(outcome => string.Equals(outcome.Availability, nameof(MatchOutcomeAvailability.Completed)
 97
 198            if (!isComplete)
 99            {
 1100                incompleteMatchdays.Add(matchday);
 101            }
 102        }
 103
 1104        return incompleteMatchdays.AsReadOnly();
 1105    }
 106
 107    public async Task<IReadOnlyList<PersistedMatchOutcome>> GetMatchdayOutcomesAsync(
 108        int matchday,
 109        string communityContext,
 110        CancellationToken cancellationToken = default)
 111    {
 1112        var query = _firestoreDb.Collection(_matchOutcomesCollection)
 1113            .WhereEqualTo("communityContext", communityContext)
 1114            .WhereEqualTo("competition", _competition)
 1115            .WhereEqualTo("matchday", matchday);
 116
 1117        var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1118        return snapshot.Documents
 1119            .Select(doc => doc.ConvertTo<FirestoreMatchOutcome>())
 1120            .Select(ConvertToPersistedMatchOutcome)
 1121            .OrderBy(outcome => outcome.HomeTeam)
 1122            .ToList()
 1123            .AsReadOnly();
 1124    }
 125
 126    private static bool NeedsUpdate(FirestoreMatchOutcome existing, CollectedMatchOutcome outcome)
 127    {
 1128        return existing.HomeGoals != outcome.HomeGoals ||
 1129               existing.AwayGoals != outcome.AwayGoals ||
 1130               !string.Equals(existing.Availability, outcome.Availability.ToString(), StringComparison.Ordinal) ||
 1131               existing.TippSpielId != outcome.TippSpielId ||
 1132               existing.StartsAt.ToDateTimeOffset() != outcome.StartsAt.ToInstant().ToDateTimeOffset();
 133    }
 134
 135    private FirestoreMatchOutcome ToFirestoreMatchOutcome(
 136        CollectedMatchOutcome outcome,
 137        string communityContext,
 138        string documentId,
 139        Timestamp createdAt,
 140        Timestamp updatedAt)
 141    {
 1142        return new FirestoreMatchOutcome
 1143        {
 1144            Id = documentId,
 1145            HomeTeam = outcome.HomeTeam,
 1146            AwayTeam = outcome.AwayTeam,
 1147            StartsAt = ConvertToTimestamp(outcome.StartsAt),
 1148            Matchday = outcome.Matchday,
 1149            HomeGoals = outcome.HomeGoals,
 1150            AwayGoals = outcome.AwayGoals,
 1151            Availability = outcome.Availability.ToString(),
 1152            TippSpielId = outcome.TippSpielId,
 1153            CreatedAt = createdAt,
 1154            UpdatedAt = updatedAt,
 1155            Competition = _competition,
 1156            CommunityContext = communityContext
 1157        };
 158    }
 159
 160    private PersistedMatchOutcome ConvertToPersistedMatchOutcome(FirestoreMatchOutcome firestoreOutcome)
 161    {
 1162        return new PersistedMatchOutcome(
 1163            firestoreOutcome.CommunityContext,
 1164            firestoreOutcome.Competition,
 1165            firestoreOutcome.HomeTeam,
 1166            firestoreOutcome.AwayTeam,
 1167            ConvertFromTimestamp(firestoreOutcome.StartsAt),
 1168            firestoreOutcome.Matchday,
 1169            firestoreOutcome.HomeGoals,
 1170            firestoreOutcome.AwayGoals,
 1171            Enum.Parse<MatchOutcomeAvailability>(firestoreOutcome.Availability, ignoreCase: false),
 1172            firestoreOutcome.TippSpielId,
 1173            firestoreOutcome.CreatedAt.ToDateTimeOffset(),
 1174            firestoreOutcome.UpdatedAt.ToDateTimeOffset());
 175    }
 176
 177    private string BuildDocumentId(CollectedMatchOutcome outcome, string communityContext)
 178    {
 1179        var tippSpielId = outcome.TippSpielId ?? throw new InvalidOperationException(
 1180            $"Cannot persist match outcome for {outcome.HomeTeam} vs {outcome.AwayTeam} on matchday {outcome.Matchday} b
 181
 1182        return string.Equals(_competition, CompetitionIds.Bundesliga2025_26, StringComparison.OrdinalIgnoreCase)
 1183            ? tippSpielId
 1184            : $"{_competition}_{communityContext}_{tippSpielId}";
 185    }
 186
 187    private static Timestamp ConvertToTimestamp(ZonedDateTime zonedDateTime)
 188    {
 1189        var instant = zonedDateTime.ToInstant();
 1190        return Timestamp.FromDateTimeOffset(instant.ToDateTimeOffset());
 191    }
 192
 193    private static ZonedDateTime ConvertFromTimestamp(Timestamp timestamp)
 194    {
 1195        var instant = Instant.FromDateTimeOffset(timestamp.ToDateTimeOffset());
 1196        return instant.InUtc();
 197    }
 198}