diff --git a/Sources/FreeAgentCLI/Commands/Expense/ExpenseUpdateCommand.swift b/Sources/FreeAgentCLI/Commands/Expense/ExpenseUpdateCommand.swift index efcb05f..811f32e 100644 --- a/Sources/FreeAgentCLI/Commands/Expense/ExpenseUpdateCommand.swift +++ b/Sources/FreeAgentCLI/Commands/Expense/ExpenseUpdateCommand.swift @@ -6,7 +6,10 @@ struct ExpenseUpdateCommand: MutatingCommand { static let configuration = CommandConfiguration( commandName: "update", abstract: "Update an expense", - discussion: "Fields left out are kept, and an empty --receipt-reference removes it. A mileage claim's engine can't be changed." + discussion: """ + Fields left out are kept, and an empty --receipt-reference removes it. A mileage claim's engine can't be \ + changed. --attachment replaces any attachment the expense has, and FreeAgent deletes the old file. + """ ) @Argument(help: "Expense ID or URL") @@ -26,6 +29,9 @@ struct ExpenseUpdateCommand: MutatingCommand { @OptionGroup var expense: ExpenseOptions + @Flag(name: .long, help: "Remove the expense's attachment, which FreeAgent deletes") + var removeAttachment = false + @Flag(help: "Print the request instead of sending it") var dryRun = false @@ -33,11 +39,12 @@ struct ExpenseUpdateCommand: MutatingCommand { var json = false func perform(client: Client) async throws -> Components.Schemas.ExpenseResponse { - let expensePayload = expense.updatePayload( + let expensePayload = try expense.updatePayload( user: user?.value, category: category?.value, datedOn: datedOn, - description: description + description: description, + removeAttachment: removeAttachment ) let input = Operations.UpdateExpense.Input( @@ -49,6 +56,12 @@ struct ExpenseUpdateCommand: MutatingCommand { .ok.body.json } + func validate() throws { + if removeAttachment, expense.attachment.path != nil { + throw ValidationError("Pass --attachment or --remove-attachment, not both") + } + } + func success(for _: Components.Schemas.ExpenseResponse) -> String { "Updated expense \(id)" } diff --git a/Sources/FreeAgentCLI/Commands/Explanation/Attachment/ExplanationAttachmentAddCommand.swift b/Sources/FreeAgentCLI/Commands/Explanation/Attachment/ExplanationAttachmentAddCommand.swift index 48b70c0..609f068 100644 --- a/Sources/FreeAgentCLI/Commands/Explanation/Attachment/ExplanationAttachmentAddCommand.swift +++ b/Sources/FreeAgentCLI/Commands/Explanation/Attachment/ExplanationAttachmentAddCommand.swift @@ -1,14 +1,8 @@ import ArgumentParser import Foundation import FreeAgentAPI -import Noora - -// MARK: - ExplanationAttachmentAddCommand struct ExplanationAttachmentAddCommand: MutatingCommand { - - // MARK: Internal - static let configuration = CommandConfiguration( commandName: "add", abstract: "Add attachments to a bank transaction explanation", @@ -32,17 +26,12 @@ struct ExplanationAttachmentAddCommand: MutatingCommand { func perform(client: Client) async throws -> Components.Schemas.AttachmentListResponse { let attachments = try file.map { path in - let url = URL(fileURLWithPath: path) - let ext = url.pathExtension.lowercased() + let attachment = try AttachmentFile(path: path) - guard let contentType = Self.contentTypes[ext] else { - throw ExplanationAttachmentAddCommandError.unsupportedFileType(ext) - } - - return try Components.Schemas.AttachmentCreatePayload( - data: Data(contentsOf: url).base64EncodedString(), - fileName: url.lastPathComponent, - contentType: contentType, + return Components.Schemas.AttachmentCreatePayload( + data: attachment.data, + fileName: attachment.fileName, + contentType: attachment.contentType, description: description ) } @@ -61,39 +50,4 @@ struct ExplanationAttachmentAddCommand: MutatingCommand { ? "Added the attachment to explanation \(id)" : "Added \(file.count) attachments to explanation \(id)" } - - // MARK: Private - - private static let contentTypes = [ - "pdf": "application/pdf", - "png": "image/png", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "gif": "image/gif", - ] - -} - -// MARK: - ExplanationAttachmentAddCommandError - -enum ExplanationAttachmentAddCommandError: CommandError { - case unsupportedFileType(String) - - var errorDescription: String? { - switch self { - case .unsupportedFileType(let ext): - "Unsupported attachment type '\(ext)'" - } - } - - var exitCode: ExitCode { - .validationFailure - } - - var takeaways: [TerminalText] { - switch self { - case .unsupportedFileType: - ["Attach a pdf, png, jpg, jpeg or gif file"] - } - } } diff --git a/Sources/FreeAgentCLI/Models/AttachmentFile.swift b/Sources/FreeAgentCLI/Models/AttachmentFile.swift new file mode 100644 index 0000000..5bf8ad0 --- /dev/null +++ b/Sources/FreeAgentCLI/Models/AttachmentFile.swift @@ -0,0 +1,76 @@ +import ArgumentParser +import Foundation +import Noora + +// MARK: - AttachmentFile + +struct AttachmentFile { + + // MARK: Lifecycle + + init(path: String) throws { + let url = URL(fileURLWithPath: path) + let ext = url.pathExtension.lowercased() + + guard let contentType = Self.contentTypes[ext] else { + throw AttachmentFileError.unsupportedFileType(ext) + } + + do { + data = try Data(contentsOf: url).base64EncodedString() + } catch { + throw AttachmentFileError.unreadable(path: path) + } + + fileName = url.lastPathComponent + self.contentType = contentType + } + + // MARK: Internal + + let data: String + let fileName: String + let contentType: String + + // MARK: Private + + private static let contentTypes = [ + "pdf": "application/pdf", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + ] + +} + +// MARK: - AttachmentFileError + +enum AttachmentFileError: CommandError { + case unsupportedFileType(String) + case unreadable(path: String) + + // MARK: Internal + + var errorDescription: String? { + switch self { + case .unsupportedFileType(let ext): + "Unsupported attachment type '\(ext)'" + case .unreadable(let path): + "Couldn't read \(path)" + } + } + + var exitCode: ExitCode { + .validationFailure + } + + var takeaways: [TerminalText] { + switch self { + case .unsupportedFileType: + ["Attach a pdf, png, jpg, jpeg or gif file"] + case .unreadable: + ["Check the file exists and is readable"] + } + } +} diff --git a/Sources/FreeAgentCLI/Options/ExpenseAttachmentOptions.swift b/Sources/FreeAgentCLI/Options/ExpenseAttachmentOptions.swift new file mode 100644 index 0000000..f19da7f --- /dev/null +++ b/Sources/FreeAgentCLI/Options/ExpenseAttachmentOptions.swift @@ -0,0 +1,38 @@ +import ArgumentParser +import FreeAgentAPI + +struct ExpenseAttachmentOptions: ParsableArguments { + @Option(name: .customLong("attachment"), help: "Path to a receipt to attach (pdf, png, jpg, jpeg or gif), up to 5MB") + var path: String? + + @Option(name: .customLong("attachment-description"), help: "Description of the attached receipt - needs --attachment") + var description: String? + + var createPayload: Components.Schemas.ExpenseAttachmentPayload? { + get throws { + guard let path else { + return nil + } + + let file = try AttachmentFile(path: path) + return .init(data: file.data, fileName: file.fileName, contentType: file.contentType, description: description) + } + } + + var updatePayload: Components.Schemas.ExpenseAttachmentUpdatePayload? { + get throws { + guard let path else { + return nil + } + + let file = try AttachmentFile(path: path) + return .init(data: file.data, fileName: file.fileName, contentType: file.contentType, description: description) + } + } + + func validate() throws { + if description != nil, path == nil { + throw ValidationError("--attachment-description needs --attachment") + } + } +} diff --git a/Sources/FreeAgentCLI/Options/ExpenseOptions.swift b/Sources/FreeAgentCLI/Options/ExpenseOptions.swift index a8723f4..b26abf0 100644 --- a/Sources/FreeAgentCLI/Options/ExpenseOptions.swift +++ b/Sources/FreeAgentCLI/Options/ExpenseOptions.swift @@ -46,14 +46,16 @@ struct ExpenseOptions: ParsableArguments { @Option(name: .long, help: "Whether a mileage claim has a VAT receipt for its fuel") var haveVatReceipt: Bool? + @OptionGroup var attachment: ExpenseAttachmentOptions + func createPayload( user: String, category: String, datedOn: String, description: String, engine: ExpenseEngineOptions - ) -> Components.Schemas.ExpenseCreatePayload { - .init( + ) throws -> Components.Schemas.ExpenseCreatePayload { + try .init( user: user, category: category, datedOn: datedOn, @@ -73,7 +75,8 @@ struct ExpenseOptions: ParsableArguments { vehicleType: vehicleType, engineType: engine.type, engineSize: engine.size, - haveVatReceipt: haveVatReceipt + haveVatReceipt: haveVatReceipt, + attachment: attachment.createPayload ) } @@ -81,9 +84,10 @@ struct ExpenseOptions: ParsableArguments { user: String?, category: String?, datedOn: String?, - description: String? - ) -> Components.Schemas.ExpenseUpdatePayload { - .init( + description: String?, + removeAttachment: Bool + ) throws -> Components.Schemas.ExpenseUpdatePayload { + try .init( user: user, category: category, datedOn: datedOn, @@ -101,7 +105,8 @@ struct ExpenseOptions: ParsableArguments { recurringEndDate: recurring.endDate, mileage: mileage, vehicleType: vehicleType, - haveVatReceipt: haveVatReceipt + haveVatReceipt: haveVatReceipt, + attachment: removeAttachment ? .init(_destroy: 1) : attachment.updatePayload ) } } diff --git a/Tests/FreeAgentAPIIntegrationTests/ExpenseIntegrationTests.swift b/Tests/FreeAgentAPIIntegrationTests/ExpenseIntegrationTests.swift index 596dfb0..edfe805 100644 --- a/Tests/FreeAgentAPIIntegrationTests/ExpenseIntegrationTests.swift +++ b/Tests/FreeAgentAPIIntegrationTests/ExpenseIntegrationTests.swift @@ -136,6 +136,53 @@ struct ExpenseIntegrationTests { _ = try await client.deleteExpense(.init(path: .init(id: id))).ok } + @Test("An expense attachment is sent on create, removed with _destroy and added on update") + func attachment() async throws { + let user = try await client.showCurrentUser(.init()).ok.body.json.user + + let created = try await client.createExpense( + .init(body: .json(.init(expense: .init( + user: Self.id(of: user.url), + category: "285", + datedOn: Self.today, + description: "ZZ Integration Test Attachment", + grossValue: "-12.0", + attachment: .init( + data: Self.onePixelPNG, + fileName: "receipt.png", + contentType: "image/png", + description: "Integration test receipt" + ) + )))) + ).created.body.json.expense + + let attached = try #require(created.attachment) + #expect(attached.url.contains("/v2/attachments/")) + #expect(attached.fileName == "receipt.png") + #expect(attached.contentType == "image/png") + #expect(attached.description == "Integration test receipt") + + let id = Self.id(of: created.url) + + let removed = try await client.updateExpense( + .init(path: .init(id: id), body: .json(.init(expense: .init(attachment: .init(_destroy: 1))))) + ).ok.body.json.expense + + #expect(removed.attachment == nil) + + let added = try await client.updateExpense( + .init(path: .init(id: id), body: .json(.init(expense: .init(attachment: .init( + data: Self.onePixelPNG, + fileName: "replacement.png", + contentType: "image/png" + ))))) + ).ok.body.json.expense + + #expect(added.attachment?.fileName == "replacement.png") + + _ = try await client.deleteExpense(.init(path: .init(id: id))).ok + } + @Test("GET /v2/expenses/mileage_settings returns typed engine options and rates") func mileageSettings() async throws { let settings = try await client.showMileageSettings(.init()).ok.body.json.mileageSettings @@ -200,6 +247,9 @@ struct ExpenseIntegrationTests { // MARK: Private + private static let onePixelPNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + private static var today: String { date(monthsFromNow: 0) } diff --git a/Tests/FreeAgentCLITests/Commands/Explanation/Attachment/ExplanationAttachmentAddCommandTests.swift b/Tests/FreeAgentCLITests/Commands/Explanation/Attachment/ExplanationAttachmentAddCommandTests.swift deleted file mode 100644 index 491f64b..0000000 --- a/Tests/FreeAgentCLITests/Commands/Explanation/Attachment/ExplanationAttachmentAddCommandTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import ArgumentParser -import Testing - -@testable import FreeAgentCLI - -struct ExplanationAttachmentAddCommandTests { - @Test("rejects an unsupported attachment type as a usage error") - func rejectsUnsupportedType() { - let error = ExplanationAttachmentAddCommandError.unsupportedFileType("txt") - - #expect(error.exitCode == .validationFailure) - #expect(error.errorDescription == "Unsupported attachment type 'txt'") - #expect(error.takeaways.map { $0.plain() } == ["Attach a pdf, png, jpg, jpeg or gif file"]) - } -} diff --git a/Tests/FreeAgentCLITests/Models/AttachmentFileTests.swift b/Tests/FreeAgentCLITests/Models/AttachmentFileTests.swift new file mode 100644 index 0000000..616a7b4 --- /dev/null +++ b/Tests/FreeAgentCLITests/Models/AttachmentFileTests.swift @@ -0,0 +1,65 @@ +import ArgumentParser +import Foundation +import Testing + +@testable import FreeAgentCLI + +struct AttachmentFileTests { + + // MARK: Internal + + @Test( + "takes the content type from the extension, ignoring case", + arguments: [ + ("pdf", "application/pdf"), + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("JPEG", "image/jpeg"), + ("gif", "image/gif"), + ] + ) + func contentType(ext: String, contentType: String) throws { + let path = try Self.file(ext: ext, contents: "receipt") + + let attachment = try AttachmentFile(path: path) + + #expect(attachment.contentType == contentType) + #expect(attachment.fileName == URL(fileURLWithPath: path).lastPathComponent) + #expect(attachment.data == Data("receipt".utf8).base64EncodedString()) + } + + @Test("rejects an unsupported attachment type as a usage error") + func rejectsUnsupportedType() throws { + let path = try Self.file(ext: "txt", contents: "receipt") + + let error = #expect(throws: AttachmentFileError.self) { + try AttachmentFile(path: path) + } + + #expect(error?.exitCode == .validationFailure) + #expect(error?.errorDescription == "Unsupported attachment type 'txt'") + #expect(error?.takeaways.map { $0.plain() } == ["Attach a pdf, png, jpg, jpeg or gif file"]) + } + + @Test("reports a file it can't read as a usage error, naming the path") + func rejectsUnreadableFile() { + let path = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).pdf").path + + let error = #expect(throws: AttachmentFileError.self) { + try AttachmentFile(path: path) + } + + #expect(error?.exitCode == .validationFailure) + #expect(error?.errorDescription == "Couldn't read \(path)") + #expect(error?.takeaways.map { $0.plain() } == ["Check the file exists and is readable"]) + } + + // MARK: Private + + private static func file(ext: String, contents: String) throws -> String { + let path = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).\(ext)").path + try Data(contents.utf8).write(to: URL(fileURLWithPath: path)) + return path + } + +} diff --git a/Tests/FreeAgentCLITests/Options/ExpenseAttachmentOptionsTests.swift b/Tests/FreeAgentCLITests/Options/ExpenseAttachmentOptionsTests.swift new file mode 100644 index 0000000..b04ca7f --- /dev/null +++ b/Tests/FreeAgentCLITests/Options/ExpenseAttachmentOptionsTests.swift @@ -0,0 +1,40 @@ +import ArgumentParser +import Testing + +@testable import FreeAgentCLI + +struct ExpenseAttachmentOptionsTests { + @Test("refuses --attachment-description without --attachment") + func descriptionNeedsAttachment() { + #expect(throws: (any Error).self) { + try ExpenseAttachmentOptions.parse(["--attachment-description", "Hotel receipt"]) + } + } + + @Test("accepts --attachment-description with --attachment") + func descriptionWithAttachment() throws { + let options = try ExpenseAttachmentOptions.parse([ + "--attachment", + "receipt.pdf", + "--attachment-description", + "Hotel receipt", + ]) + + #expect(options.path == "receipt.pdf") + #expect(options.description == "Hotel receipt") + } + + @Test("refuses --attachment with --remove-attachment on expense update") + func updateAttachmentAndRemove() { + #expect(throws: (any Error).self) { + try ExpenseUpdateCommand.parse(["577139", "--attachment", "receipt.pdf", "--remove-attachment"]) + } + } + + @Test("accepts --remove-attachment alone on expense update") + func updateRemove() throws { + let command = try ExpenseUpdateCommand.parse(["577139", "--remove-attachment"]) + + #expect(command.removeAttachment) + } +} diff --git a/openapi/components/schemas/ExpenseAttachmentPayload.yaml b/openapi/components/schemas/ExpenseAttachmentPayload.yaml new file mode 100644 index 0000000..47b19df --- /dev/null +++ b/openapi/components/schemas/ExpenseAttachmentPayload.yaml @@ -0,0 +1,19 @@ +type: object +required: +- data +- file_name +properties: + data: + type: string + description: Binary data of the file encoded as base64 + example: iVBORw0KGgo... + file_name: + type: string + example: receipt.pdf + content_type: + type: string + description: Ignored - FreeAgent detects the type from the data + example: application/pdf + description: + type: string + example: Invoice receipt diff --git a/openapi/components/schemas/ExpenseAttachmentUpdatePayload.yaml b/openapi/components/schemas/ExpenseAttachmentUpdatePayload.yaml new file mode 100644 index 0000000..21a32ca --- /dev/null +++ b/openapi/components/schemas/ExpenseAttachmentUpdatePayload.yaml @@ -0,0 +1,20 @@ +type: object +properties: + data: + type: string + description: Binary data of the file encoded as base64 + example: iVBORw0KGgo... + file_name: + type: string + example: receipt.pdf + content_type: + type: string + description: Ignored - FreeAgent detects the type from the data + example: application/pdf + description: + type: string + example: Invoice receipt + _destroy: + type: integer + description: Set to 1 to remove the attachment - any value removes it, 0 included + example: 1 diff --git a/openapi/components/schemas/ExpenseCreatePayload.yaml b/openapi/components/schemas/ExpenseCreatePayload.yaml index d900c69..d087333 100644 --- a/openapi/components/schemas/ExpenseCreatePayload.yaml +++ b/openapi/components/schemas/ExpenseCreatePayload.yaml @@ -64,3 +64,6 @@ properties: have_vat_receipt: type: boolean example: true + attachment: + $ref: '#/components/schemas/ExpenseAttachmentPayload' + description: A receipt of up to 5MB diff --git a/openapi/components/schemas/ExpenseUpdatePayload.yaml b/openapi/components/schemas/ExpenseUpdatePayload.yaml index 625d8b2..4d69e62 100644 --- a/openapi/components/schemas/ExpenseUpdatePayload.yaml +++ b/openapi/components/schemas/ExpenseUpdatePayload.yaml @@ -51,3 +51,6 @@ properties: have_vat_receipt: type: boolean example: true + attachment: + $ref: '#/components/schemas/ExpenseAttachmentUpdatePayload' + description: A receipt of up to 5MB, replacing any the expense has and deleting it, or _destroy to remove it diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 3e90d9b..6043c54 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -183,6 +183,10 @@ components: $ref: 'components/schemas/ExpenseCreatePayload.yaml' ExpenseUpdatePayload: $ref: 'components/schemas/ExpenseUpdatePayload.yaml' + ExpenseAttachmentPayload: + $ref: 'components/schemas/ExpenseAttachmentPayload.yaml' + ExpenseAttachmentUpdatePayload: + $ref: 'components/schemas/ExpenseAttachmentUpdatePayload.yaml' ExpenseRecurringPeriod: $ref: 'components/schemas/ExpenseRecurringPeriod.yaml' ExpenseRebillType: