Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SessionMonitor: handle missing cases #20

Merged
merged 4 commits into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions Sources/Scout/Core/IDs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,64 @@
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import CoreData
import Foundation

/// A structure that encapsulates various identifiers used within the Scout application.
/// This structure is intended to provide a centralized location for managing and accessing
/// An enumeration that encapsulates various identifiers used within the Scout application.
/// This enumeration is intended to provide a centralized location for managing and accessing
/// different types of IDs, ensuring consistency and reducing the risk of hardcoding values
/// throughout the codebase.
///
struct IDs {
static let session = UUID()
enum IDs {

/// A static computed property that returns an optional UUID representing the session ID.
static var session: UUID? {
let context = persistentContainer.viewContext
let request: NSFetchRequest<Session> = Session.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "startDate", ascending: true)]
request.predicate = NSPredicate(format: "launchID == %@", launch as CVarArg)
request.fetchLimit = 1
let session = try? context.fetch(request).first
return session?.sessionID
}

/// A static constant that generates a new universally unique identifier (UUID) for a launch event.
static let launch = UUID()

/// A static constant representing a unique identifier for a user.
/// The UUID is generated lazily when first accessed.
///
static let user: UUID = {
let userIDString = UserDefaults.standard.string(forKey: "scout_log_user_id")
let userID = userIDString.flatMap { UUID(uuidString: $0) } ?? UUID()
UserDefaults.standard.set(userID.uuidString, forKey: "scout_log_user_id")
let userKey = "scout_log_user_id"

if let string = UserDefaults.standard.string(forKey: userKey),
let userID = UUID(uuidString: string)
{
return userID
}

let userID = UUID()
UserDefaults.standard.set(userID.uuidString, forKey: userKey)
return userID
}()
}

// MARK: - Default Values

extension Session {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(Session.sessionID))
setPrimitiveValue(IDs.user, forKey: #keyPath(Session.userID))
setPrimitiveValue(IDs.launch, forKey: #keyPath(Session.launchID))
}
}

extension EventModel {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(IDs.session, forKey: #keyPath(EventModel.sessionID))
setPrimitiveValue(IDs.user, forKey: #keyPath(EventModel.userID))
setPrimitiveValue(IDs.launch, forKey: #keyPath(EventModel.launchID))
}
}
6 changes: 2 additions & 4 deletions Sources/Scout/Core/Log.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ func log(
_ name: String, level: Logger.Level, metadata: Logger.Metadata?, date: Date,
context: NSManagedObjectContext
) throws {
let event = EventModel(context: context)
let entity = NSEntityDescription.entity(forEntityName: "EventModel", in: context)!
let event = EventModel(entity: entity, insertInto: context)

event.date = date
event.hour = date.startOfHour
Expand All @@ -41,9 +42,6 @@ func log(
event.paramCount = Int64(params.count)
}

event.userID = IDs.user
event.sessionID = IDs.session

try context.save()
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/Scout/Core/NotificationListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import UIKit
///
public actor NotificationListener {

/// An asynchronous action that can be performed in response to a notification.
typealias Action = @Sendable () async throws -> Void

/// A table mapping notification names to actions.
typealias ActionTable = [Notification.Name: Action]

private let table: ActionTable
Expand Down
19 changes: 16 additions & 3 deletions Sources/Scout/Core/SessionMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,31 @@ struct SessionMonitor {
/// that the session is properly tracked and managed.
///
static func trigger(in context: NSManagedObjectContext) throws {
let session = Session(context: context)
let entity = NSEntityDescription.entity(forEntityName: "Session", in: context)!
let session = Session(entity: entity, insertInto: context)
session.startDate = Date()
session.uuid = UUID()
try context.save()
}
}

// MARK: - Completion

extension SessionMonitor {

/// An error that occurs when completing a session.
/// - sessionNotFound: The session to be completed was not found.
/// - alreadyCompleted: The session has already been completed.
///
enum CompleteError: LocalizedError {
case sessionNotFound
case alreadyCompleted

var errorDescription: String? {
switch self {
case .sessionNotFound:
return "Session not found"
case .alreadyCompleted:
return "Session already completed"
}
}
}
Expand All @@ -41,14 +50,18 @@ struct SessionMonitor {
///
static func complete(in context: NSManagedObjectContext) throws {
let request = NSFetchRequest<Session>(entityName: "Session")
request.predicate = NSPredicate(format: "endDate == nil")
request.sortDescriptors = [NSSortDescriptor(key: "startDate", ascending: true)]
request.predicate = NSPredicate(format: "launchID == %@", IDs.launch as CVarArg)
request.fetchLimit = 1

guard let session = try context.fetch(request).first else {
throw CompleteError.sessionNotFound
}

if let _ = session.endDate {
throw CompleteError.alreadyCompleted
}

session.endDate = Date()
try context.save()
}
Expand Down
7 changes: 5 additions & 2 deletions Sources/Scout/Scout.xcdatamodeld/Scout.xcdatamodel/contents
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
<entity name="EventModel" representedClassName="EventModel" syncable="YES" codeGenerationType="class">
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hour" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="launchID" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="level" attributeType="String"/>
<attribute name="name" attributeType="String"/>
<attribute name="paramCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="params" optional="YES" attributeType="Binary"/>
<attribute name="sessionID" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="sessionID" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="uuid" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="week" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="Session" representedClassName="Session" syncable="YES" codeGenerationType="class">
<attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="launchID" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="sessionID" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="startDate" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uuid" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
</model>
4 changes: 3 additions & 1 deletion Sources/Scout/UI/AnalyticsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ public struct AnalyticsView: View {
.navigationTitle("Events")
}
.onPreferenceChange(Message.Key.self) { message in
provider.message = message
MainActor.assumeIsolated {
provider.message = message
}
}
.message($provider.message)
.environmentObject(database)
Expand Down
14 changes: 13 additions & 1 deletion Tests/ScoutTests/Core/SessionMonitorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct SessionMonitorTests {

let session = sessions[0]
#expect(session.startDate != nil)
#expect(session.uuid != nil)
#expect(session.endDate == nil)
}

@Test("Session complete") func testComplete() throws {
Expand Down Expand Up @@ -65,4 +65,16 @@ struct SessionMonitorTests {
try SessionMonitor.complete(in: context)
}
}

@Test("Complete an already completed session") func testCompleteAlreadyCompleted() throws {
// First, trigger a session
try SessionMonitor.trigger(in: context)

// Then, complete the session
try SessionMonitor.complete(in: context)

#expect(throws: SessionMonitor.CompleteError.alreadyCompleted) {
try SessionMonitor.complete(in: context)
}
}
}
3 changes: 2 additions & 1 deletion Tests/ScoutTests/Core/SyncGroupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import Testing
let names = ["1", "2", "2"]

for name in names {
let event = EventModel(context: context)
let entity = NSEntityDescription.entity(forEntityName: "EventModel", in: context)!
let event = EventModel(entity: entity, insertInto: context)
event.name = name
event.date = Date()
event.hour = fixedDate
Expand Down
5 changes: 2 additions & 3 deletions Tests/ScoutTests/Core/SyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,13 @@ import Testing
@discardableResult func createEvent(name: String, in context: NSManagedObjectContext)
-> EventModel
{
let event = EventModel(context: context)
let entity = NSEntityDescription.entity(forEntityName: "EventModel", in: context)!
let event = EventModel(entity: entity, insertInto: context)
event.name = name
event.hour = Date()
event.week = Date()
event.date = Date()
event.uuid = UUID()
event.sessionID = UUID()
event.userID = UUID()
event.level = EventLevel.info.rawValue

return event
Expand Down