HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
602 lines | 28.698kb
Bogdan Timofte authored 2 weeks ago
1
import HealthKit
2
import SQLite3
3
import XCTest
4
@testable import HealthProbe
5

            
6
final class SQLiteHealthArchiveStoreTests: XCTestCase {
Bogdan Timofte authored 2 weeks ago
7
    private final class AsyncResultBox<T>: @unchecked Sendable {
8
        var result: Result<T, Error>?
9
    }
10

            
Bogdan Timofte authored 2 weeks ago
11
    private var temporaryDirectory: URL!
12

            
13
    override func setUpWithError() throws {
14
        temporaryDirectory = FileManager.default.temporaryDirectory
15
            .appending(path: "HealthProbeTests-\(UUID().uuidString)", directoryHint: .isDirectory)
16
        try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true)
17
    }
18

            
19
    override func tearDownWithError() throws {
20
        if let temporaryDirectory {
21
            try? FileManager.default.removeItem(at: temporaryDirectory)
22
        }
23
        temporaryDirectory = nil
24
    }
25

            
26
    func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws {
27
        let store = SQLiteHealthArchiveStore(databaseURL: databaseURL())
28

            
29
        let report = try await store.checkIntegrity()
30
        let records = try await store.records(for: HealthArchiveRecordRequest(
31
            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue
32
        ))
33

            
34
        XCTAssertTrue(report.passed)
35
        XCTAssertEqual(report.schemaVersion, 2)
36
        XCTAssertEqual(report.sqliteIntegrityStatus, "ok")
37
        XCTAssertEqual(report.foreignKeyIssueCount, 0)
38
        XCTAssertTrue(report.missingTableNames.isEmpty)
39
        XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges"))
40
        XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates"))
41
        XCTAssertTrue(records.isEmpty)
42
    }
43

            
44
    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
45
        let url = databaseURL()
46
        try createPrototypeDatabase(at: url)
47
        let store = SQLiteHealthArchiveStore(databaseURL: url)
48

            
49
        let report = try await store.checkIntegrity()
50

            
51
        XCTAssertTrue(report.passed)
52
        XCTAssertEqual(report.schemaVersion, 2)
53
        XCTAssertTrue(report.missingTableNames.isEmpty)
54
    }
55

            
56
    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
57
        let url = databaseURL()
58
        let store = SQLiteHealthArchiveStore(databaseURL: url)
59
        let sample = makeStepCountSample()
60

            
61
        let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
62
        let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060))
63
        let records = try await store.records(for: HealthArchiveRecordRequest(
64
            sampleTypeIdentifier: sample.sampleType.identifier
65
        ))
66
        let report = try await store.checkIntegrity()
67
        let versionDebugRows = try sampleVersionDebugRows(at: url)
68

            
69
        XCTAssertEqual(firstWrite.insertedCount, 1)
70
        XCTAssertEqual(firstWrite.updatedCount, 0)
71
        XCTAssertEqual(firstWrite.unchangedCount, 0)
72
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
73
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
74
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1)
75
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
Bogdan Timofte authored 2 weeks ago
76
        XCTAssertFalse(try tableExists("archive_samples", at: url))
Bogdan Timofte authored 2 weeks ago
77
        XCTAssertEqual(secondWrite.insertedCount, 0)
78
        XCTAssertEqual(secondWrite.updatedCount, 0)
79
        XCTAssertEqual(secondWrite.unchangedCount, 1)
80
        XCTAssertEqual(records.count, 1)
81
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
82
        XCTAssertTrue(report.passed)
83
    }
84

            
Bogdan Timofte authored 2 weeks ago
85
    func testVerificationUsesArchiveV2TablesWithoutLegacyMirror() async throws {
86
        let url = databaseURL()
87
        let store = SQLiteHealthArchiveStore(databaseURL: url)
88
        let sample = makeStepCountSample()
89

            
90
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
91
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
92
        let observationIDs = try observationIDs(at: url)
93

            
94
        XCTAssertEqual(observationIDs.count, 2)
Bogdan Timofte authored 2 weeks ago
95
        XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), 2)
