From 363ba9bf4beb793790a16a13c49d93e1c3ecef40 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Thu, 14 May 2026 00:21:09 +0200 Subject: [PATCH 1/3] not conform to license --- PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift index 1753e1b..28818ce 100644 --- a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift +++ b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift @@ -3,7 +3,6 @@ // PayForMe // // Created by Maximilian Fischer on 30.04.26. -// Copyright © 2026 Mayflower GmbH. All rights reserved. // import SwiftUI From 6778f3669f074a6dfdab078232224050afb19efc Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Thu, 14 May 2026 01:49:45 +0200 Subject: [PATCH 2/3] Added and unified unit tests Covers Bill params, JSON decoding, balance calculation, bill sorting, network request contracts (Cospend + iHateMoney), URL scheme decoding,and AddProjectManually URL normalization. --- PayForMe.xcodeproj/project.pbxproj | 24 + PayForMeTests/AddProjectManuallyTests.swift | 110 +++-- PayForMeTests/BalanceCalculationTests.swift | 202 ++++++++ PayForMeTests/BillSortingTests.swift | 168 +++++++ PayForMeTests/BillTests.swift | 211 +++++++++ PayForMeTests/JSONDecodingTests.swift | 269 +++++++++++ PayForMeTests/NetworkRequestTests.swift | 497 ++++++++++++++++++++ PayForMeTests/TestHelpers.swift | 146 ++++++ PayForMeTests/UrlExtensionsTests.swift | 166 ++++--- 9 files changed, 1695 insertions(+), 98 deletions(-) create mode 100644 PayForMeTests/BalanceCalculationTests.swift create mode 100644 PayForMeTests/BillSortingTests.swift create mode 100644 PayForMeTests/BillTests.swift create mode 100644 PayForMeTests/JSONDecodingTests.swift create mode 100644 PayForMeTests/NetworkRequestTests.swift create mode 100644 PayForMeTests/TestHelpers.swift diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index 333c6e2..b85f09b 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -14,7 +14,10 @@ 48CC8F7423F5C16D00DCE3D8 /* AddBillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CC8F7323F5C16D00DCE3D8 /* AddBillView.swift */; }; 48CF709B240FF82000770B97 /* AddMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CF709A240FF82000770B97 /* AddMemberView.swift */; }; 48E7F75B2403FD6B000CE4E6 /* FancyLoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E7F75A2403FD6B000CE4E6 /* FancyLoadingButton.swift */; }; + 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */; }; + 57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */; }; 62A13D412FA3AB760036EE34 /* ShareProjectQRCodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A13D402FA3AB700036EE34 /* ShareProjectQRCodeViewModel.swift */; }; + 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */; }; 650059FC23DDE1C300D1D599 /* Person.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650059FB23DDE1C300D1D599 /* Person.swift */; }; 65005A0223DDFB1A00D1D599 /* WhoPaidView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65005A0123DDFB1A00D1D599 /* WhoPaidView.swift */; }; 65005A0423DE0C5C00D1D599 /* BillList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65005A0323DE0C5C00D1D599 /* BillList.swift */; }; @@ -62,6 +65,9 @@ 65C9E2D023E1B96B00814B37 /* BalanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C9E2CF23E1B96B00814B37 /* BalanceViewModel.swift */; }; 65D225F32428038500C3F1EC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 65D225F52428038500C3F1EC /* Localizable.strings */; }; 65D9094025581BE100351F4B /* AddFromURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D9093F25581BE100351F4B /* AddFromURLView.swift */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -82,6 +88,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; + 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BalanceCalculationTests.swift; sourceTree = ""; }; + 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkRequestTests.swift; sourceTree = ""; }; 481FB4FA23E964C8003BD108 /* ProjectManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectManager.swift; sourceTree = ""; }; 481FB4FC23EAD78F003BD108 /* AddProjectManualViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProjectManualViewModel.swift; sourceTree = ""; }; 489995D923F6EC6F008B7E38 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; @@ -147,6 +156,9 @@ 65D225F7242803C800C3F1EC /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 65D9093F25581BE100351F4B /* AddFromURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFromURLView.swift; sourceTree = ""; }; 65FE0E8E2544968B002B80CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 7EED3194A2342518B0E74079 /* BillTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillTests.swift; sourceTree = ""; }; + 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = JSONDecodingTests.swift; sourceTree = ""; }; + 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillSortingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -218,6 +230,12 @@ 654754E32528B29F00A82EB6 /* AddProjectManuallyTests.swift */, 654754E52528B29F00A82EB6 /* Info.plist */, 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */, + 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */, + 7EED3194A2342518B0E74079 /* BillTests.swift */, + 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */, + 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */, + 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */, + 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */, ); path = PayForMeTests; sourceTree = ""; @@ -527,6 +545,12 @@ files = ( 654754E42528B29F00A82EB6 /* AddProjectManuallyTests.swift in Sources */, 6523FC4C25580EEF00BCD843 /* UrlExtensionsTests.swift in Sources */, + 57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */, + D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */, + BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */, + 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */, + 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */, + DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PayForMeTests/AddProjectManuallyTests.swift b/PayForMeTests/AddProjectManuallyTests.swift index 111d62f..61708c1 100644 --- a/PayForMeTests/AddProjectManuallyTests.swift +++ b/PayForMeTests/AddProjectManuallyTests.swift @@ -1,110 +1,144 @@ // -// PayForMeTests.swift +// AddProjectManuallyTests.swift // PayForMeTests // -// Created by Max Tharr on 03.10.20. +// Tests for AddProjectManualViewModel — the view model that drives the manual +// project-add flow (the "Enter server URL" screen). +// +// WHY THIS MATTERS: +// Adding a new project is the very first thing a new user does. If URL parsing +// is broken, the app is completely unusable. Cospend URLs are especially tricky: +// they embed the project name and password inside the URL path, so users often +// paste a full URL like: +// +// https://cloud.example.com/index.php/apps/cospend/myproject/mypassword +// +// The ViewModel must strip the Nextcloud-specific path, keep only the server +// root, and auto-fill the project name and password fields. Without this, the +// user would have to know and type all three values separately. +// +// NOTE ON TIMING: +// serverAddressFormatted fires synchronously (no debounce) — 1-second timeout is fine. +// validatedInput has a 1-second debounce — tests that subscribe to it need 2 seconds. // import Combine -@testable import PayForMe import XCTest +@testable import PayForMe class AddProjectManuallyTests: XCTestCase { + + // XCTest creates a new instance per test method, so viewmodel is always fresh. var viewmodel = AddProjectManualViewModel() var subscriptions = Set() - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - override func tearDownWithError() throws { subscriptions.removeAll() } - func testHTTPSPrefix() throws { + // MARK: - URL normalization (serverAddressFormatted) + + func testHTTPSPrefix_addsMissingScheme() throws { + // Users who copy-paste a server address from a browser's address bar often + // omit the scheme. The ViewModel must prepend "https://" automatically. viewmodel.serverAddress = "myserver.de" - let exp = expectation(description: "With https") + let exp = expectation(description: "https:// prefix added") viewmodel.serverAddressFormatted.sink { formatted in - XCTAssertEqual("https://myserver.de", formatted) + XCTAssertEqual(formatted, "https://myserver.de") exp.fulfill() }.store(in: &subscriptions) waitForExpectations(timeout: 1) } - func testHTTPSPrefix2() throws { + func testHTTPSPrefix_doesNotDoubleAdd() throws { + // If the user already typed the scheme, it must not be duplicated. viewmodel.serverAddress = "https://myserver.de" - let exp = expectation(description: "No double https") + let exp = expectation(description: "no double https://") viewmodel.serverAddressFormatted.sink { formatted in - XCTAssertEqual("https://myserver.de", formatted) + XCTAssertEqual(formatted, "https://myserver.de") exp.fulfill() }.store(in: &subscriptions) waitForExpectations(timeout: 1) } - func testSuffix() throws { + func testCospendSuffix_isStripped() throws { + // Cospend's share link includes the full Nextcloud path. We only want the + // server root so we can build API URLs ourselves. viewmodel.serverAddress = "https://myserver.de/index.php/apps/cospend/" - let exp = expectation(description: "Remove trunk") + let exp = expectation(description: "Cospend trunk removed") viewmodel.serverAddressFormatted.sink { formatted in - XCTAssertEqual("https://myserver.de", formatted) + XCTAssertEqual(formatted, "https://myserver.de") exp.fulfill() }.store(in: &subscriptions) waitForExpectations(timeout: 1) } - func testPreAndSuffix() throws { + func testPrefixAndSuffix_bothApplied() throws { + // The two normalizations must compose: add https://, then strip the path. viewmodel.serverAddress = "myserver.de/index.php/apps/cospend/" - let exp = expectation(description: "Remove trunk, add prefix") + let exp = expectation(description: "prefix added and trunk removed") viewmodel.serverAddressFormatted.sink { formatted in - XCTAssertEqual("https://myserver.de", formatted) + XCTAssertEqual(formatted, "https://myserver.de") exp.fulfill() }.store(in: &subscriptions) waitForExpectations(timeout: 1) } - func testAutofillName() throws { + // MARK: - Autofill from URL path + + func testAutofill_projectNameExtracted() throws { + // When the URL contains the Cospend path with a project name but no + // password, the ViewModel must extract the project name and use "no-pass" + // as the default password (Cospend's convention for passwordless projects). viewmodel.serverAddress = "https://myserver.de/index.php/apps/cospend/nameXY" - let exp1 = expectation(description: "Remove trunk, add prefix") - let exp2 = expectation(description: "set project") - let exp3 = expectation(description: "password empty") + let exp1 = expectation(description: "server stripped") + let exp2 = expectation(description: "project name filled") + let exp3 = expectation(description: "default password set") viewmodel.serverAddressFormatted.sink { formatted in - XCTAssertEqual("https://myserver.de", formatted) + XCTAssertEqual(formatted, "https://myserver.de") exp1.fulfill() }.store(in: &subscriptions) viewmodel.$projectName.sink { name in - XCTAssertEqual("nameXY", name) + XCTAssertEqual(name, "nameXY") exp2.fulfill() }.store(in: &subscriptions) viewmodel.$projectPassword.sink { password in - XCTAssertEqual("no-pass", password) + XCTAssertEqual(password, "no-pass") exp3.fulfill() }.store(in: &subscriptions) waitForExpectations(timeout: 1) } - func testAutofill() throws { + func testAutofill_projectNameAndPasswordExtracted() throws { + // When the URL contains both project name and password in the path, + // both fields must be auto-filled. viewmodel.serverAddress = "https://myserver.de/index.php/apps/cospend/nameXY/passwordXY" - let exp1 = expectation(description: "Remove trunk, add prefix") - let exp2 = expectation(description: "set project") - let exp3 = expectation(description: "set password") + let exp1 = expectation(description: "server stripped") + let exp2 = expectation(description: "project name filled") + let exp3 = expectation(description: "password filled") viewmodel.serverAddressFormatted.sink { formatted in - XCTAssertEqual("https://myserver.de", formatted) + XCTAssertEqual(formatted, "https://myserver.de") exp1.fulfill() }.store(in: &subscriptions) viewmodel.$projectName.sink { name in - XCTAssertEqual("nameXY", name) + XCTAssertEqual(name, "nameXY") exp2.fulfill() }.store(in: &subscriptions) viewmodel.$projectPassword.sink { password in - XCTAssertEqual("passwordXY", password) + XCTAssertEqual(password, "passwordXY") exp3.fulfill() }.store(in: &subscriptions) waitForExpectations(timeout: 1) } - func testProjectCreation() throws { + // MARK: - Project object creation (validatedInput) + + func testProjectCreation_cospendBackend() throws { + // validatedInput emits a complete Project object once the debounce settles. + // This is the object that gets passed to NetworkService for server validation. viewmodel.serverAddress = "https://myserver.de/index.php/apps/cospend/nameXY/passwordXY" viewmodel.projectType = .cospend - let exp = expectation(description: "Project created") + let exp = expectation(description: "Project emitted") viewmodel.validatedInput.sink { project in XCTAssertEqual(project.backend, .cospend) XCTAssertEqual(project.name, "nameXY") @@ -115,10 +149,12 @@ class AddProjectManuallyTests: XCTestCase { waitForExpectations(timeout: 2) } - func testProjectNewMethodCreation() throws { + func testProjectCreation_tokenBasedCospend() throws { + // Newer Cospend share links use a random token instead of a human-readable + // project name. The token becomes both `name` and `token` on the Project. viewmodel.serverAddress = "https://myserver.de/index.php/apps/cospend/02939asdasd12asdj23/no-pass" viewmodel.projectType = .cospend - let exp = expectation(description: "Project created") + let exp = expectation(description: "Token-based project emitted") viewmodel.validatedInput.sink { project in XCTAssertEqual(project.backend, .cospend) XCTAssertEqual(project.name, "02939asdasd12asdj23") diff --git a/PayForMeTests/BalanceCalculationTests.swift b/PayForMeTests/BalanceCalculationTests.swift new file mode 100644 index 0000000..034c261 --- /dev/null +++ b/PayForMeTests/BalanceCalculationTests.swift @@ -0,0 +1,202 @@ +// +// BalanceCalculationTests.swift +// PayForMeTests +// +// Tests for the balance calculation in BalanceViewModel.setBalances(). +// +// WHY THIS MATTERS: +// The balance screen is the core feature users come to PayForMe for — it tells +// them who owes whom how much. A bug here (wrong sign, wrong division, missed +// bill) would cause users to make incorrect payments. We test with concrete +// numbers that can be verified by hand. +// +// HOW THE FORMULA WORKS: +// balance(member) = total_paid_by_member - total_owed_by_member +// +// "owed" for a given bill = bill.amount / bill.owers.count +// (everyone in owers[] pays an equal share regardless of weight) +// +// A positive balance means the person is owed money. +// A negative balance means the person owes money. +// The sum of all balances in a project is always zero. +// + +import XCTest +@testable import PayForMe + +class BalanceCalculationTests: XCTestCase { + + let alice = Person(id: 1, weight: 1, name: "Alice", activated: true) + let bob = Person(id: 2, weight: 1, name: "Bob", activated: true) + let carla = Person(id: 3, weight: 1, name: "Carla", activated: true) + let testDate = DateFormatter.cospend.date(from: "2026-05-14")! + + // Creates an in-memory Project with the given members and bills. + 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")! + ) + project.members = Dictionary(uniqueKeysWithValues: members.map { ($0.id, $0) }) + project.bills = bills + return project + } + + // Runs the exact same logic as BalanceViewModel.setBalances() and returns + // a [memberId: balance] dictionary for easy assertion. + private func calcBalances(for project: Project) -> [Int: Double] { + let vm = BalanceViewModel() + // Override the ProjectManager.shared project with our test project. + // BalanceViewModel.currentProject is @Published var, so this is allowed. + vm.currentProject = project + vm.setBalances() + return Dictionary(uniqueKeysWithValues: vm.balances.map { ($0.id, $0.amount) }) + } + + // Dictionary subscripts return Optional. This helper unwraps the value and + // fails the test with a readable message if the member is missing entirely. + private func bal( + _ person: Person, + in balances: [Int: Double], + file: StaticString = #file, + line: UInt = #line + ) -> Double { + guard let value = balances[person.id] else { + XCTFail("No balance entry for '\(person.name)' (id \(person.id)) — " + + "is the member added to the project?", file: file, line: line) + return 0.0 + } + return value + } + + private func makeBill(id: Int, amount: Double, payerId: Int, owers: [Person]) -> Bill { + Bill(id: id, amount: amount, what: "Test", date: testDate, + payer_id: payerId, owers: owers, repeat: "n") + } + + // MARK: - Basic scenarios + + func testSimpleThreeWaySplit() { + // Alice pays 30€ for all three. Each owes 10€. + // Alice: paid 30, owes 10 → balance = +20 + // Bob: paid 0, owes 10 → balance = -10 + // Carla: paid 0, owes 10 → balance = -10 + let bill = makeBill(id: 1, amount: 30.0, payerId: alice.id, + owers: [alice, bob, carla]) + let balances = calcBalances(for: makeProject(members: [alice, bob, carla], bills: [bill])) + + XCTAssertEqual(bal(alice, in: balances), 20.0, accuracy: 0.001) + XCTAssertEqual(bal(bob, in: balances), -10.0, accuracy: 0.001) + XCTAssertEqual(bal(carla, in: balances), -10.0, accuracy: 0.001) + } + + func testPayerNotListedAsOwer() { + // Alice pays 20€ for Bob only (Alice has no obligation in this bill). + // Alice: paid 20, owes 0 → balance = +20 + // Bob: paid 0, owes 20 → balance = -20 + let bill = makeBill(id: 1, amount: 20.0, payerId: alice.id, owers: [bob]) + let balances = calcBalances(for: makeProject(members: [alice, bob], bills: [bill])) + + XCTAssertEqual(bal(alice, in: balances), 20.0, accuracy: 0.001) + XCTAssertEqual(bal(bob, in: balances), -20.0, accuracy: 0.001) + } + + func testMemberNotInvolvedInAnyBill() { + // Carla is in the project but not in this bill. + let bill = makeBill(id: 1, amount: 30.0, payerId: alice.id, owers: [alice, bob]) + let balances = calcBalances(for: makeProject(members: [alice, bob, carla], bills: [bill])) + + XCTAssertEqual(bal(carla, in: balances), 0.0, accuracy: 0.001, + "A member not in any bill must have a zero balance") + } + + func testPayerAlsoOwes() { + // Alice pays 10€ and is the only ower (e.g. she paid for herself). + // Alice: paid 10, owes 10 → balance = 0 + let bill = makeBill(id: 1, amount: 10.0, payerId: alice.id, owers: [alice]) + let balances = calcBalances(for: makeProject(members: [alice], bills: [bill])) + + XCTAssertEqual(bal(alice, in: balances), 0.0, accuracy: 0.001) + } + + // MARK: - Multiple bills + + func testMultipleBillsSamePayer() { + // Alice pays two bills: 10€ and 20€, both split with Bob. + // Total paid by Alice: 30€, total owed: 15€ → +15 + let bill1 = makeBill(id: 1, amount: 10.0, payerId: alice.id, owers: [alice, bob]) + let bill2 = makeBill(id: 2, amount: 20.0, payerId: alice.id, owers: [alice, bob]) + let balances = calcBalances(for: makeProject(members: [alice, bob], bills: [bill1, bill2])) + + XCTAssertEqual(bal(alice, in: balances), 15.0, accuracy: 0.001) + XCTAssertEqual(bal(bob, in: balances), -15.0, accuracy: 0.001) + } + + func testMultipleBillsDifferentPayers() { + // Bill 1: Alice pays 30€ for Alice, Bob, Carla (10€ each) + // Bill 2: Bob pays 15€ for Bob, Carla (7.50€ each) + // + // Alice: paid 30, owes 10 → balance = +20 + // Bob: paid 15, owes (10 + 7.50) → balance = -2.50 + // Carla: paid 0, owes (10 + 7.50) → balance = -17.50 + let bill1 = makeBill(id: 1, amount: 30.0, payerId: alice.id, + owers: [alice, bob, carla]) + let bill2 = makeBill(id: 2, amount: 15.0, payerId: bob.id, + owers: [bob, carla]) + let balances = calcBalances(for: makeProject(members: [alice, bob, carla], + bills: [bill1, bill2])) + + XCTAssertEqual(bal(alice, in: balances), 20.0, accuracy: 0.001) + XCTAssertEqual(bal(bob, in: balances), -2.5, accuracy: 0.001) + XCTAssertEqual(bal(carla, in: balances), -17.5, accuracy: 0.001) + } + + // MARK: - Conservation law + + func testBalancesSumToZero_simpleSplit() { + let bill = makeBill(id: 1, amount: 30.0, payerId: alice.id, + owers: [alice, bob, carla]) + let balances = calcBalances(for: makeProject(members: [alice, bob, carla], bills: [bill])) + let total = balances.values.reduce(0.0, +) + XCTAssertEqual(total, 0.0, accuracy: 0.001, + "Conservation law: the sum of all balances must always be zero") + } + + func testBalancesSumToZero_complexScenario() { + let bill1 = makeBill(id: 1, amount: 30.0, payerId: alice.id, + owers: [alice, bob, carla]) + let bill2 = makeBill(id: 2, amount: 15.0, payerId: bob.id, + owers: [bob, carla]) + let bill3 = makeBill(id: 3, amount: 9.0, payerId: carla.id, + owers: [alice, carla]) + let project = makeProject(members: [alice, bob, carla], + bills: [bill1, bill2, bill3]) + let total = calcBalances(for: project).values.reduce(0.0, +) + XCTAssertEqual(total, 0.0, accuracy: 0.001) + } + + // MARK: - Edge cases + + func testNoBills_allBalancesAreZero() { + let balances = calcBalances(for: makeProject(members: [alice, bob], bills: [])) + XCTAssertEqual(bal(alice, in: balances), 0.0, accuracy: 0.001) + XCTAssertEqual(bal(bob, in: balances), 0.0, accuracy: 0.001) + } + + func testNoMembers_noBalances() { + let balances = calcBalances(for: makeProject(members: [], bills: [])) + XCTAssertTrue(balances.isEmpty) + } + + func testFractionalAmounts() { + // 10€ split three ways → 3.333...€ each. The balance must handle floats. + let bill = makeBill(id: 1, amount: 10.0, payerId: alice.id, + owers: [alice, bob, carla]) + let balances = calcBalances(for: makeProject(members: [alice, bob, carla], bills: [bill])) + + let expected = 10.0 / 3.0 + XCTAssertEqual(bal(alice, in: balances), 10.0 - expected, accuracy: 0.001) + XCTAssertEqual(bal(bob, in: balances), -expected, accuracy: 0.001) + XCTAssertEqual(bal(carla, in: balances), -expected, accuracy: 0.001) + } +} diff --git a/PayForMeTests/BillSortingTests.swift b/PayForMeTests/BillSortingTests.swift new file mode 100644 index 0000000..68fc7c2 --- /dev/null +++ b/PayForMeTests/BillSortingTests.swift @@ -0,0 +1,168 @@ +// +// BillSortingTests.swift +// PayForMeTests +// +// Tests for BillListViewModel.SortedBy — the two sort modes available in the +// Bills tab. +// +// WHY THIS MATTERS: +// Users rely on the bill list to find their most recent expenses. An incorrect +// sort order means they see old bills at the top and miss new ones. This is +// especially important when multiple people add bills from different devices, +// because the `lastchanged` timestamp (set by the server) differs from the +// expense date the user chose. +// + +import XCTest +@testable import PayForMe + +class BillSortingTests: XCTestCase { + + private let base = DateFormatter.cospend.date(from: "2026-01-01")! + + private func date(addingDays days: Int) -> Date { + Calendar.current.date(byAdding: .day, value: days, to: base)! + } + + private func makeBill(id: Int, daysOffset: Int, lastchanged: Int?) -> Bill { + Bill( + id: id, + amount: 10.0, + what: "Bill \(id)", + date: date(addingDays: daysOffset), + payer_id: 1, + owers: [], + repeat: "n", + lastchanged: lastchanged + ) + } + + // MARK: - Sort by expense date + + func testExpenseDate_sortedNewestFirst() { + // Bills are sorted so the most recent *expense date* is at index 0. + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: nil), // Jan 1 + makeBill(id: 2, daysOffset: 5, lastchanged: nil), // Jan 6 + makeBill(id: 3, daysOffset: 2, lastchanged: nil), // Jan 3 + ] + let sorted = BillListViewModel.SortedBy.expenseDate.sort(bills: bills) + XCTAssertEqual(sorted.map { $0.id }, [2, 3, 1]) + } + + func testExpenseDate_preservesBothBillsOnTie() { + // Two bills on the same date must both appear (no de-duplication). + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: nil), + makeBill(id: 2, daysOffset: 0, lastchanged: nil), + ] + let sorted = BillListViewModel.SortedBy.expenseDate.sort(bills: bills) + XCTAssertEqual(sorted.count, 2) + } + + func testExpenseDate_singleBill() { + let bills = [makeBill(id: 1, daysOffset: 0, lastchanged: nil)] + let sorted = BillListViewModel.SortedBy.expenseDate.sort(bills: bills) + XCTAssertEqual(sorted.map { $0.id }, [1]) + } + + func testExpenseDate_emptyList() { + let sorted = BillListViewModel.SortedBy.expenseDate.sort(bills: []) + XCTAssertTrue(sorted.isEmpty) + } + + func testExpenseDate_alreadySorted() { + let bills = [ + makeBill(id: 3, daysOffset: 4, lastchanged: nil), + makeBill(id: 2, daysOffset: 2, lastchanged: nil), + makeBill(id: 1, daysOffset: 0, lastchanged: nil), + ] + let sorted = BillListViewModel.SortedBy.expenseDate.sort(bills: bills) + XCTAssertEqual(sorted.map { $0.id }, [3, 2, 1]) + } + + func testExpenseDate_reverseOrder() { + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: nil), + makeBill(id: 2, daysOffset: 2, lastchanged: nil), + makeBill(id: 3, daysOffset: 4, lastchanged: nil), + ] + let sorted = BillListViewModel.SortedBy.expenseDate.sort(bills: bills) + XCTAssertEqual(sorted.map { $0.id }, [3, 2, 1]) + } + + // MARK: - Sort by changed date + + func testChangedDate_sortedMostRecentFirst() { + // `lastchanged` is a Unix timestamp set by the server whenever a bill + // is created or edited. A higher value means more recently changed. + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: 100), + makeBill(id: 2, daysOffset: 0, lastchanged: 300), + makeBill(id: 3, daysOffset: 0, lastchanged: 200), + ] + let sorted = BillListViewModel.SortedBy.changedDate.sort(bills: bills) + XCTAssertEqual(sorted.map { $0.id }, [2, 3, 1]) + } + + func testChangedDate_nilTreatedAsZero() { + // Bills without a lastchanged value (older Cospend format) are treated + // as if they were last changed at Unix epoch (i.e. a long time ago). + // They should appear after any bill that has a real timestamp. + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: nil), // → 0 + makeBill(id: 2, daysOffset: 0, lastchanged: 1), // → 1 + ] + let sorted = BillListViewModel.SortedBy.changedDate.sort(bills: bills) + XCTAssertEqual(sorted.map { $0.id }, [2, 1]) + } + + func testChangedDate_allNilDoesNotCrash() { + // If no bills have lastchanged (e.g. all came from an old server), the sort + // must still complete without crashing or dropping bills. + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: nil), + makeBill(id: 2, daysOffset: 0, lastchanged: nil), + makeBill(id: 3, daysOffset: 0, lastchanged: nil), + ] + let sorted = BillListViewModel.SortedBy.changedDate.sort(bills: bills) + XCTAssertEqual(sorted.count, 3, "All nil lastchanged must not crash or drop bills") + } + + func testChangedDate_preservesBothBillsOnTie() { + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: 500), + makeBill(id: 2, daysOffset: 0, lastchanged: 500), + ] + let sorted = BillListViewModel.SortedBy.changedDate.sort(bills: bills) + XCTAssertEqual(sorted.count, 2) + } + + // MARK: - Mixed scenarios + + func testChangedDate_mixedNilAndReal() { + // Real timestamps must always sort before nil (which becomes 0). + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: nil), + makeBill(id: 2, daysOffset: 0, lastchanged: 1000), + makeBill(id: 3, daysOffset: 0, lastchanged: nil), + makeBill(id: 4, daysOffset: 0, lastchanged: 500), + ] + let sorted = BillListViewModel.SortedBy.changedDate.sort(bills: bills) + XCTAssertEqual(sorted.first?.id, 2, "Highest lastchanged must be first") + XCTAssertEqual(sorted[1].id, 4) + } + + func testExpenseDate_ignoresLastchanged() { + // When sorting by expense date the lastchanged field must be ignored. + // Bill 1 has lastchanged = 999 (high) but an older expense date — it must + // still sort after bill 2. + let bills = [ + makeBill(id: 1, daysOffset: 0, lastchanged: 999), // Jan 1, changed late + makeBill(id: 2, daysOffset: 5, lastchanged: 1), // Jan 6, changed early + ] + let sorted = BillListViewModel.SortedBy.expenseDate.sort(bills: bills) + XCTAssertEqual(sorted.map { $0.id }, [2, 1], + "Expense-date sort must ignore lastchanged") + } +} diff --git a/PayForMeTests/BillTests.swift b/PayForMeTests/BillTests.swift new file mode 100644 index 0000000..c6d9209 --- /dev/null +++ b/PayForMeTests/BillTests.swift @@ -0,0 +1,211 @@ +// +// BillTests.swift +// PayForMeTests +// +// Tests for Bill.paramsFor(_:) — the single function that translates a Bill +// into the HTTP request parameters sent to the backend. +// +// WHY THIS MATTERS: +// Cospend and iHateMoney have different API contracts for the same concept: +// • Cospend expects payed_for as a comma-separated String: "1,2,3" +// • iHateMoney expects payed_for as a JSON Array: ["1", "2", "3"] +// Getting this wrong silently causes bills to be saved without owers on one +// backend or the other, which is a data-loss bug that users would only notice +// when they check balances later. These tests act as a regression net. +// + +import XCTest +@testable import PayForMe + +class BillTests: XCTestCase { + + // A fixed date so our date-formatting tests are deterministic + let testDate = DateFormatter.cospend.date(from: "2026-05-14")! + + func makeBill( + amount: Double = 30.0, + owers: [Person] = [testAlice, testBob, testCarla], + repeat repeatValue: String? = "n" + ) -> Bill { + Bill( + id: 42, + amount: amount, + what: "Dinner", + date: testDate, + payer_id: testAlice.id, + owers: owers, + repeat: repeatValue, + lastchanged: nil + ) + } + + // MARK: - Cospend parameters + + func testCospend_payedFor_isCommaSeparatedString() { + // Cospend's API endpoint for creating a bill reads payed_for as a + // plain query-string value, so it must be a single comma-separated string. + let params = makeBill(owers: [testAlice, testBob, testCarla]).paramsFor(.cospend) + + guard let payedFor = params["payed_for"] as? String else { + return XCTFail("payed_for must be a String for Cospend, got \(type(of: params["payed_for"]))") + } + let ids = Set(payedFor.split(separator: ",").map(String.init)) + XCTAssertEqual(ids, Set(["1", "2", "3"])) + } + + func testCospend_payedFor_singleOwer() { + let params = makeBill(owers: [testBob]).paramsFor(.cospend) + XCTAssertEqual(params["payed_for"] as? String, "2") + } + + func testCospend_paymentMode_isN() { + // The Cospend API requires paymentmode; "n" means "no specific payment mode". + let params = makeBill().paramsFor(.cospend) + XCTAssertEqual(params["paymentmode"] as? String, "n") + } + + func testCospend_categoryId_isZero() { + // "0" is the default uncategorised category in Cospend. + let params = makeBill().paramsFor(.cospend) + XCTAssertEqual(params["categoryid"] as? String, "0") + } + + func testCospend_repeat_defaultsToN() { + let params = makeBill(repeat: "n").paramsFor(.cospend) + XCTAssertEqual(params["repeat"] as? String, "n") + } + + func testCospend_repeat_customValue() { + // Cospend supports recurring bills (e.g. "d"=daily, "w"=weekly). + let params = makeBill(repeat: "d").paramsFor(.cospend) + XCTAssertEqual(params["repeat"] as? String, "d") + } + + func testCospend_repeat_nilFallsBackToN() { + // If the Bill was created without a repeat value (e.g. from iHateMoney), + // paramsFor must still send "n" to Cospend — never omit the field. + let params = makeBill(repeat: nil).paramsFor(.cospend) + XCTAssertEqual(params["repeat"] as? String, "n") + } + + func testCospend_date_isFormattedYYYYMMDD() { + // Cospend uses yyyy-MM-dd format. A wrong format would cause a server-side + // parse error and silently reject the bill. + let params = makeBill().paramsFor(.cospend) + XCTAssertEqual(params["date"] as? String, "2026-05-14") + } + + func testCospend_payer_isStringNotInt() { + // The params dict is [String: Any]. payer must be a String because it is + // appended to a query string; sending an Int would type-mismatch on the server. + let params = makeBill().paramsFor(.cospend) + XCTAssertEqual(params["payer"] as? String, "1") + } + + func testCospend_amount_isStringRepresentation() { + let params = makeBill(amount: 42.5).paramsFor(.cospend) + XCTAssertEqual(params["amount"] as? String, "42.5") + } + + func testCospend_what_isPresent() { + let params = makeBill().paramsFor(.cospend) + XCTAssertEqual(params["what"] as? String, "Dinner") + } + + // MARK: - iHateMoney parameters + + func testIHateMoney_payedFor_isStringArray() { + // iHateMoney's JSON API expects payed_for as a JSON array of ID strings. + // Sending a comma-separated string would cause a 400 Bad Request. + let params = makeBill(owers: [testAlice, testBob, testCarla]).paramsFor(.iHateMoney) + + guard let payedFor = params["payed_for"] as? [String] else { + return XCTFail("payed_for must be [String] for iHateMoney, got \(type(of: params["payed_for"]))") + } + XCTAssertEqual(Set(payedFor), Set(["1", "2", "3"])) + } + + func testIHateMoney_payedFor_singleOwer() { + let params = makeBill(owers: [testBob]).paramsFor(.iHateMoney) + XCTAssertEqual(params["payed_for"] as? [String], ["2"]) + } + + func testIHateMoney_noPaymentMode() { + // iHateMoney does not have a paymentmode concept; sending it would be ignored + // at best and cause an error at worst. + let params = makeBill().paramsFor(.iHateMoney) + XCTAssertNil(params["paymentmode"], + "iHateMoney must NOT receive paymentmode") + } + + func testIHateMoney_noCategoryId() { + let params = makeBill().paramsFor(.iHateMoney) + XCTAssertNil(params["categoryid"], + "iHateMoney must NOT receive categoryid") + } + + func testIHateMoney_noRepeat() { + let params = makeBill().paramsFor(.iHateMoney) + XCTAssertNil(params["repeat"], + "iHateMoney must NOT receive repeat") + } + + func testIHateMoney_date_isFormattedYYYYMMDD() { + let params = makeBill().paramsFor(.iHateMoney) + XCTAssertEqual(params["date"] as? String, "2026-05-14") + } + + func testIHateMoney_payer_isString() { + let params = makeBill().paramsFor(.iHateMoney) + XCTAssertEqual(params["payer"] as? String, "1") + } + + func testIHateMoney_amount_isString() { + let params = makeBill(amount: 12.99).paramsFor(.iHateMoney) + XCTAssertEqual(params["amount"] as? String, "12.99") + } + + // MARK: - Shared fields (same for both backends) + + func testBothBackends_alwaysHaveDate() { + let bill = makeBill() + XCTAssertNotNil(bill.paramsFor(.cospend)["date"]) + XCTAssertNotNil(bill.paramsFor(.iHateMoney)["date"]) + } + + func testBothBackends_alwaysHavePayer() { + let bill = makeBill() + XCTAssertNotNil(bill.paramsFor(.cospend)["payer"]) + XCTAssertNotNil(bill.paramsFor(.iHateMoney)["payer"]) + } + + func testBothBackends_alwaysHaveAmount() { + let bill = makeBill() + XCTAssertNotNil(bill.paramsFor(.cospend)["amount"]) + XCTAssertNotNil(bill.paramsFor(.iHateMoney)["amount"]) + } + + // MARK: - Bill.newBill() defaults + + func testNewBill_idIsMinusOne() { + // id == -1 is the sentinel that signals "this bill has not been saved yet". + // Code in ProjectManager checks for this to decide between POST and PUT. + XCTAssertEqual(Bill.newBill().id, -1) + } + + func testNewBill_amountIsZero() { + XCTAssertEqual(Bill.newBill().amount, 0.0) + } + + func testNewBill_repeatIsN() { + XCTAssertEqual(Bill.newBill().repeat, "n") + } + + func testNewBill_owersIsEmpty() { + XCTAssertTrue(Bill.newBill().owers.isEmpty) + } + + func testNewBill_payerIdIsMinusOne() { + XCTAssertEqual(Bill.newBill().payer_id, -1) + } +} diff --git a/PayForMeTests/JSONDecodingTests.swift b/PayForMeTests/JSONDecodingTests.swift new file mode 100644 index 0000000..1278e01 --- /dev/null +++ b/PayForMeTests/JSONDecodingTests.swift @@ -0,0 +1,269 @@ +// +// JSONDecodingTests.swift +// PayForMeTests +// +// Tests that the JSON responses returned by Cospend and iHateMoney are decoded +// correctly into our model types. +// +// WHY THIS MATTERS: +// Both backends return JSON, but the field names and date formats must match +// our Swift structs exactly. A typo in a CodingKey or a wrong DateFormatter +// would cause the decoder to throw and the app to show an empty list instead +// of an error message — a silent data loss that's hard to debug in production. +// +// We test decoding in isolation (no network) so failures point to model/decoder +// issues, not connectivity issues. +// + +import XCTest +@testable import PayForMe + +class JSONDecodingTests: XCTestCase { + + // This decoder mirrors the one NetworkService creates internally. + // If NetworkService ever changes its decoder config, update this too. + private var decoder: JSONDecoder { + let d = JSONDecoder() + d.dateDecodingStrategy = .formatted(DateFormatter.cospend) + return d + } + + // MARK: - Bill decoding + + func testDecodeSingleBill() throws { + let json = """ + [{ + "id": 7, + "amount": 42.50, + "what": "Groceries", + "date": "2026-05-14", + "payer_id": 2, + "owers": [ + {"id": 1, "weight": 1, "name": "Alice", "activated": true}, + {"id": 2, "weight": 1, "name": "Bob", "activated": true} + ], + "repeat": "n", + "lastchanged": 1705744800 + }] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + XCTAssertEqual(bills.count, 1) + let bill = bills[0] + XCTAssertEqual(bill.id, 7) + XCTAssertEqual(bill.amount, 42.50, accuracy: 0.001) + XCTAssertEqual(bill.what, "Groceries") + XCTAssertEqual(bill.payer_id, 2) + XCTAssertEqual(bill.owers.count, 2) + XCTAssertEqual(bill.repeat, "n") + XCTAssertEqual(bill.lastchanged, 1705744800) + } + + func testDecodeBill_dateUsesYYYYMMDD() throws { + // This is the key contract with Cospend: date strings are yyyy-MM-dd. + // If a backend ever returns a timestamp integer instead, decoding would fail. + let json = """ + [{"id": 1, "amount": 10.0, "what": "Coffee", "date": "2026-05-14", + "payer_id": 1, "owers": [], "repeat": "n", "lastchanged": null}] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + let expected = DateFormatter.cospend.date(from: "2026-05-14")! + XCTAssertEqual(bills[0].date, expected) + } + + func testDecodeBill_optionalFieldsAbsent() throws { + // Both `repeat` and `lastchanged` are optional in the model. + // Old Cospend versions may omit them; the decoder must not throw. + let json = """ + [{"id": 3, "amount": 5.0, "what": "Water", "date": "2026-05-14", + "payer_id": 1, "owers": []}] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + XCTAssertNil(bills[0].lastchanged) + XCTAssertNil(bills[0].repeat) + } + + func testDecodeBill_lastchangedNull() throws { + // Some Cospend versions send `"lastchanged": null` explicitly. + let json = """ + [{"id": 4, "amount": 5.0, "what": "Tea", "date": "2026-05-14", + "payer_id": 1, "owers": [], "lastchanged": null}] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + XCTAssertNil(bills[0].lastchanged) + } + + func testDecodeBill_owersIncludeFullPersonData() throws { + let json = """ + [{"id": 5, "amount": 20.0, "what": "Snacks", "date": "2026-05-14", + "payer_id": 1, "owers": [ + {"id": 1, "weight": 2, "name": "Alice", "activated": true, + "color": {"r": 255, "g": 0, "b": 0}} + ], "repeat": "n"}] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + let ower = bills[0].owers[0] + XCTAssertEqual(ower.id, 1) + XCTAssertEqual(ower.weight, 2) + XCTAssertEqual(ower.name, "Alice") + XCTAssertEqual(ower.color?.r, 255) + } + + func testDecodeMultipleBills() throws { + let json = """ + [ + {"id": 1, "amount": 10.0, "what": "A", "date": "2024-01-01", "payer_id": 1, "owers": []}, + {"id": 2, "amount": 20.0, "what": "B", "date": "2024-01-02", "payer_id": 2, "owers": []} + ] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + XCTAssertEqual(bills.count, 2) + } + + func testDecodeEmptyBillsArray() throws { + let json = "[]".data(using: .utf8)! + let bills = try decoder.decode([Bill].self, from: json) + XCTAssertTrue(bills.isEmpty) + } + + // MARK: - Person / Member decoding + + func testDecodePerson_withColor() throws { + // Cospend stores per-member colours (RGB). PersonColor must decode correctly. + let json = """ + [{"id": 1, "weight": 1, "name": "Alice", "activated": true, + "color": {"r": 60, "g": 110, "b": 186}}] + """.data(using: .utf8)! + + let members = try decoder.decode([Person].self, from: json) + XCTAssertEqual(members[0].color?.r, 60) + XCTAssertEqual(members[0].color?.g, 110) + XCTAssertEqual(members[0].color?.b, 186) + } + + func testDecodePerson_withoutColor() throws { + // color is optional — members added without choosing a colour have no colour. + let json = """ + [{"id": 2, "weight": 1, "name": "Bob", "activated": true}] + """.data(using: .utf8)! + + let members = try decoder.decode([Person].self, from: json) + XCTAssertNil(members[0].color) + } + + func testDecodePerson_inactiveMemberDecodesSuccessfully() throws { + // Decoding must never fail for inactive members. NetworkService filters + // them out after decoding — but the decode step itself must not throw. + let json = """ + [{"id": 5, "weight": 1, "name": "Ghost", "activated": false}] + """.data(using: .utf8)! + + let members = try decoder.decode([Person].self, from: json) + XCTAssertFalse(members[0].activated) + } + + func testDecodeMembers_activationFilter() { + // This replicates the filter NetworkService applies after decoding: + // members.filter { $0.activated } + // Inactive members (e.g. deleted users) must not appear in the member list. + let all = [ + Person(id: 1, weight: 1, name: "Alice", activated: true), + Person(id: 2, weight: 1, name: "Deleted", activated: false), + Person(id: 3, weight: 1, name: "Bob", activated: true), + ] + let active = all.filter { $0.activated } + + XCTAssertEqual(active.count, 2) + XCTAssertFalse(active.contains { $0.name == "Deleted" }, + "Inactive member must be filtered out") + } + + func testDecodeMembers_mixedActivation() throws { + let json = """ + [ + {"id": 1, "weight": 1, "name": "Alice", "activated": true}, + {"id": 2, "weight": 1, "name": "Bob", "activated": false}, + {"id": 3, "weight": 1, "name": "Carla", "activated": true} + ] + """.data(using: .utf8)! + + let all = try decoder.decode([Person].self, from: json) + XCTAssertEqual(all.count, 3, "Decoder returns all members before filtering") + + let active = all.filter { $0.activated } + XCTAssertEqual(active.count, 2) + } + + // MARK: - APIProject decoding (used by getProjectName) + + func testDecodeAPIProject() throws { + // getProjectName fetches the project's display name from the server. + // The response contains `name` (String) and `id` (String, not Int). + let json = """ + {"name": "My Shared Trip", "id": "trip-2026"} + """.data(using: .utf8)! + + let apiProject = try JSONDecoder().decode(APIProject.self, from: json) + XCTAssertEqual(apiProject.name, "My Shared Trip") + XCTAssertEqual(apiProject.id, "trip-2026") + } + + func testDecodeAPIProject_idIsString() throws { + // The Cospend API returns the project id as a string, not an integer. + // If this field were typed as Int in the model, decoding would fail silently. + let json = """ + {"name": "Test", "id": "abc123"} + """.data(using: .utf8)! + + XCTAssertNoThrow(try JSONDecoder().decode(APIProject.self, from: json)) + } + + // MARK: - Bill sorting logic (mirrors NetworkService sort after decode) + + func testBillsSortByLastchangedDescending() throws { + // NetworkService sorts decoded bills so the most recently *modified* bill + // appears first. This ensures edits on another device appear at the top. + let json = """ + [ + {"id": 1, "amount": 5.0, "what": "A", "date": "2026-01-01", + "payer_id": 1, "owers": [], "lastchanged": 100}, + {"id": 2, "amount": 5.0, "what": "B", "date": "2026-01-02", + "payer_id": 1, "owers": [], "lastchanged": 200}, + {"id": 3, "amount": 5.0, "what": "C", "date": "2026-01-03", + "payer_id": 1, "owers": [], "lastchanged": 50} + ] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + let sorted = bills.sorted { + if let l1 = $0.lastchanged, let l2 = $1.lastchanged { return l1 > l2 } + return $0.date > $1.date + } + XCTAssertEqual(sorted.map { $0.id }, [2, 1, 3]) + } + + func testBillsSortFallsBackToDateWhenNoLastchanged() throws { + let json = """ + [ + {"id": 1, "amount": 5.0, "what": "A", "date": "2026-01-01", + "payer_id": 1, "owers": []}, + {"id": 2, "amount": 5.0, "what": "B", "date": "2026-01-03", + "payer_id": 1, "owers": []}, + {"id": 3, "amount": 5.0, "what": "C", "date": "2026-01-02", + "payer_id": 1, "owers": []} + ] + """.data(using: .utf8)! + + let bills = try decoder.decode([Bill].self, from: json) + let sorted = bills.sorted { + if let l1 = $0.lastchanged, let l2 = $1.lastchanged { return l1 > l2 } + return $0.date > $1.date + } + XCTAssertEqual(sorted.map { $0.id }, [2, 3, 1]) + } +} diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift new file mode 100644 index 0000000..e9c2b6e --- /dev/null +++ b/PayForMeTests/NetworkRequestTests.swift @@ -0,0 +1,497 @@ +// +// NetworkRequestTests.swift +// PayForMeTests +// +// Tests that NetworkService builds the correct HTTP requests for each backend. +// +// WHY THIS MATTERS: +// Cospend and iHateMoney use fundamentally different authentication and +// parameter-passing schemes. A wrong URL path or missing auth header causes +// a 401/404 on the server — users would see a blank screen with no error. +// +// COSPEND CONTRACT: +// URL path: {server}/index.php/apps/cospend/api/projects/{token}/{password}/{endpoint} +// Auth: none (credentials embedded in URL path) +// Params: sent as URL query string for GET; not applicable here +// +// IHATEMONEY CONTRACT: +// URL path: {server}/api/projects/{token}/{endpoint} (no password in URL) +// Auth: HTTP Basic — base64("{token}:{password}") in Authorization header +// Params: sent as JSON body with Content-Type: application/json +// +// WRITE OPERATIONS (both backends): +// POST /bills — create a new bill +// PUT /bills/{id} — update an existing bill +// DELETE /bills/{id} — delete a bill +// POST /members — create a new member +// PUT /members/{id} — rename a member +// DELETE /members/{id} — delete a member +// +// NetworkService write methods (postBillPublisher, updateBillPublisher, etc.) +// read `ProjectManager.shared.currentProject` to determine the backend. +// Tests must set that property before calling them. +// + +import Combine +import XCTest +@testable import PayForMe + +class NetworkRequestTests: XCTestCase { + + private var subscriptions = Set() + private var savedProject: Project! + + override func setUp() { + super.setUp() + URLProtocol.registerClass(MockURLProtocol.self) + MockURLProtocol.reset() + savedProject = ProjectManager.shared.currentProject + } + + override func tearDown() { + URLProtocol.unregisterClass(MockURLProtocol.self) + subscriptions.removeAll() + ProjectManager.shared.currentProject = savedProject + super.tearDown() + } + + // Returns a handler that yields an empty JSON array with a given status code. + private func jsonHandler(status: Int = 200, body: String = "[]") -> (URLRequest) throws -> (HTTPURLResponse, Data) { + return { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: nil + )! + return (response, body.data(using: .utf8)!) + } + } + + // MARK: - Cospend: URL structure + + func testCospend_loadBills_urlEmbeddsTokenAndPassword() { + let project = Project.makeCospend(token: "mytoken", password: "mypass", + url: "https://cloud.example.com") + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadBillsPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + guard let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString else { + return XCTFail("No request was intercepted — MockURLProtocol may not work with URLSession.shared on this iOS version") + } + // 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"), + "Cospend must put token and password in the URL path. Got: \(url)" + ) + } + + func testCospend_loadMembers_urlContainsMembersEndpoint() { + let project = Project.makeCospend(token: "tok", password: "pass") + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadMembersPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + 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"), + "Cospend must embed token and password. Got: \(url)") + } + + // MARK: - Cospend: no auth header + + func testCospend_loadBills_noAuthorizationHeader() { + // Cospend authenticates via URL path, NOT HTTP Basic Auth. + let project = Project.makeCospend() + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadBillsPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertNil( + MockURLProtocol.lastCapturedRequest?.value(forHTTPHeaderField: "Authorization"), + "Cospend requests must NOT have an Authorization header" + ) + } + + // MARK: - iHateMoney: URL structure + + 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") + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadBillsPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + 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"), + "iHateMoney URL must use /api/projects/{token}/bills. Got: \(url)") + } + + // MARK: - iHateMoney: Basic Auth header + + func testIHateMoney_loadBills_hasBasicAuthHeader() { + // The Authorization header format is: "Basic " + base64("{token}:{password}") + let project = Project.makeIHateMoney(token: "mytoken", password: "secret") + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadBillsPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + guard let auth = MockURLProtocol.lastCapturedRequest?.value(forHTTPHeaderField: "Authorization") else { + return XCTFail("iHateMoney requests must include an Authorization header") + } + XCTAssertTrue(auth.hasPrefix("Basic "), + "Authorization must use Basic scheme. Got: \(auth)") + } + + func testIHateMoney_loadBills_basicAuthCredentialsAreCorrect() { + // Verify the exact base64 encoding of "token:password". + let project = Project.makeIHateMoney(token: "mytoken", password: "secret") + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadBillsPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + guard let auth = MockURLProtocol.lastCapturedRequest?.value(forHTTPHeaderField: "Authorization"), + auth.hasPrefix("Basic ") else { return } + + let base64 = String(auth.dropFirst("Basic ".count)) + guard let data = Data(base64Encoded: base64), + let credentials = String(data: data, encoding: .utf8) else { + return XCTFail("Authorization header base64 is malformed") + } + XCTAssertEqual(credentials, "mytoken:secret", + "Basic auth credentials must be '{token}:{password}'") + } + + // MARK: - testProject() — status code passthrough + + func testProject_returns200OnSuccess() { + let project = Project.makeCospend() + MockURLProtocol.requestHandler = { req in + (.ok(for: req.url!), Data()) + } + + let exp = expectation(description: "status received") + NetworkService.shared.testProject(project) + .sink { (_, code) in + XCTAssertEqual(code, 200) + exp.fulfill() + } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + } + + func testProject_returns401OnWrongCredentials() { + let project = Project.makeCospend() + MockURLProtocol.requestHandler = { req in + (HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!, Data()) + } + + let exp = expectation(description: "401 received") + NetworkService.shared.testProject(project) + .sink { (_, code) in + XCTAssertEqual(code, 401) + exp.fulfill() + } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + } + + func testProject_returnsMinus1OnNetworkFailure() { + // If the network is completely unreachable, testProject must return -1 + // (not crash, not hang) so the UI can show an appropriate error message. + let project = Project.makeCospend() + MockURLProtocol.requestHandler = { _ in + throw URLError(.notConnectedToInternet) + } + + let exp = expectation(description: "error received as -1") + NetworkService.shared.testProject(project) + .sink { (_, code) in + XCTAssertEqual(code, -1) + exp.fulfill() + } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + } + + // MARK: - loadBills() — graceful empty result on error + + func testLoadBills_on404_publisherCompletesWithoutEmittingBills() { + let project = Project.makeCospend() + MockURLProtocol.requestHandler = { req in + (.notFound(for: req.url!), Data()) + } + + var didReceiveBills = false + let exp = expectation(description: "publisher completes without emitting (status quo, not ideal)") + + NetworkService.shared.loadBillsPublisher(project) + .handleEvents(receiveCompletion: { _ in exp.fulfill() }) + .sink { _ in didReceiveBills = true } + .store(in: &subscriptions) + + waitForExpectations(timeout: 2) + XCTAssertFalse(didReceiveBills, + "loadBillsPublisher must NOT emit on 404 — but should when proper feedback is implemented") + } + + func testLoadMembers_returnsEmptyDictOnNetworkFailure() { + let project = Project.makeCospend() + MockURLProtocol.requestHandler = { _ in + throw URLError(.timedOut) + } + + let exp = expectation(description: "empty members received") + NetworkService.shared.loadMembersPublisher(project) + .sink { members in + XCTAssertTrue(members.isEmpty, + "loadMembers must return [:] on network failure, not crash") + exp.fulfill() + } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + } + + // MARK: - HTTP methods + + func testLoadBills_usesGET() { + let project = Project.makeCospend() + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadBillsPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual(MockURLProtocol.lastCapturedRequest?.httpMethod, "GET") + } + + func testLoadMembers_usesGET() { + let project = Project.makeCospend() + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadMembersPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual(MockURLProtocol.lastCapturedRequest?.httpMethod, "GET") + } + + // MARK: - Write operations: POST bill + + func testCospend_postBill_usesPOSTMethod() { + // Creating a new expense must use POST + ProjectManager.shared.currentProject = .makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let exp = expectation(description: "request intercepted") + NetworkService.shared.postBillPublisher(bill: .make()) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual(MockURLProtocol.lastCapturedRequest?.httpMethod, "POST", + "Creating a bill must use POST") + } + + func testCospend_postBill_urlContainsBillsEndpoint() { + // Cospend's bills endpoint is /bills at the end of the project path. + // A missing or misspelled suffix causes a 404 with no user-visible error. + ProjectManager.shared.currentProject = .makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let exp = expectation(description: "request intercepted") + NetworkService.shared.postBillPublisher(bill: .make()) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.contains("/bills"), "POST bill URL must contain /bills. Got: \(url)") + } + + func testCospend_postBill_paramsInQueryStringNotBody() { + // Cospend receives bill params as URL query items, not a JSON body. + // Sending a JSON body to Cospend would be silently ignored by the server. + ProjectManager.shared.currentProject = .makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let exp = expectation(description: "request intercepted") + NetworkService.shared.postBillPublisher(bill: .make()) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertNil(MockURLProtocol.lastCapturedRequest?.httpBody, + "Cospend POST must put params in the query string, not the body") + let query = MockURLProtocol.lastCapturedRequest?.url?.query ?? "" + XCTAssertFalse(query.isEmpty, "Cospend POST must include bill params as query items") + } + + func testIHateMoney_postBill_sendsJSONBody() { + // iHateMoney expects bill params as a JSON body with Content-Type: application/json. + // Note: URLSession converts httpBody to httpBodyStream when routing through URLProtocol + ProjectManager.shared.currentProject = .makeIHateMoney() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let exp = expectation(description: "request intercepted") + NetworkService.shared.postBillPublisher(bill: .make()) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual( + MockURLProtocol.lastCapturedRequest?.value(forHTTPHeaderField: "Content-Type"), + "application/json", + "iHateMoney POST must set Content-Type: application/json" + ) + XCTAssertNil( + MockURLProtocol.lastCapturedRequest?.url?.query, + "iHateMoney POST must NOT put params in the URL query string" + ) + } + + func testIHateMoney_postBill_returnsTrue_on201() { + // iHateMoney returns 201 Created (not 200 OK) on successful bill creation. + // NetworkService must treat any 2xx response as success. + ProjectManager.shared.currentProject = .makeIHateMoney() + MockURLProtocol.requestHandler = { req in + (HTTPURLResponse(url: req.url!, statusCode: 201, httpVersion: nil, headerFields: nil)!, Data()) + } + + let exp = expectation(description: "result received") + NetworkService.shared.postBillPublisher(bill: .make()) + .sink { success in + XCTAssertTrue(success, "201 Created must be treated as success") + exp.fulfill() + } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + } + + // MARK: - Write operations: PUT / DELETE bill + + func testCospend_updateBill_usesPUTMethod() { + // Updating an existing bill must use PUT. Using POST would create a duplicate. + ProjectManager.shared.currentProject = .makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let bill = Bill.make(id: 42) + let exp = expectation(description: "request intercepted") + NetworkService.shared.updateBillPublisher(bill: bill) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual(MockURLProtocol.lastCapturedRequest?.httpMethod, "PUT", + "Updating a bill must use PUT") + let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.contains("/bills/42"), + "Update URL must include the bill id. Got: \(url)") + } + + func testCospend_deleteBill_usesDELETEMethod() { + // Deleting a bill must use DELETE with the bill id in the URL path. + ProjectManager.shared.currentProject = .makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let bill = Bill.make(id: 7) + let exp = expectation(description: "request intercepted") + NetworkService.shared.deleteBillPublisher(bill: bill) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual(MockURLProtocol.lastCapturedRequest?.httpMethod, "DELETE", + "Deleting a bill must use DELETE") + let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.contains("/bills/7"), + "Delete URL must include the bill id. Got: \(url)") + } + + // MARK: - Write operations: members + + func testCospend_createMember_usesPOSTToMembersEndpoint() { + // Creating a new group member hits /members with POST. + ProjectManager.shared.currentProject = .makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let exp = expectation(description: "request intercepted") + NetworkService.shared.createMemberPublisher(name: "Alice") + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual(MockURLProtocol.lastCapturedRequest?.httpMethod, "POST", + "Creating a member must use POST") + let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.hasSuffix("/members") || url.contains("/members?"), + "Create member URL must end with /members. Got: \(url)") + } + + // MARK: - getProjectName (async) + + func testGetProjectName_decodesServerProjectName() async throws { + // getProjectName fetches the project's display name from the server and + // returns a new Project with the server-provided name. This is how the + // app resolves token-based share links where the human name is unknown. + let project = Project.makeCospend(token: "mytoken", password: "mypass") + MockURLProtocol.requestHandler = { req in + let json = #"{"name": "Our Trip", "id": "mytoken"}"#.data(using: .utf8)! + return (.ok(for: req.url!), json) + } + + let result = try await NetworkService.shared.getProjectName(project) + XCTAssertEqual(result.name, "Our Trip", + "getProjectName must use the server-returned name, not the token") + } + + func testGetProjectName_throwsOnNonSuccessResponse() async { + // A 404 response means the project token is invalid or the server can't + // find the project. getProjectName must throw HTTPError.statuscode + let project = Project.makeCospend() + MockURLProtocol.requestHandler = { req in + (HTTPURLResponse(url: req.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!, Data()) + } + + do { + _ = try await NetworkService.shared.getProjectName(project) + XCTFail("getProjectName must throw on 404, not return silently") + } catch { + // Any thrown error is acceptable + } + } +} diff --git a/PayForMeTests/TestHelpers.swift b/PayForMeTests/TestHelpers.swift new file mode 100644 index 0000000..c7487b4 --- /dev/null +++ b/PayForMeTests/TestHelpers.swift @@ -0,0 +1,146 @@ +// +// TestHelpers.swift +// PayForMeTests +// +// Shared test infrastructure: a mock URL protocol for intercepting network +// requests without hitting real servers, plus reusable test fixtures. +// + +import Foundation +import XCTest +@testable import PayForMe + +// MARK: - MockURLProtocol +// +// How it works: +// URLSession routes every request through registered URLProtocol subclasses +// before sending it over the network. By registering MockURLProtocol we get +// to inspect and short-circuit every request made during a test. +// +// Usage in a test class: +// +// override func setUp() { +// URLProtocol.registerClass(MockURLProtocol.self) +// MockURLProtocol.requestHandler = { request in +// let response = HTTPURLResponse(url: request.url!, statusCode: 200, ...)! +// return (response, myJSON) +// } +// } +// +// override func tearDown() { +// URLProtocol.unregisterClass(MockURLProtocol.self) +// MockURLProtocol.reset() +// } +// + +class MockURLProtocol: URLProtocol { + + // Set this before each test to control what the mock returns. + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + // Read this after the publisher fires to assert on the outgoing request. + static var lastCapturedRequest: URLRequest? + + static func reset() { + requestHandler = nil + lastCapturedRequest = nil + } + + // Claim every request so nothing leaks to the real network. + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + MockURLProtocol.lastCapturedRequest = request + + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +// MARK: - Project fixtures + +extension Project { + static func makeCospend( + token: String = "mytoken", + password: String = "mypass", + url: String = "https://nextcloud.example.com" + ) -> Project { + Project( + name: "test-project", + password: password, + token: token, + backend: .cospend, + url: URL(string: url)! + ) + } + + static func makeIHateMoney( + token: String = "mytoken", + password: String = "mypass", + url: String = "https://ihatemoney.org" + ) -> Project { + Project( + name: "test-project", + password: password, + token: token, + backend: .iHateMoney, + url: URL(string: url)! + ) + } +} + +// MARK: - Person fixtures + +let testAlice = Person(id: 1, weight: 1, name: "Alice", activated: true) +let testBob = Person(id: 2, weight: 1, name: "Bob", activated: true) +let testCarla = Person(id: 3, weight: 1, name: "Carla", activated: true) + +// MARK: - Bill fixtures + +extension Bill { + static func make( + id: Int = 1, + amount: Double = 30.0, + what: String = "Dinner", + dateString: String = "2026-05-14", + payerId: Int = 1, + owers: [Person] = [testAlice, testBob, testCarla], + repeat: String? = "n", + lastchanged: Int? = nil + ) -> Bill { + Bill( + id: id, + amount: amount, + what: what, + date: DateFormatter.cospend.date(from: dateString)!, + payer_id: payerId, + owers: owers, + repeat: `repeat`, + lastchanged: lastchanged + ) + } +} + +// MARK: - HTTPURLResponse convenience + +extension HTTPURLResponse { + static func ok(for url: URL) -> HTTPURLResponse { + HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + } + static func notFound(for url: URL) -> HTTPURLResponse { + HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)! + } +} diff --git a/PayForMeTests/UrlExtensionsTests.swift b/PayForMeTests/UrlExtensionsTests.swift index 3aaaa95..8ae38bd 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -2,84 +2,119 @@ // UrlExtensionsTests.swift // PayForMeTests // -// Created by Max Tharr on 08.11.20. +// Tests for the URL extensions that decode deep-link and QR-code URLs into +// (server, project, password) triples. +// +// WHY THIS MATTERS: +// The app is launched from two URL schemes and one web URL pattern: +// +// cospend:// — native Cospend deep link (iOS) +// https://net.eneiluj.moneybuster.cospend/ — MoneyBuster web share link (Android) +// +// Both are also used as QR-code payloads. `decodeQRCode()` dispatches to the +// correct decoder based on the URL scheme. If the routing is wrong, the user +// scans a QR code and lands in the wrong decoder — producing nil for all three +// fields, and the "Add Project" form stays empty with no error message. +// +// URL parsing bugs are especially insidious because they produce no network +// request and no visible error — just a blank form. // @testable import PayForMe import XCTest class UrlExtensionsTests: XCTestCase { + + // MARK: - cospend:// deep link decoding + func testCospendStringDecoding() throws { + // Simplest form: host maps directly to the server root. + // cospend://host/project/password → server="https://host" let url = URL(string: "cospend://myserver.de/myproject/no-pass")! let (server, project, password) = url.decodeCospendString() - XCTAssertNotNil(server) - XCTAssertNotNil(project) - XCTAssertNotNil(password) - - if let server = server, let password = password, let project = project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "no-pass") - } + XCTAssertEqual(server?.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "no-pass") } func testCospendStringDecodingForSubfolders() throws { + // Nextcloud is often installed in a subfolder (e.g. example.com/nc/). + // Extra path components belong to the server URL, not the project name. + // cospend://host/folder1/folder2/project/password + // → server="https://host/folder1/folder2" let url = URL(string: "cospend://myserver.de/folder1/folder2/myproject/mypassword")! let (server, project, password) = url.decodeCospendString() - XCTAssertNotNil(server) - XCTAssertNotNil(project) - XCTAssertNotNil(password) - - if let server = server, let password = password, let project = project { - XCTAssertEqual(server.absoluteString, "https://myserver.de/folder1/folder2") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(server?.absoluteString, "https://myserver.de/folder1/folder2") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") } func testCospendStringDecodingForSubdomains() throws { + // Subdomain servers must be decoded correctly — the full hostname is the + // server root, not just the top-level domain. let url = URL(string: "cospend://subdomain.myserver.de/myproject/mypassword")! let (server, project, password) = url.decodeCospendString() - XCTAssertNotNil(server) - XCTAssertNotNil(project) - XCTAssertNotNil(password) - - if let server = server, let password = password, let project = project { - XCTAssertEqual(server.absoluteString, "https://subdomain.myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(server?.absoluteString, "https://subdomain.myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") } func testCospendStringDecodingForNonStandardPort() throws { + // Self-hosted Nextcloud instances often run on a non-standard port. + // The port must be preserved in the decoded server URL so API calls + // reach the right endpoint. let url = URL(string: "cospend://myserver.de:1234/myproject/mypassword")! let (server, project, password) = url.decodeCospendString() - XCTAssertNotNil(server) - XCTAssertNotNil(project) - XCTAssertNotNil(password) - - if let server = server, let password = password, let project = project { - XCTAssertEqual(server.absoluteString, "https://myserver.de:1234") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(server?.absoluteString, "https://myserver.de:1234") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") } - func testCospendError() throws { + func testCospendError_wrongScheme() throws { + // decodeCospendString() requires a scheme that contains "cospend". + // A plain https:// URL must return nil for all three values — even if + // 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) XCTAssertNil(project) XCTAssertNil(password) } - func testMoneyBusterError() throws { + // MARK: - MoneyBuster web link decoding + + func testMoneyBusterDecoding() throws { + // MoneyBuster share links encode the real server as the first path component + // after the fixed host prefix. The full structure is: + // 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) + } + + func testMoneyBusterError_wrongHost() throws { + // An https:// URL that does NOT use the MoneyBuster host prefix must return + // 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() @@ -88,32 +123,41 @@ class UrlExtensionsTests: XCTestCase { XCTAssertNil(password) } - func testMoneyBusterDecoding() throws { - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! + 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. + let url = URL(string: "https://net.eneiluj.moneybuster.cospend/server/project/password/extra")! let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNotNil(server) - XCTAssertNotNil(project) - XCTAssertNotNil(password) - - if let server = server, let password = password, let project = project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(password, "mypassword") - XCTAssertEqual(project, "myproject") - } + XCTAssertNil(server) + XCTAssertNil(project) + XCTAssertNil(password) } - func testMoneyBusterNoPassword() throws { - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! + // MARK: - QR code dispatcher (decodeQRCode) - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNotNil(server) - XCTAssertNotNil(project) - XCTAssertNil(password) + func testDecodeQRCode_cospendSchemeRoutesToCospendDecoder() throws { + // decodeQRCode() dispatches based on scheme: if the scheme contains + // "cospend", it calls decodeCospendString(). The result must match + // 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") + } + + 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")! - if let server = server, let project = project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - } + let (server, project, password) = url.decodeQRCode() + XCTAssertEqual(server?.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") } } From 1240f0f8c7291b871739ee362929885ca7359295 Mon Sep 17 00:00:00 2001 From: Jona Date: Thu, 14 May 2026 02:04:08 +0200 Subject: [PATCH 3/3] fixed a typo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- PayForMeTests/NetworkRequestTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index e9c2b6e..a7b7037 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -70,7 +70,7 @@ class NetworkRequestTests: XCTestCase { // MARK: - Cospend: URL structure - func testCospend_loadBills_urlEmbeddsTokenAndPassword() { + func testCospend_loadBills_urlEmbedsTokenAndPassword() { let project = Project.makeCospend(token: "mytoken", password: "mypass", url: "https://cloud.example.com") MockURLProtocol.requestHandler = jsonHandler()