exportAllConversations()'s orphan-cleanup treated "zero local conversations" as "every synced conversation was deleted," so a just-cloned repo on a new machine could get emptied and pushed before the post-clone import ever ran. orphanedExportFilenames() now returns no orphans when the local ID set is empty, and cloneRepository() imports immediately after cloning to close the window entirely.
169 lines
6.8 KiB
Swift
169 lines
6.8 KiB
Swift
//
|
|
// GitSyncServiceTests.swift
|
|
// oAITests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import oAI
|
|
|
|
@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)
|
|
}
|
|
}
|