Files
oai-swift/oAITests/GitSyncServiceTests.swift
T
rune e2284aba2b Show manual sync-conflict fix instructions in-app instead of deep-linking to Help
NSWorkspace.shared.open() silently drops #fragment anchors on file://
URLs, so "Fix It Myself" always landed on the Help Book index instead
of the relevant section. Replaced with GitSyncManualFixSheet, an
in-app sheet showing the real conflicting filenames and sync path.

Also indent conversation rows one level deeper than their containing
folder in the sidebar and conversation list, so nesting is visible on
the conversations themselves and not just the folder headers.
2026-08-04 12:21:15 +02:00

356 lines
16 KiB
Swift

//
// 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\"g<h>i|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)
}
}