96
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationIDs[0]) AND inserted_event_count = 1", at: url), 1)
Bogdan Timofte authored 2 weeks ago
97
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1)
98
        XCTAssertFalse(try tableExists("archive_samples", at: url))
99
    }
100

            
Bogdan Timofte authored 2 weeks ago
101
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
102
        let url = databaseURL()
103
        let store = SQLiteHealthArchiveStore(databaseURL: url)
104
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
105
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
106
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
107

            
108
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
109
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
110
        try await store.recordDisappearance(
111
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
112
            sampleTypeIdentifier: typeIdentifier,
113
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
114
        )
115
        let observationIDs = try observationIDs(at: url)
116
        XCTAssertEqual(observationIDs.count, 3)
117

            
118
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
119
            fromObservationID: observationIDs[0],
120
            toObservationID: observationIDs[1],
121
            sampleTypeIdentifier: typeIdentifier
122
        ))
123
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
124
            fromObservationID: observationIDs[0],
125
            toObservationID: observationIDs[1],
126
            sampleTypeIdentifier: typeIdentifier,
127
            kind: .appeared,
128
            limit: 10
129
        ))
130
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
131
            fromObservationID: observationIDs[1],
132
            toObservationID: observationIDs[2],
133
            sampleTypeIdentifier: typeIdentifier
134
        ))
135
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
136
            fromObservationID: observationIDs[1],
137
            toObservationID: observationIDs[2],
138
            sampleTypeIdentifier: typeIdentifier,
139
            kind: .disappeared,
140
            limit: 10
141
        ))
142

            
143
        XCTAssertEqual(appearedSummary.appearedCount, 1)
144
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
145
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
146
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
147
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
148
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
149
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
150
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
151
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
152
    }
153

            
Bogdan Timofte authored 2 weeks ago
154
    func testGroupedObservationKeepsPageWritesDeletesAndVerificationTogether() async throws {
155
        let url = databaseURL()
156
        let store = SQLiteHealthArchiveStore(databaseURL: url)
157
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
158
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
159
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
160

            
161
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
162
        let observationID = try await store.beginObservation(
163
            observedAt: Date(timeIntervalSince1970: 3_060),
164
            triggerReason: "manual",
165
            selectedTypeSetHash: "selected-types"
166
        )
167
        _ = try await store.upsertSamples(
168
            [secondSample],
169
            observedAt: Date(timeIntervalSince1970: 3_060),
170
            observationID: observationID
171
        )
172
        try await store.recordDisappearance(
173
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
174
            sampleTypeIdentifier: typeIdentifier,
175
            observedMissingAt: Date(timeIntervalSince1970: 3_060),
176
            observationID: observationID
177
        )
178
        try await store.markVerification(
179
            sampleType: secondSample.sampleType,
180
            verifiedAt: Date(timeIntervalSince1970: 3_060),
181
            observationID: observationID
182
        )
183
        try await store.finishObservation(
184
            observationID: observationID,
185
            status: "completed",
186
            endedAt: Date(timeIntervalSince1970: 3_070)
187
        )
188

            
189
        let observationIDs = try observationIDs(at: url)
190
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
191
            fromObservationID: observationIDs[0],
192
            toObservationID: observationID,
193
            sampleTypeIdentifier: typeIdentifier
194
        ))
195

            
196
        XCTAssertEqual(observationIDs.count, 2)
197
        XCTAssertEqual(summary.appearedCount, 1)
198
        XCTAssertEqual(summary.disappearedCount, 1)
199
        XCTAssertEqual(try countRows(in: "observations WHERE id = \(observationID) AND status = 'completed' AND selected_type_set_hash = 'selected-types'", at: url), 1)
200
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID)", at: url), 2)
201
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationID) AND inserted_event_count = 1 AND deleted_event_count = 1 AND verified_visible_count = 1 AND status = 'completed'", at: url), 1)
202
    }
203

            
Bogdan Timofte authored 2 weeks ago
204
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
205
        let url = databaseURL()
206
        let store = SQLiteHealthArchiveStore(databaseURL: url)
207
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
208
        let initialCount = 1_200
