forked from sagnik/Project_Velocity
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
1310 lines
48 KiB
Swift
1310 lines
48 KiB
Swift
import SwiftUI
|
|
import XCTest
|
|
@testable import velocity
|
|
|
|
final class VelocitySmokeTests: XCTestCase {
|
|
@MainActor
|
|
func testContentViewCanBeConstructed() {
|
|
let view = ContentView()
|
|
XCTAssertNotNil(view)
|
|
}
|
|
|
|
func testAppSectionsRemainStable() {
|
|
XCTAssertEqual(
|
|
AppSection.allCases.map(\.rawValue),
|
|
[
|
|
"Dashboard",
|
|
"Clients",
|
|
"Communications",
|
|
"Calendar",
|
|
"Inventory",
|
|
"Settings",
|
|
]
|
|
)
|
|
}
|
|
|
|
func testAppSectionDisplayTitlesMatchProductionScope() {
|
|
XCTAssertEqual(
|
|
AppSection.allCases.map(\.displayTitle),
|
|
[
|
|
"Dashboard",
|
|
"Clients",
|
|
"Communications",
|
|
"Calendar",
|
|
"Inventory",
|
|
"Settings",
|
|
]
|
|
)
|
|
}
|
|
|
|
func testShowroomDockExcludesAdministrativeWorkspaces() {
|
|
let sectionNames = Set(AppSection.allCases.map(\.rawValue))
|
|
XCTAssertFalse(sectionNames.contains("Imports"))
|
|
XCTAssertFalse(sectionNames.contains("Sentinel"))
|
|
XCTAssertFalse(sectionNames.contains("Oracle"))
|
|
XCTAssertEqual(AppSection.communications.dockTitle, "Comms")
|
|
}
|
|
|
|
func testAppConfigParsesExplicitValuesAndRejectsPlaceholders() {
|
|
XCTAssertEqual(
|
|
AppConfig.parsedValue(from: ["BASE_URL": " https://velocity.desineuron.in/api "], key: "BASE_URL"),
|
|
"https://velocity.desineuron.in/api"
|
|
)
|
|
XCTAssertNil(
|
|
AppConfig.parsedValue(from: ["BASE_URL": "$(BASE_URL)"], key: "BASE_URL")
|
|
)
|
|
XCTAssertNil(
|
|
AppConfig.parsedValue(from: ["BASE_URL": " "], key: "BASE_URL")
|
|
)
|
|
XCTAssertNil(
|
|
AppConfig.parsedValue(from: nil, key: "BASE_URL")
|
|
)
|
|
}
|
|
|
|
func testAuthModeDescriptionOnlyReportsConfiguredCredentials() {
|
|
XCTAssertEqual(
|
|
AppConfig.authModeDescription(bearerToken: "token", email: nil, password: nil),
|
|
"Bearer token"
|
|
)
|
|
XCTAssertEqual(
|
|
AppConfig.authModeDescription(bearerToken: nil, email: "user@example.com", password: "secret"),
|
|
"Email/password"
|
|
)
|
|
XCTAssertEqual(
|
|
AppConfig.authModeDescription(bearerToken: nil, email: "user@example.com", password: nil),
|
|
"Credentials required"
|
|
)
|
|
}
|
|
|
|
func testSessionDraftRejectsInsecureOrIncompleteConfiguration() {
|
|
let insecureDraft = SessionConfigurationDraft(
|
|
baseURL: "http://velocity.desineuron.in/api",
|
|
dreamWeaverBaseURL: "",
|
|
dreamWeaverAPIKey: "",
|
|
authMode: .emailPassword,
|
|
email: "operator@desineuron.in",
|
|
password: "secret",
|
|
bearerToken: "",
|
|
existingDreamWeaverAPIKeyAvailable: false,
|
|
existingPasswordAvailable: false,
|
|
existingBearerTokenAvailable: false,
|
|
baselineEmail: nil
|
|
)
|
|
XCTAssertFalse(insecureDraft.validationErrors().isEmpty)
|
|
|
|
let missingPasswordDraft = SessionConfigurationDraft(
|
|
baseURL: "https://velocity.desineuron.in/api",
|
|
dreamWeaverBaseURL: "",
|
|
dreamWeaverAPIKey: "",
|
|
authMode: .emailPassword,
|
|
email: "operator@desineuron.in",
|
|
password: "",
|
|
bearerToken: "",
|
|
existingDreamWeaverAPIKeyAvailable: false,
|
|
existingPasswordAvailable: false,
|
|
existingBearerTokenAvailable: false,
|
|
baselineEmail: nil
|
|
)
|
|
XCTAssertTrue(
|
|
missingPasswordDraft.validationErrors().contains("Password is required for email/password login.")
|
|
)
|
|
}
|
|
|
|
func testSessionDraftNormalizesHttpsOriginAndReusesExistingSecretsSafely() {
|
|
let draft = SessionConfigurationDraft(
|
|
baseURL: " https://Velocity.DesiNeuron.in/api/ ",
|
|
dreamWeaverBaseURL: " https://dreamweaver.DesiNeuron.in/ ",
|
|
dreamWeaverAPIKey: "",
|
|
authMode: .emailPassword,
|
|
email: "operator@desineuron.in",
|
|
password: "",
|
|
bearerToken: "",
|
|
existingDreamWeaverAPIKeyAvailable: true,
|
|
existingPasswordAvailable: true,
|
|
existingBearerTokenAvailable: false,
|
|
baselineEmail: "operator@desineuron.in"
|
|
)
|
|
|
|
XCTAssertEqual(draft.normalizedBaseURL, "https://velocity.desineuron.in/api")
|
|
XCTAssertEqual(draft.normalizedDreamWeaverBaseURL, "https://dreamweaver.desineuron.in")
|
|
XCTAssertEqual(
|
|
draft.resolvedPassword(existingPassword: "stored-secret"),
|
|
"stored-secret"
|
|
)
|
|
XCTAssertEqual(
|
|
draft.resolvedEmail(existingEmail: nil),
|
|
"operator@desineuron.in"
|
|
)
|
|
XCTAssertEqual(
|
|
draft.resolvedDreamWeaverAPIKey(existingKey: "stored-gateway-key"),
|
|
"stored-gateway-key"
|
|
)
|
|
}
|
|
|
|
func testSessionDraftRejectsInsecureDreamWeaverEndpoint() {
|
|
let draft = SessionConfigurationDraft(
|
|
baseURL: "https://velocity.desineuron.in/api",
|
|
dreamWeaverBaseURL: "http://54.172.172.2:8082",
|
|
dreamWeaverAPIKey: "",
|
|
authMode: .bearerToken,
|
|
email: "",
|
|
password: "",
|
|
bearerToken: "token",
|
|
existingDreamWeaverAPIKeyAvailable: false,
|
|
existingPasswordAvailable: false,
|
|
existingBearerTokenAvailable: false,
|
|
baselineEmail: nil
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
draft.validationErrors().contains(
|
|
"Dream Weaver endpoint must be an HTTPS origin like https://dreamweaver.desineuron.in."
|
|
)
|
|
)
|
|
}
|
|
|
|
func testAppSessionConfigurationReflectsDreamWeaverGatewayKeyState() {
|
|
let configured = AppSessionConfiguration(
|
|
baseURL: "https://velocity.desineuron.in/api",
|
|
dreamWeaverBaseURL: "https://dreamweaver.desineuron.in",
|
|
usesDedicatedDreamWeaverBaseURL: true,
|
|
hasDreamWeaverAPIKey: true,
|
|
email: "operator@desineuron.in",
|
|
hasPassword: true,
|
|
hasBearerToken: false,
|
|
source: .secureDeviceStorage
|
|
)
|
|
XCTAssertEqual(configured.dreamWeaverAuthenticationDescription, "API key configured")
|
|
|
|
let open = AppSessionConfiguration(
|
|
baseURL: "https://velocity.desineuron.in/api",
|
|
dreamWeaverBaseURL: "https://velocity.desineuron.in",
|
|
usesDedicatedDreamWeaverBaseURL: false,
|
|
hasDreamWeaverAPIKey: false,
|
|
email: nil,
|
|
hasPassword: false,
|
|
hasBearerToken: true,
|
|
source: .secureDeviceStorage
|
|
)
|
|
XCTAssertEqual(open.dreamWeaverAuthenticationDescription, "No gateway key configured")
|
|
}
|
|
|
|
func testGenerationJobBuildsFallbackRoutesWhenGatewayReturnsMinimalContract() throws {
|
|
let job = try JSONDecoder().decode(
|
|
GenerationJob.self,
|
|
from: Data(#"{"job_id":"job-123","status":"processing"}"#.utf8)
|
|
)
|
|
|
|
XCTAssertEqual(job.pollUrl, nil)
|
|
XCTAssertEqual(job.resultUrl, nil)
|
|
XCTAssertEqual(
|
|
try job.resolvedPollURL(baseURL: "https://dw.desineuron.in").absoluteString,
|
|
"https://dw.desineuron.in/dream-weaver/status/job-123"
|
|
)
|
|
XCTAssertEqual(
|
|
try job.resolvedResultURL(baseURL: "https://dw.desineuron.in").absoluteString,
|
|
"https://dw.desineuron.in/dream-weaver/result/job-123"
|
|
)
|
|
}
|
|
|
|
func testJobStatusResolvesRelativeResultURL() throws {
|
|
let status = try JSONDecoder().decode(
|
|
JobStatus.self,
|
|
from: Data(#"{"status":"done","ready":true,"result_url":"/dream-weaver/result/job-123"}"#.utf8)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
try status.resolvedResultURL(baseURL: "https://dw.desineuron.in", jobId: "job-123").absoluteString,
|
|
"https://dw.desineuron.in/dream-weaver/result/job-123"
|
|
)
|
|
}
|
|
|
|
func testOracleProductionScopeOnlyExposesLiveBackedModes() {
|
|
XCTAssertEqual(
|
|
OracleModeAvailability.productionVisibleModes,
|
|
[
|
|
.pipeline,
|
|
.deals,
|
|
.accountTimeline,
|
|
.calendarTasks,
|
|
]
|
|
)
|
|
XCTAssertEqual(
|
|
OracleModeAvailability.hiddenModesUntilBackendSupport,
|
|
[
|
|
.teamPerformance,
|
|
.leadMap,
|
|
]
|
|
)
|
|
}
|
|
|
|
func testOracleProductionScopeSanitizesUnsupportedSelections() {
|
|
XCTAssertEqual(
|
|
OracleModeAvailability.sanitizedProductionSelection(.teamPerformance),
|
|
.pipeline
|
|
)
|
|
XCTAssertEqual(
|
|
OracleModeAvailability.sanitizedProductionSelection(.leadMap),
|
|
.pipeline
|
|
)
|
|
XCTAssertEqual(
|
|
OracleModeAvailability.sanitizedProductionSelection(.calendarTasks),
|
|
.calendarTasks
|
|
)
|
|
XCTAssertEqual(
|
|
OracleModeAvailability.sanitizedProductionSelection(.deals),
|
|
.deals
|
|
)
|
|
}
|
|
|
|
func testInventoryProductionScopeHidesDollhouseWithoutAsset() {
|
|
XCTAssertEqual(
|
|
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: false),
|
|
[
|
|
.sunseeker,
|
|
.dreamWeaver,
|
|
]
|
|
)
|
|
XCTAssertEqual(
|
|
InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: false),
|
|
"Sunseeker · Dream Weaver"
|
|
)
|
|
}
|
|
|
|
func testCanonicalContactAdapterFiltersRowsWithoutLeadContextAndNormalizesScore() {
|
|
let summaries = VelocityLeadDTO.activeLeadSummaries(
|
|
from: [
|
|
VelocityCanonicalContactListItemDTO(
|
|
personId: "person-1",
|
|
fullName: "Amina Rahman",
|
|
primaryPhone: "+971500000001",
|
|
buyerType: "high_intent",
|
|
leadId: "lead-1",
|
|
leadStatus: "qualified",
|
|
budgetBand: "AED 12M",
|
|
urgency: "high",
|
|
primaryInterest: "Marina Penthouse",
|
|
intentScore: 0.94,
|
|
engagementScore: 0.91,
|
|
urgencyScore: 0.88,
|
|
interactionCount: 6,
|
|
pendingTasks: 2,
|
|
lastInteractionAt: "2026-04-22T10:00:00+00:00",
|
|
createdAt: "2026-04-21T10:00:00+00:00"
|
|
),
|
|
VelocityCanonicalContactListItemDTO(
|
|
personId: "person-2",
|
|
fullName: "Contact Without Lead",
|
|
primaryPhone: nil,
|
|
buyerType: nil,
|
|
leadId: nil,
|
|
leadStatus: nil,
|
|
budgetBand: nil,
|
|
urgency: nil,
|
|
primaryInterest: nil,
|
|
intentScore: 0.42,
|
|
engagementScore: 0.10,
|
|
urgencyScore: 0.20,
|
|
interactionCount: 0,
|
|
pendingTasks: 0,
|
|
lastInteractionAt: nil,
|
|
createdAt: "2026-04-21T10:00:00+00:00"
|
|
),
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(summaries.count, 1)
|
|
XCTAssertEqual(summaries[0].id, "lead-1")
|
|
XCTAssertEqual(summaries[0].personId, "person-1")
|
|
XCTAssertEqual(summaries[0].score, 94)
|
|
XCTAssertEqual(summaries[0].kanbanStatus, "Qualified")
|
|
XCTAssertEqual(summaries[0].qualification, "Whale")
|
|
XCTAssertEqual(summaries[0].pendingTaskCount, 2)
|
|
XCTAssertEqual(summaries[0].interactionCount, 6)
|
|
XCTAssertEqual(summaries[0].updatedAt, "2026-04-22T10:00:00+00:00")
|
|
}
|
|
|
|
func testClientDataContactDecodesCurrentQDFamilies() throws {
|
|
let payload = """
|
|
{
|
|
"person_id": "person-live",
|
|
"full_name": "Sanjay Chatterjee",
|
|
"primary_phone": "+91-88479-41519",
|
|
"buyer_type": "investor",
|
|
"lead_id": "lead-live",
|
|
"lead_status": "new",
|
|
"budget_band": "15-25 Cr",
|
|
"urgency": "6_months",
|
|
"primary_interest": "Eden Devprayag",
|
|
"interaction_count": 4,
|
|
"pending_tasks": 1,
|
|
"last_interaction_at": "2026-04-18T16:47:12.740450+00:00",
|
|
"qd_overview": {
|
|
"intent": {
|
|
"score_type": "intent",
|
|
"current_value": 0.73
|
|
},
|
|
"engagement": {
|
|
"score_type": "engagement",
|
|
"current_value": 0.68
|
|
},
|
|
"urgency": {
|
|
"score_type": "urgency",
|
|
"current_value": 0.91
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
let contact = try JSONDecoder().decode(
|
|
VelocityCanonicalContactListItemDTO.self,
|
|
from: Data(payload.utf8)
|
|
)
|
|
let snapshot = VelocityClient360DTO.minimal(from: contact)
|
|
|
|
XCTAssertEqual(contact.personId, "person-live")
|
|
XCTAssertEqual(contact.intentScore, 0.73)
|
|
XCTAssertEqual(contact.engagementScore, 0.68)
|
|
XCTAssertEqual(contact.urgencyScore, 0.91)
|
|
XCTAssertEqual(contact.displayIntentScore, 91)
|
|
XCTAssertEqual(snapshot.primaryQDScore?.scoreType, "overall")
|
|
XCTAssertEqual(snapshot.primaryQDScore?.displayScore, 91)
|
|
}
|
|
|
|
func testClientDataDetailAdaptsNestedProductionShape() throws {
|
|
let payload = """
|
|
{
|
|
"person": {
|
|
"person_id": "person-live",
|
|
"full_name": "Sanjay Chatterjee",
|
|
"primary_email": "sanjay@example.com",
|
|
"primary_phone": "+91-88479-41519",
|
|
"buyer_type": "investor",
|
|
"persona_labels": ["high intent"]
|
|
},
|
|
"lead": {
|
|
"lead_id": "lead-live",
|
|
"lead_status": "qualified",
|
|
"budget_band": "15-25 Cr",
|
|
"urgency": "6_months"
|
|
},
|
|
"opportunities": [
|
|
{
|
|
"opportunity_id": "opp-live",
|
|
"stage": "site_visit",
|
|
"value": 18000000,
|
|
"probability": 0.7,
|
|
"expected_close_date": "2026-05-01",
|
|
"next_action": "Schedule visit",
|
|
"notes": null,
|
|
"project_id": "project-1",
|
|
"unit_id": null,
|
|
"person_id": "person-live",
|
|
"client_name": "Sanjay Chatterjee",
|
|
"client_phone": "+91-88479-41519",
|
|
"project_name": "Eden Devprayag"
|
|
}
|
|
],
|
|
"property_interests": [
|
|
{
|
|
"interest_id": "interest-live",
|
|
"project_name": "Eden Devprayag",
|
|
"unit_preference": "Penthouse",
|
|
"configuration": "4 BHK",
|
|
"budget_min": 15000000,
|
|
"budget_max": 25000000,
|
|
"priority": 1
|
|
}
|
|
],
|
|
"recent_interactions": [
|
|
{
|
|
"interaction_id": "interaction-live",
|
|
"channel": "call",
|
|
"interaction_type": "follow_up",
|
|
"happened_at": "2026-04-18T16:47:12.740450+00:00",
|
|
"summary": "Asked for tower availability."
|
|
}
|
|
],
|
|
"tasks": [
|
|
{
|
|
"reminder_id": "task-live",
|
|
"reminder_type": "follow_up",
|
|
"title": "Share floor plan",
|
|
"notes": "Send updated inventory.",
|
|
"due_at": "2026-04-26T10:00:00+00:00",
|
|
"status": "pending",
|
|
"priority": "high",
|
|
"person_id": "person-live",
|
|
"client_name": "Sanjay Chatterjee",
|
|
"client_phone": "+91-88479-41519"
|
|
}
|
|
],
|
|
"qd_scores": [
|
|
{
|
|
"score_type": "intent",
|
|
"current_value": 0.73,
|
|
"computed_at": "2026-04-18T16:47:12.740450+00:00",
|
|
"reasoning": "Recent call showed buying intent."
|
|
},
|
|
{
|
|
"score_type": "urgency",
|
|
"current_value": 0.91
|
|
}
|
|
],
|
|
"next_best_action": "Share inventory shortlist"
|
|
}
|
|
"""
|
|
|
|
let detail = try JSONDecoder().decode(
|
|
VelocityClientDataDetailDTO.self,
|
|
from: Data(payload.utf8)
|
|
).snapshot
|
|
|
|
XCTAssertEqual(detail.identity.personId, "person-live")
|
|
XCTAssertEqual(detail.identity.fullName, "Sanjay Chatterjee")
|
|
XCTAssertEqual(detail.currentLead?.leadId, "lead-live")
|
|
XCTAssertEqual(detail.currentLead?.status, "qualified")
|
|
XCTAssertEqual(detail.activeOpportunities.count, 1)
|
|
XCTAssertEqual(detail.propertyInterests.count, 1)
|
|
XCTAssertEqual(detail.recentInteractions.count, 1)
|
|
XCTAssertEqual(detail.tasks.count, 1)
|
|
XCTAssertEqual(detail.primaryQDScore?.scoreType, "intent")
|
|
XCTAssertEqual(detail.recommendedNextActions, ["Share inventory shortlist"])
|
|
}
|
|
|
|
func testCanonicalTaskAdapterSortsUrgentAndSoonerItemsFirst() {
|
|
let tasks = VelocityTaskDTO.sortedForOperatorReview(
|
|
[
|
|
VelocityTaskDTO(
|
|
reminderId: "task-3",
|
|
reminderType: "follow_up",
|
|
title: "Later high-priority visit",
|
|
notes: nil,
|
|
dueAt: "2026-04-24T10:00:00+00:00",
|
|
status: "pending",
|
|
priority: "high",
|
|
personId: "person-1",
|
|
clientName: "Amina Rahman",
|
|
clientPhone: "+971500000001"
|
|
),
|
|
VelocityTaskDTO(
|
|
reminderId: "task-1",
|
|
reminderType: "follow_up",
|
|
title: "Urgent callback",
|
|
notes: "Call now",
|
|
dueAt: "2026-04-23T08:00:00+00:00",
|
|
status: "pending",
|
|
priority: "urgent",
|
|
personId: "person-1",
|
|
clientName: "Amina Rahman",
|
|
clientPhone: "+971500000001"
|
|
),
|
|
VelocityTaskDTO(
|
|
reminderId: "task-2",
|
|
reminderType: "follow_up",
|
|
title: "Soon high-priority visit",
|
|
notes: nil,
|
|
dueAt: "2026-04-23T09:00:00+00:00",
|
|
status: "pending",
|
|
priority: "high",
|
|
personId: "person-1",
|
|
clientName: "Amina Rahman",
|
|
clientPhone: "+971500000001"
|
|
),
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(tasks.map(\.reminderId), ["task-1", "task-2", "task-3"])
|
|
XCTAssertEqual(tasks[0].priorityLabel, "Urgent")
|
|
XCTAssertNotNil(tasks[1].dueDate)
|
|
XCTAssertEqual(tasks[1].title, "Soon high-priority visit")
|
|
}
|
|
|
|
func testCanonicalTaskSnoozeUsesLaterOfNowOrDueDate() {
|
|
let task = VelocityTaskDTO(
|
|
reminderId: "task-1",
|
|
reminderType: "follow_up",
|
|
title: "Urgent callback",
|
|
notes: nil,
|
|
dueAt: "2099-04-23T08:00:00+00:00",
|
|
status: "pending",
|
|
priority: "urgent",
|
|
personId: "person-1",
|
|
clientName: "Amina Rahman",
|
|
clientPhone: "+971500000001"
|
|
)
|
|
|
|
let snoozed = task.nextSnoozeDate(adding: 2 * 60 * 60)
|
|
|
|
XCTAssertEqual(
|
|
Int(snoozed.timeIntervalSince(task.dueDate ?? .distantPast)),
|
|
2 * 60 * 60
|
|
)
|
|
}
|
|
|
|
func testCanonicalKanbanAdapterSortsCardsByIntentScoreWithinLane() {
|
|
let board = VelocityKanbanColumnDTO.operatorDisplayBoard(
|
|
from: [
|
|
VelocityKanbanColumnDTO(
|
|
status: "qualified",
|
|
label: "Qualified",
|
|
count: 2,
|
|
items: [
|
|
VelocityKanbanCardDTO(
|
|
leadId: "lead-2",
|
|
personId: "person-2",
|
|
clientName: "Zara Khan",
|
|
clientPhone: "+971500000002",
|
|
buyerType: "investor",
|
|
budgetBand: "AED 9M",
|
|
urgency: "high",
|
|
intentScore: 0.61
|
|
),
|
|
VelocityKanbanCardDTO(
|
|
leadId: "lead-1",
|
|
personId: "person-1",
|
|
clientName: "Amina Rahman",
|
|
clientPhone: "+971500000001",
|
|
buyerType: "high_intent",
|
|
budgetBand: "AED 12M",
|
|
urgency: "urgent",
|
|
intentScore: 0.94
|
|
),
|
|
]
|
|
),
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(board.count, 1)
|
|
XCTAssertEqual(board[0].items.map(\.leadId), ["lead-1", "lead-2"])
|
|
XCTAssertEqual(board[0].items[0].displayIntentScore, 94)
|
|
XCTAssertEqual(board[0].items[0].buyerTypeLabel, "High Intent")
|
|
XCTAssertEqual(board[0].items[0].urgencyLabel, "Urgent")
|
|
}
|
|
|
|
func testClient360ResponseDecodesCanonicalSnapshotShape() throws {
|
|
let payload = """
|
|
{
|
|
"client_ref": "person-1",
|
|
"snapshot_type": "client_360",
|
|
"identity": {
|
|
"person_id": "person-1",
|
|
"full_name": "Amina Rahman",
|
|
"primary_email": "amina@example.com",
|
|
"primary_phone": "+971500000001",
|
|
"buyer_type": "high_intent",
|
|
"persona_labels": ["marina_buyer"],
|
|
"source_confidence": 1.0
|
|
},
|
|
"current_lead": {
|
|
"lead_id": "lead-1",
|
|
"status": "qualified",
|
|
"budget_band": "AED 12M",
|
|
"urgency": "high",
|
|
"financing_posture": null,
|
|
"timeline_to_decision": null,
|
|
"objections": [],
|
|
"motivations": ["view_upgrade"]
|
|
},
|
|
"active_opportunities": [
|
|
{
|
|
"opportunity_id": "opp-1",
|
|
"stage": "proposal",
|
|
"value": 12000000,
|
|
"probability": 0.8,
|
|
"expected_close_date": "2026-04-30",
|
|
"next_action": "Share proposal",
|
|
"project_id": null,
|
|
"unit_id": null
|
|
}
|
|
],
|
|
"recent_interactions": [
|
|
{
|
|
"interaction_id": "int-1",
|
|
"channel": "whatsapp",
|
|
"interaction_type": "message",
|
|
"happened_at": "2026-04-22T10:00:00+00:00",
|
|
"summary": "Asked for brochure"
|
|
}
|
|
],
|
|
"property_interests": [
|
|
{
|
|
"interest_id": "interest-1",
|
|
"project_name": "Marina Residences",
|
|
"unit_preference": "4BR",
|
|
"configuration": "Penthouse",
|
|
"budget_min": 10000000,
|
|
"budget_max": 15000000,
|
|
"priority": 1
|
|
}
|
|
],
|
|
"tasks": [
|
|
{
|
|
"reminder_id": "task-1",
|
|
"reminder_type": "follow_up",
|
|
"title": "Call back",
|
|
"due_at": "2026-04-23T09:00:00+00:00",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
}
|
|
],
|
|
"qd_overview": {
|
|
"intent_score": {
|
|
"score_type": "intent_score",
|
|
"current_value": 0.94,
|
|
"computed_at": "2026-04-22T10:00:00+00:00",
|
|
"reasoning": "Strong engagement"
|
|
}
|
|
},
|
|
"risk_flags": ["no_recent_interactions"],
|
|
"recommended_next_actions": ["Schedule follow-up"],
|
|
"note": "Derived read model. Not primary truth."
|
|
}
|
|
"""
|
|
|
|
let snapshot = try JSONDecoder().decode(
|
|
VelocityClient360DTO.self,
|
|
from: Data(payload.utf8)
|
|
)
|
|
|
|
XCTAssertEqual(snapshot.identity.fullName, "Amina Rahman")
|
|
XCTAssertEqual(snapshot.currentLead?.status, "qualified")
|
|
XCTAssertEqual(snapshot.activeOpportunities.first?.formattedValue, "AED 12.0M")
|
|
XCTAssertEqual(snapshot.qdOverview["intent_score"]?.displayScore, 94)
|
|
XCTAssertEqual(snapshot.tasks.first?.title, "Call back")
|
|
}
|
|
|
|
func testClient360DecodesLiveJsonStringArrayFields() throws {
|
|
let payload = """
|
|
{
|
|
"client_ref": "person-live",
|
|
"snapshot_type": "client_360",
|
|
"identity": {
|
|
"person_id": "person-live",
|
|
"full_name": "Sanjay Chatterjee",
|
|
"primary_email": "sanjay@example.com",
|
|
"primary_phone": "+91-88479-41519",
|
|
"buyer_type": null,
|
|
"persona_labels": "[\\"repeat_visitor\\"]",
|
|
"source_confidence": 0.94
|
|
},
|
|
"current_lead": {
|
|
"lead_id": "lead-live",
|
|
"status": "new",
|
|
"budget_band": "15-25 Cr",
|
|
"urgency": "6_months",
|
|
"financing_posture": null,
|
|
"timeline_to_decision": null,
|
|
"objections": "[]",
|
|
"motivations": "[\\"family_discussion\\"]"
|
|
},
|
|
"active_opportunities": [],
|
|
"recent_interactions": [],
|
|
"property_interests": [],
|
|
"tasks": [],
|
|
"qd_overview": {
|
|
"urgency": {
|
|
"score_type": "urgency",
|
|
"current_value": 1.0,
|
|
"computed_at": "2026-04-18T16:47:12.740450+00:00",
|
|
"reasoning": null
|
|
}
|
|
},
|
|
"risk_flags": ["no_property_interests_recorded"],
|
|
"recommended_next_actions": [],
|
|
"note": "Derived read model. Not primary truth."
|
|
}
|
|
"""
|
|
|
|
let snapshot = try JSONDecoder().decode(
|
|
VelocityClient360DTO.self,
|
|
from: Data(payload.utf8)
|
|
)
|
|
|
|
XCTAssertEqual(snapshot.identity.personaLabels, ["repeat_visitor"])
|
|
XCTAssertEqual(snapshot.currentLead?.objections, [])
|
|
XCTAssertEqual(snapshot.currentLead?.motivations, ["family_discussion"])
|
|
XCTAssertEqual(snapshot.qdOverview["urgency"]?.displayScore, 100)
|
|
}
|
|
|
|
func testOpportunitiesClientSortsHigherValueRowsFirst() {
|
|
let opportunities = [
|
|
VelocityOpportunityDTO(
|
|
opportunityId: "opp-2",
|
|
stage: "proposal",
|
|
value: 8000000,
|
|
probability: 0.6,
|
|
expectedCloseDate: nil,
|
|
nextAction: "Send brochure",
|
|
notes: nil,
|
|
projectId: nil,
|
|
unitId: nil,
|
|
personId: "person-2",
|
|
clientName: "Zara Khan",
|
|
clientPhone: "+971500000002",
|
|
projectName: "Skyline"
|
|
),
|
|
VelocityOpportunityDTO(
|
|
opportunityId: "opp-1",
|
|
stage: "negotiation",
|
|
value: 12000000,
|
|
probability: 0.8,
|
|
expectedCloseDate: nil,
|
|
nextAction: "Share proposal",
|
|
notes: nil,
|
|
projectId: nil,
|
|
unitId: nil,
|
|
personId: "person-1",
|
|
clientName: "Amina Rahman",
|
|
clientPhone: "+971500000001",
|
|
projectName: "Marina Residences"
|
|
),
|
|
].sorted { lhs, rhs in
|
|
let leftValue = lhs.value ?? 0
|
|
let rightValue = rhs.value ?? 0
|
|
if leftValue != rightValue {
|
|
return leftValue > rightValue
|
|
}
|
|
return (lhs.clientName ?? "").localizedCaseInsensitiveCompare(rhs.clientName ?? "") == .orderedAscending
|
|
}
|
|
|
|
XCTAssertEqual(opportunities.map(\.opportunityId), ["opp-1", "opp-2"])
|
|
XCTAssertEqual(opportunities[0].formattedValue, "AED 12.0M")
|
|
XCTAssertEqual(opportunities[0].probabilityLabel, "80% probability")
|
|
}
|
|
|
|
func testOpportunityMutationResponseDecodesCanonicalShape() throws {
|
|
let payload = """
|
|
{
|
|
"opportunity_id": "opp-1",
|
|
"stage": "negotiation",
|
|
"value": 12000000,
|
|
"probability": 75,
|
|
"expected_close_date": "2026-04-30",
|
|
"next_action": "Schedule commercial review",
|
|
"notes": "Updated from iPad",
|
|
"person_id": "person-1",
|
|
"client_name": "Amina Rahman",
|
|
"client_phone": "+971500000001",
|
|
"project_name": "Marina Residences"
|
|
}
|
|
"""
|
|
|
|
let opportunity = try JSONDecoder().decode(
|
|
VelocityOpportunityDTO.self,
|
|
from: Data(payload.utf8)
|
|
)
|
|
|
|
XCTAssertEqual(opportunity.opportunityId, "opp-1")
|
|
XCTAssertEqual(opportunity.stage, "negotiation")
|
|
XCTAssertEqual(opportunity.probabilityPercent, 75)
|
|
XCTAssertEqual(opportunity.probabilityLabel, "75% probability")
|
|
XCTAssertEqual(opportunity.nextAction, "Schedule commercial review")
|
|
XCTAssertEqual(opportunity.notes, "Updated from iPad")
|
|
}
|
|
|
|
func testLeadStageMutationResponseDecodesCanonicalShape() throws {
|
|
let payload = """
|
|
{
|
|
"lead_id": "lead-1",
|
|
"person_id": "person-1",
|
|
"status": "qualified",
|
|
"budget_band": "AED 12M",
|
|
"urgency": "high",
|
|
"client_name": "Amina Rahman",
|
|
"client_phone": "+971500000001"
|
|
}
|
|
"""
|
|
|
|
let update = try JSONDecoder().decode(
|
|
VelocityLeadStageUpdateDTO.self,
|
|
from: Data(payload.utf8)
|
|
)
|
|
|
|
XCTAssertEqual(update.leadId, "lead-1")
|
|
XCTAssertEqual(update.personId, "person-1")
|
|
XCTAssertEqual(update.status, "qualified")
|
|
XCTAssertEqual(update.clientName, "Amina Rahman")
|
|
}
|
|
|
|
func testImportBatchDetailDecodesCanonicalReviewShape() throws {
|
|
let payload = """
|
|
{
|
|
"batch_id": "batch-1",
|
|
"source_system": "csv_upload",
|
|
"filename": "clients.csv",
|
|
"row_count": 1,
|
|
"mapping_manifest": {"mapped_count": 4},
|
|
"lifecycle": "parsed",
|
|
"proposals": [
|
|
{
|
|
"proposal_id": "proposal-1",
|
|
"payload": {
|
|
"row_number": 1,
|
|
"canonical_payload": {"full_name": "Amina Rahman", "primary_phone": "+971500000001"},
|
|
"raw_row": {"Name": "Amina Rahman"},
|
|
"unresolved_fields": [],
|
|
"missing_required": [],
|
|
"confidence": 0.92,
|
|
"review_required": true
|
|
},
|
|
"confidence": 0.92,
|
|
"status": "pending",
|
|
"review_required": true
|
|
}
|
|
],
|
|
"proposal_count": 1
|
|
}
|
|
"""
|
|
|
|
let detail = try JSONDecoder().decode(
|
|
VelocityImportBatchDetailDTO.self,
|
|
from: Data(payload.utf8)
|
|
)
|
|
|
|
XCTAssertEqual(detail.batchId, "batch-1")
|
|
XCTAssertEqual(detail.proposals.count, 1)
|
|
XCTAssertEqual(detail.proposals[0].confidencePercent, 92)
|
|
XCTAssertEqual(detail.proposals[0].payload?.canonicalPayload?["full_name"]?.stringValue, "Amina Rahman")
|
|
}
|
|
|
|
func testInventoryProductionScopeShowsDollhouseWhenAssetIsShipped() {
|
|
XCTAssertEqual(
|
|
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: true),
|
|
[
|
|
.sunseeker,
|
|
.dreamWeaver,
|
|
.dollhouse,
|
|
]
|
|
)
|
|
XCTAssertEqual(
|
|
InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: true),
|
|
"Sunseeker · Dream Weaver · Dollhouse"
|
|
)
|
|
}
|
|
|
|
func testInventoryProductionScopeSanitizesUnsupportedSelection() {
|
|
XCTAssertEqual(
|
|
InventoryModeAvailability.sanitizedProductionSelection(.dollhouse, hasDollhouseAsset: false),
|
|
.sunseeker
|
|
)
|
|
XCTAssertEqual(
|
|
InventoryModeAvailability.sanitizedProductionSelection(.dollhouse, hasDollhouseAsset: true),
|
|
.dollhouse
|
|
)
|
|
}
|
|
|
|
func testSentinelScopeReflectsOperatorPosturePositioning() {
|
|
XCTAssertEqual(SentinelScope.navigationTitle, "Operator Posture")
|
|
XCTAssertEqual(SentinelScope.productFamilyName, "Sentinel")
|
|
XCTAssertEqual(SentinelScope.availabilityBadge, "Operator posture only")
|
|
XCTAssertEqual(
|
|
SentinelScope.disabledAnalyticsSummary,
|
|
"visitor counting, facial detections, sentiment scoring"
|
|
)
|
|
XCTAssertEqual(
|
|
SentinelScope.liveBackedSummary,
|
|
"alert posture, transcription queue visibility, upcoming calendar pressure, recent operator timeline"
|
|
)
|
|
}
|
|
|
|
func testAppStoreRefreshPolicyMatchesWebOSInventorySlice() {
|
|
XCTAssertEqual(AppStoreRefreshPolicy.inventoryPropertyLimit, 100)
|
|
XCTAssertEqual(AppStoreRefreshPolicy.canonicalTaskLimit, 50)
|
|
XCTAssertEqual(AppStoreRefreshPolicy.leadTimelineHydrationLimit, 6)
|
|
XCTAssertEqual(AppStoreRefreshPolicy.leadEventLimitPerLead, 4)
|
|
}
|
|
|
|
func testMobileEdgeBulkRefreshContractDecodesCalendarAlertsAndLeadEvents() throws {
|
|
let payload = Data(
|
|
"""
|
|
{
|
|
"calendar_events": [
|
|
{
|
|
"calendar_event_id": "cal-1",
|
|
"lead_id": "lead-1",
|
|
"title": "Site visit",
|
|
"description": "Walkthrough",
|
|
"start_at": "2026-04-26T07:00:00Z",
|
|
"end_at": "2026-04-26T08:00:00Z",
|
|
"all_day": false,
|
|
"status": "confirmed",
|
|
"reminder_minutes": [15],
|
|
"created_by": "user",
|
|
"location": "Sales lounge",
|
|
"created_at": "2026-04-26T06:30:00Z"
|
|
}
|
|
],
|
|
"lead_events": {
|
|
"lead-1": [
|
|
{
|
|
"event_id": "evt-1",
|
|
"lead_id": "lead-1",
|
|
"channel": "manual_note",
|
|
"direction": "inbound",
|
|
"provider": null,
|
|
"capture_mode": "operator_note",
|
|
"consent_state": "granted",
|
|
"timestamp": "2026-04-26T06:00:00Z",
|
|
"duration_seconds": null,
|
|
"summary": "Client wants a larger balcony.",
|
|
"raw_reference": null,
|
|
"recording_ref": null,
|
|
"provider_metadata": {},
|
|
"created_at": "2026-04-26T06:00:00Z"
|
|
}
|
|
]
|
|
},
|
|
"alerts": {
|
|
"pending_insights": 2,
|
|
"upcoming_calendar_events_24h": 1,
|
|
"pending_transcriptions": 3,
|
|
"generated_at": "2026-04-26T06:35:00Z"
|
|
},
|
|
"generated_at": "2026-04-26T06:35:00Z"
|
|
}
|
|
""".utf8
|
|
)
|
|
|
|
let bundle = try JSONDecoder().decode(VelocityMobileEdgeBulkDTO.self, from: payload)
|
|
XCTAssertEqual(bundle.calendarEvents.first?.calendarEventId, "cal-1")
|
|
XCTAssertEqual(bundle.leadEvents["lead-1"]?.first?.eventId, "evt-1")
|
|
XCTAssertEqual(bundle.alerts.pendingInsights, 2)
|
|
XCTAssertEqual(bundle.alerts.upcomingCalendarEvents24h, 1)
|
|
}
|
|
|
|
func testAppStoreRefreshPolicyPrioritizesHighestScoreLeads() {
|
|
let leads = [
|
|
VelocityLeadDTO(
|
|
id: "lead-1",
|
|
personId: "person-1",
|
|
name: "Lead One",
|
|
phone: nil,
|
|
source: "website",
|
|
qualification: "POTENTIAL",
|
|
score: 40,
|
|
kanbanStatus: "New",
|
|
budget: "",
|
|
unitInterest: "",
|
|
pendingTaskCount: 0,
|
|
interactionCount: 0,
|
|
createdAt: nil,
|
|
updatedAt: nil
|
|
),
|
|
VelocityLeadDTO(
|
|
id: "lead-2",
|
|
personId: "person-2",
|
|
name: "Lead Two",
|
|
phone: nil,
|
|
source: "website",
|
|
qualification: "HOT",
|
|
score: 95,
|
|
kanbanStatus: "Negotiation",
|
|
budget: "",
|
|
unitInterest: "",
|
|
pendingTaskCount: 1,
|
|
interactionCount: 3,
|
|
createdAt: nil,
|
|
updatedAt: nil
|
|
),
|
|
VelocityLeadDTO(
|
|
id: "lead-3",
|
|
personId: "person-3",
|
|
name: "Lead Three",
|
|
phone: nil,
|
|
source: "walkin",
|
|
qualification: "WHALE",
|
|
score: 88,
|
|
kanbanStatus: "Qualified",
|
|
budget: "",
|
|
unitInterest: "",
|
|
pendingTaskCount: 0,
|
|
interactionCount: 1,
|
|
createdAt: nil,
|
|
updatedAt: nil
|
|
),
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads, limit: 2),
|
|
["lead-2", "lead-3"]
|
|
)
|
|
}
|
|
|
|
func testCanonicalDashboardMetricsIgnoreLocalDriftAndMatchBackendContracts() {
|
|
let contacts = [
|
|
VelocityCanonicalContactListItemDTO(
|
|
personId: "person-1",
|
|
fullName: "Whale Buyer",
|
|
primaryPhone: nil,
|
|
buyerType: "investor",
|
|
leadId: "lead-1",
|
|
leadStatus: "qualified",
|
|
budgetBand: nil,
|
|
urgency: "high",
|
|
primaryInterest: nil,
|
|
intentScore: 0.95,
|
|
engagementScore: 0.70,
|
|
urgencyScore: 0.80,
|
|
interactionCount: 3,
|
|
pendingTasks: 1,
|
|
lastInteractionAt: nil,
|
|
createdAt: nil
|
|
),
|
|
VelocityCanonicalContactListItemDTO(
|
|
personId: "person-2",
|
|
fullName: "Standard Buyer",
|
|
primaryPhone: nil,
|
|
buyerType: "end_user",
|
|
leadId: "lead-2",
|
|
leadStatus: "new",
|
|
budgetBand: nil,
|
|
urgency: nil,
|
|
primaryInterest: nil,
|
|
intentScore: 0.40,
|
|
engagementScore: 0.30,
|
|
urgencyScore: 0.20,
|
|
interactionCount: 1,
|
|
pendingTasks: 0,
|
|
lastInteractionAt: nil,
|
|
createdAt: nil
|
|
),
|
|
]
|
|
let leads = VelocityLeadDTO.activeLeadSummaries(from: contacts)
|
|
let board = [
|
|
VelocityKanbanColumnDTO(status: "new", label: "New", count: 4, items: []),
|
|
VelocityKanbanColumnDTO(status: "qualified", label: "Qualified", count: 3, items: []),
|
|
]
|
|
let taskRefresh = AppStore.CalendarTaskRefresh(
|
|
tasks: [],
|
|
pendingTaskCount: 5,
|
|
pendingTaskIDs: ["task-1", "task-2", "task-3", "task-4", "task-5"],
|
|
urgentTaskCount: 2
|
|
)
|
|
let today = ISO8601DateFormatter().string(from: Date())
|
|
let metrics = AppStore.canonicalDashboardMetrics(
|
|
contacts: contacts,
|
|
leads: leads,
|
|
kanbanColumns: board,
|
|
properties: [
|
|
VelocityPropertyDTO(
|
|
propertyId: "property-1",
|
|
projectName: "Project",
|
|
developerName: "Developer",
|
|
propertyType: "tower",
|
|
location: nil,
|
|
priceBands: [],
|
|
unitMix: [],
|
|
status: "active",
|
|
ingestedAt: nil,
|
|
createdAt: nil
|
|
)
|
|
],
|
|
calendarEvents: [
|
|
VelocityCalendarEventDTO(
|
|
calendarEventId: "event-1",
|
|
leadId: nil,
|
|
title: "Confirmed visit",
|
|
description: nil,
|
|
startAt: today,
|
|
endAt: today,
|
|
allDay: false,
|
|
status: "confirmed",
|
|
reminderMinutes: [],
|
|
createdBy: "test",
|
|
location: nil,
|
|
createdAt: today
|
|
),
|
|
VelocityCalendarEventDTO(
|
|
calendarEventId: "event-2",
|
|
leadId: nil,
|
|
title: "Done visit",
|
|
description: nil,
|
|
startAt: today,
|
|
endAt: today,
|
|
allDay: false,
|
|
status: "done",
|
|
reminderMinutes: [],
|
|
createdBy: "test",
|
|
location: nil,
|
|
createdAt: today
|
|
),
|
|
],
|
|
taskRefresh: taskRefresh,
|
|
alertSnapshot: VelocityAlertSnapshotDTO(
|
|
pendingInsights: 6,
|
|
upcomingCalendarEvents24h: 1,
|
|
pendingTranscriptions: 4,
|
|
generatedAt: today
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(metrics.leadCount, 7)
|
|
XCTAssertEqual(metrics.whaleLeadCount, 1)
|
|
XCTAssertEqual(metrics.propertyCount, 1)
|
|
XCTAssertEqual(metrics.todayCalendarCount, 1)
|
|
XCTAssertEqual(metrics.pendingTaskCount, 5)
|
|
XCTAssertEqual(metrics.urgentTaskCount, 2)
|
|
XCTAssertEqual(metrics.pendingInsights, 6)
|
|
XCTAssertEqual(metrics.pendingTranscriptions, 4)
|
|
}
|
|
|
|
func testDreamWeaverHealthDecodesCheckpointReadinessAliases() throws {
|
|
let payload = Data(#"{"status":"ok","comfyui":true,"preferred_checkpoint_available":false}"#.utf8)
|
|
let health = try JSONDecoder().decode(HealthResponse.self, from: payload)
|
|
|
|
XCTAssertEqual(health.status, "ok")
|
|
XCTAssertEqual(health.comfyui, true)
|
|
XCTAssertEqual(health.checkpointReady, false)
|
|
}
|
|
|
|
func testCommunicationsThreadRequiresCanonicalCRMPersonLink() throws {
|
|
let linked = try JSONDecoder().decode(
|
|
VelocityCommsThreadDTO.self,
|
|
from: Data(#"{"threadId":"thread-1","provider":"waha","personId":"person-1","phoneE164":"+910000000000","displayName":"Amina","channel":"whatsapp","status":"open","unreadCount":1,"lastMessageAt":null,"updatedAt":"2026-04-29T10:00:00+00:00","lastMessagePreview":"Hi","crmPerson":{"id":"person-1","fullName":"Amina","primaryPhone":"+910000000000","primaryEmail":null,"buyerType":"investor","leadStatus":"new","projectName":"Tower"}}"#.utf8)
|
|
)
|
|
let unlinked = try JSONDecoder().decode(
|
|
VelocityCommsThreadDTO.self,
|
|
from: Data(#"{"threadId":"thread-2","provider":"mock","personId":null,"phoneE164":"+910000000001","displayName":null,"channel":"whatsapp","status":"open","unreadCount":0,"lastMessageAt":null,"updatedAt":"2026-04-29T10:00:00+00:00","lastMessagePreview":null,"crmPerson":null}"#.utf8)
|
|
)
|
|
|
|
XCTAssertTrue(linked.isLinkedToCanonicalPerson)
|
|
XCTAssertEqual(linked.displayTitle, "Amina")
|
|
XCTAssertFalse(unlinked.isLinkedToCanonicalPerson)
|
|
XCTAssertEqual(unlinked.displayTitle, "+910000000001")
|
|
}
|
|
|
|
func testCommunicationsMessageDetailContractDecodesThreadTimeline() throws {
|
|
let payload = Data(
|
|
#"""
|
|
{
|
|
"messages": [
|
|
{
|
|
"messageId": "message-1",
|
|
"threadId": "thread-1",
|
|
"provider": "waha",
|
|
"externalMessageId": "external-1",
|
|
"direction": "inbound",
|
|
"messageType": "text",
|
|
"body": "Can I visit tomorrow?",
|
|
"mediaUrl": null,
|
|
"mediaMimeType": null,
|
|
"deliveryStatus": "delivered",
|
|
"sentAt": "2026-04-29T10:00:00+00:00",
|
|
"deliveredAt": null,
|
|
"readAt": null,
|
|
"rawPayload": {"source": "webhook"},
|
|
"createdAt": "2026-04-29T10:00:01+00:00"
|
|
},
|
|
{
|
|
"messageId": "message-2",
|
|
"threadId": "thread-1",
|
|
"provider": "waha",
|
|
"externalMessageId": "external-2",
|
|
"direction": "outbound",
|
|
"messageType": "text",
|
|
"body": "Yes, I can schedule it.",
|
|
"mediaUrl": null,
|
|
"mediaMimeType": null,
|
|
"deliveryStatus": "sent",
|
|
"sentAt": "2026-04-29T10:02:00+00:00",
|
|
"deliveredAt": null,
|
|
"readAt": null,
|
|
"rawPayload": {},
|
|
"createdAt": "2026-04-29T10:02:00+00:00"
|
|
}
|
|
],
|
|
"thread": {
|
|
"threadId": "thread-1",
|
|
"provider": "waha",
|
|
"personId": "person-1",
|
|
"phoneE164": "+910000000000",
|
|
"displayName": "Amina",
|
|
"channel": "whatsapp",
|
|
"status": "open",
|
|
"unreadCount": 1,
|
|
"lastMessageAt": "2026-04-29T10:02:00+00:00",
|
|
"updatedAt": "2026-04-29T10:02:00+00:00",
|
|
"lastMessagePreview": "Yes, I can schedule it.",
|
|
"crmPerson": {
|
|
"id": "person-1",
|
|
"fullName": "Amina",
|
|
"primaryPhone": "+910000000000",
|
|
"primaryEmail": null,
|
|
"buyerType": "investor",
|
|
"leadStatus": "new",
|
|
"projectName": "Tower"
|
|
}
|
|
}
|
|
}
|
|
"""#.utf8
|
|
)
|
|
|
|
let detail = try JSONDecoder().decode(VelocityCommsThreadMessagesDTO.self, from: payload)
|
|
|
|
XCTAssertEqual(detail.messages.count, 2)
|
|
XCTAssertEqual(detail.messages.first?.direction, "inbound")
|
|
XCTAssertEqual(detail.messages.last?.deliveryStatus, "sent")
|
|
XCTAssertEqual(detail.thread.threadId, "thread-1")
|
|
XCTAssertTrue(detail.thread.isLinkedToCanonicalPerson)
|
|
}
|
|
|
|
func testCommunicationsCallLogContractDecodesTranscriptState() throws {
|
|
let payload = Data(
|
|
#"""
|
|
{
|
|
"calls": [
|
|
{
|
|
"callId": "call-1",
|
|
"threadId": "thread-1",
|
|
"personId": "person-1",
|
|
"provider": "waha",
|
|
"externalCallId": "provider-call-1",
|
|
"phoneE164": "+910000000000",
|
|
"direction": "inbound",
|
|
"status": "completed",
|
|
"startedAt": "2026-04-29T10:00:00+00:00",
|
|
"endedAt": "2026-04-29T10:05:00+00:00",
|
|
"durationSeconds": 300,
|
|
"recordingUrl": "https://example.test/recording.mp3",
|
|
"transcriptId": null,
|
|
"transcriptText": "Client asked for a Sunday visit.",
|
|
"rawPayload": {"provider": "waha"},
|
|
"createdAt": "2026-04-29T10:05:01+00:00"
|
|
}
|
|
],
|
|
"thread": {
|
|
"threadId": "thread-1",
|
|
"provider": "waha",
|
|
"personId": "person-1",
|
|
"phoneE164": "+910000000000",
|
|
"displayName": "Amina",
|
|
"channel": "whatsapp",
|
|
"status": "open",
|
|
"unreadCount": 0,
|
|
"lastMessageAt": "2026-04-29T10:02:00+00:00",
|
|
"updatedAt": "2026-04-29T10:02:00+00:00",
|
|
"lastMessagePreview": "Done",
|
|
"crmPerson": {
|
|
"id": "person-1",
|
|
"fullName": "Amina",
|
|
"primaryPhone": "+910000000000",
|
|
"primaryEmail": null,
|
|
"buyerType": "investor",
|
|
"leadStatus": "new",
|
|
"projectName": "Tower"
|
|
}
|
|
}
|
|
}
|
|
"""#.utf8
|
|
)
|
|
|
|
let detail = try JSONDecoder().decode(VelocityCommsThreadCallsDTO.self, from: payload)
|
|
|
|
XCTAssertEqual(detail.calls.count, 1)
|
|
XCTAssertEqual(detail.calls.first?.durationSeconds, 300)
|
|
XCTAssertEqual(detail.calls.first?.transcriptText, "Client asked for a Sunday visit.")
|
|
XCTAssertTrue(detail.thread.isLinkedToCanonicalPerson)
|
|
}
|
|
}
|