Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions PayForMe.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */
Expand All @@ -82,6 +88,9 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
0865D2DAF541B24078D1DC41 /* TestHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BalanceCalculationTests.swift; sourceTree = "<group>"; };
316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkRequestTests.swift; sourceTree = "<group>"; };
481FB4FA23E964C8003BD108 /* ProjectManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectManager.swift; sourceTree = "<group>"; };
481FB4FC23EAD78F003BD108 /* AddProjectManualViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProjectManualViewModel.swift; sourceTree = "<group>"; };
489995D923F6EC6F008B7E38 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -147,6 +156,9 @@
65D225F7242803C800C3F1EC /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
65D9093F25581BE100351F4B /* AddFromURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFromURLView.swift; sourceTree = "<group>"; };
65FE0E8E2544968B002B80CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
7EED3194A2342518B0E74079 /* BillTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillTests.swift; sourceTree = "<group>"; };
8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = JSONDecodingTests.swift; sourceTree = "<group>"; };
92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillSortingTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -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 = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
1 change: 0 additions & 1 deletion PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
// PayForMe
//
// Created by Maximilian Fischer on 30.04.26.
// Copyright © 2026 Mayflower GmbH. All rights reserved.
//

import SwiftUI
Expand Down
110 changes: 73 additions & 37 deletions PayForMeTests/AddProjectManuallyTests.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()

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")
Expand All @@ -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")
Expand Down
Loading
Loading