209
        let appearedCount = 180
210
        let disappearedCount = 160
211
        let pageSize = 25
212
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
213
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
214

            
215
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
216
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
217
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
218
            try await store.recordDisappearance(
219
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
220
                sampleTypeIdentifier: typeIdentifier,
221
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
222
            )
223
        }
224

            
225
        let observationIDs = try observationIDs(at: url)
226
        let firstObservationID = try XCTUnwrap(observationIDs.first)
227
        let lastObservationID = try XCTUnwrap(observationIDs.last)
228
        let queryStartedAt = Date()
229
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
230
            fromObservationID: firstObservationID,
231
            toObservationID: lastObservationID,
232
            sampleTypeIdentifier: typeIdentifier
233
        ))
234
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
235
            fromObservationID: firstObservationID,
236
            toObservationID: lastObservationID,
237
            sampleTypeIdentifier: typeIdentifier,
238
            kind: .appeared,
239
            limit: pageSize
240
        ))
241
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
242
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
243
            fromObservationID: firstObservationID,
244
            toObservationID: lastObservationID,
245
            sampleTypeIdentifier: typeIdentifier,
246
            kind: .appeared,
247
            afterCursor: RecordCursor(
248
                startDate: firstPageLastRecord.startDate,
249
                strictFingerprint: firstPageLastRecord.strictFingerprint
250
            ),
251
            limit: pageSize
252
        ))
253
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
254

            
255
        XCTAssertEqual(summary.appearedCount, appearedCount)
256
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
257
        XCTAssertEqual(summary.representationChangedCount, 0)
258
        XCTAssertEqual(firstPage.count, pageSize)
259
        XCTAssertEqual(secondPage.count, pageSize)
260
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
261
        XCTAssertLessThan(queryElapsedSeconds, 10)
262
    }
263

            
Bogdan Timofte authored 2 weeks ago
264
    func testLargeSyntheticDiffQueryMetrics() throws {
265
        let url = databaseURL()
266
        let store = SQLiteHealthArchiveStore(databaseURL: url)
267
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
268
        let initialCount = 900
269
        let appearedCount = 120
270
        let disappearedCount = 100
271
        let pageSize = 25
272
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
273
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
274

            
275
        try waitForArchiveOperation {
276
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
277
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
278
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
279
                try await store.recordDisappearance(
280
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
281
                    sampleTypeIdentifier: typeIdentifier,
282
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
283
                )
284
            }
285
        }
286

            
287
        let observationIDs = try observationIDs(at: url)
288
        let firstObservationID = try XCTUnwrap(observationIDs.first)
289
        let lastObservationID = try XCTUnwrap(observationIDs.last)
290
        let options = XCTMeasureOptions()
291
        options.iterationCount = 3
292

            
293
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
294
            do {
295
                let result = try waitForArchiveOperation {
296
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
297
                        fromObservationID: firstObservationID,
298
                        toObservationID: lastObservationID,
299
                        sampleTypeIdentifier: typeIdentifier
300
                    ))
301
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
302
                        fromObservationID: firstObservationID,
303
                        toObservationID: lastObservationID,
304
                        sampleTypeIdentifier: typeIdentifier,
305
                        kind: .appeared,
306
                        limit: pageSize
307
                    ))
308
                    return (summary, records)
309
                }
310
                XCTAssertEqual(result.0.appearedCount, appearedCount)
311
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
312
                XCTAssertEqual(result.1.count, pageSize)
313
            } catch {
314
                XCTFail("Measured archive query failed: \(error)")
315
            }
316
        }
317
    }
318

            
Bogdan Timofte authored 2 weeks ago
319
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
320
        let url = databaseURL()
321
        let store = SQLiteHealthArchiveStore(databaseURL: url)
322
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
323
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
324
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
325

            
326
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
327
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
328
        let observationIDs = try observationIDs(at: url)
329
        XCTAssertEqual(observationIDs.count, 2)
330

            
331
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
332
            fromObservationID: observationIDs[0],
333
            toObservationID: observationIDs[1],
334
            sampleTypeIdentifier: typeIdentifier,
335
            limit: 10
