Files
oai-swift/oAITests/GitSyncServiceTests.swift
T
rune 6480a50eee Sync folder structure via Git Sync (folders.json), plus bugs found testing it
Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
  last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
  conversationId→folderId assignments, imported before conversation
  files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
  guarded the same way conversation-orphan cleanup already is against
  an empty/stale manifest wiping everything.

Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
  directly into the database — only reloaded on launch or when the
  advanced conversation list closed, with no equivalent hook for the
  Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
  as an untracked file that then collided with the remote's tracked
  copy on the next pull ("untracked working tree files would be
  overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
  import, so any conversation already synced to a machine before this
  feature existed never got filed — which in practice is every
  conversation on a second machine, not an edge case. Now backfills
  a folder assignment for existing conversations that aren't filed
  anywhere locally yet, without clobbering an already-set folderId.

Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
2026-08-03 11:48:23 +02:00

236 lines
9.6 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)
}
}