diff --git a/Sources/FreeAgentAPI/TypeOverrides/PermissionLevel.swift b/Sources/FreeAgentAPI/TypeOverrides/PermissionLevel.swift new file mode 100644 index 0000000..57023f7 --- /dev/null +++ b/Sources/FreeAgentAPI/TypeOverrides/PermissionLevel.swift @@ -0,0 +1,11 @@ +public enum PermissionLevel: Int, Codable, Hashable, Sendable, CaseIterable { + case noAccess = 0 + case time = 1 + case myMoney = 2 + case contactsAndProjects = 3 + case invoicesEstimatesAndFiles = 4 + case bills = 5 + case banking = 6 + case taxAccountingAndUsers = 7 + case full = 8 +} diff --git a/Sources/FreeAgentCLI/Commands/User/UserCreateCommand.swift b/Sources/FreeAgentCLI/Commands/User/UserCreateCommand.swift new file mode 100644 index 0000000..8ccb9ea --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/User/UserCreateCommand.swift @@ -0,0 +1,55 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct UserCreateCommand: MutatingCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a user", + discussion: "The permission level defaults to No Access, and an Accountant needs Tax, Accounting & Users or Full." + ) + + @Option(name: .long, help: "Login email address") + var email: String + + @Option(name: .long, help: "First name") + var firstName: String + + @Option(name: .long, help: "Last name") + var lastName: String + + @Option(name: .long, help: "Role in the company") + var role: Components.Schemas.UserRole + + @OptionGroup var profile: UserProfileOptions + + @Option(name: .long, help: "Email the user an invitation to set their password") + var sendInvitation = false + + @Flag(help: "Print the request instead of sending it") + var dryRun = false + + @Flag(name: .long, help: "Output JSON") + var json = false + + func perform(client: Client) async throws -> Components.Schemas.UserResponse { + let userPayload = profile.createPayload( + email: email, + firstName: firstName, + lastName: lastName, + role: role, + sendInvitation: sendInvitation + ) + + let input = Operations.CreateUser.Input( + body: .json(.init(user: userPayload)) + ) + + return try await client.createUser(input) + .created.body.json + } + + func success(for response: Components.Schemas.UserResponse) -> String { + "Created user \(response.user.url)" + } +} diff --git a/Sources/FreeAgentCLI/Commands/User/UserDeleteCommand.swift b/Sources/FreeAgentCLI/Commands/User/UserDeleteCommand.swift new file mode 100644 index 0000000..63e20f4 --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/User/UserDeleteCommand.swift @@ -0,0 +1,38 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct UserDeleteCommand: DestructiveCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a user", + discussion: "A user with timeslips, expenses or other records cannot be deleted, but can be hidden instead with 'freeagent user update --hidden true'." + ) + + @Argument(help: "User ID or URL") + var id: ResourceID + + @Flag(help: "Print the request instead of sending it") + var dryRun = false + + @Flag(help: "Skip the confirmation prompt") + var yes = false + + var confirmation: String { + "Delete user \(id)?" + } + + func perform(client: Client) async throws -> EmptyResponse { + let input = Operations.DeleteUser.Input( + path: .init(id: id.value) + ) + + _ = try await client.deleteUser(input).ok + + return EmptyResponse() + } + + func success(for _: EmptyResponse) -> String { + "Deleted user \(id)" + } +} diff --git a/Sources/FreeAgentCLI/Commands/User/UserListCommand.swift b/Sources/FreeAgentCLI/Commands/User/UserListCommand.swift new file mode 100644 index 0000000..51dc3a3 --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/User/UserListCommand.swift @@ -0,0 +1,49 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct UserListCommand: AsyncPaginatedListCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List users" + ) + + static let noun = "users" + + static var columns: [Field] { + Field("ID") { .id(url: $0.url) } + Field("First Name") { .text($0.firstName) } + Field("Last Name") { .text($0.lastName) } + Field("Email") { .text($0.email) } + Field("Role") { .text($0.role) } + Field("Permission") { .text($0.permissionLevel?.description) } + Field("Hidden") { .flag($0.hidden) } + } + + @Option(name: .long, help: "Filter by role and visibility - every user is listed by default") + var view: Operations.ListUsers.Input.Query.ViewPayload? + + @OptionGroup var pagination: PaginationOptions + + @Flag(name: .long, help: "Output JSON") + var json = false + + func fetch( + client: Client, + page: Int + ) async throws -> (response: Components.Schemas.UserListResponse, totalCount: Int?) { + let ok = try await client.listUsers( + .init(query: .init( + view: view, + page: page, + perPage: pagination.size + )) + ).ok + + return (try ok.body.json, ok.headers.xTotalCount) + } + + func items(in response: Components.Schemas.UserListResponse) -> [Components.Schemas.User] { + response.users + } +} diff --git a/Sources/FreeAgentCLI/Commands/User/UserShowCommand.swift b/Sources/FreeAgentCLI/Commands/User/UserShowCommand.swift new file mode 100644 index 0000000..1afccba --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/User/UserShowCommand.swift @@ -0,0 +1,57 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct UserShowCommand: ShowCommand { + static let configuration = CommandConfiguration( + commandName: "show", + abstract: "Show user details" + ) + + static let title = "User" + + static var sections: [FieldSection] { + FieldSection("Details") { + Field("First Name") { .text($0.firstName) } + Field("Last Name") { .text($0.lastName) } + Field("Email") { .text($0.email) } + Field("Role") { .text($0.role) } + Field("Permission") { .text($0.permissionLevel?.description) } + Field("Hidden") { .flag($0.hidden) } + } + FieldSection("Tax") { + Field("NI Number") { .text($0.niNumber) } + Field("Unique Tax Reference") { .text($0.uniqueTaxReference) } + Field("Opening Mileage") { .text($0.openingMileage) } + } + FieldSection("Previous Employment") { + Field("Total Pay") { .currency($0.currentPayrollProfile?.totalPayInPreviousEmployment, code: nil) } + Field("Total Tax") { .currency($0.currentPayrollProfile?.totalTaxInPreviousEmployment, code: nil) } + } + FieldSection("Dates") { + Field("Created") { .timestamp($0.createdAt) } + Field("Updated") { .timestamp($0.updatedAt) } + } + FieldSection("IDs") { + Field("User") { .id(url: $0.url) } + } + } + + @Argument(help: "User ID or URL - defaults to the logged-in user") + var id: ResourceID? + + @Flag(name: .long, help: "Output JSON") + var json = false + + func fetch(client: Client) async throws -> Components.Schemas.UserResponse { + guard let id else { + return try await client.showCurrentUser(.init()).ok.body.json + } + + return try await client.showUser(.init(path: .init(id: id.value))).ok.body.json + } + + func record(in response: Components.Schemas.UserResponse) -> Components.Schemas.User { + response.user + } +} diff --git a/Sources/FreeAgentCLI/Commands/User/UserUpdateCommand.swift b/Sources/FreeAgentCLI/Commands/User/UserUpdateCommand.swift new file mode 100644 index 0000000..e8a8bbc --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/User/UserUpdateCommand.swift @@ -0,0 +1,60 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct UserUpdateCommand: MutatingCommand { + static let configuration = CommandConfiguration( + commandName: "update", + abstract: "Update a user", + discussion: "Fields left out are kept, and an empty --ni-number or --unique-tax-reference removes it." + ) + + @Argument(help: "User ID or URL - defaults to the logged-in user") + var id: ResourceID? + + @Option(name: .long, help: "Login email address") + var email: String? + + @Option(name: .long, help: "First name") + var firstName: String? + + @Option(name: .long, help: "Last name") + var lastName: String? + + @Option(name: .long, help: "Role in the company") + var role: Components.Schemas.UserRole? + + @OptionGroup var profile: UserProfileOptions + + @Flag(help: "Print the request instead of sending it") + var dryRun = false + + @Flag(name: .long, help: "Output JSON") + var json = false + + func perform(client: Client) async throws -> Components.Schemas.UserResponse { + let userPayload = profile.updatePayload( + email: email, + firstName: firstName, + lastName: lastName, + role: role + ) + + guard let id else { + return try await client.updateCurrentUser(.init(body: .json(.init(user: userPayload)))) + .ok.body.json + } + + let input = Operations.UpdateUser.Input( + path: .init(id: id.value), + body: .json(.init(user: userPayload)) + ) + + return try await client.updateUser(input) + .ok.body.json + } + + func success(for response: Components.Schemas.UserResponse) -> String { + "Updated user \(ResourceID(response.user.url))" + } +} diff --git a/Sources/FreeAgentCLI/Commands/UserCommand.swift b/Sources/FreeAgentCLI/Commands/UserCommand.swift new file mode 100644 index 0000000..223e550 --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/UserCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct UserCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "user", + abstract: "Manage users", + subcommands: [ + UserListCommand.self, + UserCreateCommand.self, + UserShowCommand.self, + UserUpdateCommand.self, + UserDeleteCommand.self, + ] + ) +} diff --git a/Sources/FreeAgentCLI/CustomStringConvertible/PermissionLevel+CustomStringConvertible.swift b/Sources/FreeAgentCLI/CustomStringConvertible/PermissionLevel+CustomStringConvertible.swift new file mode 100644 index 0000000..fa082a6 --- /dev/null +++ b/Sources/FreeAgentCLI/CustomStringConvertible/PermissionLevel+CustomStringConvertible.swift @@ -0,0 +1,17 @@ +import FreeAgentAPI + +extension PermissionLevel: CustomStringConvertible { + public var description: String { + switch self { + case .noAccess: "No Access" + case .time: "Time" + case .myMoney: "My Money" + case .contactsAndProjects: "Contacts & Projects" + case .invoicesEstimatesAndFiles: "Invoices, Estimates & Files" + case .bills: "Bills" + case .banking: "Banking" + case .taxAccountingAndUsers: "Tax, Accounting & Users" + case .full: "Full" + } + } +} diff --git a/Sources/FreeAgentCLI/ExpressibleByArgument/PermissionLevel+ExpressibleByArgument.swift b/Sources/FreeAgentCLI/ExpressibleByArgument/PermissionLevel+ExpressibleByArgument.swift new file mode 100644 index 0000000..635a022 --- /dev/null +++ b/Sources/FreeAgentCLI/ExpressibleByArgument/PermissionLevel+ExpressibleByArgument.swift @@ -0,0 +1,8 @@ +import ArgumentParser +import FreeAgentAPI + +extension PermissionLevel: ExpressibleByArgument { + public var defaultValueDescription: String { + description + } +} diff --git a/Sources/FreeAgentCLI/ExpressibleByArgument/UserRole+ExpressibleByArgument.swift b/Sources/FreeAgentCLI/ExpressibleByArgument/UserRole+ExpressibleByArgument.swift new file mode 100644 index 0000000..08db77a --- /dev/null +++ b/Sources/FreeAgentCLI/ExpressibleByArgument/UserRole+ExpressibleByArgument.swift @@ -0,0 +1,4 @@ +import ArgumentParser +import FreeAgentAPI + +extension Components.Schemas.UserRole: ExpressibleByArgument { } diff --git a/Sources/FreeAgentCLI/ExpressibleByArgument/UserViewPayload+ExpressibleByArgument.swift b/Sources/FreeAgentCLI/ExpressibleByArgument/UserViewPayload+ExpressibleByArgument.swift new file mode 100644 index 0000000..edde4d2 --- /dev/null +++ b/Sources/FreeAgentCLI/ExpressibleByArgument/UserViewPayload+ExpressibleByArgument.swift @@ -0,0 +1,4 @@ +import ArgumentParser +import FreeAgentAPI + +extension Operations.ListUsers.Input.Query.ViewPayload: ExpressibleByArgument { } diff --git a/Sources/FreeAgentCLI/FreeAgentCLI.swift b/Sources/FreeAgentCLI/FreeAgentCLI.swift index 2c8cfe8..fd8275d 100644 --- a/Sources/FreeAgentCLI/FreeAgentCLI.swift +++ b/Sources/FreeAgentCLI/FreeAgentCLI.swift @@ -17,6 +17,7 @@ struct FreeAgentCLI: AsyncParsableCommand { CreditNoteCommand.self, ProjectCommand.self, TaskCommand.self, + UserCommand.self, TimeslipCommand.self, BillCommand.self, ContactCommand.self, diff --git a/Sources/FreeAgentCLI/Options/UserProfileOptions.swift b/Sources/FreeAgentCLI/Options/UserProfileOptions.swift new file mode 100644 index 0000000..bb94492 --- /dev/null +++ b/Sources/FreeAgentCLI/Options/UserProfileOptions.swift @@ -0,0 +1,59 @@ +import ArgumentParser +import FreeAgentAPI + +struct UserProfileOptions: ParsableArguments { + @Option(name: .long, help: "What the user can see and do in FreeAgent") + var permissionLevel: PermissionLevel? + + @Option(name: .long, help: "UK National Insurance number, e.g. AB123456C") + var niNumber: String? + + @Option(name: .long, help: "10-digit UK Unique Taxpayer Reference") + var uniqueTaxReference: String? + + @Option(name: .long, help: "Opening mileage as of the company start date") + var openingMileage: Double? + + @Option(name: .long, help: "Whether the user is hidden") + var hidden: Bool? + + func createPayload( + email: String, + firstName: String, + lastName: String, + role: Components.Schemas.UserRole, + sendInvitation: Bool + ) -> Components.Schemas.UserCreatePayload { + .init( + email: email, + firstName: firstName, + lastName: lastName, + role: role, + permissionLevel: permissionLevel, + niNumber: niNumber, + uniqueTaxReference: uniqueTaxReference, + openingMileage: openingMileage, + hidden: hidden, + sendInvitation: sendInvitation + ) + } + + func updatePayload( + email: String?, + firstName: String?, + lastName: String?, + role: Components.Schemas.UserRole? + ) -> Components.Schemas.UserUpdatePayload { + .init( + email: email, + firstName: firstName, + lastName: lastName, + role: role, + permissionLevel: permissionLevel, + niNumber: niNumber, + uniqueTaxReference: uniqueTaxReference, + openingMileage: openingMileage, + hidden: hidden + ) + } +} diff --git a/Tests/FreeAgentAPIIntegrationTests/UserIntegrationTests.swift b/Tests/FreeAgentAPIIntegrationTests/UserIntegrationTests.swift index 840a0f0..26ea56f 100644 --- a/Tests/FreeAgentAPIIntegrationTests/UserIntegrationTests.swift +++ b/Tests/FreeAgentAPIIntegrationTests/UserIntegrationTests.swift @@ -30,8 +30,88 @@ struct UserIntegrationTests { #expect(user.updatedAt != nil) } + @Test("PUT /v2/users/me returns the updated user the token belongs to") + func updateCurrentUser() async throws { + let user = try await client.showCurrentUser(.init()).ok.body.json.user + + let updated = try await client.updateCurrentUser( + .init(body: .json(.init(user: .init(firstName: user.firstName)))) + ).ok.body.json.user + + #expect(updated.url == user.url) + #expect(updated.firstName == user.firstName) + } + + @Test("GET /v2/users returns a typed, counted list") + func listUsers() async throws { + let ok = try await client.listUsers(.init(query: .init(view: .activeStaff))).ok + let users = try ok.body.json.users + + #expect(ok.headers.xTotalCount != nil) + #expect(users.allSatisfy { $0.url.contains("/v2/users/") }) + } + + @Test("A user can be created, updated, listed, shown and deleted") + func userLifecycle() async throws { + let email = "integration-test-\(Int(Date().timeIntervalSince1970))@example.com" + + let created = try await client.createUser( + .init(body: .json(.init(user: .init( + email: email, + firstName: "Integration", + lastName: "Test", + role: .employee, + permissionLevel: .time, + niNumber: "AB123456C", + uniqueTaxReference: "1234567890", + openingMileage: 120.5, + hidden: false, + sendInvitation: false + )))) + ).created.body.json.user + + #expect(created.url.contains("/v2/users/")) + #expect(created.email == email) + #expect(created.firstName == "Integration") + #expect(created.lastName == "Test") + #expect(created.role == "Employee") + #expect(created.permissionLevel == .time) + #expect(created.niNumber == "AB123456C") + #expect(created.uniqueTaxReference == "1234567890") + #expect(created.openingMileage == "120.5") + #expect(created.hidden == false) + + let id = Self.id(of: created.url) + + let updated = try await client.updateUser( + .init(path: .init(id: id), body: .json(.init(user: .init( + lastName: "Updated", + role: .accountant, + permissionLevel: .taxAccountingAndUsers, + hidden: true + )))) + ).ok.body.json.user + + #expect(updated.url == created.url) + #expect(updated.lastName == "Updated") + #expect(updated.role == "Accountant") + #expect(updated.permissionLevel == .taxAccountingAndUsers) + #expect(updated.hidden == true) + + _ = try await client.listUsers(.init(query: .init(view: .advisors))).ok.body.json.users + + let shown = try await client.showUser(.init(path: .init(id: id))).ok.body.json.user + #expect(shown.url == created.url) + + _ = try await client.deleteUser(.init(path: .init(id: id))).ok + } + // MARK: Private private let client: Client + private static func id(of url: String) -> String { + String(url.split(separator: "/").last ?? "") + } + } diff --git a/Tests/FreeAgentAPITests/TypeOverrides/PermissionLevelTests.swift b/Tests/FreeAgentAPITests/TypeOverrides/PermissionLevelTests.swift new file mode 100644 index 0000000..749e0e4 --- /dev/null +++ b/Tests/FreeAgentAPITests/TypeOverrides/PermissionLevelTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing + +@testable import FreeAgentAPI + +struct PermissionLevelTests { + + @Test("decodes every documented level", arguments: PermissionLevel.allCases) + func decodesDocumentedLevels(level: PermissionLevel) throws { + #expect(try JSONDecoder().decode(PermissionLevel.self, from: Data("\(level.rawValue)".utf8)) == level) + } + + @Test("encodes as FreeAgent's integer") + func encodesInteger() throws { + #expect(try String(decoding: JSONEncoder().encode(PermissionLevel.full), as: UTF8.self) == "8") + } + + @Test("refuses a level FreeAgent does not document") + func refusesUnknownLevel() { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PermissionLevel.self, from: Data("9".utf8)) + } + } + +} diff --git a/Tests/FreeAgentCLITests/Commands/User/UserShowCommandTests.swift b/Tests/FreeAgentCLITests/Commands/User/UserShowCommandTests.swift new file mode 100644 index 0000000..55bc21d --- /dev/null +++ b/Tests/FreeAgentCLITests/Commands/User/UserShowCommandTests.swift @@ -0,0 +1,56 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI +import HTTPTypes +import Mockable +import OpenAPIRuntime +import Testing + +@testable import FreeAgentCLI + +struct UserShowCommandTests { + + // MARK: Internal + + @Test("shows the logged-in user when no ID is given") + func currentUser() async throws { + let transport = transport() + let command = try UserShowCommand.parse([]) + + _ = try await command.fetch(client: client(transport)) + + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("showCurrentUser")).called(1) + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("showUser")).called(0) + } + + @Test("shows the user with the given ID") + func userByID() async throws { + let transport = transport() + let command = try UserShowCommand.parse(["43088"]) + + _ = try await command.fetch(client: client(transport)) + + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("showUser")).called(1) + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("showCurrentUser")).called(0) + } + + // MARK: Private + + private func transport() -> MockClientTransportInterface { + let transport = MockClientTransportInterface() + for operationID in ["showCurrentUser", "showUser"] { + given(transport) + .send(.any, body: .any, baseURL: .any, operationID: .value(operationID)) + .willReturn(( + HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), + HTTPBody(Data(#"{"user":{"url":"https://api.freeagent.com/v2/users/32382"}}"#.utf8)) + )) + } + + return transport + } + + private func client(_ transport: MockClientTransportInterface) -> Client { + Client(serverURL: Environment.sandbox.baseURL, transport: transport) + } +} diff --git a/Tests/FreeAgentCLITests/Commands/User/UserUpdateCommandTests.swift b/Tests/FreeAgentCLITests/Commands/User/UserUpdateCommandTests.swift new file mode 100644 index 0000000..a7f018d --- /dev/null +++ b/Tests/FreeAgentCLITests/Commands/User/UserUpdateCommandTests.swift @@ -0,0 +1,57 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI +import HTTPTypes +import Mockable +import OpenAPIRuntime +import Testing + +@testable import FreeAgentCLI + +struct UserUpdateCommandTests { + + // MARK: Internal + + @Test("updates the logged-in user when no ID is given") + func currentUser() async throws { + let transport = transport() + let command = try UserUpdateCommand.parse(["--first-name", "Jane"]) + + let response = try await command.perform(client: client(transport)) + + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("updateCurrentUser")).called(1) + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("updateUser")).called(0) + #expect(command.success(for: response) == "Updated user 32382") + } + + @Test("updates the user with the given ID") + func userByID() async throws { + let transport = transport() + let command = try UserUpdateCommand.parse(["43088", "--first-name", "Jane"]) + + _ = try await command.perform(client: client(transport)) + + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("updateUser")).called(1) + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("updateCurrentUser")).called(0) + } + + // MARK: Private + + private func transport() -> MockClientTransportInterface { + let transport = MockClientTransportInterface() + for operationID in ["updateCurrentUser", "updateUser"] { + given(transport) + .send(.any, body: .any, baseURL: .any, operationID: .value(operationID)) + .willReturn(( + HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), + HTTPBody(Data(#"{"user":{"url":"https://api.freeagent.com/v2/users/32382"}}"#.utf8)) + )) + } + + return transport + } + + private func client(_ transport: MockClientTransportInterface) -> Client { + Client(serverURL: Environment.sandbox.baseURL, transport: transport) + } +} diff --git a/Tests/FreeAgentCLITests/CustomStringConvertible/PermissionLevel+CustomStringConvertibleTests.swift b/Tests/FreeAgentCLITests/CustomStringConvertible/PermissionLevel+CustomStringConvertibleTests.swift new file mode 100644 index 0000000..85c41cb --- /dev/null +++ b/Tests/FreeAgentCLITests/CustomStringConvertible/PermissionLevel+CustomStringConvertibleTests.swift @@ -0,0 +1,21 @@ +import FreeAgentAPI +import Testing + +@testable import FreeAgentCLI + +struct PermissionLevelCustomStringConvertibleTests { + @Test("names each level as FreeAgent's docs do") + func namesLevels() { + #expect(PermissionLevel.allCases.map(\.description) == [ + "No Access", + "Time", + "My Money", + "Contacts & Projects", + "Invoices, Estimates & Files", + "Bills", + "Banking", + "Tax, Accounting & Users", + "Full", + ]) + } +} diff --git a/Tests/FreeAgentCLITests/ExpressibleByArgument/PermissionLevel+ExpressibleByArgumentTests.swift b/Tests/FreeAgentCLITests/ExpressibleByArgument/PermissionLevel+ExpressibleByArgumentTests.swift new file mode 100644 index 0000000..a0362d5 --- /dev/null +++ b/Tests/FreeAgentCLITests/ExpressibleByArgument/PermissionLevel+ExpressibleByArgumentTests.swift @@ -0,0 +1,14 @@ +import ArgumentParser +import FreeAgentAPI +import Testing + +@testable import FreeAgentCLI + +struct PermissionLevelExpressibleByArgumentTests { + @Test("takes FreeAgent's integer and names it in help") + func namesLevelsInHelp() { + #expect(PermissionLevel(argument: "7") == .taxAccountingAndUsers) + #expect(PermissionLevel(argument: "9") == nil) + #expect(PermissionLevel.allValueDescriptions["8"] == "Full") + } +} diff --git a/openapi-generator-config.yaml b/openapi-generator-config.yaml index a4aae6c..c495440 100644 --- a/openapi-generator-config.yaml +++ b/openapi-generator-config.yaml @@ -6,4 +6,5 @@ namingStrategy: idiomatic typeOverrides: schemas: InvoiceView: InvoiceViewFilter - CreditNoteView: CreditNoteViewFilter \ No newline at end of file + CreditNoteView: CreditNoteViewFilter + UserPermissionLevel: PermissionLevel \ No newline at end of file diff --git a/openapi/components/schemas/User.yaml b/openapi/components/schemas/User.yaml index dd5d9a5..fa53332 100644 --- a/openapi/components/schemas/User.yaml +++ b/openapi/components/schemas/User.yaml @@ -19,19 +19,21 @@ properties: description: One of Owner, Director, Partner, Company Secretary, Employee, Shareholder or Accountant example: Director permission_level: - type: integer - example: 8 + $ref: '#/components/schemas/UserPermissionLevel' ni_number: type: string - example: QQ123456C + description: Omitted until set + example: AB123456C unique_tax_reference: type: string + description: Omitted until set example: '1234567890' opening_mileage: type: string example: '0.0' hidden: type: boolean + description: Undocumented, and writable example: false current_payroll_profile: $ref: 'UserPayrollProfile.yaml' diff --git a/openapi/components/schemas/UserCreatePayload.yaml b/openapi/components/schemas/UserCreatePayload.yaml new file mode 100644 index 0000000..5e3fa37 --- /dev/null +++ b/openapi/components/schemas/UserCreatePayload.yaml @@ -0,0 +1,40 @@ +type: object +required: +- email +- first_name +- last_name +- role +properties: + email: + type: string + description: An invalid or taken address is a 422 + example: jane@example.com + first_name: + type: string + example: Jane + last_name: + type: string + example: Smith + role: + $ref: '#/components/schemas/UserRole' + permission_level: + $ref: '#/components/schemas/UserPermissionLevel' + ni_number: + type: string + description: A UK National Insurance number, validated by FreeAgent + example: AB123456C + unique_tax_reference: + type: string + description: Ten digits without spaces + example: '1234567890' + opening_mileage: + type: number + description: Documented as required, but defaults to 0 + example: 0 + hidden: + type: boolean + example: false + send_invitation: + type: boolean + description: True to email the user an invitation to set their password + example: false diff --git a/openapi/components/schemas/UserListResponse.yaml b/openapi/components/schemas/UserListResponse.yaml new file mode 100644 index 0000000..73651c0 --- /dev/null +++ b/openapi/components/schemas/UserListResponse.yaml @@ -0,0 +1,8 @@ +type: object +required: +- users +properties: + users: + type: array + items: + $ref: 'User.yaml' diff --git a/openapi/components/schemas/UserPermissionLevel.yaml b/openapi/components/schemas/UserPermissionLevel.yaml new file mode 100644 index 0000000..de9b6d7 --- /dev/null +++ b/openapi/components/schemas/UserPermissionLevel.yaml @@ -0,0 +1,13 @@ +type: integer +description: 0 No Access, 1 Time, 2 My Money, 3 Contacts & Projects, 4 Invoices, Estimates & Files, 5 Bills, 6 Banking, 7 Tax, Accounting & Users, 8 Full - a new user defaults to 0, and FreeAgent turns anything above 8 into 8 +enum: +- 0 +- 1 +- 2 +- 3 +- 4 +- 5 +- 6 +- 7 +- 8 +example: 1 diff --git a/openapi/components/schemas/UserRole.yaml b/openapi/components/schemas/UserRole.yaml new file mode 100644 index 0000000..4f4defa --- /dev/null +++ b/openapi/components/schemas/UserRole.yaml @@ -0,0 +1,11 @@ +type: string +description: Any other value, lowercase included, is a 422 - an Accountant needs permission level 7 or more +enum: +- Owner +- Director +- Partner +- Company Secretary +- Employee +- Shareholder +- Accountant +example: Employee diff --git a/openapi/components/schemas/UserUpdatePayload.yaml b/openapi/components/schemas/UserUpdatePayload.yaml new file mode 100644 index 0000000..7246ef0 --- /dev/null +++ b/openapi/components/schemas/UserUpdatePayload.yaml @@ -0,0 +1,31 @@ +type: object +description: Fields left out are kept - an empty ni_number or unique_tax_reference removes it +properties: + email: + type: string + description: An invalid or taken address is a 422 + example: jane@example.com + first_name: + type: string + example: Jane + last_name: + type: string + example: Smith + role: + $ref: '#/components/schemas/UserRole' + permission_level: + $ref: '#/components/schemas/UserPermissionLevel' + ni_number: + type: string + description: A UK National Insurance number, validated by FreeAgent + example: AB123456C + unique_tax_reference: + type: string + description: Ten digits without spaces + example: '1234567890' + opening_mileage: + type: number + example: 0 + hidden: + type: boolean + example: false diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 0869ea9..5914190 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -145,8 +145,12 @@ paths: $ref: 'paths/corporation_tax_returns.yaml#/mark_as_paid' /v2/corporation_tax_returns/{period_ends_on}/mark_as_unpaid: $ref: 'paths/corporation_tax_returns.yaml#/mark_as_unpaid' + /v2/users: + $ref: 'paths/users.yaml#/list' /v2/users/me: $ref: 'paths/users.yaml#/me' + /v2/users/{id}: + $ref: 'paths/users.yaml#/show' /v2/users/{user_id}/self_assessment_returns: $ref: 'paths/self_assessment_returns.yaml#/list' /v2/users/{user_id}/self_assessment_returns/{period_ends_on}: @@ -347,6 +351,16 @@ components: $ref: 'components/schemas/UserPayrollProfile.yaml' UserResponse: $ref: 'components/schemas/UserResponse.yaml' + UserListResponse: + $ref: 'components/schemas/UserListResponse.yaml' + UserCreatePayload: + $ref: 'components/schemas/UserCreatePayload.yaml' + UserUpdatePayload: + $ref: 'components/schemas/UserUpdatePayload.yaml' + UserRole: + $ref: 'components/schemas/UserRole.yaml' + UserPermissionLevel: + $ref: 'components/schemas/UserPermissionLevel.yaml' Errors: $ref: 'components/schemas/Errors.yaml' ErrorDetail: diff --git a/openapi/paths/users.yaml b/openapi/paths/users.yaml index 17ee571..eb69ee3 100644 --- a/openapi/paths/users.yaml +++ b/openapi/paths/users.yaml @@ -1,3 +1,138 @@ +list: + get: + tags: + - Users + summary: List users + description: List users - sort and updated_since are ignored + operationId: listUsers + parameters: + - name: view + in: query + description: Filter users by role and visibility - FreeAgent answers an unknown view with a 400 + schema: + type: string + enum: + - all + - staff + - active_staff + - advisors + - active_advisors + example: active_staff + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PerPage' + responses: + '200': + description: Success + headers: + Link: + $ref: '#/components/headers/Link' + X-Total-Count: + $ref: '#/components/headers/XTotalCount' + content: + application/json: + schema: + $ref: '#/components/schemas/UserListResponse' + default: + $ref: '#/components/responses/ErrorResponse' + post: + tags: + - Users + summary: Create user + description: Create user - it needs an email, first name, last name and role + operationId: createUser + requestBody: + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/UserCreatePayload' + examples: + Create User: + value: + user: + email: jane@example.com + first_name: Jane + last_name: Smith + role: Employee + permission_level: 1 + send_invitation: false + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + default: + $ref: '#/components/responses/ErrorResponse' +show: + get: + tags: + - Users + summary: Show user + description: Show user + operationId: showUser + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + default: + $ref: '#/components/responses/ErrorResponse' + put: + tags: + - Users + summary: Update user + description: Update user - an empty user is a 422 whose body is the unchanged user + operationId: updateUser + requestBody: + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/UserUpdatePayload' + examples: + Update User: + value: + user: + permission_level: 3 + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + default: + $ref: '#/components/responses/ErrorResponse' + delete: + tags: + - Users + summary: Delete user + description: Delete user - one with timeslips, expenses or other records is a 422 saying it has dependencies + operationId: deleteUser + responses: + '200': + description: Success + default: + $ref: '#/components/responses/ErrorResponse' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '' me: get: tags: @@ -14,3 +149,33 @@ me: $ref: '#/components/schemas/UserResponse' default: $ref: '#/components/responses/ErrorResponse' + put: + tags: + - Users + summary: Update the current user + description: Update the user the access token belongs to - needs only Time access, and an empty user is a 400 + operationId: updateCurrentUser + requestBody: + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/UserUpdatePayload' + examples: + Update Current User: + value: + user: + first_name: Jane + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + default: + $ref: '#/components/responses/ErrorResponse'