From 9af8f9644cd2f8020d9bc5eb55e5e436862e080b Mon Sep 17 00:00:00 2001 From: "Migration 4.6.1" Date: Tue, 2 Jun 2026 13:38:34 +0000 Subject: [PATCH] feat: NetBox 4.6.1 alignment + bulk ops and object-changes polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exigence A — bulk_create/update/delete on array bodies (NetboxClientRuby::BulkOperations, mixed into every collection); full-object return with ids; typed per-element 400 errors via NetboxClientRuby::BulkError#failures. Exigence B — core.object_changes (/api/core/object-changes/) read-only collection; generalized get_list(filters, limit:, offset:) with #results/#next_url/#previous_url and #all_pages/#each_page aggregation on all entities. Specs for bulk + multi-page pagination, fixtures, standalone harness, docs/GUIDE_TESTEURS.md, CHANGELOG, VERSION 0.17.0. --- CHANGELOG.md | 18 ++ VERSION | 2 +- docs/GUIDE_TESTEURS.md | 56 ++++++ harness.rb | 189 ++++++++++++++++++ lib/netbox_client_ruby.rb | 4 + lib/netbox_client_ruby/api/core.rb | 17 ++ .../api/core/object_change.rb | 16 ++ .../api/core/object_changes.rb | 26 +++ lib/netbox_client_ruby/bulk_operations.rb | 95 +++++++++ lib/netbox_client_ruby/entities.rb | 67 +++++++ lib/netbox_client_ruby/error.rb | 46 +++++ spec/fixtures/core/object-changes.json | 11 + spec/fixtures/core/object-changes_page1.json | 6 + spec/fixtures/core/object-changes_page2.json | 6 + spec/fixtures/dcim/cables_bulk_create.json | 8 + spec/fixtures/dcim/cables_bulk_errors.json | 5 + spec/fixtures/dcim/cables_bulk_update.json | 4 + .../api/core/object_changes_spec.rb | 66 ++++++ .../bulk_operations_spec.rb | 86 ++++++++ 19 files changed, 727 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 docs/GUIDE_TESTEURS.md create mode 100644 harness.rb create mode 100644 lib/netbox_client_ruby/api/core.rb create mode 100644 lib/netbox_client_ruby/api/core/object_change.rb create mode 100644 lib/netbox_client_ruby/api/core/object_changes.rb create mode 100644 lib/netbox_client_ruby/bulk_operations.rb create mode 100644 spec/fixtures/core/object-changes.json create mode 100644 spec/fixtures/core/object-changes_page1.json create mode 100644 spec/fixtures/core/object-changes_page2.json create mode 100644 spec/fixtures/dcim/cables_bulk_create.json create mode 100644 spec/fixtures/dcim/cables_bulk_errors.json create mode 100644 spec/fixtures/dcim/cables_bulk_update.json create mode 100644 spec/netbox_client_ruby/api/core/object_changes_spec.rb create mode 100644 spec/netbox_client_ruby/bulk_operations_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a1198e4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## 0.17.0 — Alignement NetBox 4.6.1 + bulk & object-changes + +### Ajouts (rétrocompatibles) +- `NetboxClientRuby::BulkOperations` mixé dans toutes les collections : + `#bulk_create`, `#bulk_update`, `#bulk_delete` (corps en tableau, retour des + objets complets avec `id`). +- `NetboxClientRuby::BulkError` : erreur typée portant `#failures` + (`{index:, errors:, item:}` par élément rejeté), `#errors`, `#total_failure?`. +- `NetboxClientRuby.core.object_changes` (`/api/core/object-changes/`), + lecture seule, pour le polling incrémental. +- `Entities#get_list(filters, limit:, offset:)`, `#results`, `#next_url`, + `#previous_url`, `#all_pages`, `#each_page` — généralisés à toutes les entités. + +### Aucune rupture d'API publique +Les méthodes existantes (`#all`, `#filter`, `#limit`, `#offset`, `#total`, +énumération) sont inchangées. Les nouvelles méthodes sont purement additives. diff --git a/VERSION b/VERSION index 04a373e..c5523bd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.16.0 +0.17.0 diff --git a/docs/GUIDE_TESTEURS.md b/docs/GUIDE_TESTEURS.md new file mode 100644 index 0000000..a2e64ec --- /dev/null +++ b/docs/GUIDE_TESTEURS.md @@ -0,0 +1,56 @@ +# Guide testeurs — migration NetBox 4.6.1 + +> Mise à jour requise par les guidelines projet : toute fonctionnalité majeure +> documente ici son impact sur le parcours de test. + +## Périmètre de la branche `feat/netbox-4.6.1-bulk-object-changes` + +Alignement de la gem sur NetBox **4.6.1** (le fork ciblait déjà la sérialisation +v4 : `status`/`action` sont des objets `{value, label}`, les FK sont des objets +imbriqués en lecture et acceptent un entier en écriture). Deux ajouts majeurs : + +- **Exigence A — Opérations de masse** (`NetboxClientRuby::BulkOperations`, + disponible sur toutes les collections). +- **Exigence B — Lecture incrémentale des changements** (`core.object_changes`) + + `get_list` filtrable/paginé généralisé à toutes les entités. + +## Ce qu'un testeur doit vérifier + +### Bulk (calqué sur les lots IDAS ports/câbles) +1. `dcim.cables.bulk_create([...])` renvoie les objets **complets** : vérifier + que chaque élément retourné porte un `id` exploitable (`result.map(&:id)`). +2. `bulk_update([...])` : chaque élément **doit** contenir un `id`, sinon + `NetboxClientRuby::LocalError` est levé avant tout appel réseau. +3. `bulk_delete([id, {id:}, entity])` : accepte ids, hashes ou entités, envoie + un corps `[{id: …}]`, renvoie le nombre d'éléments supprimés. +4. **Erreur 400 par élément** : sur rejet partiel, une `NetboxClientRuby::BulkError` + est levée ; vérifier `error.failures` → liste `{index:, errors:, item:}` ne + contenant **que** les éléments rejetés (l'item renvoyé est le payload soumis, + pour reconstituer la ligne IDAS fautive). `error.total_failure?` distingue un + rejet partiel d'un rejet global. + +### Pagination / polling incrémental +1. `core.object_changes.get_list({ changed_object_type_id: [12, 34], + time_after: ts, ordering: '-time' }, limit: 100)` : confirmer l'encodage en + `changed_object_type_id=12&changed_object_type_id=34&…` (clés répétées). +2. `#results` / `#next_url` / `#previous_url` exposent la page courante et les + curseurs bruts renvoyés par NetBox. +3. `#all_pages` suit `next` jusqu'à épuisement et agrège tous les enregistrements + (à utiliser pour un cycle de polling complet). Vérifier sur un jeu > 1 page. + +## Lancer les tests + +``` +bundle install +bundle exec rspec spec/netbox_client_ruby/bulk_operations_spec.rb \ + spec/netbox_client_ruby/api/core/object_changes_spec.rb +``` + +> Note : la logique bulk + agrégation multi-pages est aussi couverte par un +> harness autonome (`harness.rb`) qui n'exige aucune dépendance externe, utile +> en environnement sans accès rubygems. + +## Limites connues / à confirmer côté instance 4.6.1 +- En-tête de version d'API : non imposé par le schéma fourni ; à valider au runtime. +- Champs réellement requis sur Cable (le schéma déclare `required: []`). +- Liste exacte des `object_type` acceptés pour les terminaisons de câble. diff --git a/harness.rb b/harness.rb new file mode 100644 index 0000000..16231cc --- /dev/null +++ b/harness.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +# Runtime proof of the new logic WITHOUT the gem's external dependencies +# (faraday / dry-configurable / zeitwerk). We load the real source files and +# drive them with fake Faraday-style responses. + +GEM = File.expand_path('lib/netbox_client_ruby', __dir__) +require "#{GEM}/error" +require "#{GEM}/communication" +require "#{GEM}/bulk_operations" +require "#{GEM}/entities" + +# Minimal stand-in for NetboxClientRuby.config (real gem uses dry-configurable). +Pagination = Struct.new(:default_limit, :max_limit) +Netbox = Struct.new(:pagination) +Config = Struct.new(:netbox) +module NetboxClientRuby + def self.config + @config ||= Config.new(Netbox.new(Pagination.new(50, 1000))) + end +end + +# --- Fakes ------------------------------------------------------------------- +FakeResponse = Struct.new(:status, :body) + +class FakeFaraday + attr_reader :calls + + def initialize(script) + @script = script # Hash: [method, path] => FakeResponse + @calls = [] + end + + def post(path, body = nil) + record(:post, path, body) + end + + def patch(path, body = nil) + record(:patch, path, body) + end + + def delete(path) + req = Object.new + captured = nil + req.define_singleton_method(:body=) { |b| captured = b } + yield req if block_given? + record(:delete, path, captured) + end + + def get(path) + record(:get, path, nil) + end + + private + + def record(method, path, body) + @calls << { method: method, path: path, body: body } + @script[[method, path]] || FakeResponse.new(200, []) + end +end + +class FakeEntity + attr_accessor :data + + def initialize(id) = (@id = id) + def id = @id + def raw_data! = data +end + +# A collection backed by the REAL Entities + BulkOperations modules. +class FakeCables + include NetboxClientRuby::Entities + + path 'dcim/cables/' + data_key 'results' + count_key 'count' + entity_creator :entity_creator + + def initialize(faraday) + @faraday = faraday + end + + # Override Communication#connection with our fake. + def connection + @faraday + end + + # Allow pre-seeding the first page (bypasses config-dependent #get). + def seed!(page) + @data = page + self + end + + private + + def entity_creator(raw) + FakeEntity.new(raw['id']) + end +end + +def assert(label, cond) + raise "FAILED: #{label}" unless cond + + puts " ok #{label}" +end + +puts '== Exigence A: bulk_create returns full objects with ids ==' +script = { + [:post, 'dcim/cables/'] => FakeResponse.new(201, [ + { 'id' => 101, 'label' => 'A', 'status' => { 'value' => 'connected' } }, + { 'id' => 102, 'label' => 'B', 'status' => { 'value' => 'connected' } } + ]) +} +faraday = FakeFaraday.new(script) +cables = FakeCables.new(faraday) +created = cables.bulk_create([{ label: 'A' }, { label: 'B' }]) +assert('POST sent to collection path', faraday.calls.last[:path] == 'dcim/cables/') +assert('request body is an array of 2', faraday.calls.last[:body].length == 2) +assert('returns wrapped entities', created.all? { |c| c.is_a?(FakeEntity) }) +assert('ids are recoverable', created.map(&:id) == [101, 102]) +assert('full body is preserved', created.first.raw_data!['label'] == 'A') + +puts '== Exigence A: bulk_update requires id, sends PATCH ==' +script = { [:patch, 'dcim/cables/'] => FakeResponse.new(200, [{ 'id' => 5, 'label' => 'x' }]) } +cables = FakeCables.new(FakeFaraday.new(script)) +updated = cables.bulk_update([{ id: 5, label: 'x' }]) +assert('returns updated entity', updated.first.id == 5) +begin + FakeCables.new(FakeFaraday.new({})).bulk_update([{ label: 'no-id' }]) + assert('missing id should raise', false) +rescue NetboxClientRuby::LocalError => e + assert('missing id raises LocalError', e.message.include?('missing')) +end + +puts '== Exigence A: bulk_delete sends [{id:}] body ==' +faraday = FakeFaraday.new({ [:delete, 'dcim/cables/'] => FakeResponse.new(204, nil) }) +n = FakeCables.new(faraday).bulk_delete([101, { id: 102 }, FakeEntity.new(103)]) +assert('DELETE body normalized to id hashes', + faraday.calls.last[:body] == [{ id: 101 }, { id: 102 }, { id: 103 }]) +assert('returns count deleted', n == 3) + +puts '== Exigence A: typed per-element 400 errors ==' +body400 = [{}, { 'name' => ['This field is required.'] }, {}] +faraday = FakeFaraday.new({ [:post, 'dcim/cables/'] => FakeResponse.new(400, body400) }) +begin + FakeCables.new(faraday).bulk_create([{ a: 1 }, { a: 2 }, { a: 3 }]) + assert('400 should raise', false) +rescue NetboxClientRuby::BulkError => e + assert('only failed elements surface', e.failures.length == 1) + assert('failure carries its index', e.failures.first[:index] == 1) + assert('failure carries submitted item', e.failures.first[:item] == { a: 2 }) + assert('failure carries field errors', e.failures.first[:errors]['name'].first.include?('required')) + assert('not a total failure', e.total_failure? == false) +end + +puts '== Exigence B: get_list exposes results + next_url ==' +page1 = { + 'count' => 5, 'next' => 'http://nb/api/core/object-changes/?limit=2&offset=2', + 'previous' => nil, + 'results' => [{ 'id' => 1 }, { 'id' => 2 }] +} +changes = FakeCables.new(FakeFaraday.new({})).seed!(page1) +changes.get_list({ changed_object_type_id: [12, 34], time_after: 't', ordering: '-time' }, limit: 2) +# get_list calls filter/limit which reset @data; re-seed to inspect page accessors: +changes.seed!(page1) +assert('results returns raw page array', changes.results == [{ 'id' => 1 }, { 'id' => 2 }]) +assert('next_url exposed', changes.next_url.end_with?('offset=2')) +assert('previous_url nil on first page', changes.previous_url.nil?) + +puts '== Exigence B: all_pages follows next across pages ==' +page1 = { + 'next' => 'http://nb/api/core/object-changes/?offset=2', + 'results' => [{ 'id' => 1 }, { 'id' => 2 }] +} +page2 = { + 'next' => 'http://nb/api/core/object-changes/?offset=4', + 'results' => [{ 'id' => 3 }, { 'id' => 4 }] +} +page3 = { 'next' => nil, 'results' => [{ 'id' => 5 }] } +faraday = FakeFaraday.new({ + [:get, 'http://nb/api/core/object-changes/?offset=2'] => FakeResponse.new(200, page2), + [:get, 'http://nb/api/core/object-changes/?offset=4'] => FakeResponse.new(200, page3) + }) +agg = FakeCables.new(faraday).seed!(page1) +all = agg.all_pages +assert('aggregated all 5 across 3 pages', all.map(&:id) == [1, 2, 3, 4, 5]) +assert('followed next exactly twice', faraday.calls.count { |c| c[:method] == :get } == 2) + +puts "\nALL HARNESS CHECKS PASSED" diff --git a/lib/netbox_client_ruby.rb b/lib/netbox_client_ruby.rb index 28df3dc..387b6fd 100644 --- a/lib/netbox_client_ruby.rb +++ b/lib/netbox_client_ruby.rb @@ -82,6 +82,10 @@ def self.circuits NetboxClientRuby::Circuits end + def self.core + NetboxClientRuby::Core + end + def self.dcim NetboxClientRuby::DCIM end diff --git a/lib/netbox_client_ruby/api/core.rb b/lib/netbox_client_ruby/api/core.rb new file mode 100644 index 0000000..001df86 --- /dev/null +++ b/lib/netbox_client_ruby/api/core.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module NetboxClientRuby + module Core + { + object_changes: ObjectChanges, + }.each_pair do |method_name, class_name| + NetboxClientRuby.load_collection(self, method_name, class_name) + end + + { + object_change: ObjectChange, + }.each_pair do |method_name, class_name| + NetboxClientRuby.load_entity(self, method_name, class_name) + end + end +end diff --git a/lib/netbox_client_ruby/api/core/object_change.rb b/lib/netbox_client_ruby/api/core/object_change.rb new file mode 100644 index 0000000..8bf7c0e --- /dev/null +++ b/lib/netbox_client_ruby/api/core/object_change.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module NetboxClientRuby + module Core + ## + # A single entry of /api/core/object-changes/. Read-only: the changelog is + # produced by NetBox, never written by clients. + class ObjectChange + include Entity + + id id: :id + deletable false + path 'core/object-changes/:id/' + end + end +end diff --git a/lib/netbox_client_ruby/api/core/object_changes.rb b/lib/netbox_client_ruby/api/core/object_changes.rb new file mode 100644 index 0000000..366fe7d --- /dev/null +++ b/lib/netbox_client_ruby/api/core/object_changes.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module NetboxClientRuby + module Core + ## + # /api/core/object-changes/ — the change log used for incremental polling. + # + # Supports the standard filterable/paginated reads via #get_list, e.g. + # multiple changed_object_type_id, time_after, ordering, limit/offset, and + # the all-pages aggregation inherited from Entities (#all_pages). + class ObjectChanges + include Entities + + path 'core/object-changes/' + data_key 'results' + count_key 'count' + entity_creator :entity_creator + + private + + def entity_creator(raw_entity) + ObjectChange.new raw_entity['id'] + end + end + end +end diff --git a/lib/netbox_client_ruby/bulk_operations.rb b/lib/netbox_client_ruby/bulk_operations.rb new file mode 100644 index 0000000..bd84c1d --- /dev/null +++ b/lib/netbox_client_ruby/bulk_operations.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module NetboxClientRuby + ## + # Bulk create / update / delete against a collection endpoint, using an array + # request body (NetBox's native bulk mode). Mixed into Entities, so every + # collection (dcim.cables, dcim.front_ports, ...) gains these methods. + # + # Semantics (Exigence A): + # * +bulk_create+ / +bulk_update+ return the FULL objects sent back by + # NetBox, wrapped as entities, so the caller can read the assigned +id+. + # * +bulk_update+ requires an +id+ on every element (PATCH semantics). + # * +bulk_delete+ accepts ids, hashes, or entities; sends [{id: ...}, ...]. + # * an HTTP 400 raises NetboxClientRuby::BulkError, whose +#failures+ maps + # each rejected element back to its submitted payload and index. + module BulkOperations + include NetboxClientRuby::Communication + + def bulk_create(objects) + payload = normalize_payload(objects) + wrap_entities bulk_request(:post, payload), payload + end + + def bulk_update(objects) + payload = normalize_payload(objects) + payload.each_with_index do |element, index| + unless element.is_a?(Hash) && (element['id'] || element[:id]) + raise NetboxClientRuby::LocalError, + "bulk_update: element ##{index} is missing an 'id'." + end + end + wrap_entities bulk_request(:patch, payload), payload + end + + def bulk_delete(objects) + payload = Array(objects).map { |object| { id: extract_id(object) } } + bulk_request(:delete, payload) + payload.length + end + + private + + def bulk_path + self.class.path + end + + def normalize_payload(objects) + raise ArgumentError, 'A bulk operation expects an Array.' unless objects.is_a?(Array) + + objects.map { |object| object.respond_to?(:raw_data!) ? object.raw_data! : object } + end + + def extract_id(object) + return object['id'] || object[:id] if object.is_a?(Hash) + return object.id if object.respond_to?(:id) + + object + end + + def bulk_request(method, payload) + faraday_response = + if method == :delete + connection.delete(bulk_path) { |request| request.body = payload } + else + connection.public_send(method, bulk_path, payload) + end + + handle_bulk_response(faraday_response, payload) + end + + def handle_bulk_response(faraday_response, payload) + status = faraday_response.status + return [] if status == 204 + + if status == 400 + raise NetboxClientRuby::BulkError.new( + 'Bulk operation rejected', body: faraday_response.body, request: payload + ) + end + + # Delegates 2xx / other errors to the shared Communication handling. + response faraday_response + end + + ## + # NetBox returns the full objects (with ids) for create/update. Reuse the + # collection's entity_creator so callers get real entity instances. + def wrap_entities(raw_objects, _payload) + return raw_objects unless raw_objects.is_a?(Array) + return raw_objects unless respond_to?(:as_entity, true) + + raw_objects.map { |raw_object| as_entity(raw_object) } + end + end +end diff --git a/lib/netbox_client_ruby/entities.rb b/lib/netbox_client_ruby/entities.rb index 35d4ab6..f43fbea 100644 --- a/lib/netbox_client_ruby/entities.rb +++ b/lib/netbox_client_ruby/entities.rb @@ -3,6 +3,7 @@ module NetboxClientRuby module Entities # rubocop:disable Metrics/ModuleLength include NetboxClientRuby::Communication + include NetboxClientRuby::BulkOperations include Enumerable def self.included(other_klass) @@ -167,6 +168,72 @@ def raw_data! alias size length alias count total + ## + # Generalized filterable + paginated read (Exigence B), available on every + # collection. Configures filter/limit/offset and returns self so the caller + # can read +#results+, +#next_url+, +#total+, or enumerate. Chainable. + # + # nb.core.object_changes.get_list( + # { changed_object_type_id: [12, 34], time_after: ts, ordering: '-time' }, + # limit: 100 + # ) + def get_list(filters = {}, limit: nil, offset: nil) + filter(filters) unless filters.nil? || filters.empty? + self.limit(limit) unless limit.nil? + self.offset(offset) unless offset.nil? + self + end + + ## + # Raw +results+ array of the current page (no entity wrapping). + def results + raw_data_array + end + + ## + # Absolute URL of the next / previous page as returned by NetBox, or nil. + def next_url + data['next'] + end + + def previous_url + data['previous'] + end + + ## + # Follows +next+ from the current (filtered/limited) query until exhausted + # and returns every entity across all pages. This is the incremental-polling + # aggregation used against /core/object-changes/ (Exigence B). + def all_pages + collected = [] + page = data + + loop do + (page[self.class.data_key] || []).each { |raw_entity| collected << as_entity(raw_entity) } + next_page_url = page['next'] + break if next_page_url.nil? || next_page_url.empty? + + page = response connection.get(next_page_url) + end + + collected + end + + ## + # Lazily yields each raw page Hash ({results:, next:, ...}), following next. + def each_page + return enum_for(:each_page) unless block_given? + + page = data + loop do + yield page + next_page_url = page['next'] + break if next_page_url.nil? || next_page_url.empty? + + page = response connection.get(next_page_url) + end + end + private def reset diff --git a/lib/netbox_client_ruby/error.rb b/lib/netbox_client_ruby/error.rb index 1bd6754..4437431 100644 --- a/lib/netbox_client_ruby/error.rb +++ b/lib/netbox_client_ruby/error.rb @@ -5,4 +5,50 @@ class Error < StandardError; end class ClientError < Error; end class LocalError < Error; end class RemoteError < Error; end + + ## + # Raised when a bulk operation (create/update/delete on an array body) + # returns HTTP 400. NetBox returns either: + # * an Array aligned, index by index, to the submitted payload, where each + # entry is an empty object (element OK) or a Hash of field => [messages] + # (element rejected), or + # * a single Hash for a request-level error (e.g. {"detail" => "..."}). + # + # +#errors+ exposes the raw structure. +#failures+ zips the rejected + # elements back to their submitted payload and index, so the caller can map + # an IDAS batch failure straight back to the offending port/cable. + class BulkError < ClientError + attr_reader :errors, :request, :status + + def initialize(message, body: nil, request: nil, status: 400) + @status = status + @request = request || [] + @errors = body + super("#{message} (#{failures.length} element(s) rejected)") + end + + ## + # When the body is per-element: an Array of { index:, errors:, item: } for + # every rejected element only. When the body is a request-level Hash: + # a single entry with index = nil. + def failures + if errors.is_a?(Array) + errors.each_with_index.filter_map do |element_errors, index| + next if element_errors.nil? || element_errors.empty? + + { index: index, errors: element_errors, item: request[index] } + end + elsif errors.nil? || (errors.respond_to?(:empty?) && errors.empty?) + [] + else + [{ index: nil, errors: errors, item: nil }] + end + end + + ## + # True when every submitted element was rejected (or a request-level error). + def total_failure? + errors.is_a?(Array) ? failures.length == request.length : !failures.empty? + end + end end diff --git a/spec/fixtures/core/object-changes.json b/spec/fixtures/core/object-changes.json new file mode 100644 index 0000000..d97d122 --- /dev/null +++ b/spec/fixtures/core/object-changes.json @@ -0,0 +1,11 @@ +{ + "count": 2, "next": null, "previous": null, + "results": [ + { "id": 9001, "action": { "value": "update", "label": "Updated" }, + "changed_object_type": "dcim.device", "changed_object_id": 5, + "time": "2026-06-02T10:00:00Z", "object_repr": "device-5" }, + { "id": 9002, "action": { "value": "create", "label": "Created" }, + "changed_object_type": "dcim.cable", "changed_object_id": 12, + "time": "2026-06-02T10:01:00Z", "object_repr": "cable-12" } + ] +} diff --git a/spec/fixtures/core/object-changes_page1.json b/spec/fixtures/core/object-changes_page1.json new file mode 100644 index 0000000..54fe609 --- /dev/null +++ b/spec/fixtures/core/object-changes_page1.json @@ -0,0 +1,6 @@ +{ + "count": 3, + "next": "http://netbox.test/api/core/object-changes/?limit=2&offset=2", + "previous": null, + "results": [ { "id": 1 }, { "id": 2 } ] +} diff --git a/spec/fixtures/core/object-changes_page2.json b/spec/fixtures/core/object-changes_page2.json new file mode 100644 index 0000000..88933c6 --- /dev/null +++ b/spec/fixtures/core/object-changes_page2.json @@ -0,0 +1,6 @@ +{ + "count": 3, + "next": null, + "previous": "http://netbox.test/api/core/object-changes/?limit=2&offset=0", + "results": [ { "id": 3 } ] +} diff --git a/spec/fixtures/dcim/cables_bulk_create.json b/spec/fixtures/dcim/cables_bulk_create.json new file mode 100644 index 0000000..76bc5af --- /dev/null +++ b/spec/fixtures/dcim/cables_bulk_create.json @@ -0,0 +1,8 @@ +[ + { "id": 101, "url": "http://netbox.test/api/dcim/cables/101/", "display": "#101", + "type": "cat6", "label": "A", "status": { "value": "connected", "label": "Connected" }, + "a_terminations": [], "b_terminations": [], "custom_fields": {} }, + { "id": 102, "url": "http://netbox.test/api/dcim/cables/102/", "display": "#102", + "type": "cat6", "label": "B", "status": { "value": "connected", "label": "Connected" }, + "a_terminations": [], "b_terminations": [], "custom_fields": {} } +] diff --git a/spec/fixtures/dcim/cables_bulk_errors.json b/spec/fixtures/dcim/cables_bulk_errors.json new file mode 100644 index 0000000..ca4b4f1 --- /dev/null +++ b/spec/fixtures/dcim/cables_bulk_errors.json @@ -0,0 +1,5 @@ +[ + {}, + { "type": ["This field may not be blank."] }, + {} +] diff --git a/spec/fixtures/dcim/cables_bulk_update.json b/spec/fixtures/dcim/cables_bulk_update.json new file mode 100644 index 0000000..99c60a6 --- /dev/null +++ b/spec/fixtures/dcim/cables_bulk_update.json @@ -0,0 +1,4 @@ +[ + { "id": 101, "label": "A2", "status": { "value": "planned", "label": "Planned" } }, + { "id": 102, "label": "B2", "status": { "value": "planned", "label": "Planned" } } +] diff --git a/spec/netbox_client_ruby/api/core/object_changes_spec.rb b/spec/netbox_client_ruby/api/core/object_changes_spec.rb new file mode 100644 index 0000000..851f767 --- /dev/null +++ b/spec/netbox_client_ruby/api/core/object_changes_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Exercises Exigence B (filterable/paginated get_list + multi-page aggregation) +# against /api/core/object-changes/ — the incremental polling source. +RSpec.describe NetboxClientRuby::Core::ObjectChanges, faraday_stub: true do + subject(:object_changes) { NetboxClientRuby.core.object_changes } + + let(:request_url) { '/api/core/object-changes/' } + let(:response) { File.read('spec/fixtures/core/object-changes.json') } + + describe 'single-page get_list' do + let(:expected_length) { 2 } + + it 'fetches the change records' do + expect(object_changes.length).to be expected_length + end + + it 'wraps them as ObjectChange entities' do + expect(object_changes.to_a).to all(be_a(NetboxClientRuby::Core::ObjectChange)) + end + + it 'exposes raw results and pagination cursors' do + expect(object_changes.results.length).to eq(2) + expect(object_changes.next_url).to be_nil + expect(object_changes.previous_url).to be_nil + end + end + + describe 'filterable get_list (multi changed_object_type_id, time_after, ordering)' do + let(:request_url_params) do + { changed_object_type_id: [12, 34], time_after: '2026-06-01T00:00:00Z', ordering: '-time', limit: 100 } + end + + it 'encodes repeated changed_object_type_id and the extra filters' do + result = object_changes.get_list( + { changed_object_type_id: [12, 34], time_after: '2026-06-01T00:00:00Z', ordering: '-time' }, + limit: 100 + ) + + expect(result.length).to eq(2) + end + end + + describe '#all_pages across multiple pages' do + let(:response) { File.read('spec/fixtures/core/object-changes_page1.json') } + + before do + page2 = File.read('spec/fixtures/core/object-changes_page2.json') + faraday_stubs.get('/api/core/object-changes/?limit=2&offset=2') do |_env| + [200, { content_type: 'application/json' }, page2] + end + end + + it 'follows next and aggregates every record across pages' do + all = object_changes.all_pages + + expect(all.map(&:id)).to eq([1, 2, 3]) + end + + it 'returns ObjectChange entities' do + expect(object_changes.all_pages).to all(be_a(NetboxClientRuby::Core::ObjectChange)) + end + end +end diff --git a/spec/netbox_client_ruby/bulk_operations_spec.rb b/spec/netbox_client_ruby/bulk_operations_spec.rb new file mode 100644 index 0000000..015b6ff --- /dev/null +++ b/spec/netbox_client_ruby/bulk_operations_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Exercises Exigence A (bulk create / update / delete with full-object return +# and typed per-element 400 errors) through a real collection: dcim.cables. +RSpec.describe NetboxClientRuby::BulkOperations, faraday_stub: true do + subject(:cables) { NetboxClientRuby.dcim.cables } + + let(:request_url) { '/api/dcim/cables/' } + + describe '#bulk_create' do + let(:request_method) { :post } + let(:request_params) { [{ 'label' => 'A' }, { 'label' => 'B' }] } + let(:response_status) { 201 } + let(:response) { File.read('spec/fixtures/dcim/cables_bulk_create.json') } + + it 'returns the full objects as entities' do + result = cables.bulk_create([{ 'label' => 'A' }, { 'label' => 'B' }]) + + expect(result).to all(be_a(NetboxClientRuby::DCIM::Cable)) + end + + it 'exposes the ids assigned by NetBox' do + result = cables.bulk_create([{ 'label' => 'A' }, { 'label' => 'B' }]) + + expect(result.map(&:id)).to eq([101, 102]) + end + + it 'preserves the full payload of each created object' do + result = cables.bulk_create([{ 'label' => 'A' }, { 'label' => 'B' }]) + + expect(result.first.raw_data!['label']).to eq('A') + end + end + + describe '#bulk_update' do + let(:request_method) { :patch } + let(:request_params) { [{ 'id' => 101, 'label' => 'A2' }, { 'id' => 102, 'label' => 'B2' }] } + let(:response) { File.read('spec/fixtures/dcim/cables_bulk_update.json') } + + it 'returns the updated objects' do + result = cables.bulk_update([{ 'id' => 101, 'label' => 'A2' }, { 'id' => 102, 'label' => 'B2' }]) + + expect(result.map(&:id)).to eq([101, 102]) + end + + it 'raises a LocalError when an element is missing its id' do + expect do + cables.bulk_update([{ 'label' => 'no-id' }]) + end.to raise_error(NetboxClientRuby::LocalError, /missing an 'id'/) + end + end + + describe '#bulk_delete' do + let(:request_method) { :delete } + let(:request_params) { [{ id: 101 }, { id: 102 }] } + let(:response_status) { 204 } + let(:response) { nil } + + it 'normalizes ids/hashes/entities into an [{id:}] body and returns the count' do + deleted = cables.bulk_delete([101, { 'id' => 102 }]) + + expect(deleted).to eq(2) + end + end + + describe 'typed per-element 400 errors' do + let(:request_method) { :post } + let(:request_params) { [{ 'a' => 1 }, { 'a' => 2 }, { 'a' => 3 }] } + let(:response_status) { 400 } + let(:response) { File.read('spec/fixtures/dcim/cables_bulk_errors.json') } + + it 'raises a BulkError that surfaces only the rejected elements' do + expect do + cables.bulk_create([{ 'a' => 1 }, { 'a' => 2 }, { 'a' => 3 }]) + end.to raise_error(NetboxClientRuby::BulkError) do |error| + expect(error.failures.length).to eq(1) + expect(error.failures.first[:index]).to eq(1) + expect(error.failures.first[:item]).to eq({ 'a' => 2 }) + expect(error.failures.first[:errors]['type']).to include(/may not be blank/) + expect(error.total_failure?).to be(false) + end + end + end +end