Skip to content

Commit 095e212

Browse files
ChrisPelatariclaude
andcommitted
test(69): RED — push parity: local moves, tag diffs, assignee diffs
Four tests on the LWW local-newer branch: a moved card POSTs triage with the new column id; tag edits push EXACT toggle diffs (tag_ids on PUT is rejected live — probe notes); assignee edits toggle symmetrically; no-diff cycles add zero toggle calls (pin). RED: 3 failed, 1 pin passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 49bfa52 commit 095e212

2 files changed

Lines changed: 270 additions & 3 deletions

File tree

FenixKanban.xcodeproj/project.pbxproj

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@
174174
DDAD4306A7EC34303A1EA66A /* LabelPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 705401ADCC82CB1481075EA4 /* LabelPickerView.swift */; };
175175
DEFA903A08A0D1F9DC624551 /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B696D2AF5C3863296D4DB139 /* MockURLProtocol.swift */; };
176176
E28CEDF04F9DC799CB8AA774 /* AssigneePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B9186731B8F85F0294268 /* AssigneePickerView.swift */; };
177+
E2E644FBE7E2A293B9E46891 /* FizzySyncEnginePushParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D201003BBFD4EC2F7DEDD79F /* FizzySyncEnginePushParityTests.swift */; };
177178
E558EBBB7243AC828B2FCC92 /* FizzyClient+Directory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D940AFA2D7BAA47055968F /* FizzyClient+Directory.swift */; };
178179
E63AADE0F3925286DD626477 /* NewCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1EA2F5FA16B1A5E2A0BDF1 /* NewCardSheet.swift */; };
179180
E7142545CF6A399053654C8C /* FizzySyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263A4B64EBC1D39BF38D2CDC /* FizzySyncEngine.swift */; };
@@ -371,6 +372,7 @@
371372
D10A143D4EF691E4A0436C89 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
372373
D18968874E78EB2EDD474B05 /* FizzySyncEngineETagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FizzySyncEngineETagTests.swift; sourceTree = "<group>"; };
373374
D1C257D017738FEE517DB1CA /* SyncStatusIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusIndicator.swift; sourceTree = "<group>"; };
375+
D201003BBFD4EC2F7DEDD79F /* FizzySyncEnginePushParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FizzySyncEnginePushParityTests.swift; sourceTree = "<group>"; };
374376
D23D26B5A48CB195655A3C6F /* CardCommentsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardCommentsViewModelTests.swift; sourceTree = "<group>"; };
375377
D26870A7B74A1F2FDA417A7D /* FizzySyncMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FizzySyncMappingTests.swift; sourceTree = "<group>"; };
376378
D613029DDB577CD954C47EEC /* FirstSyncModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstSyncModeTests.swift; sourceTree = "<group>"; };
@@ -848,6 +850,7 @@
848850
D18968874E78EB2EDD474B05 /* FizzySyncEngineETagTests.swift */,
849851
BF01511B907D39483068BC2D /* FizzySyncEngineLifecycleTests.swift */,
850852
177BD7C5276E01988386E418 /* FizzySyncEnginePullPlacementTests.swift */,
853+
D201003BBFD4EC2F7DEDD79F /* FizzySyncEnginePushParityTests.swift */,
851854
3EBBBBF705E70A9B9C8F880B /* FizzySyncEngineTests.swift */,
852855
D26870A7B74A1F2FDA417A7D /* FizzySyncMappingTests.swift */,
853856
B696D2AF5C3863296D4DB139 /* MockURLProtocol.swift */,
@@ -1068,15 +1071,15 @@
10681071
LastUpgradeCheck = 1600;
10691072
TargetAttributes = {
10701073
29B0805C04158CE223599CD4 = {
1071-
DevelopmentTeam = YOUR_TEAM_ID_HERE;
1074+
DevelopmentTeam = QU2C2R55C4;
10721075
ProvisioningStyle = Automatic;
10731076
};
10741077
5FBE7EC4307D66DC5CD8CF44 = {
1075-
DevelopmentTeam = YOUR_TEAM_ID_HERE;
1078+
DevelopmentTeam = QU2C2R55C4;
10761079
ProvisioningStyle = Manual;
10771080
};
10781081
62212B85E018F8DAAD57EB30 = {
1079-
DevelopmentTeam = YOUR_TEAM_ID_HERE;
1082+
DevelopmentTeam = QU2C2R55C4;
10801083
ProvisioningStyle = Automatic;
10811084
};
10821085
};
@@ -1279,6 +1282,7 @@
12791282
1DDBA80628EF2F9759EEF34F /* FizzySyncEngineETagTests.swift in Sources */,
12801283
1EE67FEFD13A28512384FA70 /* FizzySyncEngineLifecycleTests.swift in Sources */,
12811284
233055E81253557CFCF35550 /* FizzySyncEnginePullPlacementTests.swift in Sources */,
1285+
E2E644FBE7E2A293B9E46891 /* FizzySyncEnginePushParityTests.swift in Sources */,
12821286
D6D9021F8F2C22915DB77D4C /* FizzySyncEngineTests.swift in Sources */,
12831287
321C0D7DB3CF3DD8D9ECD6B4 /* FizzySyncMappingTests.swift in Sources */,
12841288
E9E42C62557AFB457356C145 /* FizzySyncProviderTests.swift in Sources */,
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import Testing
2+
import CoreData
3+
import Foundation
4+
@testable import FenixKanban
5+
6+
// MARK: - File-scope fixtures (URLProtocol handler runs off-actor)
7+
8+
private let parityColumnsJSON = """
9+
[
10+
{"id":"FC1","name":"Triage","color":{"name":"Slate","value":"x"},"created_at":"2026-05-25T00:00:00Z"},
11+
{"id":"FC2","name":"Doing","color":{"name":"Lime","value":"y"},"created_at":"2026-05-25T00:00:00Z"}
12+
]
13+
"""
14+
15+
private func parityCardJSON(
16+
id: String, number: Int, title: String,
17+
columnID: String = "FC1", columnName: String = "Triage",
18+
tags: [String] = [], assigneeIDs: [String] = [],
19+
lastActiveAt: String = "2026-05-25T00:00:00Z"
20+
) -> String {
21+
let tagsJSON = tags.map { "\"\($0)\"" }.joined(separator: ",")
22+
let assigneesJSON = assigneeIDs.map {
23+
"""
24+
{"id":"\($0)","name":"User \($0)","role":"member","active":true,
25+
"email_address":"\($0)@example.com","created_at":"2026-05-25T00:00:00Z",
26+
"url":null,"avatar_url":null}
27+
"""
28+
}.joined(separator: ",")
29+
return """
30+
{"id":"\(id)","number":\(number),"title":"\(title)","status":"published",
31+
"description":null,"description_html":null,"image_url":null,
32+
"has_attachments":false,"tags":[\(tagsJSON)],"closed":false,"postponed":false,
33+
"golden":false,"last_active_at":"\(lastActiveAt)",
34+
"created_at":"2026-05-25T00:00:00Z",
35+
"url":"https://fizzy.bluefenix.net/ACCT/cards/\(number)",
36+
"column":{"id":"\(columnID)","name":"\(columnName)","color":"var(--color-card-4)","created_at":"2026-05-25T00:00:00Z"},
37+
"assignees":[\(assigneesJSON)]}
38+
"""
39+
}
40+
41+
/// Records mutation requests (method, path, body) for exact-diff assertions.
42+
private final class ParityBoard: @unchecked Sendable {
43+
let cardsJSON: String
44+
private let lock = NSLock()
45+
private var _mutations: [(method: String, path: String, body: String)] = []
46+
var mutations: [(method: String, path: String, body: String)] {
47+
lock.lock(); defer { lock.unlock() }; return _mutations
48+
}
49+
50+
init(cardsJSON: String) { self.cardsJSON = cardsJSON }
51+
52+
func handler(_ req: URLRequest) throws -> (Data, HTTPURLResponse) {
53+
let path = req.url?.path ?? ""
54+
let method = req.httpMethod ?? "?"
55+
switch method {
56+
case "GET" where path.hasSuffix("/my/pins"):
57+
return (Data("[]".utf8), .ok(for: req))
58+
case "GET" where path.hasSuffix("/columns"):
59+
return (Data(parityColumnsJSON.utf8), .ok(for: req))
60+
case "GET" where path.contains("/columns/") && path.hasSuffix("/cards"):
61+
let columnID = path.components(separatedBy: "/columns/").last?
62+
.components(separatedBy: "/cards").first ?? ""
63+
return (Data((columnID == "FC1" ? cardsJSON : "[]").utf8), .ok(for: req))
64+
case "PUT" where path.contains("/cards/"):
65+
record(method, path, req)
66+
// Echo the updated card so the push can record lastActiveAt.
67+
return (Data(parityCardJSON(id: "fz9", number: 9, title: "Edited", lastActiveAt: "2026-06-12T05:00:00Z").utf8), .ok(for: req))
68+
case "POST" where path.contains("/taggings") || path.contains("/triage") || path.contains("/assignments"):
69+
record(method, path, req)
70+
return (Data(), .response(for: req, status: 204))
71+
default:
72+
Issue.record("unexpected request: \(method) \(path)")
73+
return (Data(), .response(for: req, status: 500))
74+
}
75+
}
76+
77+
private func record(_ method: String, _ path: String, _ req: URLRequest) {
78+
let body = req.httpBody ?? req.httpBodyStream.map { stream -> Data in
79+
stream.open(); defer { stream.close() }
80+
var data = Data(); let buf = UnsafeMutablePointer<UInt8>.allocate(capacity: 4096)
81+
defer { buf.deallocate() }
82+
while stream.hasBytesAvailable {
83+
let n = stream.read(buf, maxLength: 4096)
84+
if n <= 0 { break }
85+
data.append(buf, count: n)
86+
}
87+
return data
88+
} ?? Data()
89+
lock.lock(); defer { lock.unlock() }
90+
_mutations.append((method, path, String(data: body, encoding: .utf8) ?? ""))
91+
}
92+
}
93+
94+
// MARK: - Suite
95+
96+
/// Task #69 — push parity (C4b/C5/C6): local card moves, tag edits, and
97+
/// assignee edits reach the server on the LWW local-newer branch.
98+
///
99+
/// Wire facts (live-probed 2026-06-12, docs/fizzy-api-notes.md): moves push
100+
/// via `POST /cards/:n/triage {column_id}`; `tag_ids` on PUT is rejected →
101+
/// tags push as EXACT toggle diffs via `POST /taggings {tag_title}` (toggles
102+
/// are not idempotent); assignments toggle symmetrically via
103+
/// `POST /assignments {assignee_id}`.
104+
@Suite("FizzySyncEngine — push parity (move/tags/assignees)", .serialized)
105+
@MainActor
106+
struct FizzySyncEnginePushParityTests {
107+
108+
private struct Harness {
109+
let mock = MockHTTPState()
110+
let persistence: PersistenceController
111+
let boardRepo: BoardRepository
112+
let cardRepo: CardRepository
113+
let board: Board
114+
let triage: Column
115+
let doing: Column
116+
let engine: FizzySyncEngine
117+
let suiteName: String
118+
let authState: FizzyAuthState
119+
let mappingDefaults: UserDefaults
120+
let pairingStore: FizzyCardPairingStore
121+
122+
@MainActor
123+
init() {
124+
persistence = PersistenceController(inMemory: true, useCloudKit: false)
125+
boardRepo = BoardRepository(context: persistence.viewContext)
126+
pairingStore = FizzyCardPairingStore(
127+
fileURL: FileManager.default.temporaryDirectory
128+
.appendingPathComponent("fk-pairings-\(UUID().uuidString).json")
129+
)
130+
cardRepo = CardRepository(context: persistence.viewContext, pairingStore: pairingStore)
131+
board = boardRepo.createBoard(name: "Roadmap")
132+
triage = boardRepo.createColumn(in: board, name: "Triage")
133+
doing = boardRepo.createColumn(in: board, name: "Doing")
134+
triage.fizzyColumnID = "FC1"
135+
doing.fizzyColumnID = "FC2"
136+
try! persistence.viewContext.save()
137+
138+
let prefix = "test.fizzy.parity.\(UUID().uuidString)"
139+
authState = FizzyAuthState(keyPrefix: prefix)
140+
authState.setAccessToken("t")
141+
authState.setAccountSlug("ACCT")
142+
143+
suiteName = "test.fizzy.parity.mapping.\(UUID().uuidString)"
144+
mappingDefaults = UserDefaults(suiteName: suiteName)!
145+
let mapping = FizzyBoardMapping(defaults: mappingDefaults)
146+
mapping.setPairing(localBoardID: board.id!, fizzyBoardID: "FB1")
147+
148+
let session = mock.makeSession()
149+
let client = FizzyClient(
150+
baseURL: URL(string: "https://fizzy.bluefenix.net")!,
151+
accessToken: "t",
152+
accountSlug: "ACCT",
153+
urlSession: session,
154+
clock: ImmediateClock()
155+
)
156+
157+
engine = FizzySyncEngine(
158+
client: client,
159+
authState: authState,
160+
mapping: mapping,
161+
context: persistence.viewContext,
162+
pairingStore: pairingStore
163+
)
164+
}
165+
166+
func tearDown() {
167+
authState.clear()
168+
mappingDefaults.removePersistentDomain(forName: suiteName)
169+
try? FileManager.default.removeItem(at: pairingStore.fileURL)
170+
}
171+
172+
/// Local card paired to remote fz9/#9 at t0, locally edited at t0+120.
173+
func seedLocallyNewerCard(in column: Column, title: String = "Edited") -> Card {
174+
let t0 = Date(timeIntervalSince1970: 1_750_000_000)
175+
let card = cardRepo.createCard(in: column, title: title)
176+
try! persistence.viewContext.save()
177+
pairingStore.setPairing(
178+
FizzyCardPairing(fizzyID: "fz9", fizzyNumber: 9, fizzyUpdatedAt: t0),
179+
for: card.id!
180+
)
181+
card.modifiedAt = t0.addingTimeInterval(120)
182+
return card
183+
}
184+
}
185+
186+
@Test("local column move pushes POST /cards/:n/triage with the new column id")
187+
func localMovePushesTriage() async throws {
188+
let h = Harness()
189+
defer { h.tearDown() }
190+
// Local card sits in Doing (FC2); remote still lists it under FC1.
191+
_ = h.seedLocallyNewerCard(in: h.doing)
192+
let board = ParityBoard(cardsJSON: "[\(parityCardJSON(id: "fz9", number: 9, title: "Edited"))]")
193+
h.mock.handler = { try board.handler($0) }
194+
195+
let result = try await h.engine.sync()
196+
#expect(result.errors.isEmpty)
197+
198+
let triages = board.mutations.filter { $0.path.hasSuffix("/cards/9/triage") }
199+
#expect(triages.count == 1)
200+
#expect(triages.first?.body.contains("FC2") == true,
201+
"triage body must carry the local column id, got: \(triages.first?.body ?? "")")
202+
}
203+
204+
@Test("local tag edits push exact toggle diffs (add missing, remove extra)")
205+
func localTagEditsPushToggleDiffs() async throws {
206+
let h = Harness()
207+
defer { h.tearDown() }
208+
let card = h.seedLocallyNewerCard(in: h.triage)
209+
// Local labels: alpha, beta. Remote tags: alpha, gamma.
210+
let labelRepo = LabelRepository(context: h.persistence.viewContext)
211+
let alpha = labelRepo.createLabel(name: "alpha", colorHex: "#808080")
212+
let beta = labelRepo.createLabel(name: "beta", colorHex: "#808080")
213+
card.labels = NSSet(array: [alpha, beta])
214+
let board = ParityBoard(cardsJSON: "[\(parityCardJSON(id: "fz9", number: 9, title: "Edited", tags: ["alpha", "gamma"]))]")
215+
h.mock.handler = { try board.handler($0) }
216+
217+
let result = try await h.engine.sync()
218+
#expect(result.errors.isEmpty)
219+
220+
let toggles = board.mutations.filter { $0.path.hasSuffix("/cards/9/taggings") }
221+
let bodies = Set(toggles.map(\.body))
222+
#expect(toggles.count == 2, "exactly two toggles (beta on, gamma off), got: \(toggles)")
223+
#expect(bodies.contains { $0.contains("beta") })
224+
#expect(bodies.contains { $0.contains("gamma") })
225+
#expect(!bodies.contains { $0.contains("alpha") }, "alpha matches both sides — toggling it would REMOVE it")
226+
}
227+
228+
@Test("local assignee edits push exact toggle diffs")
229+
func localAssigneeEditsPushToggleDiffs() async throws {
230+
let h = Harness()
231+
defer { h.tearDown() }
232+
let card = h.seedLocallyNewerCard(in: h.triage)
233+
card.assignees = [CardAssignee(id: "u1", name: "User u1")]
234+
// Remote has u2 assigned.
235+
let board = ParityBoard(cardsJSON: "[\(parityCardJSON(id: "fz9", number: 9, title: "Edited", assigneeIDs: ["u2"]))]")
236+
h.mock.handler = { try board.handler($0) }
237+
238+
let result = try await h.engine.sync()
239+
#expect(result.errors.isEmpty)
240+
241+
let toggles = board.mutations.filter { $0.path.hasSuffix("/cards/9/assignments") }
242+
let bodies = Set(toggles.map(\.body))
243+
#expect(toggles.count == 2, "exactly two toggles (u1 on, u2 off), got: \(toggles)")
244+
#expect(bodies.contains { $0.contains("u1") })
245+
#expect(bodies.contains { $0.contains("u2") })
246+
}
247+
248+
@Test("no diffs → PUT only, zero triage/tagging/assignment calls (pin)")
249+
func noDiffsMeansNoToggles() async throws {
250+
let h = Harness()
251+
defer { h.tearDown() }
252+
_ = h.seedLocallyNewerCard(in: h.triage)
253+
let board = ParityBoard(cardsJSON: "[\(parityCardJSON(id: "fz9", number: 9, title: "Edited"))]")
254+
h.mock.handler = { try board.handler($0) }
255+
256+
let result = try await h.engine.sync()
257+
#expect(result.errors.isEmpty)
258+
259+
let extras = board.mutations.filter { !$0.path.hasSuffix("/cards/9") }
260+
#expect(extras.isEmpty, "no toggles expected, got: \(extras)")
261+
#expect(board.mutations.contains { $0.method == "PUT" })
262+
}
263+
}

0 commit comments

Comments
 (0)