From 6a4380637add0db168626334fb338e62b447c5e3 Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Fri, 29 May 2026 15:01:18 +0200 Subject: [PATCH] Add RSpec suite with WebMock: core, OAuth, pagination, and resource behavior --- spec/fiken/client_spec.rb | 57 +++++++++++++++ spec/fiken/collection_spec.rb | 43 +++++++++++ spec/fiken/error_spec.rb | 33 +++++++++ spec/fiken/oauth_spec.rb | 56 +++++++++++++++ spec/fiken/object_spec.rb | 49 +++++++++++++ spec/fiken/resources_spec.rb | 130 ++++++++++++++++++++++++++++++++++ spec/spec_helper.rb | 45 ++++++++++++ 7 files changed, 413 insertions(+) create mode 100644 spec/fiken/client_spec.rb create mode 100644 spec/fiken/collection_spec.rb create mode 100644 spec/fiken/error_spec.rb create mode 100644 spec/fiken/oauth_spec.rb create mode 100644 spec/fiken/object_spec.rb create mode 100644 spec/fiken/resources_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/spec/fiken/client_spec.rb b/spec/fiken/client_spec.rb new file mode 100644 index 0000000..2491e39 --- /dev/null +++ b/spec/fiken/client_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +RSpec.describe Fiken::Client do + subject(:client) { described_class.new(token: "test-token") } + + describe "#initialize" do + it "raises without a token" do + expect { described_class.new }.to raise_error(ArgumentError) + end + + it "accepts an access_token alias" do + expect { described_class.new(access_token: "x") }.not_to raise_error + end + end + + describe "#user" do + it "GETs /user with a bearer token and wraps the response" do + stub_fiken(:get, "/user", body: { "name" => "Runar", "email" => "runar@rin.no" }) + + user = client.user + + expect(user.name).to eq("Runar") + expect(a_request(:get, "#{API_BASE}/user") + .with(headers: { "Authorization" => "Bearer test-token" })).to have_been_made + end + end + + describe "#companies" do + it "lists companies" do + stub_fiken(:get, "/companies", body: [{ "slug" => "acme", "name" => "Acme AS" }]) + + companies = client.companies.list + + expect(companies.map(&:slug)).to eq(["acme"]) + end + + it "finds a single company by slug" do + stub_fiken(:get, "/companies/acme", body: { "slug" => "acme", "name" => "Acme AS" }) + + expect(client.company("acme").name).to eq("Acme AS") + end + end + + describe "error mapping" do + it "raises Unauthorized on 401" do + stub_fiken(:get, "/user", status: 401, body: { "message" => "Bad token" }) + + expect { client.user }.to raise_error(Fiken::Unauthorized, "Bad token") + end + + it "raises NotFound on 404" do + stub_fiken(:get, "/companies/missing", status: 404) + + expect { client.company("missing") }.to raise_error(Fiken::NotFound) + end + end +end diff --git a/spec/fiken/collection_spec.rb b/spec/fiken/collection_spec.rb new file mode 100644 index 0000000..92e68b0 --- /dev/null +++ b/spec/fiken/collection_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.describe Fiken::Collection do + subject(:client) { Fiken::Client.new(token: "t") } + + def page_headers(page:, page_count:, result_count:) + { + "Fiken-Api-Page" => page.to_s, + "Fiken-Api-Page-Size" => "25", + "Fiken-Api-Page-Count" => page_count.to_s, + "Fiken-Api-Result-Count" => result_count.to_s + } + end + + it "exposes pagination metadata from response headers" do + stub_fiken(:get, "/companies", body: [{ "slug" => "a" }], + headers: page_headers(page: 0, page_count: 3, result_count: 3)) + + collection = client.companies.list + + expect(collection.page).to eq(0) + expect(collection.page_count).to eq(3) + expect(collection.result_count).to eq(3) + expect(collection.last_page?).to be(false) + end + + it "walks every page with auto_paging_each" do + stub_request(:get, "#{API_BASE}/companies") + .to_return(status: 200, body: JSON.generate([{ "slug" => "a" }]), + headers: { "Content-Type" => "application/json" } + .merge(page_headers(page: 0, page_count: 2, result_count: 2))) + + stub_request(:get, "#{API_BASE}/companies") + .with(query: { "page" => "1" }) + .to_return(status: 200, body: JSON.generate([{ "slug" => "b" }]), + headers: { "Content-Type" => "application/json" } + .merge(page_headers(page: 1, page_count: 2, result_count: 2))) + + slugs = client.companies.list.auto_paging_each.map(&:slug) + + expect(slugs).to eq(%w[a b]) + end +end diff --git a/spec/fiken/error_spec.rb b/spec/fiken/error_spec.rb new file mode 100644 index 0000000..7f5f5f4 --- /dev/null +++ b/spec/fiken/error_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Fiken::Error do + describe ".from_response" do + it "maps known statuses to specific subclasses" do + { + 400 => Fiken::BadRequest, + 401 => Fiken::Unauthorized, + 403 => Fiken::Forbidden, + 404 => Fiken::NotFound, + 422 => Fiken::UnprocessableEntity, + 429 => Fiken::RateLimited + }.each do |status, klass| + expect(described_class.from_response(status, {})).to be_a(klass) + end + end + + it "maps unknown 5xx statuses to ServerError" do + expect(described_class.from_response(503, "")).to be_a(Fiken::ServerError) + end + + it "extracts a message from a JSON error body" do + error = described_class.from_response(400, { "message" => "Bad slug" }) + expect(error.message).to eq("Bad slug") + expect(error.status).to eq(400) + end + + it "falls back to a generic message when the body has none" do + error = described_class.from_response(404, "") + expect(error.message).to eq("Fiken API error (HTTP 404)") + end + end +end diff --git a/spec/fiken/oauth_spec.rb b/spec/fiken/oauth_spec.rb new file mode 100644 index 0000000..a7876df --- /dev/null +++ b/spec/fiken/oauth_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +RSpec.describe Fiken::OAuth do + subject(:oauth) do + described_class.new(client_id: "id", client_secret: "secret", redirect_uri: "https://app/cb") + end + + describe "#authorize_url" do + it "builds the authorize URL with the expected query params" do + url = oauth.authorize_url(state: "xyz") + query = URI.decode_www_form(URI(url).query).to_h + + expect(url).to start_with("https://fiken.no/oauth/authorize?") + expect(query).to include( + "response_type" => "code", + "client_id" => "id", + "redirect_uri" => "https://app/cb", + "state" => "xyz" + ) + end + end + + describe "#exchange_code" do + it "POSTs the code with Basic auth and returns the token" do + stub_request(:post, "https://fiken.no/oauth/token") + .with( + body: hash_including("grant_type" => "authorization_code", "code" => "the-code"), + headers: { "Authorization" => "Basic #{Base64.strict_encode64('id:secret')}" } + ) + .to_return( + status: 200, + body: JSON.generate("access_token" => "AT", "refresh_token" => "RT", "expires_in" => 3600), + headers: { "Content-Type" => "application/json" } + ) + + token = oauth.exchange_code("the-code") + + expect(token.access_token).to eq("AT") + expect(token.refresh_token).to eq("RT") + end + end + + describe "#refresh" do + it "POSTs the refresh grant" do + stub_request(:post, "https://fiken.no/oauth/token") + .with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "RT")) + .to_return( + status: 200, + body: JSON.generate("access_token" => "AT2"), + headers: { "Content-Type" => "application/json" } + ) + + expect(oauth.refresh("RT").access_token).to eq("AT2") + end + end +end diff --git a/spec/fiken/object_spec.rb b/spec/fiken/object_spec.rb new file mode 100644 index 0000000..d817000 --- /dev/null +++ b/spec/fiken/object_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +RSpec.describe Fiken::Object do + subject(:object) do + described_class.new( + "invoiceNumber" => 5, + "customer" => { "name" => "Acme" }, + "lines" => [{ "vat" => 25 }, { "vat" => 0 }] + ) + end + + it "exposes camelCase keys via dot access" do + expect(object.invoiceNumber).to eq(5) + end + + it "exposes the same keys via snake_case dot access" do + expect(object.invoice_number).to eq(5) + end + + it "supports hash-style access with string or symbol keys" do + expect(object[:invoiceNumber]).to eq(5) + expect(object["invoiceNumber"]).to eq(5) + end + + it "wraps nested hashes recursively" do + expect(object.customer.name).to eq("Acme") + end + + it "wraps arrays of hashes recursively" do + expect(object.lines.map(&:vat)).to eq([25, 0]) + end + + it "round-trips to a plain hash" do + expect(object.to_h).to eq( + invoiceNumber: 5, + customer: { name: "Acme" }, + lines: [{ vat: 25 }, { vat: 0 }] + ) + end + + it "raises NoMethodError for unknown keys" do + expect { object.nope }.to raise_error(NoMethodError) + end + + it "answers key? for present and absent keys" do + expect(object.key?(:invoiceNumber)).to be(true) + expect(object.key?(:missing)).to be(false) + end +end diff --git a/spec/fiken/resources_spec.rb b/spec/fiken/resources_spec.rb new file mode 100644 index 0000000..938761d --- /dev/null +++ b/spec/fiken/resources_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "stringio" + +RSpec.describe "Resources" do + let(:client) { Fiken::Client.new(token: "t") } + let(:slug) { "acme" } + + describe Fiken::Resources::Contacts do + it "lists, finds, creates (via Location), updates with PUT and deletes" do + stub_fiken(:get, "/companies/acme/contacts", body: [{ "contactId" => 1, "name" => "A" }]) + stub_fiken(:get, "/companies/acme/contacts/1", body: { "contactId" => 1, "name" => "A" }) + stub_created(:post, "/companies/acme/contacts", to: "/companies/acme/contacts/9") + stub_fiken(:put, "/companies/acme/contacts/1", body: { "contactId" => 1, "name" => "B" }) + stub_fiken(:delete, "/companies/acme/contacts/1", status: 204) + + contacts = client.contacts(slug) + + expect(contacts.list.map(&:name)).to eq(["A"]) + expect(contacts.find(1).name).to eq("A") + expect(contacts.create(name: "New").id).to eq("9") + expect(contacts.update(1, name: "B").name).to eq("B") + expect(contacts.delete(1)).to be(true) + + expect(a_request(:put, "#{API_BASE}/companies/acme/contacts/1") + .with(body: { name: "B" })).to have_been_made + end + + it "uploads an attachment as multipart" do + stub_fiken(:post, "/companies/acme/contacts/1/attachments", status: 201) + + client.contacts(slug).attachments(1).add(io: StringIO.new("PDF"), filename: "f.pdf") + + expect(a_request(:post, "#{API_BASE}/companies/acme/contacts/1/attachments") + .with(headers: { "Content-Type" => %r{multipart/form-data} })).to have_been_made + end + + it "reaches contact persons under a contact" do + stub_fiken(:get, "/companies/acme/contacts/1/contactPerson", body: [{ "contactPersonId" => 7 }]) + + expect(client.contacts(slug).contact_persons(1).list.first.contact_person_id).to eq(7) + end + end + + describe Fiken::Resources::Invoices do + let(:invoices) { client.invoices(slug) } + + it "updates a finalized invoice with PATCH" do + stub_fiken(:patch, "/companies/acme/invoices/5", body: { "invoiceId" => 5 }) + + invoices.update(5, sentManually: true) + + expect(a_request(:patch, "#{API_BASE}/companies/acme/invoices/5")).to have_been_made + end + + it "sends an invoice via dispatch" do + stub_fiken(:post, "/companies/acme/invoices/send", status: 200, body: { "success" => true }) + + invoices.dispatch(invoiceId: 5, method: ["email"]) + + expect(a_request(:post, "#{API_BASE}/companies/acme/invoices/send")).to have_been_made + end + + it "reads and sets the counter" do + stub_fiken(:get, "/companies/acme/invoices/counter", body: { "value" => 100 }) + stub_fiken(:post, "/companies/acme/invoices/counter", status: 201) + + expect(invoices.counter.value).to eq(100) + invoices.create_counter(value: 1000) + expect(a_request(:post, "#{API_BASE}/companies/acme/invoices/counter")).to have_been_made + end + + it "creates a draft then finalizes it into an invoice" do + stub_created(:post, "/companies/acme/invoices/drafts", to: "/companies/acme/invoices/drafts/3") + stub_created(:post, "/companies/acme/invoices/drafts/3/createInvoice", to: "/companies/acme/invoices/77") + + draft = invoices.drafts.create(type: "invoice") + expect(draft.id).to eq("3") + expect(invoices.drafts.create_document(3).id).to eq("77") + end + end + + describe Fiken::Resources::Sales do + let(:sales) { client.sales(slug) } + + it "settles, writes off and deletes via PATCH sub-paths" do + stub_fiken(:patch, "/companies/acme/sales/5/settled", body: { "saleId" => 5 }) + stub_fiken(:patch, "/companies/acme/sales/5/writeOff", body: { "saleId" => 5 }) + stub_fiken(:patch, "/companies/acme/sales/5/delete", status: 200) + + sales.settle(5, settledDate: "2026-01-01") + sales.write_off(5) + expect(sales.delete(5)).to be(true) + + expect(a_request(:patch, "#{API_BASE}/companies/acme/sales/5/writeOff")).to have_been_made + end + + it "creates a payment on a sale" do + stub_created(:post, "/companies/acme/sales/5/payments", to: "/companies/acme/sales/5/payments/2") + + expect(client.sales(slug).payments(5).create(amount: 100).id).to eq("2") + end + end + + describe Fiken::Resources::Transactions do + it "deletes via PATCH /{id}/delete" do + stub_fiken(:patch, "/companies/acme/transactions/8/delete", status: 200) + + expect(client.transactions(slug).delete(8, description: "oops")).to be(true) + end + end + + describe Fiken::Resources::JournalEntries do + it "creates a general journal entry on the sibling path" do + stub_created(:post, "/companies/acme/generalJournalEntries", to: "/companies/acme/journalEntries/4") + + expect(client.journal_entries(slug).create_general(description: "x").id).to eq("4") + end + end + + describe Fiken::Resources::Products do + it "requests a sales report" do + stub_fiken(:post, "/companies/acme/products/salesReport", + body: [{ "product" => { "name" => "Widget" } }]) + + report = client.products(slug).sales_report(from: "2026-01-01", to: "2026-01-31") + expect(report.first.product.name).to eq("Widget") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..86bfda8 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "json" +require "base64" +require "fiken" +require "webmock/rspec" +require "vcr" + +VCR.configure do |config| + config.cassette_library_dir = "spec/cassettes" + config.hook_into :webmock + config.configure_rspec_metadata! + config.default_cassette_options = { record: :none } + config.filter_sensitive_data("") { ENV.fetch("FIKEN_TOKEN", nil) } +end + +RSpec.configure do |config| + config.expect_with(:rspec) { |c| c.syntax = :expect } + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed + + config.example_status_persistence_file_path = ".rspec_status" +end + +API_BASE = "https://api.fiken.no/api/v2" + +def stub_fiken(method, path, status: 200, body: "", headers: {}) + stub_request(method, "#{API_BASE}#{path}") + .to_return( + status: status, + body: body.is_a?(String) ? body : JSON.generate(body), + headers: { "Content-Type" => "application/json" }.merge(headers) + ) +end + +# Convenience: a Location response header pointing at an API path. +def location(path) + { "Location" => "#{API_BASE}#{path}" } +end + +# Stub a create endpoint that returns 201 with a Location header (no body). +def stub_created(method, path, to:) + stub_fiken(method, path, status: 201, headers: location(to)) +end