File size: 12,080 Bytes
4977db2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 | import Foundation
import GRDB
import XCTest
@testable import PromptGarageCore
final class DatabaseBootstrapServiceTests: XCTestCase {
func testInstallCopiesLegacyDatabaseWithoutChangingSourceOrBackfillingDates() throws {
let root = try makeTemporaryDirectory(prefix: "PromptGarageBootstrap")
let sourceURL = root.appendingPathComponent("legacy-prompts.db")
try createLegacyDatabase(at: sourceURL)
let sourceBefore = try Data(contentsOf: sourceURL)
let service = try DatabaseBootstrapService(
applicationSupportRoot: root.appendingPathComponent("ApplicationSupport")
)
let sourceReport = try service.validateSource(at: sourceURL)
XCTAssertFalse(sourceReport.hasPromptedAt)
XCTAssertEqual(sourceReport.promptCount, 1)
let repository = try service.installDatabase(from: sourceURL)
XCTAssertEqual(try Data(contentsOf: sourceURL), sourceBefore)
XCTAssertFalse(try columnNames(at: sourceURL).contains("prompted_at"))
XCTAssertTrue(try columnNames(at: service.databaseURL).contains("prompted_at"))
XCTAssertNil(try repository.prompt(id: 1)?.promptedAt)
XCTAssertEqual(try repository.legacyUndatedCount(), 1)
XCTAssertTrue(try repository.dayGroups().isEmpty)
let destinationReport = try DatabaseSchema.validate(
DatabaseQueue(path: service.databaseURL.path)
)
XCTAssertTrue(destinationReport.hasPromptedAt)
XCTAssertEqual(destinationReport.promptCount, destinationReport.ftsCount)
}
func testNewImportsAreDatedWithoutChangingLegacyTagsOrDates() throws {
let root = try makeTemporaryDirectory(prefix: "PromptGarageChronology")
let sourceURL = root.appendingPathComponent("legacy-prompts.db")
try createLegacyDatabase(at: sourceURL)
let service = try DatabaseBootstrapService(
applicationSupportRoot: root.appendingPathComponent("ApplicationSupport")
)
let repository = try service.installDatabase(from: sourceURL)
let promptedAt = "2026-07-15T22:30:00.000Z"
let statistics = try repository.importCandidates([
ImportCandidate(
title: "A new Mac prompt",
content: "Explain a safe native macOS migration with a real end to end verification step",
source: "manual-import:0123456789ab",
tags: "swift,mac",
model: nil,
promptedAt: promptedAt
),
])
XCTAssertEqual(statistics.imported, 1)
XCTAssertEqual(try repository.legacyUndatedCount(), 1)
let expectedLocalDay = try localDay(for: promptedAt)
XCTAssertEqual(
try repository.dayGroups(),
[CountedValue(name: expectedLocalDay, count: 1)]
)
XCTAssertEqual(
try repository.page(matching: PromptQuery(promptedDay: expectedLocalDay)).total,
1
)
XCTAssertEqual(try repository.prompt(id: 1)?.tags, "legacy,testing")
XCTAssertNil(try repository.prompt(id: 1)?.promptedAt)
let topics = try repository.topicGroups()
XCTAssertEqual(topics.first(where: { $0.name == "legacy" })?.count, 1)
XCTAssertEqual(topics.first(where: { $0.name == "swift" })?.count, 1)
}
func testStaleFTSIsRejectedReadOnlyButRepairedInWritableAppCopy() throws {
let root = try makeTemporaryDirectory(prefix: "PromptGarageStaleFTS")
let databaseURL = root.appendingPathComponent("prompts.db")
try createLegacyDatabase(at: databaseURL)
let corruptor = try DatabaseQueue(path: databaseURL.path)
try corruptor.write { db in
try db.execute(sql: """
INSERT INTO prompts_fts(prompts_fts, rowid, title, content, tags)
VALUES('delete', 1, 'Legacy prompt',
'This legacy prompt must retain its original metadata without inferred dates',
'legacy,testing')
""")
}
try corruptor.close()
var readOnlyConfiguration = Configuration()
readOnlyConfiguration.readonly = true
let readOnly = try DatabaseQueue(
path: databaseURL.path,
configuration: readOnlyConfiguration
)
defer { try? readOnly.close() }
XCTAssertThrowsError(try DatabaseSchema.validateReadOnly(readOnly)) { error in
guard let validationError = error as? DatabaseValidationError,
case .ftsIndexInvalid = validationError
else {
return XCTFail("Expected ftsIndexInvalid, got \(error)")
}
}
XCTAssertThrowsError(
try PromptRepository(databaseURL: databaseURL, readOnly: true)
)
let writable = try PromptRepository(databaseURL: databaseURL)
XCTAssertEqual(try writable.page(matching: PromptQuery(text: "Legacy")).total, 1)
}
func testInstallRefusesValidExistingDestinationAndPreservesEveryByte() throws {
let root = try makeTemporaryDirectory(prefix: "PromptGarageNoReplacement")
let sourceURL = root.appendingPathComponent("source.db")
try createLegacyDatabase(at: sourceURL)
let service = try DatabaseBootstrapService(
applicationSupportRoot: root.appendingPathComponent("ApplicationSupport")
)
try createLegacyDatabase(at: service.databaseURL)
let destinationBefore = try Data(contentsOf: service.databaseURL)
XCTAssertThrowsError(try service.installDatabase(from: sourceURL)) { error in
XCTAssertEqual(
error as? DatabaseBootstrapError,
.destinationAlreadyExists(service.databaseURL)
)
XCTAssertTrue(error.localizedDescription.contains("niemals automatisch"))
XCTAssertTrue(error.localizedDescription.contains("verschiebe oder benenne"))
}
XCTAssertEqual(try Data(contentsOf: service.databaseURL), destinationBefore)
XCTAssertFalse(try columnNames(at: service.databaseURL).contains("prompted_at"))
}
func testDestinationAppearingAfterLaunchCheckRefusesBeforeSourceAccessAndPreservesBytes() throws {
let root = try makeTemporaryDirectory(prefix: "PromptGarageInvalidNoReplacement")
let service = try DatabaseBootstrapService(
applicationSupportRoot: root.appendingPathComponent("ApplicationSupport")
)
XCTAssertEqual(service.inspect(), .needsDatabase)
// Simulate a destination appearing after AppModel's first-launch state
// check but before its detached install operation begins. The selected
// source is intentionally missing: destinationAlreadyExists must win,
// proving installDatabase did not inspect or open the source first.
let invalidBytes = Data("not a sqlite database; preserve me exactly".utf8)
try invalidBytes.write(to: service.databaseURL)
let missingSource = root.appendingPathComponent("missing-source.db")
XCTAssertThrowsError(try service.installDatabase(from: missingSource)) { error in
XCTAssertEqual(
error as? DatabaseBootstrapError,
.destinationAlreadyExists(service.databaseURL)
)
}
XCTAssertEqual(try Data(contentsOf: service.databaseURL), invalidBytes)
}
func testFourWayParityGateRejectsAnyCountMismatch() throws {
let source = DatabaseValidationReport(
integrity: "ok",
promptCount: 7,
ftsCount: 7
)
let destination = DatabaseValidationReport(
integrity: "ok",
promptCount: 7,
ftsCount: 6,
hasPromptedAt: true
)
XCTAssertThrowsError(try DatabaseBootstrapService.validateCopyParity(
source: source,
destination: destination
)) { error in
XCTAssertEqual(
error as? DatabaseBootstrapError,
.copyParityMismatch(
sourcePrompts: 7,
sourceFTS: 7,
destinationPrompts: 7,
destinationFTS: 6
)
)
}
}
private func makeTemporaryDirectory(prefix: String) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(at: directory) }
return directory
}
private func createLegacyDatabase(at url: URL) throws {
let queue = try DatabaseQueue(path: url.path)
try queue.write { db in
try db.execute(sql: """
CREATE TABLE prompts (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT,
is_starred INTEGER DEFAULT 0,
tags TEXT,
model TEXT,
rating INTEGER,
use_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE VIRTUAL TABLE prompts_fts USING fts5(
title, content, tags, content=prompts, content_rowid=id
);
CREATE TRIGGER prompts_ai AFTER INSERT ON prompts BEGIN
INSERT INTO prompts_fts(rowid, title, content, tags)
VALUES (new.id, new.title, new.content, new.tags);
END;
CREATE TRIGGER prompts_ad AFTER DELETE ON prompts BEGIN
INSERT INTO prompts_fts(prompts_fts, rowid, title, content, tags)
VALUES ('delete', old.id, old.title, old.content, old.tags);
END;
CREATE TRIGGER prompts_au AFTER UPDATE ON prompts BEGIN
INSERT INTO prompts_fts(prompts_fts, rowid, title, content, tags)
VALUES ('delete', old.id, old.title, old.content, old.tags);
INSERT INTO prompts_fts(rowid, title, content, tags)
VALUES (new.id, new.title, new.content, new.tags);
END;
INSERT INTO prompts (
id, title, content, source, tags, created_at, updated_at
) VALUES (
1,
'Legacy prompt',
'This legacy prompt must retain its original metadata without inferred dates',
'manual',
'legacy,testing',
'2024-01-02 03:04:05',
'2024-01-02 03:04:05'
);
""")
}
try queue.close()
}
private func columnNames(at url: URL) throws -> Set<String> {
var configuration = Configuration()
configuration.readonly = true
let queue = try DatabaseQueue(path: url.path, configuration: configuration)
defer { try? queue.close() }
return try queue.read { db in
let rows = try Row.fetchAll(db, sql: "PRAGMA table_info(prompts)")
return Set(rows.compactMap { row -> String? in row["name"] })
}
}
private func localDay(for timestamp: String) throws -> String {
let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let date = try XCTUnwrap(parser.date(from: timestamp))
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
}
|