336
        ))
337

            
338
        XCTAssertEqual(rows.count, 1)
339
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
340
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
341
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
342
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
343
        XCTAssertEqual(rows.first?.fromValueSum, 42)
344
        XCTAssertEqual(rows.first?.toValueSum, 49)
345
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
346
    }
347

            
Bogdan Timofte authored 2 weeks ago
348
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
349
        let url = databaseURL()
350
        let store = SQLiteHealthArchiveStore(databaseURL: url)
351
        let sample = makeStepCountSample(value: 42, start: 1_000)
352
        let typeIdentifier = sample.sampleType.identifier
353

            
354
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 3_000))
355

            
356
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
357
            visibleAtObservationID: nil,
358
            sampleTypeIdentifier: typeIdentifier,
359
            limit: 10
360
        ))
361

            
362
        XCTAssertEqual(rows.count, 1)
363
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
364
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
365
        XCTAssertEqual(rows.first?.valueSum, 42)
366
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
367
    }
368

            
369
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
370
        let url = databaseURL()
371
        let store = SQLiteHealthArchiveStore(databaseURL: url)
372
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
373

            
374
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
375
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
376
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
377

            
378
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
379
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
380
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
381
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
382

            
383
        let observationIDs = try observationIDs(at: url)
384
        XCTAssertEqual(observationIDs.count, 4)
385

            
386
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
387
            fromObservationID: observationIDs[0],
388
            toObservationID: observationIDs[observationIDs.count - 1],
389
            sampleTypeIdentifier: typeIdentifier
390
        ))
391

            
392
        XCTAssertEqual(rows.count, 1)
393
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
394
        XCTAssertEqual(rows.first?.disappearedCount, 2)
395
        XCTAssertEqual(rows.first?.appearedCount, 1)
396
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
397
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
398
        XCTAssertEqual(rows.first?.fromValueSum, 30)
399
        XCTAssertEqual(rows.first?.toValueSum, 30)
400
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
401
        XCTAssertTrue(rows.first?.sourceCompatible == true)
402
    }
403

            
404
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
405
        let url = databaseURL()
406
        let store = SQLiteHealthArchiveStore(databaseURL: url)
407
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
408

            
409
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
410
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
411
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
412

            
413
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
414
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
415
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
416
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
417

            
418
        let observationIDs = try observationIDs(at: url)
419
        XCTAssertEqual(observationIDs.count, 4)
420

            
421
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
422
            fromObservationID: observationIDs[0],
423
            toObservationID: observationIDs[observationIDs.count - 1],
424
            sampleTypeIdentifier: typeIdentifier
425
        ))
426

            
427
        XCTAssertEqual(rows.count, 1)
428
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
429
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
430
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
431
        XCTAssertEqual(rows.first?.fromValueSum, 30)
432
        XCTAssertEqual(rows.first?.toValueSum, 30)
433
    }
434

            
Bogdan Timofte authored 2 weeks ago
435
    private func databaseURL() -> URL {
436
        temporaryDirectory.appending(path: "Archive.sqlite")
437
    }
438

            
439
    private func createPrototypeDatabase(at url: URL) throws {
440
        var db: OpaquePointer?
441
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
442
            sqlite3_close(db)
443
            XCTFail("Could not create prototype database")
444
            return
445
        }
446
        defer { sqlite3_close(db) }
447
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
448
        XCTAssertEqual(status, SQLITE_OK)
449
    }
450

            
451
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
452
        makeStepCountSample(value: 42, start: 1_000)
453
    }
454

            
Bogdan Timofte authored 2 weeks ago
455
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
456
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
457
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
458
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
459
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
460
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
461
    }
462

            
Bogdan Timofte authored 2 weeks ago
463
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
464
        (0..<count).map { offset in
465
            let index = startIndex + offset
466
            return makeStepCountSample(
467
                value: Double((index % 97) + 1),
468
                start: 10_000 + Double(index * 600)
469
            )
470
        }
471
    }
472

            
Bogdan Timofte authored 2 weeks ago
473
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
474
        let expectation = expectation(description: "archive operation")
