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)
    }
}