| 1 | import XCTest |
| 2 | import RastrilloApple |
| 3 | @testable import CalendarKit |
| 4 | |
| 5 | // B11 (polish-audit PLAN): a failed load used to run `events = []`, so |
| 6 | // any blip — a dropped connection, a 500, going offline — blanked a |
| 7 | // calendar that was on screen a second earlier, and the menu-bar panel |
| 8 | // shares the model, so opening it offline blanked the window too. |
| 9 | final class LoadFailureTests: XCTestCase { |
| 10 | @MainActor func testFailedReloadKeepsLastGoodEventsAndReportsTheError() async throws { |
| 11 | let process = Process() |
| 12 | process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") |
| 13 | process.arguments = ["-u", "-c", """ |
| 14 | import http.server, json |
| 15 | failing=False |
| 16 | event={'id':7,'calendar_id':1,'status':'confirmed','title':'Standup','location':'','notes':'','all_day':False, |
| 17 | 'start_local':'2030-10-25T09:00','end_local':'2030-10-25T09:30','tz':'Europe/Dublin','start':1919232000,'end':1919233800, |
| 18 | 'recurring':False,'writable':True,'calendar_name':'Personal','occurrence':'','day_start':'2030-10-25','day_end':'2030-10-25'} |
| 19 | class Handler(http.server.BaseHTTPRequestHandler): |
| 20 | def log_message(self,*args): pass |
| 21 | def reply(self,code,value): |
| 22 | data=json.dumps(value).encode(); self.send_response(code); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data) |
| 23 | def do_GET(self): |
| 24 | global failing |
| 25 | if self.path=='/fail': failing=True; self.reply(200,{}); return |
| 26 | if self.path=='/recover': failing=False; self.reply(200,{}); return |
| 27 | if failing: self.reply(500,{'error':'fixture outage'}); return |
| 28 | if self.path.startswith('/api/native/session'): |
| 29 | self.reply(200,{'week_start':1,'zone':'Europe/Dublin','calendars':[{'id':1,'name':'Personal','hue':200,'writable':True}],'default_calendar_id':1}); return |
| 30 | if self.path.startswith('/api/native/events'): self.reply(200,{'events':[event]}); return |
| 31 | self.reply(404,{}) |
| 32 | server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) |
| 33 | print(server.server_port,flush=True); server.serve_forever() |
| 34 | """] |
| 35 | let output = Pipe(); process.standardOutput = output; process.standardError = FileHandle.nullDevice |
| 36 | try process.run() |
| 37 | defer { process.terminate() } |
| 38 | let data = output.fileHandleForReading.availableData |
| 39 | let port = try XCTUnwrap(String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)) |
| 40 | let client = NativeHTTP(origin: URL(string: "http://127.0.0.1:\(port)")!) |
| 41 | let model = CalendarModel(zone: "Europe/Dublin", date: Date(timeIntervalSince1970: 1919232000)) |
| 42 | |
| 43 | // A first load that FAILS has nothing to keep: the error stands alone. |
| 44 | _ = try await client.response("/fail") |
| 45 | await model.load(client) |
| 46 | XCTAssertFalse(model.error.isEmpty) |
| 47 | XCTAssertFalse(model.hasLoaded) |
| 48 | XCTAssertTrue(model.events.isEmpty) |
| 49 | |
| 50 | // It recovers, and the day shows its event. |
| 51 | _ = try await client.response("/recover") |
| 52 | await model.load(client) |
| 53 | XCTAssertTrue(model.error.isEmpty) |
| 54 | XCTAssertTrue(model.hasLoaded) |
| 55 | XCTAssertEqual(model.events.map(\.title), ["Standup"]) |
| 56 | XCTAssertEqual(model.calendars.map(\.name), ["Personal"]) |
| 57 | |
| 58 | // Then the server goes away. The refresh fails, the error is |
| 59 | // reported, and what was on screen stays on screen. |
| 60 | _ = try await client.response("/fail") |
| 61 | await model.load(client) |
| 62 | XCTAssertFalse(model.error.isEmpty, "a failed refresh must still say it failed") |
| 63 | XCTAssertTrue(model.hasLoaded) |
| 64 | XCTAssertFalse(model.loading) |
| 65 | XCTAssertEqual(model.events.map(\.title), ["Standup"], "a failed refresh blanked the events on screen") |
| 66 | XCTAssertEqual(model.calendars.map(\.name), ["Personal"]) |
| 67 | XCTAssertEqual(model.dayEvents(model.date).map(\.title), ["Standup"]) |
| 68 | |
| 69 | // Signing out is the one thing that does clear it. |
| 70 | model.clear() |
| 71 | XCTAssertFalse(model.hasLoaded) |
| 72 | XCTAssertTrue(model.events.isEmpty) |
| 73 | } |
| 74 | } |
| 75 | |