From ba76d31cd444dc47d3776f958064069a1f078416 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Mon, 25 May 2026 20:53:01 +0200 Subject: [PATCH 01/10] support IHateMoney QR codes --- PayForMe/Model/Project.swift | 24 ++-- PayForMe/Services/NetworkService.swift | 60 +++++++- PayForMe/Services/ProjectManager.swift | 6 +- PayForMe/Services/StorageService.swift | 8 +- PayForMe/Util/Util.swift | 104 +++++++++++--- .../Manual/AddProjectManualView.swift | 60 ++++---- .../Manual/AddProjectManualViewModel.swift | 83 +++++++---- .../QRCodes/AddProjectQRViewModel.swift | 72 ++++++---- .../ShareProjectQRCodeViewModel.swift | 2 +- PayForMeTests/BalanceCalculationTests.swift | 3 +- PayForMeTests/NetworkRequestTests.swift | 12 +- PayForMeTests/TestHelpers.swift | 12 +- PayForMeTests/UrlExtensionsTests.swift | 130 +++++++++++------- 13 files changed, 396 insertions(+), 180 deletions(-) diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index 0935ecd..d75a86d 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -19,11 +19,13 @@ class Project: Codable, Identifiable { var bills: [Bill] var me: Int? - convenience init(name: String, password: String, token: String, backend: ProjectBackend, url: URL) { - self.init(name: name, password: password, token: token, backend: backend, url: url, id: nil) + let projectId: String + + convenience init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, projectId: String) { + self.init(name: name, password: password, token: token, backend: backend, url: url, id: nil, projectId: projectId) } - fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil) { + fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil, projectId: String) { self.name = name self.password = password self.token = token @@ -33,6 +35,7 @@ class Project: Codable, Identifiable { members = [:] bills = [] self.me = me + self.projectId = projectId } } @@ -49,8 +52,9 @@ struct StoredProject: Codable { let backend: ProjectBackend var id: Int? let me: Int? + let projectId: String - init(name: String, password: String, token: String, url: URL, backend: ProjectBackend) { + init(name: String, password: String, token: String, url: URL, backend: ProjectBackend, projectId: String) { self.name = name self.password = password self.token = token @@ -58,6 +62,7 @@ struct StoredProject: Codable { self.backend = backend id = nil me = nil + self.projectId = projectId } init(project: Project) { @@ -68,10 +73,11 @@ struct StoredProject: Codable { backend = project.backend id = project.id me = project.me + projectId = project.projectId } func toProject() -> Project { - Project(name: name, password: password, token: token, backend: backend, url: url, id: id!, me: me) + Project(name: name, password: password, token: token, backend: backend, url: url, id: id!, me: me, projectId: projectId) } } @@ -101,10 +107,10 @@ enum ProjectBackend: Int, Codable { } } -let previewProject = Project(name: "TestProject", password: "TestPassword", token: "asdasdas", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 0) +let previewProject = Project(name: "TestProject", password: "TestPassword", token: "asdasdas", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 0, projectId: "TestProject") let previewProjects = [ previewProject, - Project(name: "test1", password: "test23", token: "dasdasa", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 1), - Project(name: "test2", password: "test45", token: "123123122", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 2), + Project(name: "test1", password: "test23", token: "dasdasa", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 1, projectId: "test1"), + Project(name: "test2", password: "test45", token: "123123122", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 2, projectId: "test2"), ] -let demoProject = Project(name: "study-group", password: "no-pass", token: "9da50e410157dc1ca63e594af022f3a2", backend: .cospend, url: URL(string: "https://intranet.mayflower.de")!, id: 1) +let demoProject = Project(name: "study-group", password: "no-pass", token: "9da50e410157dc1ca63e594af022f3a2", backend: .cospend, url: URL(string: "https://intranet.mayflower.de")!, id: 1, projectId: "study-group") diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index b67638e..02e1465 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -91,7 +91,43 @@ class NetworkService { } let apiProject = try JSONDecoder().decode(APIProject.self, from: data) - return Project(name: apiProject.name, password: project.password, token: project.token, backend: project.backend, url: project.url) + return Project(name: apiProject.name, password: project.password, token: project.token, backend: project.backend, url: project.url, projectId: apiProject.id) + } + + func getProjectName(invite: InviteData) async throws -> Project { + var request = URLRequest(url: URL(string: invite.url + "/api/projects/" + invite.project)!) + request.httpMethod = "GET" + request.setValue( + "Bearer \(invite.token)", + forHTTPHeaderField: "Authorization" + ) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode / 100 == 2 else { + throw HTTPError.statuscode + } + let apiProject = try JSONDecoder().decode(APIProject.self, from: data) + + return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: URL(string: invite.url)!, projectId: apiProject.id) + } + + func fetchInvite(_ invite: InviteData?) async throws { + guard let invite = invite, let url = URL(string: invite.url) else { + return + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue( + "Bearer \(invite.token)", + forHTTPHeaderField: "Authorization" + ) + + let (data, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + print("status:", httpResponse.statusCode) + } + print(String(data: data, encoding: .utf8) ?? "") } func postBillPublisher(bill: Bill) -> AnyPublisher { @@ -147,13 +183,21 @@ class NetworkService { .replaceError(with: false) .eraseToAnyPublisher() } + private func baseURLFor(_ project: Project, suffix: String) -> URL { var url = project.url .appendingPathComponent(project.backend.staticPath) - .appendingPathComponent(project.token) + if project.backend == .cospend { - url = url.appendingPathComponent(project.password) + url = url + .appendingPathComponent(project.projectId) + .appendingPathComponent(project.password) + } + + if project.backend == .iHateMoney { + url = url + .appendingPathComponent(project.projectId) } if suffix.isEmpty { return url @@ -181,8 +225,14 @@ class NetworkService { request = URLRequest(url: requestURL) if project.backend == .iHateMoney { - guard let authString = "\(project.token):\(project.password)".data(using: .utf8)?.base64EncodedString() else { fatalError("error generating authString. THIS SHOULD NOT HAPPEN") } - request.setValue("Basic \(authString)", forHTTPHeaderField: "Authorization") + if project.password.isEmpty { + request.setValue("Bearer \(project.token)", forHTTPHeaderField: "Authorization") + } else { + guard let authString = "\(project.token):\(project.password)".data(using: .utf8)?.base64EncodedString() else { + fatalError("error generating authString. THIS SHOULD NOT HAPPEN") + } + request.setValue("Basic \(authString)", forHTTPHeaderField: "Authorization") + } if !params.isEmpty { request.httpBody = try? JSONSerialization.data(withJSONObject: params) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 00a327d..8e16dc0 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -44,11 +44,7 @@ class ProjectManager: ObservableObject { func openedByURL(url: URL) { let data = url.decodeCospendString() - guard let _ = data.server, - let _ = data.project - else { - return - } + guard data != nil else { return } openedByURL = url } diff --git a/PayForMe/Services/StorageService.swift b/PayForMe/Services/StorageService.swift index a766417..2285f5e 100644 --- a/PayForMe/Services/StorageService.swift +++ b/PayForMe/Services/StorageService.swift @@ -44,6 +44,12 @@ class StorageService { table.add(column: "me") }) } + migrator.registerMigration("v4") { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "projectId") + }) + try db.execute(sql: "UPDATE storedProject SET projectId = name;") + } // #if DEBUG //// Speed up development by nuking the database when migrations change // migrator.eraseDatabaseOnSchemaChange = true @@ -153,6 +159,6 @@ private class OldProject: Codable, Identifiable { var bills: [Bill] func toProject() -> StoredProject { - return StoredProject(name: name, password: password, token: name, url: url, backend: backend) + return StoredProject(name: name, password: password, token: name, url: url, backend: backend, projectId: name) } } diff --git a/PayForMe/Util/Util.swift b/PayForMe/Util/Util.swift index a191874..6c40aeb 100644 --- a/PayForMe/Util/Util.swift +++ b/PayForMe/Util/Util.swift @@ -176,43 +176,115 @@ extension StringProtocol { } } -typealias ProjectData = (server: URL?, project: String?, passwd: String?) +protocol ProjectData { + var server: URL { get } + var project: String { get } +} + +struct ProjectDataWithToken: ProjectData { + var server: URL + var project: String + var token: String + + init(server: URL, project: String, token: String) { + self.server = server + self.project = project + self.token = token + } + +} + +struct ProjectDataWithPassword: ProjectData { + var server: URL + var project: String + var password: String? + + init(server: URL, project: String, password: String?) { + self.server = server + self.project = project + self.password = password + } +} extension URL { - func decodeMoneyBusterString() -> ProjectData { + func decodeMoneyBusterString() -> ProjectDataWithPassword? { guard absoluteString.hasPrefix("https://net.eneiluj.moneybuster.cospend/"), - pathComponents.count >= 3, pathComponents.count <= 4 else { return (nil, nil, nil) } - return (URL(string: "https://" + pathComponents[1]), pathComponents[2], pathComponents[safe: 3]) + pathComponents.count >= 3, pathComponents.count <= 4 else { + return nil + } + + guard let hostUrl = URL(string: "https://" + pathComponents[1]) else { + return nil + } + + let password = pathComponents[safe: 3] + + return ProjectDataWithPassword( + server: hostUrl, + project: pathComponents[2], + password: password + ) } } extension URL { - func decodeCospendString() -> ProjectData { + func decodeCospendString() -> ProjectDataWithPassword? { guard let host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("cospend") else { - return (nil, nil, nil) + return nil } - - var hostString = "https://\(host)" - + + var hostString = host + if let port = port {hostString += ":\(port)"} if pathComponents.count > 3 { hostString += "/" + pathComponents[1..<(pathComponents.count - 2)].joined(separator: "/") } - - return (URL(string: hostString), - pathComponents[safe: pathComponents.count - 2], - pathComponents.last) + + guard + let hostUrl = URL(string: "https://" + hostString), + let project = pathComponents[safe: pathComponents.count - 2], + let password = pathComponents.last + else { return nil } + + return ProjectDataWithPassword( + server: hostUrl, + project: project, + password: password) + } +} + +extension URL { + func decodeIHateMoenyString() -> ProjectDataWithToken? { + guard var host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("ihatemoney") else { + return nil + } + + let hostUrl = "https://" + host + + guard + let url = URL(string: hostUrl), + let project = pathComponents[safe: pathComponents.count - 3], + let token = pathComponents.last + else { return nil} + + return ProjectDataWithToken(server: url, project: project, token: token) } } extension URL { - func decodeQRCode() -> ProjectData { - guard let scheme = scheme else { return (nil, nil, nil) } - return scheme.contains("cospend") ? decodeCospendString() : decodeMoneyBusterString() + func decodeQRCode() -> ProjectData? { + guard let scheme = scheme else { return nil } + if scheme.contains("cospend") { + return decodeCospendString() + } else if scheme.contains("ihatemoney") { + return decodeIHateMoenyString() + } else { + return decodeMoneyBusterString() + } } } diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift index edca69c..9aaf3ae 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift @@ -25,18 +25,16 @@ struct AddProjectManualView: View { } .pickerStyle(SegmentedPickerStyle()) .padding(EdgeInsets(top: 8, leading: 8, bottom: 0, trailing: 8)) - if viewmodel.projectType == .cospend { - if #available(iOS 16.0, *) { - PasteButton(payloadType: String.self) { strings in - pasteLink(pasteString: strings[0]) - } - .padding(.top, 10) - } else { - Button("Paste Link") { - pasteLink() - } - .padding(.top, 10) + if #available(iOS 16.0, *) { + PasteButton(payloadType: String.self) { strings in + pasteLink(pasteString: strings[0]) + } + .padding(.top, 10) + } else { + Button("Paste Link") { + pasteLink() } + .padding(.top, 10) } Form { Section( @@ -44,7 +42,7 @@ struct AddProjectManualView: View { ) { TextFieldContainer( viewmodel.projectType == .cospend - ? "https://mynextcloud.org" : "https://ihatemoney.org", + ? "https://mynextcloud.org" : "https://ihatemoney.org", text: self.$viewmodel.serverAddress ) } @@ -52,26 +50,32 @@ struct AddProjectManualView: View { Section(header: Text("Project ID & Password")) { TextField("Enter project id", text: self.$viewmodel.projectName) - .autocapitalization(.none) + .autocapitalization(.none) SecureField("Enter project password", text: self.$viewmodel.projectPassword) } + + if viewmodel.projectType == .iHateMoney { + Section(header: Text("Invite Token")) { + TextField("Enter invite url", text: self.$viewmodel.inviteUrl) + .autocapitalization(.none) + } + } + + + SlickLoadingSpinner(connectionState: viewmodel.validationProgress) + .frame(width: 50, height: 50) + FancyButton( + add: false, + action: addButton, + text: "Add Project" + ) + .disabled(viewmodel.validationProgress != .success) + if !viewmodel.errorText.isEmpty { + Text(viewmodel.errorText) + } } - .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney") - .frame(width: UIScreen.main.bounds.width, height: 220, alignment: .center) - SlickLoadingSpinner(connectionState: viewmodel.validationProgress) - .frame(width: 50, height: 50) - FancyButton( - add: false, - action: addButton, - text: "Add Project" - ) - .disabled(viewmodel.validationProgress != .success) - if !viewmodel.errorText.isEmpty { - Text(viewmodel.errorText) - } - Spacer() - } + .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney")} .padding(.horizontal, 20) .padding(.vertical, 50) .background(Color.PFMBackground) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index defa90e..52be310 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -10,6 +10,13 @@ import Foundation import SlickLoadingSpinner import UIKit +struct InviteData { + let url: String + let baseUrl: String + let token: String + let project: String +} + class AddProjectManualViewModel: ObservableObject { @Published var projectType = ProjectBackend.cospend @@ -23,19 +30,31 @@ class AddProjectManualViewModel: ObservableObject { @Published var projectPassword = "" + @Published var inviteUrl = "" + @Published var validationProgress = LoadingState.notStarted @Published var errorText = "" private var lastProjectTestedSuccessfully: Project? + private var cancellables = Set() + init() { validatedInput.map { _ in LoadingState.connecting }.assign(to: &$validationProgress) validatedServer.map { $0 == 200 ? LoadingState.success : LoadingState.failure }.assign(to: &$validationProgress) errorTextPublisher.assign(to: &$errorText) serverCheckUnsupportedProtocoll.assign(to: &$errorText) + + $inviteUrl + .filter { _ in self.projectType == .iHateMoney } + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] token in self?.validateInviteToken(token) } + .store(in: &cancellables) } + + func reset() { serverAddress = "" projectName = "" @@ -51,34 +70,46 @@ class AddProjectManualViewModel: ObservableObject { } } - func pasteAddress(address: String) { - let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard let url = URL(string: trimmedAddress) else { - return - } - - // If it is a moneybuster URL - let (mUrl, mName, mPassword) = url.decodeQRCode() - if let url = mUrl, let name = mName { - serverAddress = url.absoluteString - projectName = name - if let password = mPassword { - projectPassword = password + private func validateInviteToken(_ token: String) { + guard !token.isEmpty, !serverAddress.isEmpty, !projectName.isEmpty else { return } + let baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" + let inviteData = InviteData(url: baseUrl, baseUrl: baseUrl, token: token, project: projectName) + validationProgress = .connecting + Task { @MainActor in + do { + let testedProject = try await NetworkService.shared.getProjectName(invite: inviteData) + self.lastProjectTestedSuccessfully = testedProject + self.validationProgress = .success + } catch { + print("Invite URL failed: \(error)") + self.validationProgress = .failure } - return } - // If it is another url - - let pathComponents = url.pathComponents - let pureUrl = url.deletingPathExtension().absoluteString - let trimmIndices = url.absoluteString.indices(of: "/") - if let cutIndex = trimmIndices[safe: 2] { - let trimmedUrl = pureUrl[...cutIndex] - serverAddress = String(trimmedUrl) - } else { - serverAddress = pureUrl + } + + func pasteAddress(address: String) { + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmedAddress) else { return } + + switch url.decodeQRCode() { + case let project as ProjectDataWithPassword: + serverAddress = project.server.absoluteString + projectName = project.project + projectPassword = project.password ?? "" + case let project as ProjectDataWithToken: + projectType = .iHateMoney + serverAddress = project.server.absoluteString + projectName = project.project + inviteUrl = project.token + default: + guard url.pathComponents.contains("join"), + let scheme = url.scheme, let host = url.host, + url.pathComponents.count >= 4 else { return } + projectType = .iHateMoney + serverAddress = "\(scheme)://\(host)" + projectName = url.pathComponents[1] + inviteUrl = url.pathComponents[3] } - fillFieldsFromComponents(components: pathComponents) } var serverAddressFormatted: AnyPublisher { @@ -134,7 +165,7 @@ class AddProjectManualViewModel: ObservableObject { .compactMap { server, token, password -> Project? in if let address = server.address, address.isValidURL, !token.isEmpty, !password.isEmpty { guard let url = URL(string: address) else { return nil } - return Project(name: token, password: password, token: token, backend: server.0, url: url) + return Project(name: token, password: password, token: token, backend: server.0, url: url, projectId: self.projectName) } else { return nil } diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift index 1456a04..620456f 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift @@ -50,7 +50,8 @@ class AddProjectQRViewModel: ObservableObject { ) .map { url, token, password in self.isTestingSubject.send(.connecting) - return Project(name: "", password: password, token: token, backend: .cospend, url: url) + + return Project(name: "", password: password, token: token, backend: .cospend, url: url, projectId: token) } .flatMap { project in NetworkService.shared.testProject(project) @@ -92,42 +93,59 @@ class AddProjectQRViewModel: ObservableObject { foundCode .sink { codedUrl in let projectData = codedUrl.decodeQRCode() - guard let url = projectData.server, let token = projectData.project else { return } - if let password = projectData.passwd { + + if let projectData = projectData as? ProjectDataWithPassword { + if let password = projectData.password { + self.isTestingSubject.send(.connecting) + let project = Project(name: projectData.project, password: password, token: projectData.project, backend: .cospend, url: projectData.server, projectId: projectData.project) + Task(priority: .userInitiated) { + do { + let apiProject = try await NetworkService.shared.getProjectName(project) + try ProjectManager.shared.addProject(apiProject) + self.isTestingSubject.send(.success) + } catch { + print(codedUrl) + print() + print(error) + self.isTestingSubject.send(.failure) + } + } + // NetworkService.shared.testProject(project) + // .asUIPublisher + // .sink(receiveValue: { + // project, code in + // if code == 200 { + // DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { + // ProjectManager.shared.addProject(project) + // } + // self.isTestingSubject.send(.success) + // } else { + // self.isTestingSubject.send(.failure) + // } + // }).store(in: &self.subscriptions) + } else { + withAnimation { + self.url = projectData.server + self.name = projectData.project + self.askForPassword.toggle() + } + } + return + } + + if let projectData = projectData as? ProjectDataWithToken { self.isTestingSubject.send(.connecting) - let project = Project(name: token, password: password, token: token, backend: .cospend, url: url) Task(priority: .userInitiated) { do { - let apiProject = try await NetworkService.shared.getProjectName(project) + let apiProject = try await NetworkService.shared.getProjectName(invite: InviteData(url: projectData.server.absoluteString, baseUrl: projectData.server.absoluteString, token: projectData.token, project: projectData.project)) try ProjectManager.shared.addProject(apiProject) self.isTestingSubject.send(.success) } catch { - print(codedUrl) - print() - print(error) self.isTestingSubject.send(.failure) } } -// NetworkService.shared.testProject(project) -// .asUIPublisher -// .sink(receiveValue: { -// project, code in -// if code == 200 { -// DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { -// ProjectManager.shared.addProject(project) -// } -// self.isTestingSubject.send(.success) -// } else { -// self.isTestingSubject.send(.failure) -// } -// }).store(in: &self.subscriptions) - } else { - withAnimation { - self.url = url - self.name = token - self.askForPassword.toggle() - } } } + } } diff --git a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift index 28818ce..e11eeeb 100644 --- a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift +++ b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift @@ -25,7 +25,7 @@ final class ShareProjectQRCodeViewModel: ObservableObject { private func generate() { let server = project.url.relativeString .replacingOccurrences(of: "https://", with: "") - self.dataString = "cospend://\(server)/\(project.token)/\(project.password)" + self.dataString = "cospend://\(server)/\(project.projectId)/\(project.password)" self.qrCodeImage = generateQRCode(from: dataString) } diff --git a/PayForMeTests/BalanceCalculationTests.swift b/PayForMeTests/BalanceCalculationTests.swift index 034c261..a7cc990 100644 --- a/PayForMeTests/BalanceCalculationTests.swift +++ b/PayForMeTests/BalanceCalculationTests.swift @@ -35,7 +35,8 @@ class BalanceCalculationTests: XCTestCase { private func makeProject(members: [Person], bills: [Bill]) -> Project { let project = Project( name: "test", password: "pw", token: "tok", - backend: .cospend, url: URL(string: "https://test.de")! + backend: .cospend, url: URL(string: "https://test.de")!, + projectId: "" ) project.members = Dictionary(uniqueKeysWithValues: members.map { ($0.id, $0) }) project.bills = bills diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index a7b7037..d0e67bc 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -72,7 +72,7 @@ class NetworkRequestTests: XCTestCase { func testCospend_loadBills_urlEmbedsTokenAndPassword() { let project = Project.makeCospend(token: "mytoken", password: "mypass", - url: "https://cloud.example.com") + url: "https://cloud.example.com", projectId: "myproject") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -87,13 +87,13 @@ class NetworkRequestTests: XCTestCase { // The full Cospend path must be: // /index.php/apps/cospend/api/projects/{token}/{password}/bills XCTAssertTrue( - url.contains("/index.php/apps/cospend/api/projects/mytoken/mypass/bills"), + url.contains("/index.php/apps/cospend/api/projects/myproject/mypass/bills"), "Cospend must put token and password in the URL path. Got: \(url)" ) } func testCospend_loadMembers_urlContainsMembersEndpoint() { - let project = Project.makeCospend(token: "tok", password: "pass") + let project = Project.makeCospend(token: "tok", password: "pass", projectId: "proj") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -105,7 +105,7 @@ class NetworkRequestTests: XCTestCase { let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" XCTAssertTrue(url.hasSuffix("/members") || url.contains("/members"), "Members endpoint must end with /members. Got: \(url)") - XCTAssertTrue(url.contains("/tok/pass/members"), + XCTAssertTrue(url.contains("/proj/pass/members"), "Cospend must embed token and password. Got: \(url)") } @@ -133,7 +133,7 @@ class NetworkRequestTests: XCTestCase { func testIHateMoney_loadBills_urlDoesNotContainPassword() { // iHateMoney uses HTTP Basic Auth — the password must NEVER appear in the URL. let project = Project.makeIHateMoney(token: "mytoken", password: "secret", - url: "https://ihatemoney.org") + url: "https://ihatemoney.org", projectId: "myproject") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -145,7 +145,7 @@ class NetworkRequestTests: XCTestCase { let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" XCTAssertFalse(url.contains("secret"), "iHateMoney password must NOT appear in the URL. Got: \(url)") - XCTAssertTrue(url.contains("/api/projects/mytoken/bills"), + XCTAssertTrue(url.contains("/api/projects/myproject/bills"), "iHateMoney URL must use /api/projects/{token}/bills. Got: \(url)") } diff --git a/PayForMeTests/TestHelpers.swift b/PayForMeTests/TestHelpers.swift index c7487b4..76bdddf 100644 --- a/PayForMeTests/TestHelpers.swift +++ b/PayForMeTests/TestHelpers.swift @@ -76,28 +76,32 @@ extension Project { static func makeCospend( token: String = "mytoken", password: String = "mypass", - url: String = "https://nextcloud.example.com" + url: String = "https://nextcloud.example.com", + projectId: String = "my-project" ) -> Project { Project( name: "test-project", password: password, token: token, backend: .cospend, - url: URL(string: url)! + url: URL(string: url)!, + projectId: projectId, ) } static func makeIHateMoney( token: String = "mytoken", password: String = "mypass", - url: String = "https://ihatemoney.org" + url: String = "https://ihatemoney.org", + projectId: String = "my-project" ) -> Project { Project( name: "test-project", password: password, token: token, backend: .iHateMoney, - url: URL(string: url)! + url: URL(string: url)!, + projectId: projectId ) } } diff --git a/PayForMeTests/UrlExtensionsTests.swift b/PayForMeTests/UrlExtensionsTests.swift index 8ae38bd..cebedcb 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -32,10 +32,14 @@ class UrlExtensionsTests: XCTestCase { // cospend://host/project/password → server="https://host" let url = URL(string: "cospend://myserver.de/myproject/no-pass")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "no-pass") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "no-pass") + } } func testCospendStringDecodingForSubfolders() throws { @@ -45,10 +49,14 @@ class UrlExtensionsTests: XCTestCase { // → server="https://host/folder1/folder2" let url = URL(string: "cospend://myserver.de/folder1/folder2/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de/folder1/folder2") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de/folder1/folder2") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendStringDecodingForSubdomains() throws { @@ -56,10 +64,14 @@ class UrlExtensionsTests: XCTestCase { // server root, not just the top-level domain. let url = URL(string: "cospend://subdomain.myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://subdomain.myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://subdomain.myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendStringDecodingForNonStandardPort() throws { @@ -68,10 +80,14 @@ class UrlExtensionsTests: XCTestCase { // reach the right endpoint. let url = URL(string: "cospend://myserver.de:1234/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de:1234") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de:1234") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendError_wrongScheme() throws { @@ -80,10 +96,9 @@ class UrlExtensionsTests: XCTestCase { // its path looks like it might contain project data. let url = URL(string: "https://myserver/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertNil(server) + let project = url.decodeCospendString() + XCTAssertNil(project) - XCTAssertNil(password) } // MARK: - MoneyBuster web link decoding @@ -94,22 +109,11 @@ class UrlExtensionsTests: XCTestCase { // https://net.eneiluj.moneybuster.cospend/{server}/{project}/{password} let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } - - func testMoneyBusterNoPassword() throws { - // MoneyBuster omits the password component for passwordless projects. - // The decoder must return nil for password without crashing — the default - // "no-pass" value is set by AddProjectManualViewModel, not here. - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! - - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertNil(password) + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testMoneyBusterError_wrongHost() throws { @@ -117,23 +121,18 @@ class UrlExtensionsTests: XCTestCase { // nil. Without this guard, any https URL would decode as a project link. let url = URL(string: "https://myserver/myproject/mypassword")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNil(server) + let project = url.decodeMoneyBusterString() XCTAssertNil(project) - XCTAssertNil(password) } func testMoneyBusterDecoding_tooManyPathComponents_returnsNil() throws { // The decoder guards: pathComponents.count must be 3 or 4. // (["", server, project] = 3, or + password = 4) // URLs with more components are malformed — the server/project boundary - // is ambiguous, so all three fields must be nil. + // is ambiguous, so nil must be returned. let url = URL(string: "https://net.eneiluj.moneybuster.cospend/server/project/password/extra")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNil(server) - XCTAssertNil(project) - XCTAssertNil(password) + XCTAssertNil(url.decodeMoneyBusterString()) } // MARK: - QR code dispatcher (decodeQRCode) @@ -144,10 +143,11 @@ class UrlExtensionsTests: XCTestCase { // a direct call to decodeCospendString() for the same URL. let url = URL(string: "cospend://myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeQRCode() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeQRCode() as? ProjectDataWithPassword + XCTAssertNotNil(project) + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testDecodeQRCode_httpsSchemeRoutesToMoneyBusterDecoder() throws { @@ -155,9 +155,37 @@ class UrlExtensionsTests: XCTestCase { // A MoneyBuster QR code uses https:// with the fixed MoneyBuster host prefix. let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeQRCode() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(password, "mypassword") + XCTAssertEqual(project, "myproject") + } + } + + func testMoneyBusterNoPassword() throws { + let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! + + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + + if let server = project?.server, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + } + } + + func testIHateMoneyQRDecoding() throws { + let url: URL = URL(string: "ihatemoney://my-server.de/demo-project/join/WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q")! + + let project = url.decodeIHateMoenyString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://my-server.de") + XCTAssertEqual(project?.project, "demo-project") + XCTAssertEqual(project?.token, "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q") + } } From fea620e8d06ca4322f341458f3b71eb32ebac615 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Wed, 24 Jun 2026 17:45:24 +0200 Subject: [PATCH 02/10] cleanup, naming and test optimizations --- PayForMe/Services/NetworkService.swift | 19 ------- PayForMe/Util/Util.swift | 29 ++-------- .../QRCodes/AddProjectQRViewModel.swift | 13 ----- PayForMeTests/UrlExtensionsTests.swift | 56 +++++-------------- 4 files changed, 20 insertions(+), 97 deletions(-) diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 02e1465..1bf9933 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -111,25 +111,6 @@ class NetworkService { return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: URL(string: invite.url)!, projectId: apiProject.id) } - func fetchInvite(_ invite: InviteData?) async throws { - guard let invite = invite, let url = URL(string: invite.url) else { - return - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue( - "Bearer \(invite.token)", - forHTTPHeaderField: "Authorization" - ) - - let (data, response) = try await URLSession.shared.data(for: request) - if let httpResponse = response as? HTTPURLResponse { - print("status:", httpResponse.statusCode) - } - print(String(data: data, encoding: .utf8) ?? "") - } - func postBillPublisher(bill: Bill) -> AnyPublisher { let request = buildURLRequest("bills", params: bill.paramsFor(currentProject.backend), project: currentProject, httpMethod: "POST") return sendBillPublisher(request: request) diff --git a/PayForMe/Util/Util.swift b/PayForMe/Util/Util.swift index 6c40aeb..2ebe363 100644 --- a/PayForMe/Util/Util.swift +++ b/PayForMe/Util/Util.swift @@ -185,25 +185,12 @@ struct ProjectDataWithToken: ProjectData { var server: URL var project: String var token: String - - init(server: URL, project: String, token: String) { - self.server = server - self.project = project - self.token = token - } - } struct ProjectDataWithPassword: ProjectData { var server: URL var project: String var password: String? - - init(server: URL, project: String, password: String?) { - self.server = server - self.project = project - self.password = password - } } extension URL { @@ -225,9 +212,7 @@ extension URL { password: password ) } -} -extension URL { func decodeCospendString() -> ProjectDataWithPassword? { guard let host = host, let scheme = scheme, @@ -239,7 +224,7 @@ extension URL { var hostString = host if let port = port {hostString += ":\(port)"} - + if pathComponents.count > 3 { hostString += "/" + pathComponents[1..<(pathComponents.count - 2)].joined(separator: "/") } @@ -255,11 +240,9 @@ extension URL { project: project, password: password) } -} -extension URL { - func decodeIHateMoenyString() -> ProjectDataWithToken? { - guard var host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("ihatemoney") else { + func decodeIHateMoneyString() -> ProjectDataWithToken? { + guard let host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("ihatemoney") else { return nil } @@ -273,23 +256,21 @@ extension URL { return ProjectDataWithToken(server: url, project: project, token: token) } -} -extension URL { func decodeQRCode() -> ProjectData? { guard let scheme = scheme else { return nil } if scheme.contains("cospend") { return decodeCospendString() } else if scheme.contains("ihatemoney") { - return decodeIHateMoenyString() + return decodeIHateMoneyString() } else { return decodeMoneyBusterString() } } } -// Why is URL an identifier but not identifiable? extension URL: Identifiable { + // Why is URL an identifier but not identifiable? public var id: URL { self } diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift index 620456f..bbe973c 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift @@ -110,19 +110,6 @@ class AddProjectQRViewModel: ObservableObject { self.isTestingSubject.send(.failure) } } - // NetworkService.shared.testProject(project) - // .asUIPublisher - // .sink(receiveValue: { - // project, code in - // if code == 200 { - // DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { - // ProjectManager.shared.addProject(project) - // } - // self.isTestingSubject.send(.success) - // } else { - // self.isTestingSubject.send(.failure) - // } - // }).store(in: &self.subscriptions) } else { withAnimation { self.url = projectData.server diff --git a/PayForMeTests/UrlExtensionsTests.swift b/PayForMeTests/UrlExtensionsTests.swift index cebedcb..3cb5e67 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -34,12 +34,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "no-pass") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "no-pass") } func testCospendStringDecodingForSubfolders() throws { @@ -52,11 +49,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de/folder1/folder2") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de/folder1/folder2") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendStringDecodingForSubdomains() throws { @@ -67,11 +62,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://subdomain.myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(project?.server.absoluteString, "https://subdomain.myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendStringDecodingForNonStandardPort() throws { @@ -83,11 +76,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de:1234") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de:1234") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendError_wrongScheme() throws { @@ -150,37 +141,20 @@ class UrlExtensionsTests: XCTestCase { XCTAssertEqual(project?.password, "mypassword") } - func testDecodeQRCode_httpsSchemeRoutesToMoneyBusterDecoder() throws { - // For https:// QR codes, decodeQRCode() must call decodeMoneyBusterString(). - // A MoneyBuster QR code uses https:// with the fixed MoneyBuster host prefix. - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! - - let project = url.decodeMoneyBusterString() - XCTAssertNotNil(project) - - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(password, "mypassword") - XCTAssertEqual(project, "myproject") - } - } - func testMoneyBusterNoPassword() throws { let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! let project = url.decodeMoneyBusterString() XCTAssertNotNil(project) - if let server = project?.server, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") } func testIHateMoneyQRDecoding() throws { let url: URL = URL(string: "ihatemoney://my-server.de/demo-project/join/WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q")! - let project = url.decodeIHateMoenyString() + let project = url.decodeIHateMoneyString() XCTAssertNotNil(project) XCTAssertEqual(project?.server.absoluteString, "https://my-server.de") From 2e7a1e64c7b9d1b6f7670115b4ce95e78d3cc617 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Fri, 3 Jul 2026 18:47:07 +0200 Subject: [PATCH 03/10] openByURL should also handle IHM links --- PayForMe/SceneDelegate.swift | 2 +- PayForMe/Services/ProjectManager.swift | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/PayForMe/SceneDelegate.swift b/PayForMe/SceneDelegate.swift index 720c4b3..f897003 100644 --- a/PayForMe/SceneDelegate.swift +++ b/PayForMe/SceneDelegate.swift @@ -67,7 +67,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_: UIScene, openURLContexts URLContexts: Set) { guard let context = URLContexts.first, let scheme = context.url.scheme, - scheme.localizedCaseInsensitiveContains("cospend") + scheme.localizedCaseInsensitiveContains("cospend") || scheme.localizedCaseInsensitiveContains("ihatemoney") else { return } diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 8e16dc0..9f0f839 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -43,8 +43,7 @@ class ProjectManager: ObservableObject { } func openedByURL(url: URL) { - let data = url.decodeCospendString() - guard data != nil else { return } + guard url.decodeCospendString() != nil || url.decodeIHateMoneyString() != nil else { return } openedByURL = url } From 83bced4a4c1c6b1bfb7f1cb803e8bd781be1ebe5 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Fri, 3 Jul 2026 18:47:40 +0200 Subject: [PATCH 04/10] fix cospend api url --- PayForMe/Services/NetworkService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 1bf9933..ccf408c 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -172,7 +172,7 @@ class NetworkService { if project.backend == .cospend { url = url - .appendingPathComponent(project.projectId) + .appendingPathComponent(project.token) .appendingPathComponent(project.password) } From f32e23cc4b892bd7d1f110cfbd17211a306b76f2 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Sun, 19 Jul 2026 14:12:12 +0200 Subject: [PATCH 05/10] fix migration of old i-hate-money-projects --- PayForMe.xcodeproj/project.pbxproj | 4 ++ PayForMe/Services/StorageService.swift | 70 ++++++++++++----------- PayForMeTests/NetworkRequestTests.swift | 4 +- PayForMeTests/StorageMigrationTests.swift | 36 ++++++++++++ 4 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 PayForMeTests/StorageMigrationTests.swift diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index b85f09b..f4f8eb6 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */; }; D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EED3194A2342518B0E74079 /* BillTests.swift */; }; DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */; }; + 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00012F1A000100000001 /* StorageMigrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -106,6 +107,7 @@ 6514934B26CFC71000F00851 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/LaunchScreen.strings; sourceTree = ""; }; 6514934C26CFC71000F00851 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlExtensionsTests.swift; sourceTree = ""; }; + 65AF00012F1A000100000001 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; 6523FC502558153600BCD843 /* AddPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPasswordView.swift; sourceTree = ""; }; 6533A02A23D888B100FF822B /* BillCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillCell.swift; sourceTree = ""; }; 654754B72528736F00A82EB6 /* Views+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views+Extensions.swift"; sourceTree = ""; }; @@ -236,6 +238,7 @@ 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */, 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */, 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */, + 65AF00012F1A000100000001 /* StorageMigrationTests.swift */, ); path = PayForMeTests; sourceTree = ""; @@ -551,6 +554,7 @@ 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */, 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */, DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */, + 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PayForMe/Services/StorageService.swift b/PayForMe/Services/StorageService.swift index 2285f5e..3a8ce51 100644 --- a/PayForMe/Services/StorageService.swift +++ b/PayForMe/Services/StorageService.swift @@ -19,43 +19,47 @@ class StorageService { private let dbQueue: DatabaseQueue - init() { - do { - var migrator = DatabaseMigrator() - - migrator.registerMigration("v1", migrate: { db in - try db.create(table: "storedProject", ifNotExists: true) { table in - table.autoIncrementedPrimaryKey("id") - table.column("name") - table.column("password") - table.column("url") - table.column("backend") - } + static func makeMigrator() -> DatabaseMigrator { + var migrator = DatabaseMigrator() + + migrator.registerMigration("v1", migrate: { db in + try db.create(table: "storedProject", ifNotExists: true) { table in + table.autoIncrementedPrimaryKey("id") + table.column("name") + table.column("password") + table.column("url") + table.column("backend") + } + }) + migrator.registerMigration("v2", migrate: { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "token") }) - migrator.registerMigration("v2", migrate: { db in - try db.alter(table: "storedProject", body: { table in - table.add(column: "token") - }) - try db.execute(sql: "UPDATE storedProject SET token = name;") + try db.execute(sql: "UPDATE storedProject SET token = name;") + }) + migrator.registerMigration("v3") { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "me") }) - migrator.registerMigration("v3") { db in - try db.alter(table: "storedProject", body: { table in - table.add(column: "me") - }) - } - migrator.registerMigration("v4") { db in - try db.alter(table: "storedProject", body: { table in - table.add(column: "projectId") - }) - try db.execute(sql: "UPDATE storedProject SET projectId = name;") - } - // #if DEBUG - //// Speed up development by nuking the database when migrations change - // migrator.eraseDatabaseOnSchemaChange = true - // #endif + } + migrator.registerMigration("v4") { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "projectId") + }) + try db.execute(sql: "UPDATE storedProject SET projectId = token;") + } + // #if DEBUG + //// Speed up development by nuking the database when migrations change + // migrator.eraseDatabaseOnSchemaChange = true + // #endif + return migrator + } + + init() { + do { dbQueue = try DatabaseQueue(path: databasePath.appendingPathComponent("payforme.sqlite").path) - try migrator.migrate(dbQueue) + try Self.makeMigrator().migrate(dbQueue) } catch { print("Storage couldn't be initialized \(error.localizedDescription)") fatalError() diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index d0e67bc..875ab5e 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -87,7 +87,7 @@ class NetworkRequestTests: XCTestCase { // The full Cospend path must be: // /index.php/apps/cospend/api/projects/{token}/{password}/bills XCTAssertTrue( - url.contains("/index.php/apps/cospend/api/projects/myproject/mypass/bills"), + url.contains("/index.php/apps/cospend/api/projects/mytoken/mypass/bills"), "Cospend must put token and password in the URL path. Got: \(url)" ) } @@ -105,7 +105,7 @@ class NetworkRequestTests: XCTestCase { let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" XCTAssertTrue(url.hasSuffix("/members") || url.contains("/members"), "Members endpoint must end with /members. Got: \(url)") - XCTAssertTrue(url.contains("/proj/pass/members"), + XCTAssertTrue(url.contains("/tok/pass/members"), "Cospend must embed token and password. Got: \(url)") } diff --git a/PayForMeTests/StorageMigrationTests.swift b/PayForMeTests/StorageMigrationTests.swift new file mode 100644 index 0000000..b418884 --- /dev/null +++ b/PayForMeTests/StorageMigrationTests.swift @@ -0,0 +1,36 @@ +// +// StorageMigrationTests.swift +// PayForMeTests +// + +import GRDB +@testable import PayForMe +import XCTest + +class StorageMigrationTests: XCTestCase { + /* + Test Notes: Projects added before V3 used the column "token" to store the project id. + From V4: Projects store project id in column "project id" and use the column token to store a token used for authorization + */ + func test_IHM_legacy_projects_migrate_token_to_project_id() throws { + let dbQueue = try DatabaseQueue() + let migrator = StorageService.makeMigrator() + try migrator.migrate(dbQueue, upTo: "v3") + + try dbQueue.write { db in + try db.execute( + sql: "INSERT INTO storedProject (name, password, url, backend, token) VALUES (?, ?, ?, ?, ?)", + arguments: ["Haushalt", "totally-secret", "https://ihatemoney.org", ProjectBackend.iHateMoney.rawValue, "haushalt-token"] + ) + } + + try migrator.migrate(dbQueue) + + let projects = try dbQueue.read { db in + try StoredProject.fetchAll(db) + } + XCTAssertEqual(projects.count, 1) + XCTAssertEqual(projects[0].projectId, "haushalt-token") + XCTAssertEqual(projects[0].name, "Haushalt") + } +} From e0c467a18c3b7a26901353c2e849244feaa07699 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 01:53:03 +0200 Subject: [PATCH 06/10] InviteData.baseUrl was never read anywhere in the app --- PayForMe/Services/NetworkService.swift | 4 ++-- .../Views/Projects/Manual/AddProjectManualViewModel.swift | 3 +-- PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index ccf408c..04fb638 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -95,7 +95,7 @@ class NetworkService { } func getProjectName(invite: InviteData) async throws -> Project { - var request = URLRequest(url: URL(string: invite.url + "/api/projects/" + invite.project)!) + var request = URLRequest(url: URL(string: invite.baseUrl + "/api/projects/" + invite.project)!) request.httpMethod = "GET" request.setValue( "Bearer \(invite.token)", @@ -108,7 +108,7 @@ class NetworkService { } let apiProject = try JSONDecoder().decode(APIProject.self, from: data) - return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: URL(string: invite.url)!, projectId: apiProject.id) + return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: URL(string: invite.baseUrl)!, projectId: apiProject.id) } func postBillPublisher(bill: Bill) -> AnyPublisher { diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index 52be310..c84f3ff 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -11,7 +11,6 @@ import SlickLoadingSpinner import UIKit struct InviteData { - let url: String let baseUrl: String let token: String let project: String @@ -73,7 +72,7 @@ class AddProjectManualViewModel: ObservableObject { private func validateInviteToken(_ token: String) { guard !token.isEmpty, !serverAddress.isEmpty, !projectName.isEmpty else { return } let baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" - let inviteData = InviteData(url: baseUrl, baseUrl: baseUrl, token: token, project: projectName) + let inviteData = InviteData(baseUrl: baseUrl, token: token, project: projectName) validationProgress = .connecting Task { @MainActor in do { diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift index bbe973c..3ebfdf3 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift @@ -124,7 +124,7 @@ class AddProjectQRViewModel: ObservableObject { self.isTestingSubject.send(.connecting) Task(priority: .userInitiated) { do { - let apiProject = try await NetworkService.shared.getProjectName(invite: InviteData(url: projectData.server.absoluteString, baseUrl: projectData.server.absoluteString, token: projectData.token, project: projectData.project)) + let apiProject = try await NetworkService.shared.getProjectName(invite: InviteData(baseUrl: projectData.server.absoluteString, token: projectData.token, project: projectData.project)) try ProjectManager.shared.addProject(apiProject) self.isTestingSubject.send(.success) } catch { From c82cf554aba994834092f80cf04cbd7cb88b70f7 Mon Sep 17 00:00:00 2001 From: Jona Date: Wed, 22 Jul 2026 02:02:46 +0200 Subject: [PATCH 07/10] allow optional server address for iHateMoney Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Projects/Manual/AddProjectManualViewModel.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index 52be310..ef30cb2 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -71,8 +71,15 @@ class AddProjectManualViewModel: ObservableObject { } private func validateInviteToken(_ token: String) { - guard !token.isEmpty, !serverAddress.isEmpty, !projectName.isEmpty else { return } - let baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" + guard !token.isEmpty, !projectName.isEmpty else { return } + + let baseUrl: String + if serverAddress.isEmpty { + baseUrl = NetworkService.iHateMoneyURLString + } else { + baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" + } + let inviteData = InviteData(url: baseUrl, baseUrl: baseUrl, token: token, project: projectName) validationProgress = .connecting Task { @MainActor in From 94b704f033b40a08fd47ead5ea9ad6ac14b82516 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 02:26:04 +0200 Subject: [PATCH 08/10] prevent crashing the app when url contains an unexpected value --- PayForMe/Services/NetworkService.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 04fb638..c29c37b 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -95,7 +95,15 @@ class NetworkService { } func getProjectName(invite: InviteData) async throws -> Project { - var request = URLRequest(url: URL(string: invite.baseUrl + "/api/projects/" + invite.project)!) + guard var components = URLComponents(string: invite.baseUrl), + let baseURL = components.url else { + throw URLError(.badURL) + } + components.path += iHateMoneyStaticPath + "/" + invite.project + guard let url = components.url else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue( "Bearer \(invite.token)", @@ -108,7 +116,7 @@ class NetworkService { } let apiProject = try JSONDecoder().decode(APIProject.self, from: data) - return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: URL(string: invite.baseUrl)!, projectId: apiProject.id) + return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: baseURL, projectId: apiProject.id) } func postBillPublisher(bill: Bill) -> AnyPublisher { From cf2d71e92c638805bd2b1ff45386ddba2599d209 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 02:30:27 +0200 Subject: [PATCH 09/10] Update UrlExtensionsTests.swift --- PayForMeTests/UrlExtensionsTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/PayForMeTests/UrlExtensionsTests.swift b/PayForMeTests/UrlExtensionsTests.swift index 3cb5e67..a177f07 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -149,6 +149,7 @@ class UrlExtensionsTests: XCTestCase { XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") XCTAssertEqual(project?.project, "myproject") + XCTAssertNil(project?.password) } func testIHateMoneyQRDecoding() throws { From 8a89c7d75284da24c67fcbbbaf7817695bd7bb48 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 03:08:39 +0200 Subject: [PATCH 10/10] fix flaky test involving outbound network traffic --- PayForMeTests/AddProjectManuallyTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/PayForMeTests/AddProjectManuallyTests.swift b/PayForMeTests/AddProjectManuallyTests.swift index 61708c1..0e63aee 100644 --- a/PayForMeTests/AddProjectManuallyTests.swift +++ b/PayForMeTests/AddProjectManuallyTests.swift @@ -32,7 +32,22 @@ class AddProjectManuallyTests: XCTestCase { var viewmodel = AddProjectManualViewModel() var subscriptions = Set() + override func setUp() { + super.setUp() + // The view model validates the server over the network once a full + // triple is entered. Intercept it so tests stay offline and deterministic. + URLProtocol.registerClass(MockURLProtocol.self) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, + httpVersion: nil, headerFields: nil)! + let body = #"{"name": "nameXY", "id": "nameXY"}"#.data(using: .utf8)! + return (response, body) + } + } + override func tearDownWithError() throws { + URLProtocol.unregisterClass(MockURLProtocol.self) + MockURLProtocol.reset() subscriptions.removeAll() }