// // 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) } // MARK: - orphanedNoteFilenames @Test("A note file still referenced by a current conversation is not orphaned") func keepsNoteFilesForExistingConversations() { let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["chat-a3f2.md"], existingFiles: ["chat-a3f2.md"]) #expect(orphans.isEmpty) } @Test("A note file no longer referenced by any current conversation is orphaned") func flagsNoteFilesForDeletedConversations() { let orphans = GitSyncService.orphanedNoteFilenames( currentFilenames: ["kept-b1c2.md"], existingFiles: ["kept-b1c2.md", "deleted-d3e4.md"] ) #expect(orphans == ["deleted-d3e4.md"]) } @Test("Non-markdown files in the notes directory are never flagged as orphans") func nonMarkdownFilesAreNotFlaggedAsOrphans() { let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["kept.md"], existingFiles: [".DS_Store"]) #expect(orphans.isEmpty) } @Test("Empty existing file list produces no orphans") func emptyExistingNoteFilesProducesNoOrphans() { #expect(GitSyncService.orphanedNoteFilenames(currentFilenames: ["a.md"], existingFiles: []).isEmpty) } @Test("Empty current filenames set never orphans existing note files, even when files exist") func emptyCurrentNoteFilenamesNeverOrphansExistingFiles() { // Same class of regression guard as emptyCurrentIdsNeverOrphansExistingFiles / // emptyManifestNeverOrphansLocalFolders: an empty local-state snapshot must never be // read as "delete everything in the sync repo." let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: [], existingFiles: ["a.md", "b.md"]) #expect(orphans.isEmpty) } // MARK: - NotesSyncManifest round-trip @Test("NotesSyncManifest round-trips through JSON encode/decode") func notesSyncManifestRoundTrips() throws { let conversationId = UUID().uuidString let manifest = NotesSyncManifest( notes: [conversationId: NotesSyncManifest.Entry(filename: "Chat-a3f2.md", enabled: true)] ) let data = try JSONEncoder().encode(manifest) let decoded = try JSONDecoder().decode(NotesSyncManifest.self, from: data) #expect(decoded.notes[conversationId]?.filename == "Chat-a3f2.md") #expect(decoded.notes[conversationId]?.enabled == true) } // MARK: - parseUntrackedFileConflict @Test("Parses a single colliding file out of git's untracked-files error") func parseUntrackedFileConflictSingleFile() { let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\nPlease move or remove them before you merge.\nAborting" #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json"]) } @Test("Parses multiple colliding files out of git's untracked-files error") func parseUntrackedFileConflictMultipleFiles() { let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\n\tnotes.json\nPlease move or remove them before you merge.\nAborting" #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json", "notes.json"]) } @Test("Wrapped SyncError.gitFailed description still parses correctly") func parseUntrackedFileConflictWrappedMessage() { let message = "Git command failed: error: The following untracked working tree files would be overwritten by merge:\n\tnotes/Chat-a3f2.md\nPlease move or remove them before you merge.\nAborting" #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["notes/Chat-a3f2.md"]) } @Test("Unrelated git errors return nil, not an empty or bogus file list") func parseUntrackedFileConflictUnrelatedErrors() { #expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: Authentication failed for 'https://gitlab.pm/rune/oai-swift.git/'") == nil) #expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: unable to access: Could not resolve host") == nil) #expect(GitSyncService.parseUntrackedFileConflict(from: "error: Your local changes to the following files would be overwritten by merge:\n\tconversations/x.md") == nil) } // MARK: - isFileSafeToAutoDelete @Test("Known sync-manifest files are safe to auto-delete") func isFileSafeToAutoDeleteAllowsKnownFiles() { #expect(GitSyncService.isFileSafeToAutoDelete("folders.json")) #expect(GitSyncService.isFileSafeToAutoDelete("notes.json")) #expect(GitSyncService.isFileSafeToAutoDelete("conversations/my-chat.md")) #expect(GitSyncService.isFileSafeToAutoDelete("notes/Chat-a3f2.md")) } @Test("Path traversal and absolute paths are never safe to auto-delete") func isFileSafeToAutoDeleteRejectsTraversal() { #expect(!GitSyncService.isFileSafeToAutoDelete("../etc/passwd")) #expect(!GitSyncService.isFileSafeToAutoDelete("/etc/passwd")) #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/../../../etc/passwd")) } @Test("Files outside the known shape are never safe to auto-delete") func isFileSafeToAutoDeleteRejectsUnknownFiles() { #expect(!GitSyncService.isFileSafeToAutoDelete("README.md")) #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/sub/x.md")) #expect(!GitSyncService.isFileSafeToAutoDelete("notes/sub/x.md")) #expect(!GitSyncService.isFileSafeToAutoDelete("")) #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/")) } // MARK: - PendingGitConflict.canAutoFix @Test("canAutoFix is true when every file is safe to auto-delete") func pendingGitConflictCanAutoFixAllSafe() { let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "notes.json"], rawError: "") #expect(conflict.canAutoFix) } @Test("canAutoFix is false when any file isn't safe to auto-delete") func pendingGitConflictCanAutoFixOneUnsafe() { let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "README.md"], rawError: "") #expect(!conflict.canAutoFix) } }