From cff7b87f4cd9a5b2e97f027f998cd33d5f29283b Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Wed, 20 May 2026 20:07:37 +0200 Subject: [PATCH 1/4] Deduplicate GitHub Copilot accounts --- .../Providers/CopilotProvider.swift | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift index 3a4db53..07708ee 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift @@ -339,10 +339,11 @@ final class CopilotProvider: ProviderProtocol { candidates: [CopilotAccountCandidate], cookieCandidate: CopilotAccountCandidate? ) -> ProviderResult { + let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) let merged = CandidateDedupe.merge( candidates, - accountId: { $0.accountId }, - isSameUsage: { _, _ in false }, + accountId: { normalizedCopilotIdentity($0.accountId) }, + isSameUsage: { isDuplicateCopilotUsage($0, $1) }, priority: { $0.sourcePriority } ) let sorted = merged.sorted { $0.sourcePriority > $1.sourcePriority } @@ -391,6 +392,79 @@ final class CopilotProvider: ProviderProtocol { ) } + private func removePlaceholderCandidatesWhenRealUsageExists( + _ candidates: [CopilotAccountCandidate] + ) -> [CopilotAccountCandidate] { + let hasRealUsage = candidates.contains { candidate in + (candidate.usage.totalEntitlement ?? 0) > 0 + } + guard hasRealUsage else { return candidates } + + return candidates.filter { candidate in + let entitlement = candidate.usage.totalEntitlement ?? 0 + if entitlement > 0 { return true } + + let authSource = candidate.details.authSource?.lowercased() ?? "" + return !authSource.contains("browser cookies") + } + } + + private func isDuplicateCopilotUsage( + _ lhs: CopilotAccountCandidate, + _ rhs: CopilotAccountCandidate + ) -> Bool { + guard lhs.usage.totalEntitlement == rhs.usage.totalEntitlement, + lhs.usage.remainingQuota == rhs.usage.remainingQuota, + (lhs.usage.totalEntitlement ?? 0) > 0 else { + return false + } + + if let lhsUsed = lhs.details.copilotUsedRequests, + let rhsUsed = rhs.details.copilotUsedRequests, + lhsUsed != rhsUsed { + return false + } + + if let lhsLimit = lhs.details.copilotLimitRequests, + let rhsLimit = rhs.details.copilotLimitRequests, + lhsLimit != rhsLimit { + return false + } + + let lhsPlan = normalizedCopilotIdentity(lhs.details.planType) + let rhsPlan = normalizedCopilotIdentity(rhs.details.planType) + if let lhsPlan, let rhsPlan, lhsPlan != rhsPlan { + return false + } + + let lhsIdentity = copilotIdentityCandidates(lhs) + let rhsIdentity = copilotIdentityCandidates(rhs) + if !lhsIdentity.isEmpty && !rhsIdentity.isEmpty && !lhsIdentity.isDisjoint(with: rhsIdentity) { + return true + } + + return lhs.details.copilotUsedRequests != nil || rhs.details.copilotUsedRequests != nil + } + + private func copilotIdentityCandidates(_ candidate: CopilotAccountCandidate) -> Set { + var identities = Set() + if let accountId = normalizedCopilotIdentity(candidate.accountId) { + identities.insert(accountId) + } + if let email = normalizedCopilotIdentity(candidate.details.email) { + identities.insert(email) + } + return identities + } + + private func normalizedCopilotIdentity(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized.isEmpty ? nil : normalized + } + // MARK: - Customer ID Fetching private func fetchCustomerId(cookies: GitHubCookies) async -> String? { From 1af5491531c82dbb883204676d4f578c30f43709 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Wed, 20 May 2026 20:18:37 +0200 Subject: [PATCH 2/4] Tighten GitHub Copilot dedupe --- .../CLI/Providers/CopilotCLIProvider.swift | 80 ++++++++++++++++++- .../Providers/CopilotProvider.swift | 6 +- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift index dabdb53..8cd053c 100644 --- a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift +++ b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift @@ -297,10 +297,11 @@ actor CopilotCLIProvider: ProviderProtocol { candidates: [CopilotAccountCandidate], cookieCandidate: CopilotAccountCandidate? ) -> ProviderResult { + let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) let merged = CandidateDedupe.merge( candidates, - accountId: { $0.accountId }, - isSameUsage: { _, _ in false }, + accountId: { normalizedCopilotIdentity($0.accountId) }, + isSameUsage: { isDuplicateCopilotUsage($0, $1) }, priority: { $0.sourcePriority } ) let sorted = merged.sorted { $0.sourcePriority > $1.sourcePriority } @@ -338,7 +339,7 @@ actor CopilotCLIProvider: ProviderProtocol { ) } - let primaryDetails = cookieCandidate?.details ?? accountResults.first?.details + let primaryDetails = accountResults.first?.details ?? cookieCandidate?.details logger.info("CopilotCLIProvider: Finalized \(accountResults.count) account row(s)") @@ -349,6 +350,79 @@ actor CopilotCLIProvider: ProviderProtocol { ) } + private func removePlaceholderCandidatesWhenRealUsageExists( + _ candidates: [CopilotAccountCandidate] + ) -> [CopilotAccountCandidate] { + let hasRealUsage = candidates.contains { candidate in + (candidate.usage.totalEntitlement ?? 0) > 0 + } + guard hasRealUsage else { return candidates } + + return candidates.filter { candidate in + let entitlement = candidate.usage.totalEntitlement ?? 0 + if entitlement > 0 { return true } + + let authSource = candidate.details.authSource?.lowercased() ?? "" + return !authSource.contains("browser cookies") + } + } + + private func isDuplicateCopilotUsage( + _ lhs: CopilotAccountCandidate, + _ rhs: CopilotAccountCandidate + ) -> Bool { + guard lhs.usage.totalEntitlement == rhs.usage.totalEntitlement, + lhs.usage.remainingQuota == rhs.usage.remainingQuota, + (lhs.usage.totalEntitlement ?? 0) > 0 else { + return false + } + + if let lhsUsed = lhs.details.copilotUsedRequests, + let rhsUsed = rhs.details.copilotUsedRequests, + lhsUsed != rhsUsed { + return false + } + + if let lhsLimit = lhs.details.copilotLimitRequests, + let rhsLimit = rhs.details.copilotLimitRequests, + lhsLimit != rhsLimit { + return false + } + + let lhsPlan = normalizedCopilotIdentity(lhs.details.planType) + let rhsPlan = normalizedCopilotIdentity(rhs.details.planType) + if let lhsPlan, let rhsPlan, lhsPlan != rhsPlan { + return false + } + + let lhsIdentity = copilotIdentityCandidates(lhs) + let rhsIdentity = copilotIdentityCandidates(rhs) + if !lhsIdentity.isEmpty && !rhsIdentity.isEmpty { + return !lhsIdentity.isDisjoint(with: rhsIdentity) + } + + return lhs.details.copilotUsedRequests != nil || rhs.details.copilotUsedRequests != nil + } + + private func copilotIdentityCandidates(_ candidate: CopilotAccountCandidate) -> Set { + var identities = Set() + if let accountId = normalizedCopilotIdentity(candidate.accountId) { + identities.insert(accountId) + } + if let email = normalizedCopilotIdentity(candidate.details.email) { + identities.insert(email) + } + return identities + } + + private func normalizedCopilotIdentity(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized.isEmpty ? nil : normalized + } + // MARK: - Cookie-based Fetching (fallback) private func fetchCustomerId(cookies: GitHubCookies) async -> String? { diff --git a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift index 07708ee..e559adb 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift @@ -381,7 +381,7 @@ final class CopilotProvider: ProviderProtocol { ) } - let primaryDetails = cookieCandidate?.details ?? accountResults.first?.details + let primaryDetails = accountResults.first?.details ?? cookieCandidate?.details logger.info("CopilotProvider: Finalized \(accountResults.count) account row(s)") @@ -439,8 +439,8 @@ final class CopilotProvider: ProviderProtocol { let lhsIdentity = copilotIdentityCandidates(lhs) let rhsIdentity = copilotIdentityCandidates(rhs) - if !lhsIdentity.isEmpty && !rhsIdentity.isEmpty && !lhsIdentity.isDisjoint(with: rhsIdentity) { - return true + if !lhsIdentity.isEmpty && !rhsIdentity.isEmpty { + return !lhsIdentity.isDisjoint(with: rhsIdentity) } return lhs.details.copilotUsedRequests != nil || rhs.details.copilotUsedRequests != nil From 8b30e867389ecedb3ce191da350e2342e6f5b5f2 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Wed, 20 May 2026 20:25:28 +0200 Subject: [PATCH 3/4] Require identity overlap for Copilot dedupe --- CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift | 6 +++--- .../CopilotMonitor/Providers/CopilotProvider.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift index 8cd053c..d251da8 100644 --- a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift +++ b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift @@ -397,11 +397,11 @@ actor CopilotCLIProvider: ProviderProtocol { let lhsIdentity = copilotIdentityCandidates(lhs) let rhsIdentity = copilotIdentityCandidates(rhs) - if !lhsIdentity.isEmpty && !rhsIdentity.isEmpty { - return !lhsIdentity.isDisjoint(with: rhsIdentity) + guard !lhsIdentity.isEmpty, !rhsIdentity.isEmpty else { + return false } - return lhs.details.copilotUsedRequests != nil || rhs.details.copilotUsedRequests != nil + return !lhsIdentity.isDisjoint(with: rhsIdentity) } private func copilotIdentityCandidates(_ candidate: CopilotAccountCandidate) -> Set { diff --git a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift index e559adb..94e1447 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift @@ -439,11 +439,11 @@ final class CopilotProvider: ProviderProtocol { let lhsIdentity = copilotIdentityCandidates(lhs) let rhsIdentity = copilotIdentityCandidates(rhs) - if !lhsIdentity.isEmpty && !rhsIdentity.isEmpty { - return !lhsIdentity.isDisjoint(with: rhsIdentity) + guard !lhsIdentity.isEmpty, !rhsIdentity.isEmpty else { + return false } - return lhs.details.copilotUsedRequests != nil || rhs.details.copilotUsedRequests != nil + return !lhsIdentity.isDisjoint(with: rhsIdentity) } private func copilotIdentityCandidates(_ candidate: CopilotAccountCandidate) -> Set { From 42659757c1d5cc872a65211df046ec6826424ae7 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Wed, 20 May 2026 20:30:34 +0200 Subject: [PATCH 4/4] Share Copilot dedupe safeguards --- .../CLI/Providers/CopilotCLIProvider.swift | 82 ++++++------------ .../Models/ProviderResult.swift | 77 +++++++++++++++++ .../Providers/CopilotProvider.swift | 85 ++++++------------- .../CopilotProviderTests.swift | 81 ++++++++++++++++++ 4 files changed, 208 insertions(+), 117 deletions(-) diff --git a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift index d251da8..98cefa4 100644 --- a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift +++ b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift @@ -33,6 +33,20 @@ actor CopilotCLIProvider: ProviderProtocol { let usage: ProviderUsage let details: DetailedUsage let sourcePriority: Int + let isPlaceholder: Bool + + var dedupeInput: CopilotCandidateDedupeInput { + CopilotCandidateDedupeInput( + accountId: accountId, + email: details.email, + planType: details.planType, + totalEntitlement: usage.totalEntitlement, + remainingQuota: usage.remainingQuota, + usedRequests: details.copilotUsedRequests, + limitRequests: details.copilotLimitRequests, + isPlaceholder: isPlaceholder + ) + } } // MARK: - ProviderProtocol Implementation @@ -256,7 +270,8 @@ actor CopilotCLIProvider: ProviderProtocol { accountId: login, usage: providerUsage, details: details, - sourcePriority: sourcePriority + sourcePriority: sourcePriority, + isPlaceholder: false ) } @@ -287,7 +302,8 @@ actor CopilotCLIProvider: ProviderProtocol { accountId: info.accountId ?? info.login, usage: providerUsage, details: details, - sourcePriority: sourcePriority(info.source) + sourcePriority: sourcePriority(info.source), + isPlaceholder: false ) } @@ -300,7 +316,7 @@ actor CopilotCLIProvider: ProviderProtocol { let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) let merged = CandidateDedupe.merge( candidates, - accountId: { normalizedCopilotIdentity($0.accountId) }, + accountId: { CopilotCandidateDedupe.normalizedIdentity($0.accountId) }, isSameUsage: { isDuplicateCopilotUsage($0, $1) }, priority: { $0.sourcePriority } ) @@ -359,11 +375,10 @@ actor CopilotCLIProvider: ProviderProtocol { guard hasRealUsage else { return candidates } return candidates.filter { candidate in - let entitlement = candidate.usage.totalEntitlement ?? 0 - if entitlement > 0 { return true } - - let authSource = candidate.details.authSource?.lowercased() ?? "" - return !authSource.contains("browser cookies") + !CopilotCandidateDedupe.shouldDropPlaceholder( + candidate.dedupeInput, + whenAnyCandidateHasRealUsage: hasRealUsage + ) } } @@ -371,56 +386,7 @@ actor CopilotCLIProvider: ProviderProtocol { _ lhs: CopilotAccountCandidate, _ rhs: CopilotAccountCandidate ) -> Bool { - guard lhs.usage.totalEntitlement == rhs.usage.totalEntitlement, - lhs.usage.remainingQuota == rhs.usage.remainingQuota, - (lhs.usage.totalEntitlement ?? 0) > 0 else { - return false - } - - if let lhsUsed = lhs.details.copilotUsedRequests, - let rhsUsed = rhs.details.copilotUsedRequests, - lhsUsed != rhsUsed { - return false - } - - if let lhsLimit = lhs.details.copilotLimitRequests, - let rhsLimit = rhs.details.copilotLimitRequests, - lhsLimit != rhsLimit { - return false - } - - let lhsPlan = normalizedCopilotIdentity(lhs.details.planType) - let rhsPlan = normalizedCopilotIdentity(rhs.details.planType) - if let lhsPlan, let rhsPlan, lhsPlan != rhsPlan { - return false - } - - let lhsIdentity = copilotIdentityCandidates(lhs) - let rhsIdentity = copilotIdentityCandidates(rhs) - guard !lhsIdentity.isEmpty, !rhsIdentity.isEmpty else { - return false - } - - return !lhsIdentity.isDisjoint(with: rhsIdentity) - } - - private func copilotIdentityCandidates(_ candidate: CopilotAccountCandidate) -> Set { - var identities = Set() - if let accountId = normalizedCopilotIdentity(candidate.accountId) { - identities.insert(accountId) - } - if let email = normalizedCopilotIdentity(candidate.details.email) { - identities.insert(email) - } - return identities - } - - private func normalizedCopilotIdentity(_ value: String?) -> String? { - guard let value else { return nil } - let normalized = value - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - return normalized.isEmpty ? nil : normalized + CopilotCandidateDedupe.isSameAccountUsage(lhs.dedupeInput, rhs.dedupeInput) } // MARK: - Cookie-based Fetching (fallback) diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift index f336cf5..e3fac9b 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift @@ -1155,6 +1155,83 @@ struct CandidateDedupe { } } +struct CopilotCandidateDedupeInput { + let accountId: String? + let email: String? + let planType: String? + let totalEntitlement: Int? + let remainingQuota: Int? + let usedRequests: Int? + let limitRequests: Int? + let isPlaceholder: Bool +} + +enum CopilotCandidateDedupe { + static func normalizedIdentity(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized.isEmpty ? nil : normalized + } + + static func shouldDropPlaceholder( + _ candidate: CopilotCandidateDedupeInput, + whenAnyCandidateHasRealUsage hasRealUsage: Bool + ) -> Bool { + guard hasRealUsage else { return false } + return candidate.isPlaceholder && (candidate.totalEntitlement ?? 0) == 0 + } + + static func isSameAccountUsage( + _ lhs: CopilotCandidateDedupeInput, + _ rhs: CopilotCandidateDedupeInput + ) -> Bool { + guard lhs.totalEntitlement == rhs.totalEntitlement, + lhs.remainingQuota == rhs.remainingQuota, + (lhs.totalEntitlement ?? 0) > 0 else { + return false + } + + if let lhsUsed = lhs.usedRequests, + let rhsUsed = rhs.usedRequests, + lhsUsed != rhsUsed { + return false + } + + if let lhsLimit = lhs.limitRequests, + let rhsLimit = rhs.limitRequests, + lhsLimit != rhsLimit { + return false + } + + let lhsPlan = normalizedIdentity(lhs.planType) + let rhsPlan = normalizedIdentity(rhs.planType) + if let lhsPlan, let rhsPlan, lhsPlan != rhsPlan { + return false + } + + let lhsIdentity = identityCandidates(lhs) + let rhsIdentity = identityCandidates(rhs) + guard !lhsIdentity.isEmpty, !rhsIdentity.isEmpty else { + return false + } + + return !lhsIdentity.isDisjoint(with: rhsIdentity) + } + + private static func identityCandidates(_ candidate: CopilotCandidateDedupeInput) -> Set { + var identities = Set() + if let accountId = normalizedIdentity(candidate.accountId) { + identities.insert(accountId) + } + if let email = normalizedIdentity(candidate.email) { + identities.insert(email) + } + return identities + } +} + /// Shared numeric parser for API response dictionaries. /// APIs may return Double, Int, NSNumber, or String for numeric fields. enum APIValueParser { diff --git a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift index 94e1447..ffa8e26 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift @@ -168,6 +168,20 @@ final class CopilotProvider: ProviderProtocol { let usage: ProviderUsage let details: DetailedUsage let sourcePriority: Int + let isPlaceholder: Bool + + var dedupeInput: CopilotCandidateDedupeInput { + CopilotCandidateDedupeInput( + accountId: accountId, + email: details.email, + planType: details.planType, + totalEntitlement: usage.totalEntitlement, + remainingQuota: usage.remainingQuota, + usedRequests: details.copilotUsedRequests, + limitRequests: details.copilotLimitRequests, + isPlaceholder: isPlaceholder + ) + } } private func sourcePriority(_ source: CopilotAuthSource?) -> Int { @@ -289,7 +303,8 @@ final class CopilotProvider: ProviderProtocol { accountId: login, usage: providerUsage, details: details, - sourcePriority: sourcePriority + sourcePriority: sourcePriority, + isPlaceholder: false ) } @@ -320,7 +335,8 @@ final class CopilotProvider: ProviderProtocol { accountId: info.accountId ?? info.login, usage: providerUsage, details: details, - sourcePriority: sourcePriority(info.source) + sourcePriority: sourcePriority(info.source), + isPlaceholder: false ) } @@ -331,7 +347,8 @@ final class CopilotProvider: ProviderProtocol { accountId: details.email, usage: cachedResult.usage, details: details, - sourcePriority: 0 + sourcePriority: 0, + isPlaceholder: true ) } @@ -342,7 +359,7 @@ final class CopilotProvider: ProviderProtocol { let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) let merged = CandidateDedupe.merge( candidates, - accountId: { normalizedCopilotIdentity($0.accountId) }, + accountId: { CopilotCandidateDedupe.normalizedIdentity($0.accountId) }, isSameUsage: { isDuplicateCopilotUsage($0, $1) }, priority: { $0.sourcePriority } ) @@ -401,11 +418,10 @@ final class CopilotProvider: ProviderProtocol { guard hasRealUsage else { return candidates } return candidates.filter { candidate in - let entitlement = candidate.usage.totalEntitlement ?? 0 - if entitlement > 0 { return true } - - let authSource = candidate.details.authSource?.lowercased() ?? "" - return !authSource.contains("browser cookies") + !CopilotCandidateDedupe.shouldDropPlaceholder( + candidate.dedupeInput, + whenAnyCandidateHasRealUsage: hasRealUsage + ) } } @@ -413,56 +429,7 @@ final class CopilotProvider: ProviderProtocol { _ lhs: CopilotAccountCandidate, _ rhs: CopilotAccountCandidate ) -> Bool { - guard lhs.usage.totalEntitlement == rhs.usage.totalEntitlement, - lhs.usage.remainingQuota == rhs.usage.remainingQuota, - (lhs.usage.totalEntitlement ?? 0) > 0 else { - return false - } - - if let lhsUsed = lhs.details.copilotUsedRequests, - let rhsUsed = rhs.details.copilotUsedRequests, - lhsUsed != rhsUsed { - return false - } - - if let lhsLimit = lhs.details.copilotLimitRequests, - let rhsLimit = rhs.details.copilotLimitRequests, - lhsLimit != rhsLimit { - return false - } - - let lhsPlan = normalizedCopilotIdentity(lhs.details.planType) - let rhsPlan = normalizedCopilotIdentity(rhs.details.planType) - if let lhsPlan, let rhsPlan, lhsPlan != rhsPlan { - return false - } - - let lhsIdentity = copilotIdentityCandidates(lhs) - let rhsIdentity = copilotIdentityCandidates(rhs) - guard !lhsIdentity.isEmpty, !rhsIdentity.isEmpty else { - return false - } - - return !lhsIdentity.isDisjoint(with: rhsIdentity) - } - - private func copilotIdentityCandidates(_ candidate: CopilotAccountCandidate) -> Set { - var identities = Set() - if let accountId = normalizedCopilotIdentity(candidate.accountId) { - identities.insert(accountId) - } - if let email = normalizedCopilotIdentity(candidate.details.email) { - identities.insert(email) - } - return identities - } - - private func normalizedCopilotIdentity(_ value: String?) -> String? { - guard let value else { return nil } - let normalized = value - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - return normalized.isEmpty ? nil : normalized + CopilotCandidateDedupe.isSameAccountUsage(lhs.dedupeInput, rhs.dedupeInput) } // MARK: - Customer ID Fetching diff --git a/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift index ed46b65..b777109 100644 --- a/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift @@ -142,6 +142,87 @@ final class CopilotProviderTests: XCTestCase { XCTAssertEqual(CopilotAuthSource.vscodeApps.priority, 0) } + // MARK: - CopilotCandidateDedupe + + func testCopilotDedupeRejectsMatchingUsageWithDifferentIdentities() { + let personal = makeDedupeInput(accountId: "personal", email: "personal@example.com") + let work = makeDedupeInput(accountId: "work", email: "work@example.com") + + XCTAssertFalse(CopilotCandidateDedupe.isSameAccountUsage(personal, work)) + } + + func testCopilotDedupeAcceptsMatchingUsageWithCaseInsensitiveIdentity() { + let first = makeDedupeInput(accountId: "Foo", email: "Foo@Example.com") + let second = makeDedupeInput(accountId: "foo", email: "foo@example.com") + + XCTAssertTrue(CopilotCandidateDedupe.isSameAccountUsage(first, second)) + } + + func testCopilotDedupeRejectsIdentitylessMatchingUsage() { + let identified = makeDedupeInput(accountId: "foo", email: "foo@example.com") + let identityless = makeDedupeInput(accountId: nil, email: nil) + + XCTAssertFalse(CopilotCandidateDedupe.isSameAccountUsage(identified, identityless)) + } + + func testCopilotDedupeDropsOnlyPlaceholderWithoutRealUsage() { + let placeholder = makeDedupeInput( + accountId: nil, + email: nil, + entitlement: 0, + remaining: 0, + isPlaceholder: true + ) + let emptyRealCandidate = makeDedupeInput( + accountId: nil, + email: nil, + entitlement: 0, + remaining: 0, + isPlaceholder: false + ) + + XCTAssertTrue( + CopilotCandidateDedupe.shouldDropPlaceholder( + placeholder, + whenAnyCandidateHasRealUsage: true + ) + ) + XCTAssertFalse( + CopilotCandidateDedupe.shouldDropPlaceholder( + emptyRealCandidate, + whenAnyCandidateHasRealUsage: true + ) + ) + XCTAssertFalse( + CopilotCandidateDedupe.shouldDropPlaceholder( + placeholder, + whenAnyCandidateHasRealUsage: false + ) + ) + } + + private func makeDedupeInput( + accountId: String?, + email: String?, + entitlement: Int = 300, + remaining: Int = 284, + usedRequests: Int? = 16, + limitRequests: Int? = 300, + planType: String? = "Individual", + isPlaceholder: Bool = false + ) -> CopilotCandidateDedupeInput { + CopilotCandidateDedupeInput( + accountId: accountId, + email: email, + planType: planType, + totalEntitlement: entitlement, + remainingQuota: remaining, + usedRequests: usedRequests, + limitRequests: limitRequests, + isPlaceholder: isPlaceholder + ) + } + private func loadFixture(named: String) -> Data { let bundle = Bundle(for: type(of: self)) guard let url = bundle.url(forResource: named, withExtension: nil) else {