// // GitSyncServiceTests.swift // oAITests // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen import Testing import Foundation @testable import Confab @Suite("GitSyncService pure helpers") struct GitSyncServiceTests { private var service: GitSyncService { GitSyncService.shared } // MARK: - convertToSSH @Test("Already-SSH URLs pass through unchanged") func convertToSSHAlreadySSH() { #expect(service.convertToSSH("git@github.com:user/repo.git") == "git@github.com:user/repo.git") } @Test("HTTPS URL converts to SSH form") func convertToSSHFromHTTPS() { #expect(service.convertToSSH("https://gitlab.pm/rune/oAI-Sync.git") == "git@gitlab.pm:rune/oAI-Sync.git") } @Test("HTTP URL converts to SSH form") func convertToSSHFromHTTP() { #expect(service.convertToSSH("http://example.com/user/repo.git") == "git@example.com:user/repo.git") } @Test("Malformed URL with no path segment is returned unchanged") func convertToSSHMalformedNoSlash() { #expect(service.convertToSSH("https://onlyhost") == "https://onlyhost") } @Test("Unknown scheme is returned unchanged") func convertToSSHUnknownScheme() { #expect(service.convertToSSH("ftp://example.com/repo") == "ftp://example.com/repo") } // MARK: - injectCredentials @Test("Injects username and password into an HTTPS URL") func injectCredentialsHTTPS() { let result = service.injectCredentials("https://gitlab.pm/rune/repo.git", username: "rune", password: "secret") #expect(result == "https://rune:secret@gitlab.pm/rune/repo.git") } @Test("Non-HTTPS URLs are returned unchanged (credentials not injected)") func injectCredentialsNonHTTPS() { let result = service.injectCredentials("git@gitlab.pm:rune/repo.git", username: "rune", password: "secret") #expect(result == "git@gitlab.pm:rune/repo.git") } // MARK: - sanitizeFilename @Test("Strips filesystem-invalid characters, replacing each with a dash") func sanitizeFilenameStripsInvalidChars() { #expect(service.sanitizeFilename("a/b\\c:d*e?f\"gi|j") == "a-b-c-d-e-f-g-h-i-j") } @Test("Leaves an already-valid filename untouched") func sanitizeFilenameValidInput() { #expect(service.sanitizeFilename("My Chat 2026-01-15") == "My Chat 2026-01-15") } // MARK: - detectSecretsInText @Test("Detects an OpenAI-style API key") func detectSecretsOpenAIKey() { let text = "here's my key: sk-abcdefghijklmnopqrstuvwxyz123456" #expect(service.detectSecretsInText(text).contains("OpenAI Key")) } @Test("Detects a GitHub personal access token") func detectSecretsGitHubToken() { let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890" #expect(service.detectSecretsInText(text).contains("Access Token")) } @Test("Plain conversational text has no detected secrets") func detectSecretsCleanText() { #expect(service.detectSecretsInText("Just a normal conversation about the weather.").isEmpty) } @Test("Does not flag an OpenAI-shaped string one character short of the length threshold") func detectSecretsJustBelowThreshold() { // "sk-" + 31 chars = 34 total, but the pattern requires 32+ chars after "sk-" let shortKey = "sk-" + String(repeating: "a", count: 31) #expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key")) } // MARK: - extractProvider @Test("Recognizes github.com, gitlab.com, and gitea URLs by name") func extractProviderRecognizesKnownHosts() { #expect(GitSyncService.extractProvider(from: "https://github.com/user/repo.git") == "GitHub") #expect(GitSyncService.extractProvider(from: "https://gitlab.com/user/repo.git") == "GitLab") #expect(GitSyncService.extractProvider(from: "https://my-gitea-instance.example.com/user/repo.git") == "Gitea") } @Test("Falls back to a generic label for an unrecognized host") func extractProviderFallsBackForUnknownHost() { #expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository") } // MARK: - orphanedExportFilenames private func makeExportMarkdown(id: String, name: String = "Test") -> String { ConversationExport( id: id, name: name, createdAt: Date(), updatedAt: Date(), primaryModel: nil, messages: [.init(role: "user", content: "hi", timestamp: Date(), tokens: nil, cost: nil, modelId: nil)] ).toMarkdown() } @Test("A file whose ID still exists locally is not orphaned") func keepsFilesForExistingConversations() { let id = UUID().uuidString let files = [(filename: "chat.md", markdown: makeExportMarkdown(id: id))] let orphans = GitSyncService.orphanedExportFilenames(currentIds: [id], files: files) #expect(orphans.isEmpty) } @Test("A file whose ID no longer exists locally is orphaned") func flagsFilesForDeletedConversations() { let deletedId = UUID().uuidString let keptId = UUID().uuidString let files = [ (filename: "deleted.md", markdown: makeExportMarkdown(id: deletedId)), (filename: "kept.md", markdown: makeExportMarkdown(id: keptId)), ] let orphans = GitSyncService.orphanedExportFilenames(currentIds: [keptId], files: files) #expect(orphans == ["deleted.md"]) } @Test("Files that fail to parse are left alone rather than deleted") func unparsableFilesAreNotFlaggedAsOrphans() { let files = [(filename: "not-a-conversation.md", markdown: "not a valid export file")] let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files) #expect(orphans.isEmpty) } @Test("Empty file list produces no orphans") func emptyFileListProducesNoOrphans() { #expect(GitSyncService.orphanedExportFilenames(currentIds: [UUID().uuidString], files: []).isEmpty) } @Test("Empty local conversation list never flags files as orphaned, even when files exist") func emptyCurrentIdsNeverOrphansExistingFiles() { // Regression test: this is the exact shape of the production bug. A fresh clone (or any // moment where the local DB hasn't caught up yet) has zero local conversations, while the // sync repo still has every previously-synced file on disk. Without the guard, all of them // used to be flagged orphaned and deleted+pushed, wiping the whole sync repo. let files = [ (filename: "a.md", markdown: makeExportMarkdown(id: UUID().uuidString)), (filename: "b.md", markdown: makeExportMarkdown(id: UUID().uuidString)), ] let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files) #expect(orphans.isEmpty) } // MARK: - orphanedLocalFolderIds @Test("A local folder still present in the manifest is not orphaned") func keepsLocalFoldersStillInManifest() { let id = UUID().uuidString let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [id], localFolderIds: [id]) #expect(orphans.isEmpty) } @Test("A local folder missing from the manifest is orphaned") func flagsLocalFoldersMissingFromManifest() { let keptId = UUID().uuidString let deletedId = UUID().uuidString let orphans = GitSyncService.orphanedLocalFolderIds( manifestFolderIds: [keptId], localFolderIds: [keptId, deletedId] ) #expect(orphans == [deletedId]) } @Test("An empty manifest never orphans existing local folders") func emptyManifestNeverOrphansLocalFolders() { // Same safety guard as orphanedExportFilenames: an empty manifest is indistinguishable // from "folders.json hasn't been imported yet" (older sync repo, or a fresh clone before // the first export), so treating it as "delete every local folder" would repeat the exact // class of mass-deletion bug that hit conversation sync. let orphans = GitSyncService.orphanedLocalFolderIds( manifestFolderIds: [], localFolderIds: [UUID().uuidString, UUID().uuidString] ) #expect(orphans.isEmpty) } @Test("Empty local folder list produces no orphans") func emptyLocalFolderListProducesNoOrphans() { let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [UUID().uuidString], localFolderIds: []) #expect(orphans.isEmpty) } // MARK: - FolderSyncManifest round-trip @Test("FolderSyncManifest round-trips through JSON encode/decode") func folderSyncManifestRoundTrips() throws { let folderId = UUID().uuidString let conversationId = UUID().uuidString let manifest = FolderSyncManifest( folders: [ FolderSyncManifest.FolderEntry( id: folderId, name: "Work", parentId: nil, createdAt: Date(timeIntervalSince1970: 1_000), updatedAt: Date(timeIntervalSince1970: 2_000) ) ], assignments: [conversationId: folderId] ) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let data = try encoder.encode(manifest) let decoded = try decoder.decode(FolderSyncManifest.self, from: data) #expect(decoded.folders.count == 1) #expect(decoded.folders.first?.id == folderId) #expect(decoded.folders.first?.name == "Work") #expect(decoded.assignments[conversationId] == folderId) } }