Add RSpec suite with WebMock: core, OAuth, pagination, and resource behavior

This commit is contained in:
2026-05-29 15:01:18 +02:00
parent 3b4d5ae5c3
commit 6a4380637a
7 changed files with 413 additions and 0 deletions
+57
View File
@@ -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
+43
View File
@@ -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
+33
View File
@@ -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
+56
View File
@@ -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
+49
View File
@@ -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
+130
View File
@@ -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
+45
View File
@@ -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("<FIKEN_TOKEN>") { 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