475
        let box = AsyncResultBox<T>()
476

            
477
        Task {
478
            do {
479
                box.result = .success(try await operation())
480
            } catch {
481
                box.result = .failure(error)
482
            }
483
            expectation.fulfill()
484
        }
485

            
486
        wait(for: [expectation], timeout: 20)
487
        return try XCTUnwrap(box.result).get()
488
    }
489

            
Bogdan Timofte authored 2 weeks ago
490
    private func countRows(in tableName: String, at url: URL) throws -> Int {
491
        var db: OpaquePointer?
492
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
493
            sqlite3_close(db)
494
            XCTFail("Could not open test database")
495
            return 0
496
        }
497
        defer { sqlite3_close(db) }
498

            
499
        var statement: OpaquePointer?
500
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
501
            sqlite3_finalize(statement)
502
            XCTFail("Could not prepare count query")
503
            return 0
504
        }
505
        defer { sqlite3_finalize(statement) }
506

            
507
        guard sqlite3_step(statement) == SQLITE_ROW else {
508
            return 0
509
        }
510
        return Int(sqlite3_column_int(statement, 0))
511
    }
512

            
Bogdan Timofte authored 2 weeks ago
513
    private func tableExists(_ tableName: String, at url: URL) throws -> Bool {
514
        var db: OpaquePointer?
515
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
516
            sqlite3_close(db)
517
            XCTFail("Could not open test database")
518
            return false
519
        }
520
        defer { sqlite3_close(db) }
521

            
522
        let sql = """
523
        SELECT 1
524
        FROM sqlite_master
525
        WHERE type = 'table' AND name = ?
526
        LIMIT 1
527
        """
528
        var statement: OpaquePointer?
529
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
530
            sqlite3_finalize(statement)
531
            XCTFail("Could not prepare table existence query")
532
            return false
533
        }
534
        defer { sqlite3_finalize(statement) }
535

            
536
        sqlite3_bind_text(statement, 1, tableName, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
537
        return sqlite3_step(statement) == SQLITE_ROW
538
    }
539

            
Bogdan Timofte authored 2 weeks ago
540
    private func observationIDs(at url: URL) throws -> [Int64] {
541
        var db: OpaquePointer?
542
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
543
            sqlite3_close(db)
544
            XCTFail("Could not open test database")
545
            return []
546
        }
547
        defer { sqlite3_close(db) }
548

            
549
        var statement: OpaquePointer?
550
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
551
            sqlite3_finalize(statement)
552
            XCTFail("Could not prepare observation query")
553
            return []
554
        }
555
        defer { sqlite3_finalize(statement) }
556

            
557
        var ids: [Int64] = []
558
        while sqlite3_step(statement) == SQLITE_ROW {
559
            ids.append(sqlite3_column_int64(statement, 0))
560
        }
561
        return ids
562
    }
563

            
Bogdan Timofte authored 2 weeks ago
564
    private func sampleVersionDebugRows(at url: URL) throws -> String {
565
        var db: OpaquePointer?
566
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
567
            sqlite3_close(db)
568
            return "could not open database"
569
        }
570
        defer { sqlite3_close(db) }
571

            
572
        let sql = """
573
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
574
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
575
               sr.operating_system_version, v.hk_device_id, v.metadata_id
576
        FROM sample_versions v
577
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
578
        LEFT JOIN sources src ON src.id = sr.source_id
579
        ORDER BY v.id
580
        """
581
        var statement: OpaquePointer?
582
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
583
            sqlite3_finalize(statement)
584
            return "could not prepare version debug query"
585
        }
586
        defer { sqlite3_finalize(statement) }
587

            
588
        var rows: [String] = []
589
        while sqlite3_step(statement) == SQLITE_ROW {
590
            rows.append((0..<13).map { index in
591
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
592
                    return "null"
593
                }
594
                if let text = sqlite3_column_text(statement, Int32(index)) {
595
                    return String(cString: text)
596
                }
597
                return "\(sqlite3_column_double(statement, Int32(index)))"
598
            }.joined(separator: "|"))
599
        }
600
        return rows.joined(separator: "\n")
601
    }
602
}