mirror of https://github.com/oxen-io/session-ios
commit
edfe0c35be
@ -0,0 +1,341 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Quick
|
||||
import Nimble
|
||||
import SessionUIKit
|
||||
import SessionSnodeKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@testable import Session
|
||||
@testable import SessionUtilitiesKit
|
||||
|
||||
class DatabaseSpec: QuickSpec {
|
||||
fileprivate static let ignoredTables: Set<String> = [
|
||||
"sqlite_sequence", "grdb_migrations", "*_fts*"
|
||||
]
|
||||
|
||||
override class func spec() {
|
||||
// MARK: Configuration
|
||||
@TestState var dependencies: Dependencies! = Dependencies()
|
||||
@TestState var mockStorage: Storage! = SynchronousStorage(customWriter: try! DatabaseQueue())
|
||||
@TestState var initialResult: Result<Void, Error>! = nil
|
||||
@TestState var finalResult: Result<Void, Error>! = nil
|
||||
|
||||
let allMigrations: [Storage.KeyedMigration] = SynchronousStorage.sortedMigrationInfo(
|
||||
migrationTargets: [
|
||||
SNUtilitiesKit.self,
|
||||
SNSnodeKit.self,
|
||||
SNMessagingKit.self,
|
||||
SNUIKit.self
|
||||
]
|
||||
)
|
||||
let dynamicTests: [MigrationTest] = MigrationTest.extractTests(allMigrations)
|
||||
let allDatabaseTypes: [(TableRecord & FetchableRecord).Type] = MigrationTest.extractDatabaseTypes(allMigrations)
|
||||
MigrationTest.explicitValues = [
|
||||
// Specific enum values needed
|
||||
TableColumn(SessionThread.self, .notificationSound): 1000,
|
||||
TableColumn(ConfigDump.self, .variant): "userProfile",
|
||||
|
||||
// libSession will throw if we try to insert a community with an invalid
|
||||
// 'server' value or a room that is too long
|
||||
TableColumn(OpenGroup.self, .server): "https://www.oxen.io",
|
||||
TableColumn(OpenGroup.self, .roomToken): "testRoom",
|
||||
|
||||
// libSession will fail to load state if the ConfigDump data is invalid
|
||||
TableColumn(ConfigDump.self, .data): Data()
|
||||
]
|
||||
|
||||
// MARK: - a Database
|
||||
describe("a Database") {
|
||||
beforeEach {
|
||||
// FIXME: These should be mocked out instead of set this way
|
||||
dependencies.caches.mutate(cache: .general) { $0.encodedPublicKey = "05\(TestConstants.publicKey)" }
|
||||
SessionUtil.clearMemoryState()
|
||||
}
|
||||
|
||||
// MARK: -- can be created from an empty state
|
||||
it("can be created from an empty state") {
|
||||
mockStorage.perform(
|
||||
migrationTargets: [
|
||||
SNUtilitiesKit.self,
|
||||
SNSnodeKit.self,
|
||||
SNMessagingKit.self,
|
||||
SNUIKit.self
|
||||
],
|
||||
async: false,
|
||||
onProgressUpdate: nil,
|
||||
onMigrationRequirement: { _, _ in },
|
||||
onComplete: { result, _ in initialResult = result }
|
||||
)
|
||||
|
||||
expect(initialResult).to(beSuccess())
|
||||
}
|
||||
|
||||
// MARK: -- can still parse the database types
|
||||
it("can still parse the database types") {
|
||||
mockStorage.perform(
|
||||
sortedMigrations: allMigrations,
|
||||
async: false,
|
||||
onProgressUpdate: nil,
|
||||
onMigrationRequirement: { _, _ in },
|
||||
onComplete: { result, _ in initialResult = result }
|
||||
)
|
||||
expect(initialResult).to(beSuccess())
|
||||
|
||||
// Generate dummy data (fetching below won't do anything)
|
||||
expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: false)).toNot(throwError())
|
||||
|
||||
// Fetch the records which are required by the migrations or were modified by them to
|
||||
// ensure the decoding is also still working correctly
|
||||
mockStorage.read { db in
|
||||
allDatabaseTypes.forEach { table in
|
||||
expect { try table.fetchAll(db) }.toNot(throwError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -- can still parse the database types setting null where possible
|
||||
it("can still parse the database types setting null where possible") {
|
||||
mockStorage.perform(
|
||||
sortedMigrations: allMigrations,
|
||||
async: false,
|
||||
onProgressUpdate: nil,
|
||||
onMigrationRequirement: { _, _ in },
|
||||
onComplete: { result, _ in initialResult = result }
|
||||
)
|
||||
expect(initialResult).to(beSuccess())
|
||||
|
||||
// Generate dummy data (fetching below won't do anything)
|
||||
expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: true)).toNot(throwError())
|
||||
|
||||
// Fetch the records which are required by the migrations or were modified by them to
|
||||
// ensure the decoding is also still working correctly
|
||||
mockStorage.read { db in
|
||||
allDatabaseTypes.forEach { table in
|
||||
expect { try table.fetchAll(db) }.toNot(throwError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -- can migrate from X to Y
|
||||
dynamicTests.forEach { test in
|
||||
it("can migrate from \(test.initialMigrationKey) to \(test.finalMigrationKey)") {
|
||||
mockStorage.perform(
|
||||
sortedMigrations: test.initialMigrations,
|
||||
async: false,
|
||||
onProgressUpdate: nil,
|
||||
onMigrationRequirement: { _, _ in },
|
||||
onComplete: { result, _ in initialResult = result }
|
||||
)
|
||||
expect(initialResult).to(beSuccess())
|
||||
|
||||
// Generate dummy data (otherwise structural issues or invalid foreign keys won't error)
|
||||
expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: false)).toNot(throwError())
|
||||
|
||||
// Peform the target migrations to ensure the migrations themselves worked correctly
|
||||
mockStorage.perform(
|
||||
sortedMigrations: test.migrationsToTest,
|
||||
async: false,
|
||||
onProgressUpdate: nil,
|
||||
onMigrationRequirement: { _, _ in },
|
||||
onComplete: { result, _ in finalResult = result }
|
||||
)
|
||||
expect(finalResult).to(beSuccess())
|
||||
|
||||
/// Ensure all of the `fetchedTables` records can still be decoded correctly after the migrations have completed (since
|
||||
/// we perform multiple migrations above it's possible these won't work after the `initialMigrations` but actually will
|
||||
/// work when required as an intermediate migration could have satisfied the data requirements)
|
||||
mockStorage.read { db in
|
||||
test.migrationsToTest.forEach { _, _, migration in
|
||||
migration.fetchedTables.forEach { table in
|
||||
expect { try table.fetchAll(db) }.toNot(throwError())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
private extension Database.ColumnType {
|
||||
init(rawValue: Any) {
|
||||
switch rawValue as? String {
|
||||
case .some(let value): self = Database.ColumnType(rawValue: value)
|
||||
case .none: self = Database.ColumnType.any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TableColumn: Hashable {
|
||||
let tableName: String
|
||||
let columnName: String
|
||||
|
||||
init<T: TableRecord & ColumnExpressible>(_ type: T.Type, _ column: T.Columns) {
|
||||
self.tableName = T.databaseTableName
|
||||
self.columnName = column.name
|
||||
}
|
||||
|
||||
init?(_ tableName: String, _ columnName: Any?) {
|
||||
guard let finalColumnName: String = columnName as? String else { return nil }
|
||||
|
||||
self.tableName = tableName
|
||||
self.columnName = finalColumnName
|
||||
}
|
||||
}
|
||||
|
||||
private class MigrationTest {
|
||||
static var explicitValues: [TableColumn: (any DatabaseValueConvertible)] = [:]
|
||||
|
||||
let initialMigrations: [Storage.KeyedMigration]
|
||||
let migrationsToTest: [Storage.KeyedMigration]
|
||||
|
||||
var initialMigrationKey: String { return (initialMigrations.last?.key ?? "an empty database") }
|
||||
var finalMigrationKey: String { return (migrationsToTest.last?.key ?? "invalid") }
|
||||
|
||||
private init(
|
||||
initialMigrations: [Storage.KeyedMigration],
|
||||
migrationsToTest: [Storage.KeyedMigration]
|
||||
) {
|
||||
self.initialMigrations = initialMigrations
|
||||
self.migrationsToTest = migrationsToTest
|
||||
}
|
||||
|
||||
// MARK: - Test Data
|
||||
|
||||
static func extractTests(_ allMigrations: [Storage.KeyedMigration]) -> [MigrationTest] {
|
||||
return (0..<(allMigrations.count - 1))
|
||||
.flatMap { index -> [MigrationTest] in
|
||||
((index + 1)..<allMigrations.count).map { targetMigrationIndex -> MigrationTest in
|
||||
MigrationTest(
|
||||
initialMigrations: Array(allMigrations[0..<index]),
|
||||
migrationsToTest: Array(allMigrations[index..<targetMigrationIndex])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func extractDatabaseTypes(_ allMigrations: [Storage.KeyedMigration]) -> [(TableRecord & FetchableRecord).Type] {
|
||||
return allMigrations
|
||||
.reduce(into: [:]) { result, next in
|
||||
next.migration.fetchedTables.forEach { table in
|
||||
result[ObjectIdentifier(table).hashValue] = table
|
||||
}
|
||||
|
||||
next.migration.createdOrAlteredTables.forEach { table in
|
||||
result[ObjectIdentifier(table).hashValue] = table
|
||||
}
|
||||
}
|
||||
.values
|
||||
.asArray()
|
||||
}
|
||||
|
||||
// MARK: - Mock Data
|
||||
|
||||
static func generateDummyData(_ storage: Storage, nullsWherePossible: Bool) throws {
|
||||
var generationError: Error? = nil
|
||||
|
||||
// The `PRAGMA foreign_keys` is a no-op within a transaction so we have to do it outside of one
|
||||
try storage.testDbWriter?.writeWithoutTransaction { db in try db.execute(sql: "PRAGMA foreign_keys = OFF") }
|
||||
storage.write { db in
|
||||
do {
|
||||
try MigrationTest.generateDummyData(db, nullsWherePossible: nullsWherePossible)
|
||||
try db.checkForeignKeys()
|
||||
}
|
||||
catch { generationError = error }
|
||||
}
|
||||
try storage.testDbWriter?.writeWithoutTransaction { db in try db.execute(sql: "PRAGMA foreign_keys = ON") }
|
||||
|
||||
// Throw the error if there was one
|
||||
if let error: Error = generationError { throw error }
|
||||
}
|
||||
|
||||
private static func generateDummyData(_ db: Database, nullsWherePossible: Bool) throws {
|
||||
// Fetch table schema information
|
||||
let disallowedPrefixes: Set<String> = DatabaseSpec.ignoredTables
|
||||
.filter { $0.hasPrefix("*") && !$0.hasSuffix("*") }
|
||||
.map { String($0[$0.index(after: $0.startIndex)...]) }
|
||||
.asSet()
|
||||
let disallowedSuffixes: Set<String> = DatabaseSpec.ignoredTables
|
||||
.filter { $0.hasSuffix("*") && !$0.hasPrefix("*") }
|
||||
.map { String($0[$0.startIndex..<$0.index(before: $0.endIndex)]) }
|
||||
.asSet()
|
||||
let disallowedContains: Set<String> = DatabaseSpec.ignoredTables
|
||||
.filter { $0.hasPrefix("*") && $0.hasSuffix("*") }
|
||||
.map { String($0[$0.index(after: $0.startIndex)..<$0.index(before: $0.endIndex)]) }
|
||||
.asSet()
|
||||
let tables: [Row] = try Row
|
||||
.fetchAll(db, sql: "SELECT * from sqlite_schema WHERE type = 'table'")
|
||||
.filter { tableInfo -> Bool in
|
||||
guard let name: String = tableInfo["name"] else { return false }
|
||||
|
||||
return (
|
||||
!DatabaseSpec.ignoredTables.contains(name) &&
|
||||
!disallowedPrefixes.contains(where: { name.hasPrefix($0) }) &&
|
||||
!disallowedSuffixes.contains(where: { name.hasSuffix($0) }) &&
|
||||
!disallowedContains.contains(where: { name.contains($0) })
|
||||
)
|
||||
}
|
||||
|
||||
// Generate data via schema inspection for all other tables
|
||||
try tables.forEach { tableInfo in
|
||||
switch tableInfo["name"] as? String {
|
||||
case .none: throw StorageError.generic
|
||||
|
||||
case Identity.databaseTableName:
|
||||
// If there is an 'Identity' table then insert "proper" identity info (otherwise mock
|
||||
// data might get deleted as invalid in libSession migrations)
|
||||
try [
|
||||
Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!),
|
||||
Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!),
|
||||
Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)!),
|
||||
Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!)
|
||||
].forEach { try $0.insert(db) }
|
||||
|
||||
case .some(let name):
|
||||
// No need to insert dummy data if it already exists in the table
|
||||
guard try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM '\(name)'") == 0 else { return }
|
||||
|
||||
let columnInfo: [Row] = try Row.fetchAll(db, sql: "PRAGMA table_info('\(name)');")
|
||||
let validNames: [String] = columnInfo.compactMap { $0["name"].map { "'\($0)'" } }
|
||||
let columnNames: String = validNames.joined(separator: ", ")
|
||||
let columnArgs: String = validNames.map { _ in "?" }.joined(separator: ", ")
|
||||
|
||||
try db.execute(
|
||||
sql: "INSERT INTO \(name) (\(columnNames)) VALUES (\(columnArgs))",
|
||||
arguments: StatementArguments(columnInfo.map { column in
|
||||
// If we want to allow setting nulls (and the column is nullable but not a primary
|
||||
// key) then use null for it's value
|
||||
guard !nullsWherePossible || column["notnull"] != 0 || column["pk"] == 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If this column has an explicitly defined value then use that
|
||||
if
|
||||
let key: TableColumn = TableColumn(name, column["name"]),
|
||||
let explicitValue: (any DatabaseValueConvertible) = MigrationTest.explicitValues[key]
|
||||
{
|
||||
return explicitValue
|
||||
}
|
||||
|
||||
// Otherwise generate some mock data (trying to use potentially real values in case
|
||||
// something is a primary/foreign key)
|
||||
switch Database.ColumnType(rawValue: column["type"]) {
|
||||
case .text: return "05\(TestConstants.publicKey)"
|
||||
case .blob: return Data([1, 2, 3])
|
||||
case .boolean: return false
|
||||
case .integer, .numeric, .double, .real: return 1
|
||||
case .date, .datetime: return Date(timeIntervalSince1970: 1234567890)
|
||||
case .any: return nil
|
||||
default: return nil
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Result where Failure == Error {
|
||||
init(_ closure: @autoclosure () throws -> Success) {
|
||||
do { self = Result.success(try closure()) }
|
||||
catch { self = Result.failure(error) }
|
||||
}
|
||||
|
||||
func onFailure(closure: (Failure) -> ()) -> Result<Success, Failure> {
|
||||
switch self {
|
||||
case .success: break
|
||||
case .failure(let failure): closure(failure)
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func successOrThrow() throws -> Success {
|
||||
switch self {
|
||||
case .success(let value): return value
|
||||
case .failure(let error): throw error
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue