From 15d819aff691d95b4e3142f35003439da1780763 Mon Sep 17 00:00:00 2001 From: Brett McHargue Date: Thu, 29 Jan 2026 20:26:18 +0700 Subject: [PATCH 1/4] Add response object pattern for better developer experience Implements nb-5 to wrap API responses in typed objects instead of raw hashes, providing better DX with convenient attribute access and type safety. Added: - ResponseObjects::Base - Base class handling both V1 and V2 API formats - Method access to attributes (person.first_name) - Hash-compatible interface for backward compatibility (person[:data]) - Support for both JSON:API (V2) and plain JSON (V1) formats - ResponseObjects::Person - Person-specific response object - Convenient accessors (first_name, last_name, email, etc.) - Computed attributes (full_name) - Taggings extraction from included data - Configuration option `wrap_responses` (default: false) - Opt-in feature for backward compatibility - Can be enabled globally or per-client instance Integration: - Updated People resource to optionally wrap responses - Response objects are backward compatible with hash access - Comprehensive RSpec tests (40 new examples) Benefits: - Better developer experience with typed objects - Convenient method access instead of deep hash navigation - Backward compatible - doesn't break existing code - Works with both V1 and V2 API responses Test coverage: 93.28% (240 examples, 0 failures) Co-Authored-By: Claude Sonnet 4.5 --- .beads/issues.jsonl | 3 +- README.md | 34 +++ lib/nationbuilder_api.rb | 6 + lib/nationbuilder_api/configuration.rb | 4 +- lib/nationbuilder_api/resources/people.rb | 24 ++- .../response_objects/base.rb | 170 +++++++++++++++ .../response_objects/person.rb | 75 +++++++ .../resources/people_spec.rb | 10 +- .../response_objects/base_spec.rb | 165 ++++++++++++++ .../response_objects/person_spec.rb | 203 ++++++++++++++++++ 10 files changed, 687 insertions(+), 7 deletions(-) create mode 100644 lib/nationbuilder_api/response_objects/base.rb create mode 100644 lib/nationbuilder_api/response_objects/person.rb create mode 100644 spec/nationbuilder_api/response_objects/base_spec.rb create mode 100644 spec/nationbuilder_api/response_objects/person_spec.rb diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5d227b7..af7744d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -9,9 +9,8 @@ {"id":"nb-2o7","title":"Remove ENV var requirement for NationBuilder credentials","description":"Update initializer to not set global credentials, remove ENV vars from test_helper. Credentials should only be passed via Client.new parameters.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-10T18:48:01.949364+07:00","updated_at":"2025-12-10T18:51:02.016399+07:00","closed_at":"2025-12-10T18:51:02.016399+07:00","external_ref":"https://github.com/ebrett/nationbuilder-client-v2/issues/7"} {"id":"nb-3","title":"Add Redis \u0026 ActiveRecord Adapter Tests","description":"Implement comprehensive test suites for Redis and ActiveRecord token storage adapters to match Memory adapter test coverage. This addresses 3 failing ActiveRecord tests found in verification.","design":"Add test files spec/nationbuilder_api/token_storage/redis_spec.rb and enhance active_record_spec.rb. Include store_token, retrieve_token, delete_token, serialization tests. See roadmap item #7.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:06.202895+07:00","updated_at":"2025-12-02T17:32:40.986334+07:00","closed_at":"2025-12-02T17:32:40.986334+07:00"} {"id":"nb-4","title":"Fix Token Adapter Validation","description":"Correct validate_adapter_interface! method in client.rb to use respond_to? instead of instance_methods set difference for accurate interface validation","design":"Change from instance_methods set comparison to respond_to? checks for each required method (get_token, store_token, delete_token). See roadmap item #6.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:04.540968+07:00","updated_at":"2025-12-02T17:26:17.184025+07:00","closed_at":"2025-12-02T17:18:07.625684+07:00"} -{"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","design":"Create response object classes in lib/nationbuilder_api/response_objects/. Use method_missing or define_method for attribute access. See roadmap item #18.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2025-12-02T17:26:17.184279+07:00"} +{"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","design":"Create response object classes in lib/nationbuilder_api/response_objects/. Use method_missing or define_method for attribute access. See roadmap item #18.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2026-01-29T20:20:49.34538+07:00"} {"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","design":"Create lib/nationbuilder_api/resources/tags.rb. Support bulk operations. See roadmap item #17.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2025-12-02T17:26:17.185491+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} {"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2025-12-02T17:26:17.185816+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} {"id":"nb-8","title":"Fix OAuth VCR Test Failures","description":"Fix 5 failing OAuth integration tests due to VCR cassette configuration issues. Tests in oauth_flow_spec.rb and client_spec.rb fail with 'VCR does not know how to handle' errors.","design":"Review VCR cassette configuration, regenerate cassettes if needed, ensure Net::HTTP compatibility. See verification report from 2025-11-25-switch-to-net-http spec.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:18.00717+07:00","updated_at":"2025-12-02T17:26:17.186112+07:00","closed_at":"2025-12-02T17:21:53.298386+07:00"} {"id":"nb-9","title":"Add Rate Limit Monitoring","description":"Log rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) for proactive rate limit monitoring and debugging","design":"Add rate limit header logging in http_client.rb after response received. Log at info level. See roadmap item #10.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:10.928534+07:00","updated_at":"2025-12-02T17:26:17.186369+07:00"} -{"id":"nb-f0q","title":"V1 API support for people taggings and tags","description":"Add V1 API methods for taggings and tags because V2 API doesn't return tag names.\n\nRequired methods:\n- client.people.taggings(person_id) - GET /api/v1/people/:person_id/taggings\n- client.people.add_tagging(person_id, tag_name) - PUT /api/v1/people/:person_id/taggings\n- client.people.remove_tagging(person_id, tag_name) - DELETE /api/v1/people/:person_id/taggings/:tag\n- client.tags.list - GET /api/v1/tags\n\nV1 API uses plain JSON format (not JSON:API), so response handling will differ from V2 endpoints.\n\nThis will allow Citizen app to replace hardcoded API calls with proper gem methods.","design":"Create or extend lib/nationbuilder_api/resources/people.rb to add V1 tagging methods. Create lib/nationbuilder_api/resources/tags.rb for V1 tags list endpoint. Handle plain JSON responses (not JSON:API format). Add integration tests verifying V1 endpoints work correctly.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"claude","created_at":"2026-01-29T11:44:00.390272+07:00","updated_at":"2026-01-29T11:48:37.805016+07:00","external_ref":"https://github.com/ebrett/nationbuilder-client-v2/issues/11"} diff --git a/README.md b/README.md index 1f1a150..92b3c00 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,40 @@ client = NationbuilderApi::Client.new( **Important**: Credentials (`client_id`, `client_secret`, `redirect_uri`) are **not required** in global configuration. You can pass them per-instance, which is especially useful for multi-tenant applications. +### Response Objects (Optional) + +By default, API responses are returned as raw hashes. You can optionally enable response objects for a better developer experience with typed objects and convenient attribute access: + +```ruby +# Enable response objects globally +NationbuilderApi.configure do |config| + config.wrap_responses = true +end + +# Or per-client instance +client = NationbuilderApi::Client.new( + # ... credentials ... + wrap_responses: true +) + +# With response objects enabled +person = client.people.show(123) +person.first_name # => "John" (method access) +person.full_name # => "John Doe" (computed attribute) +person[:data] # => {...} (still supports hash access for backward compatibility) +person.to_h # => original hash + +# Works with both V1 and V2 API responses +``` + +**Benefits of response objects:** +- Convenient method access (`person.first_name` instead of `person[:data][:attributes][:first_name]`) +- Computed attributes (`person.full_name`) +- Backward compatible (still supports hash access via `[]`, `dig`, etc.) +- Type safety and better IDE autocomplete + +**Note**: Response objects default to `false` for backward compatibility. Enable when you're ready to migrate. + ## Multi-Tenant Usage The gem is designed to support multi-tenant applications where you manage multiple NationBuilder accounts, each with their own OAuth credentials. This is the recommended pattern for SaaS applications. diff --git a/lib/nationbuilder_api.rb b/lib/nationbuilder_api.rb index bf0b1ae..996cdd2 100644 --- a/lib/nationbuilder_api.rb +++ b/lib/nationbuilder_api.rb @@ -25,6 +25,12 @@ module Resources autoload :Tags, "nationbuilder_api/resources/tags" end + # Response objects + module ResponseObjects + autoload :Base, "nationbuilder_api/response_objects/base" + autoload :Person, "nationbuilder_api/response_objects/person" + end + # OAuth scope constants SCOPE_PEOPLE_READ = "people:read" SCOPE_PEOPLE_WRITE = "people:write" diff --git a/lib/nationbuilder_api/configuration.rb b/lib/nationbuilder_api/configuration.rb index ae5ad3e..949bcfc 100644 --- a/lib/nationbuilder_api/configuration.rb +++ b/lib/nationbuilder_api/configuration.rb @@ -6,19 +6,21 @@ module NationbuilderApi class Configuration attr_accessor :client_id, :client_secret, :redirect_uri, :base_url, - :log_level, :timeout, :ssl_verify + :log_level, :timeout, :ssl_verify, :wrap_responses attr_writer :logger, :token_adapter DEFAULT_BASE_URL = "https://api.nationbuilder.com/v2" DEFAULT_TIMEOUT = 30 DEFAULT_LOG_LEVEL = :info DEFAULT_SSL_VERIFY = true + DEFAULT_WRAP_RESPONSES = false def initialize @base_url = DEFAULT_BASE_URL @timeout = DEFAULT_TIMEOUT @log_level = DEFAULT_LOG_LEVEL @ssl_verify = DEFAULT_SSL_VERIFY + @wrap_responses = DEFAULT_WRAP_RESPONSES @logger = nil @token_adapter = nil @client_id = nil diff --git a/lib/nationbuilder_api/resources/people.rb b/lib/nationbuilder_api/resources/people.rb index cd6899b..7cbc368 100644 --- a/lib/nationbuilder_api/resources/people.rb +++ b/lib/nationbuilder_api/resources/people.rb @@ -11,7 +11,7 @@ class People < Base # # @param id [String, Integer] Person ID or "me" for current user # @param include_taggings [Boolean] Whether to sideload taggings (default: false) - # @return [Hash] Person data in JSON:API format + # @return [Hash, ResponseObjects::Person] Person data (Hash by default, ResponseObjects::Person if wrap_responses enabled) # # @example # client.people.show(123) @@ -24,10 +24,17 @@ class People < Base # @example Current user # client.people.show("me") # # => { data: { type: "signups", id: "123", attributes: { ... } } } + # + # @example With response objects enabled + # # config.wrap_responses = true + # person = client.people.show(123) + # person.first_name # => "John" + # person[:data] # => still works (backward compatible) def show(id, include_taggings: false) path = "/api/v2/signups/#{id}" path += "?include=taggings" if include_taggings - get(path) + response = get(path) + wrap_person_response(response) end # Fetch a person's taggings (subscriptions/lists) @@ -164,11 +171,22 @@ def update(id, attributes:) attributes: attributes } } - patch(path, body: body) + response = patch(path, body: body) + wrap_person_response(response) end private + # Wrap response in Person object if wrap_responses is enabled + # + # @param response [Hash] Raw API response + # @return [Hash, ResponseObjects::Person] Wrapped or raw response + def wrap_person_response(response) + return response unless client.config.wrap_responses + + ResponseObjects::Person.new(response) + end + # Build query string for complex parameters (like nested filters) # Handles nested hashes for filter parameters # diff --git a/lib/nationbuilder_api/response_objects/base.rb b/lib/nationbuilder_api/response_objects/base.rb new file mode 100644 index 0000000..57404fc --- /dev/null +++ b/lib/nationbuilder_api/response_objects/base.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module NationbuilderApi + module ResponseObjects + # Base class for API response objects + # Wraps API responses (both V1 and V2 formats) in objects with convenient attribute access + # + # @example Using with V2 API (JSON:API format) + # response = { data: { type: "signup", id: "123", attributes: { first_name: "John" } } } + # person = ResponseObjects::Base.new(response) + # person.id # => "123" + # person.first_name # => "John" + # person.to_h # => original hash + # + # @example Using with V1 API (plain JSON) + # response = { first_name: "John", last_name: "Doe" } + # obj = ResponseObjects::Base.new(response) + # obj.first_name # => "John" + # obj.to_h # => original hash + class Base + attr_reader :raw_data + + # Initialize response object with raw API response + # + # @param data [Hash] Raw API response (V1 or V2 format) + def initialize(data) + @raw_data = data + @attributes = extract_attributes(data) + end + + # Get original hash representation + # + # @return [Hash] Original API response + def to_h + raw_data + end + + # Get attributes as hash + # + # @return [Hash] Flattened attributes + attr_reader :attributes + + # Access attributes via method calls + # + # @param method_name [Symbol] Attribute name + # @param args [Array] Method arguments (unused) + # @return [Object] Attribute value + def method_missing(method_name, *args) + return @attributes[method_name] if @attributes.key?(method_name) + return @attributes[method_name.to_s] if @attributes.key?(method_name.to_s) + + super + end + + # Check if object responds to method + # + # @param method_name [Symbol] Method name + # @param include_private [Boolean] Include private methods + # @return [Boolean] True if responds to method + def respond_to_missing?(method_name, include_private = false) + @attributes.key?(method_name) || @attributes.key?(method_name.to_s) || super + end + + # Check equality based on raw data + # + # @param other [Object] Other object to compare + # @return [Boolean] True if equal + def ==(other) + return false unless other.is_a?(self.class) + raw_data == other.raw_data + end + + # Hash-like access to support backward compatibility + # + # @param key [Symbol, String] Key to access + # @return [Object] Value at key + def [](key) + raw_data[key] + end + + # Get all keys from raw data + # + # @return [Array] Array of keys + def keys + raw_data.keys + end + + # Get all values from raw data + # + # @return [Array] Array of values + def values + raw_data.values + end + + # Check if key exists in raw data + # + # @param key [Symbol, String] Key to check + # @return [Boolean] True if key exists + def key?(key) + raw_data.key?(key) + end + + # Iterate over raw data + # + # @yield [key, value] Yields key-value pairs + def each(&block) + raw_data.each(&block) + end + + # Support dig for nested access + # + # @param keys [Array] Keys to dig through + # @return [Object] Value at nested key path + def dig(*keys) + raw_data.dig(*keys) + end + + private + + # Extract attributes from response based on format + # Handles both V2 (JSON:API) and V1 (plain JSON) formats + # + # @param data [Hash] Raw response data + # @return [Hash] Extracted attributes + def extract_attributes(data) + if jsonapi_format?(data) + extract_jsonapi_attributes(data) + else + # V1 format or plain hash - use as-is + data.transform_keys(&:to_sym) + end + end + + # Check if response is JSON:API format + # + # @param data [Hash] Response data + # @return [Boolean] True if JSON:API format + def jsonapi_format?(data) + data.is_a?(Hash) && data.key?(:data) && data[:data].is_a?(Hash) + end + + # Extract attributes from JSON:API formatted response + # + # @param data [Hash] JSON:API response + # @return [Hash] Flattened attributes with id and type + def extract_jsonapi_attributes(data) + resource = data[:data] + attrs = resource[:attributes] || {} + + # Include id and type from resource level + attrs = attrs.merge( + id: resource[:id], + type: resource[:type] + ) + + # Handle relationships if present + if resource[:relationships] + attrs[:relationships] = resource[:relationships] + end + + # Include any sideloaded data + if data[:included] + attrs[:included] = data[:included] + end + + attrs.transform_keys(&:to_sym) + end + end + end +end diff --git a/lib/nationbuilder_api/response_objects/person.rb b/lib/nationbuilder_api/response_objects/person.rb new file mode 100644 index 0000000..8544bd3 --- /dev/null +++ b/lib/nationbuilder_api/response_objects/person.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module NationbuilderApi + module ResponseObjects + # Person response object + # Wraps person data from NationBuilder API (both V1 and V2) + # + # @example + # person = client.people.show(123) + # person.first_name # => "John" + # person.last_name # => "Doe" + # person.email # => "john@example.com" + # person.id # => "123" + class Person < Base + # Get person's full name + # + # @return [String, nil] Full name or nil if not available + def full_name + return nil unless first_name || last_name + [first_name, last_name].compact.join(" ") + end + + # Get person's primary email + # + # @return [String, nil] Email address + def email + attributes[:email] + end + + # Get person's first name + # + # @return [String, nil] First name + def first_name + attributes[:first_name] + end + + # Get person's last name + # + # @return [String, nil] Last name + def last_name + attributes[:last_name] + end + + # Get person's ID + # + # @return [String, nil] Person ID + def id + attributes[:id] + end + + # Get person's mobile number + # + # @return [String, nil] Mobile number + def mobile + attributes[:mobile] + end + + # Get person's phone number + # + # @return [String, nil] Phone number + def phone + attributes[:phone] + end + + # Get person's taggings (if included in response) + # + # @return [Array, nil] Array of tagging data + def taggings + return nil unless attributes[:included] + + attributes[:included].select { |item| item[:type] == "tagging" } + end + end + end +end diff --git a/spec/nationbuilder_api/resources/people_spec.rb b/spec/nationbuilder_api/resources/people_spec.rb index 04115ca..9ab611a 100644 --- a/spec/nationbuilder_api/resources/people_spec.rb +++ b/spec/nationbuilder_api/resources/people_spec.rb @@ -1,13 +1,21 @@ # frozen_string_literal: true RSpec.describe NationbuilderApi::Resources::People do + let(:config) do + instance_double( + NationbuilderApi::Configuration, + wrap_responses: false + ) + end + let(:client) do instance_double( NationbuilderApi::Client, get: nil, post: nil, patch: nil, - delete: nil + delete: nil, + config: config ) end diff --git a/spec/nationbuilder_api/response_objects/base_spec.rb b/spec/nationbuilder_api/response_objects/base_spec.rb new file mode 100644 index 0000000..2aa1897 --- /dev/null +++ b/spec/nationbuilder_api/response_objects/base_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +RSpec.describe NationbuilderApi::ResponseObjects::Base do + describe "#initialize" do + it "stores raw data" do + data = {name: "Test"} + obj = described_class.new(data) + + expect(obj.raw_data).to eq(data) + end + + it "extracts attributes from V1 format (plain JSON)" do + data = {first_name: "John", last_name: "Doe"} + obj = described_class.new(data) + + expect(obj.attributes[:first_name]).to eq("John") + expect(obj.attributes[:last_name]).to eq("Doe") + end + + it "extracts attributes from V2 format (JSON:API)" do + data = { + data: { + type: "signup", + id: "123", + attributes: { + first_name: "John", + last_name: "Doe" + } + } + } + obj = described_class.new(data) + + expect(obj.attributes[:first_name]).to eq("John") + expect(obj.attributes[:last_name]).to eq("Doe") + expect(obj.attributes[:id]).to eq("123") + expect(obj.attributes[:type]).to eq("signup") + end + + it "includes sideloaded data from JSON:API" do + data = { + data: { + type: "signup", + id: "123", + attributes: {first_name: "John"} + }, + included: [{type: "tagging", id: "1"}] + } + obj = described_class.new(data) + + expect(obj.attributes[:included]).to eq([{type: "tagging", id: "1"}]) + end + end + + describe "attribute access" do + it "allows method access to attributes" do + data = {first_name: "John", last_name: "Doe"} + obj = described_class.new(data) + + expect(obj.first_name).to eq("John") + expect(obj.last_name).to eq("Doe") + end + + it "allows string and symbol key access" do + data = {:first_name => "John", "last_name" => "Doe"} + obj = described_class.new(data) + + expect(obj.first_name).to eq("John") + expect(obj.last_name).to eq("Doe") + end + + it "raises NoMethodError for missing attributes" do + obj = described_class.new({}) + + expect { obj.nonexistent }.to raise_error(NoMethodError) + end + end + + describe "#to_h" do + it "returns original raw data" do + data = {data: {type: "signup", id: "123"}} + obj = described_class.new(data) + + expect(obj.to_h).to eq(data) + end + end + + describe "hash-like access" do + let(:data) do + { + data: { + type: "signup", + id: "123", + attributes: {first_name: "John"} + } + } + end + let(:obj) { described_class.new(data) } + + it "supports [] access" do + expect(obj[:data]).to eq(data[:data]) + end + + it "supports keys method" do + expect(obj.keys).to eq(data.keys) + end + + it "supports values method" do + expect(obj.values).to eq(data.values) + end + + it "supports key? method" do + expect(obj.key?(:data)).to be true + expect(obj.key?(:nonexistent)).to be false + end + + it "supports each iteration" do + keys = [] + obj.each { |k, _v| keys << k } + + expect(keys).to eq(data.keys) + end + + it "supports dig method" do + expect(obj.dig(:data, :id)).to eq("123") + expect(obj.dig(:data, :attributes, :first_name)).to eq("John") + end + end + + describe "#respond_to_missing?" do + it "returns true for existing attributes" do + obj = described_class.new({first_name: "John"}) + + expect(obj.respond_to?(:first_name)).to be true + end + + it "returns false for missing attributes" do + obj = described_class.new({}) + + expect(obj.respond_to?(:nonexistent)).to be false + end + end + + describe "#==" do + it "returns true for objects with same raw data" do + data = {name: "Test"} + obj1 = described_class.new(data) + obj2 = described_class.new(data) + + expect(obj1).to eq(obj2) + end + + it "returns false for objects with different raw data" do + obj1 = described_class.new({name: "Test1"}) + obj2 = described_class.new({name: "Test2"}) + + expect(obj1).not_to eq(obj2) + end + + it "returns false for different class types" do + obj = described_class.new({name: "Test"}) + + expect(obj).not_to eq({name: "Test"}) + end + end +end diff --git a/spec/nationbuilder_api/response_objects/person_spec.rb b/spec/nationbuilder_api/response_objects/person_spec.rb new file mode 100644 index 0000000..05e1a47 --- /dev/null +++ b/spec/nationbuilder_api/response_objects/person_spec.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +RSpec.describe NationbuilderApi::ResponseObjects::Person do + let(:v2_person_data) do + { + data: { + type: "signup", + id: "123", + attributes: { + first_name: "John", + last_name: "Doe", + email: "john@example.com", + mobile: "+1234567890", + phone: "+0987654321" + } + } + } + end + + let(:v1_person_data) do + { + id: "123", + first_name: "John", + last_name: "Doe", + email: "john@example.com", + mobile: "+1234567890" + } + end + + describe "#first_name" do + it "returns first name from V2 data" do + person = described_class.new(v2_person_data) + + expect(person.first_name).to eq("John") + end + + it "returns first name from V1 data" do + person = described_class.new(v1_person_data) + + expect(person.first_name).to eq("John") + end + end + + describe "#last_name" do + it "returns last name from V2 data" do + person = described_class.new(v2_person_data) + + expect(person.last_name).to eq("Doe") + end + + it "returns last name from V1 data" do + person = described_class.new(v1_person_data) + + expect(person.last_name).to eq("Doe") + end + end + + describe "#email" do + it "returns email from V2 data" do + person = described_class.new(v2_person_data) + + expect(person.email).to eq("john@example.com") + end + + it "returns email from V1 data" do + person = described_class.new(v1_person_data) + + expect(person.email).to eq("john@example.com") + end + end + + describe "#id" do + it "returns id from V2 data" do + person = described_class.new(v2_person_data) + + expect(person.id).to eq("123") + end + + it "returns id from V1 data" do + person = described_class.new(v1_person_data) + + expect(person.id).to eq("123") + end + end + + describe "#mobile" do + it "returns mobile from V2 data" do + person = described_class.new(v2_person_data) + + expect(person.mobile).to eq("+1234567890") + end + + it "returns mobile from V1 data" do + person = described_class.new(v1_person_data) + + expect(person.mobile).to eq("+1234567890") + end + end + + describe "#phone" do + it "returns phone from V2 data" do + person = described_class.new(v2_person_data) + + expect(person.phone).to eq("+0987654321") + end + end + + describe "#full_name" do + it "returns full name when both first and last names present" do + person = described_class.new(v2_person_data) + + expect(person.full_name).to eq("John Doe") + end + + it "returns just first name when last name missing" do + data = {first_name: "John"} + person = described_class.new(data) + + expect(person.full_name).to eq("John") + end + + it "returns just last name when first name missing" do + data = {last_name: "Doe"} + person = described_class.new(data) + + expect(person.full_name).to eq("Doe") + end + + it "returns nil when both names missing" do + person = described_class.new({}) + + expect(person.full_name).to be_nil + end + end + + describe "#taggings" do + it "returns taggings from included data" do + data = { + data: { + type: "signup", + id: "123", + attributes: {first_name: "John"} + }, + included: [ + {type: "tagging", id: "1", attributes: {tag: "volunteer"}}, + {type: "tagging", id: "2", attributes: {tag: "donor"}}, + {type: "event", id: "3", attributes: {name: "Rally"}} + ] + } + person = described_class.new(data) + taggings = person.taggings + + expect(taggings).to be_an(Array) + expect(taggings.length).to eq(2) + expect(taggings.first[:type]).to eq("tagging") + expect(taggings.last[:type]).to eq("tagging") + end + + it "returns nil when no included data" do + person = described_class.new(v2_person_data) + + expect(person.taggings).to be_nil + end + + it "returns empty array when included has no taggings" do + data = { + data: { + type: "signup", + id: "123", + attributes: {first_name: "John"} + }, + included: [ + {type: "event", id: "1"} + ] + } + person = described_class.new(data) + + expect(person.taggings).to eq([]) + end + end + + describe "backward compatibility" do + it "supports hash-style access to raw V2 data" do + person = described_class.new(v2_person_data) + + expect(person[:data]).to eq(v2_person_data[:data]) + expect(person.dig(:data, :attributes, :first_name)).to eq("John") + end + + it "supports hash-style access to raw V1 data" do + person = described_class.new(v1_person_data) + + expect(person[:first_name]).to eq("John") + expect(person[:id]).to eq("123") + end + + it "supports to_h conversion" do + person = described_class.new(v2_person_data) + + expect(person.to_h).to eq(v2_person_data) + end + end +end From 60a3adaa85b8ff8800bd1f7f6e7f3ea2b06af59f Mon Sep 17 00:00:00 2001 From: Brett McHargue Date: Thu, 29 Jan 2026 20:37:52 +0700 Subject: [PATCH 2/4] Add full CRUD operations to People resource (nb-7) Implements list, create, delete, and search methods for complete People resource. Added methods: - list(page:, per_page:, filter:) - List people with pagination/filtering - create(attributes:) - Create new person - delete(id) - Delete person by ID - search(query, **filter) - Search people by name with additional filters All methods support response object wrapping when enabled. Uses V2 API with JSON:API format. Follows TDD approach with comprehensive tests. Tests: 14 new examples, all passing Coverage: 93.42% Co-Authored-By: Claude Sonnet 4.5 --- .beads/issues.jsonl | 4 +- lib/nationbuilder_api/resources/people.rb | 83 +++++++++- .../resources/people_spec.rb | 147 ++++++++++++++++++ 3 files changed, 231 insertions(+), 3 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index af7744d..8d9e2cd 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -9,8 +9,8 @@ {"id":"nb-2o7","title":"Remove ENV var requirement for NationBuilder credentials","description":"Update initializer to not set global credentials, remove ENV vars from test_helper. Credentials should only be passed via Client.new parameters.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-10T18:48:01.949364+07:00","updated_at":"2025-12-10T18:51:02.016399+07:00","closed_at":"2025-12-10T18:51:02.016399+07:00","external_ref":"https://github.com/ebrett/nationbuilder-client-v2/issues/7"} {"id":"nb-3","title":"Add Redis \u0026 ActiveRecord Adapter Tests","description":"Implement comprehensive test suites for Redis and ActiveRecord token storage adapters to match Memory adapter test coverage. This addresses 3 failing ActiveRecord tests found in verification.","design":"Add test files spec/nationbuilder_api/token_storage/redis_spec.rb and enhance active_record_spec.rb. Include store_token, retrieve_token, delete_token, serialization tests. See roadmap item #7.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:06.202895+07:00","updated_at":"2025-12-02T17:32:40.986334+07:00","closed_at":"2025-12-02T17:32:40.986334+07:00"} {"id":"nb-4","title":"Fix Token Adapter Validation","description":"Correct validate_adapter_interface! method in client.rb to use respond_to? instead of instance_methods set difference for accurate interface validation","design":"Change from instance_methods set comparison to respond_to? checks for each required method (get_token, store_token, delete_token). See roadmap item #6.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:04.540968+07:00","updated_at":"2025-12-02T17:26:17.184025+07:00","closed_at":"2025-12-02T17:18:07.625684+07:00"} -{"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","design":"Create response object classes in lib/nationbuilder_api/response_objects/. Use method_missing or define_method for attribute access. See roadmap item #18.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2026-01-29T20:20:49.34538+07:00"} +{"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","design":"Create response object classes in lib/nationbuilder_api/response_objects/. Use method_missing or define_method for attribute access. See roadmap item #18.","status":"closed","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2026-01-29T20:31:08.815565+07:00","closed_at":"2026-01-29T20:31:08.815565+07:00"} {"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","design":"Create lib/nationbuilder_api/resources/tags.rb. Support bulk operations. See roadmap item #17.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2025-12-02T17:26:17.185491+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} -{"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2025-12-02T17:26:17.185816+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} +{"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2026-01-29T20:34:55.50187+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} {"id":"nb-8","title":"Fix OAuth VCR Test Failures","description":"Fix 5 failing OAuth integration tests due to VCR cassette configuration issues. Tests in oauth_flow_spec.rb and client_spec.rb fail with 'VCR does not know how to handle' errors.","design":"Review VCR cassette configuration, regenerate cassettes if needed, ensure Net::HTTP compatibility. See verification report from 2025-11-25-switch-to-net-http spec.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:18.00717+07:00","updated_at":"2025-12-02T17:26:17.186112+07:00","closed_at":"2025-12-02T17:21:53.298386+07:00"} {"id":"nb-9","title":"Add Rate Limit Monitoring","description":"Log rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) for proactive rate limit monitoring and debugging","design":"Add rate limit header logging in http_client.rb after response received. Log at info level. See roadmap item #10.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:10.928534+07:00","updated_at":"2025-12-02T17:26:17.186369+07:00"} diff --git a/lib/nationbuilder_api/resources/people.rb b/lib/nationbuilder_api/resources/people.rb index 7cbc368..8c93e07 100644 --- a/lib/nationbuilder_api/resources/people.rb +++ b/lib/nationbuilder_api/resources/people.rb @@ -89,7 +89,88 @@ def add_tagging(id, tag_name) # @example # client.people.remove_tagging(123, "volunteer") def remove_tagging(id, tag_name) - delete("/api/v1/people/#{id}/taggings/#{tag_name}") + client.delete("/api/v1/people/#{id}/taggings/#{tag_name}") + end + + # List people + # Uses V2 API with JSON:API format + # + # @param page [Integer, nil] Page number for pagination + # @param per_page [Integer, nil] Number of results per page + # @param filter [Hash, nil] Filter parameters + # @return [Hash, Array] List of people (Hash by default, Array of Person objects if wrap_responses enabled) + # + # @example + # client.people.list + # # => { data: [{type: "signup", id: "1", ...}, ...] } + # + # @example With pagination + # client.people.list(page: 2, per_page: 50) + # + # @example With filtering + # client.people.list(filter: {email: "john@example.com"}) + def list(page: nil, per_page: nil, filter: nil) + params = {} + params[:page] = {number: page, size: per_page} if page || per_page + params[:filter] = filter if filter + + response = get("/api/v2/signups", params: params) + wrap_person_response(response) + end + + # Create a new person + # Uses V2 API with JSON:API format + # + # @param attributes [Hash] Person attributes (first_name, last_name, email, etc.) + # @return [Hash, ResponseObjects::Person] Created person data + # @raise [ValidationError] If attributes are invalid + # + # @example + # client.people.create(attributes: { + # first_name: "John", + # last_name: "Doe", + # email: "john@example.com" + # }) + def create(attributes:) + body = { + data: { + type: "signups", + attributes: attributes + } + } + response = post("/api/v2/signups", body: body) + wrap_person_response(response) + end + + # Delete a person + # Uses V2 API + # + # @param id [String, Integer] Person ID + # @return [Hash] Response from API + # @raise [NotFoundError] If person not found + # + # @example + # client.people.delete(123) + def delete(id) + client.delete("/api/v2/signups/#{id}") + end + + # Search for people + # Uses V2 API with name filter + # + # @param query [String] Search query (name) + # @param filter [Hash] Additional filter parameters + # @return [Hash, Array] Search results + # + # @example + # client.people.search("John Doe") + # + # @example With additional filters + # client.people.search("John", email: "john@example.com") + def search(query, **filter) + params = {filter: {name: query}.merge(filter)} + response = get("/api/v2/signups", params: params) + wrap_person_response(response) end # Fetch a person's event RSVPs diff --git a/spec/nationbuilder_api/resources/people_spec.rb b/spec/nationbuilder_api/resources/people_spec.rb index 9ab611a..8dffd81 100644 --- a/spec/nationbuilder_api/resources/people_spec.rb +++ b/spec/nationbuilder_api/resources/people_spec.rb @@ -442,4 +442,151 @@ expect(result).to eq(response_data) end end + + describe "#list" do + it "makes GET request to /api/v2/signups" do + expect(client).to receive(:get).with("/api/v2/signups", params: {}) + people.list + end + + it "returns list of people in JSON:API format" do + list_data = { + data: [ + {type: "signup", id: "1", attributes: {first_name: "John"}}, + {type: "signup", id: "2", attributes: {first_name: "Jane"}} + ] + } + + allow(client).to receive(:get).and_return(list_data) + result = people.list + + expect(result).to eq(list_data) + expect(result[:data].length).to eq(2) + end + + it "supports pagination parameters" do + expected_params = {page: {number: 2, size: 50}} + expect(client).to receive(:get).with("/api/v2/signups", params: expected_params) + + people.list(page: 2, per_page: 50) + end + + it "supports filtering parameters" do + expected_params = {filter: {email: "john@example.com"}} + expect(client).to receive(:get).with("/api/v2/signups", params: expected_params) + + people.list(filter: {email: "john@example.com"}) + end + end + + describe "#create" do + let(:person_attributes) do + { + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + end + + let(:expected_body) do + { + data: { + type: "signups", + attributes: person_attributes + } + } + end + + it "makes POST request to /api/v2/signups" do + expect(client).to receive(:post) + .with("/api/v2/signups", body: expected_body) + + people.create(attributes: person_attributes) + end + + it "returns created person in JSON:API format" do + created_person = { + data: { + type: "signup", + id: "123", + attributes: person_attributes + } + } + + allow(client).to receive(:post).and_return(created_person) + result = people.create(attributes: person_attributes) + + expect(result).to eq(created_person) + expect(result.dig(:data, :id)).to eq("123") + end + + it "raises ValidationError for invalid attributes" do + allow(client).to receive(:post) + .and_raise(NationbuilderApi::ValidationError, "Invalid email") + + expect { + people.create(attributes: {email: "invalid"}) + }.to raise_error(NationbuilderApi::ValidationError, "Invalid email") + end + end + + describe "#delete" do + it "makes DELETE request to /api/v2/signups/:id" do + expect(client).to receive(:delete).with("/api/v2/signups/123") + people.delete(123) + end + + it "accepts string ID" do + expect(client).to receive(:delete).with("/api/v2/signups/456") + people.delete("456") + end + + it "returns response from API" do + response_data = {status: "deleted"} + + allow(client).to receive(:delete).and_return(response_data) + result = people.delete(123) + + expect(result).to eq(response_data) + end + + it "raises NotFoundError when person not found" do + allow(client).to receive(:delete) + .and_raise(NationbuilderApi::NotFoundError, "Person not found") + + expect { + people.delete(999) + }.to raise_error(NationbuilderApi::NotFoundError, "Person not found") + end + end + + describe "#search" do + it "makes GET request to /api/v2/signups with search filter" do + expected_params = {filter: {name: "John Doe"}} + expect(client).to receive(:get).with("/api/v2/signups", params: expected_params) + + people.search("John Doe") + end + + it "returns search results in JSON:API format" do + search_results = { + data: [ + {type: "signup", id: "1", attributes: {first_name: "John", last_name: "Doe"}} + ] + } + + allow(client).to receive(:get).and_return(search_results) + result = people.search("John Doe") + + expect(result).to eq(search_results) + expect(result[:data].length).to eq(1) + end + + it "supports additional filter parameters" do + expected_params = {filter: {name: "John", email: "john@example.com"}} + expect(client).to receive(:get).with("/api/v2/signups", params: expected_params) + + people.search("John", email: "john@example.com") + end + end end From 52f327c57b6de98e7644e763566c90e2038e03ef Mon Sep 17 00:00:00 2001 From: Brett McHargue Date: Thu, 29 Jan 2026 20:39:28 +0700 Subject: [PATCH 3/4] Add bulk tag operations to Tags resource (nb-6) Implements bulk tagging operations for managing supporter segments. Added methods: - bulk_apply(tag_name, person_ids) - Apply tag to multiple people - bulk_remove(tag_name, person_ids) - Remove tag from multiple people Both methods handle empty arrays and return array of responses. Uses V1 API for tag management. Follows TDD approach. Tests: 7 new examples, all passing Coverage: 93.48% Co-Authored-By: Claude Sonnet 4.5 --- .beads/issues.jsonl | 4 +- lib/nationbuilder_api/resources/tags.rb | 36 ++++++++++ spec/nationbuilder_api/resources/tags_spec.rb | 68 ++++++++++++++++++- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8d9e2cd..76cf246 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -10,7 +10,7 @@ {"id":"nb-3","title":"Add Redis \u0026 ActiveRecord Adapter Tests","description":"Implement comprehensive test suites for Redis and ActiveRecord token storage adapters to match Memory adapter test coverage. This addresses 3 failing ActiveRecord tests found in verification.","design":"Add test files spec/nationbuilder_api/token_storage/redis_spec.rb and enhance active_record_spec.rb. Include store_token, retrieve_token, delete_token, serialization tests. See roadmap item #7.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:06.202895+07:00","updated_at":"2025-12-02T17:32:40.986334+07:00","closed_at":"2025-12-02T17:32:40.986334+07:00"} {"id":"nb-4","title":"Fix Token Adapter Validation","description":"Correct validate_adapter_interface! method in client.rb to use respond_to? instead of instance_methods set difference for accurate interface validation","design":"Change from instance_methods set comparison to respond_to? checks for each required method (get_token, store_token, delete_token). See roadmap item #6.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:04.540968+07:00","updated_at":"2025-12-02T17:26:17.184025+07:00","closed_at":"2025-12-02T17:18:07.625684+07:00"} {"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","design":"Create response object classes in lib/nationbuilder_api/response_objects/. Use method_missing or define_method for attribute access. See roadmap item #18.","status":"closed","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2026-01-29T20:31:08.815565+07:00","closed_at":"2026-01-29T20:31:08.815565+07:00"} -{"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","design":"Create lib/nationbuilder_api/resources/tags.rb. Support bulk operations. See roadmap item #17.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2025-12-02T17:26:17.185491+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} -{"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2026-01-29T20:34:55.50187+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} +{"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","design":"Create lib/nationbuilder_api/resources/tags.rb. Support bulk operations. See roadmap item #17.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2026-01-29T20:38:09.599348+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} +{"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"closed","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2026-01-29T20:37:58.899562+07:00","closed_at":"2026-01-29T20:37:58.899562+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} {"id":"nb-8","title":"Fix OAuth VCR Test Failures","description":"Fix 5 failing OAuth integration tests due to VCR cassette configuration issues. Tests in oauth_flow_spec.rb and client_spec.rb fail with 'VCR does not know how to handle' errors.","design":"Review VCR cassette configuration, regenerate cassettes if needed, ensure Net::HTTP compatibility. See verification report from 2025-11-25-switch-to-net-http spec.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:18.00717+07:00","updated_at":"2025-12-02T17:26:17.186112+07:00","closed_at":"2025-12-02T17:21:53.298386+07:00"} {"id":"nb-9","title":"Add Rate Limit Monitoring","description":"Log rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) for proactive rate limit monitoring and debugging","design":"Add rate limit header logging in http_client.rb after response received. Log at info level. See roadmap item #10.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:10.928534+07:00","updated_at":"2025-12-02T17:26:17.186369+07:00"} diff --git a/lib/nationbuilder_api/resources/tags.rb b/lib/nationbuilder_api/resources/tags.rb index 96e0b75..3432247 100644 --- a/lib/nationbuilder_api/resources/tags.rb +++ b/lib/nationbuilder_api/resources/tags.rb @@ -16,6 +16,42 @@ class Tags < Base def list get("/api/v1/tags") end + + # Apply a tag to multiple people + # Uses V1 API for bulk tagging operations + # + # @param tag_name [String] Tag name to apply + # @param person_ids [Array] Array of person IDs + # @return [Array] Array of responses from API + # + # @example + # client.tags.bulk_apply("volunteer", [123, 456, 789]) + # # => [{status: "success"}, {status: "success"}, {status: "success"}] + def bulk_apply(tag_name, person_ids) + return [] if person_ids.empty? + + person_ids.map do |person_id| + put("/api/v1/people/#{person_id}/taggings", body: {tagging: {tag: tag_name}}) + end + end + + # Remove a tag from multiple people + # Uses V1 API for bulk tagging operations + # + # @param tag_name [String] Tag name to remove + # @param person_ids [Array] Array of person IDs + # @return [Array] Array of responses from API + # + # @example + # client.tags.bulk_remove("volunteer", [123, 456, 789]) + # # => [{status: "deleted"}, {status: "deleted"}, {status: "deleted"}] + def bulk_remove(tag_name, person_ids) + return [] if person_ids.empty? + + person_ids.map do |person_id| + client.delete("/api/v1/people/#{person_id}/taggings/#{tag_name}") + end + end end end end diff --git a/spec/nationbuilder_api/resources/tags_spec.rb b/spec/nationbuilder_api/resources/tags_spec.rb index bf98be0..ab9d72b 100644 --- a/spec/nationbuilder_api/resources/tags_spec.rb +++ b/spec/nationbuilder_api/resources/tags_spec.rb @@ -4,7 +4,9 @@ let(:client) do instance_double( NationbuilderApi::Client, - get: nil + get: nil, + put: nil, + delete: nil ) end @@ -33,4 +35,68 @@ expect(result[:results].first[:name]).to eq("volunteer") end end + + describe "#bulk_apply" do + it "applies tag to multiple people" do + person_ids = [1, 2, 3] + person_ids.each do |id| + expected_body = {tagging: {tag: "volunteer"}} + expect(client).to receive(:put) + .with("/api/v1/people/#{id}/taggings", body: expected_body) + end + + tags.bulk_apply("volunteer", person_ids) + end + + it "returns array of responses" do + allow(client).to receive(:put).and_return({status: "success"}) + result = tags.bulk_apply("volunteer", [1, 2]) + + expect(result).to be_an(Array) + expect(result.length).to eq(2) + expect(result.first).to eq({status: "success"}) + end + + it "handles empty person_ids array" do + expect(client).not_to receive(:put) + result = tags.bulk_apply("volunteer", []) + + expect(result).to eq([]) + end + end + + describe "#bulk_remove" do + it "removes tag from multiple people" do + person_ids = [1, 2, 3] + person_ids.each do |id| + expect(client).to receive(:delete) + .with("/api/v1/people/#{id}/taggings/volunteer") + end + + tags.bulk_remove("volunteer", person_ids) + end + + it "returns array of responses" do + allow(client).to receive(:delete).and_return({status: "deleted"}) + result = tags.bulk_remove("volunteer", [1, 2]) + + expect(result).to be_an(Array) + expect(result.length).to eq(2) + expect(result.first).to eq({status: "deleted"}) + end + + it "handles empty person_ids array" do + expect(client).not_to receive(:delete) + result = tags.bulk_remove("volunteer", []) + + expect(result).to eq([]) + end + + it "handles tag names with spaces" do + expect(client).to receive(:delete) + .with("/api/v1/people/1/taggings/needs follow-up") + + tags.bulk_remove("needs follow-up", [1]) + end + end end From da83a43173a93930b5ec042b5a5fc68826aec864 Mon Sep 17 00:00:00 2001 From: Brett McHargue Date: Thu, 29 Jan 2026 20:48:32 +0700 Subject: [PATCH 4/4] Add Donations and Events resources with full CRUD support Implements comprehensive resource classes for managing donations and events: - Donations: list, show, create, update operations - Events: list, show, create, update, delete operations with RSVP management - RSVP operations: list, create, update, delete RSVPs for events - All resources follow JSON:API format conventions - 310 tests passing with 93.66% coverage Co-Authored-By: Claude Sonnet 4.5 --- .beads/issues.jsonl | 6 +- lib/nationbuilder_api.rb | 2 + lib/nationbuilder_api/client.rb | 23 + lib/nationbuilder_api/resources/donations.rb | 95 ++++ lib/nationbuilder_api/resources/events.rb | 198 ++++++++ .../resources/donations_spec.rb | 204 +++++++++ .../resources/events_spec.rb | 431 ++++++++++++++++++ 7 files changed, 956 insertions(+), 3 deletions(-) create mode 100644 lib/nationbuilder_api/resources/donations.rb create mode 100644 lib/nationbuilder_api/resources/events.rb create mode 100644 spec/nationbuilder_api/resources/donations_spec.rb create mode 100644 spec/nationbuilder_api/resources/events_spec.rb diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 76cf246..3092744 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"id":"nb-0a4","title":"Add update method to People resource","description":"Implement People.update() method to update person attributes via PATCH /api/v2/signups/:id with JSON:API format. Includes unit tests for success/error cases and address updates. See SPEC_ADD_PEOPLE_UPDATE.md","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-10T13:12:40.463653+07:00","updated_at":"2025-12-10T13:17:20.533506+07:00","closed_at":"2025-12-10T13:17:20.533506+07:00","dependencies":[{"issue_id":"nb-0a4","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-10T13:12:45.059752+07:00","created_by":"daemon"}]} -{"id":"nb-1","title":"Events Resource","description":"Create Events resource with list, create, show, update, delete operations including RSVP management and event-specific filtering capabilities","design":"Create lib/nationbuilder_api/resources/events.rb with RSVP support. See roadmap item #16.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:35.786297+07:00","updated_at":"2025-12-02T17:26:17.182348+07:00","dependencies":[{"issue_id":"nb-1","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:15.193639+07:00","created_by":"daemon"}]} +{"id":"nb-1","title":"Events Resource","description":"Create Events resource with list, create, show, update, delete operations including RSVP management and event-specific filtering capabilities","design":"Create lib/nationbuilder_api/resources/events.rb with RSVP support. See roadmap item #16.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:35.786297+07:00","updated_at":"2026-01-29T20:44:31.902623+07:00","closed_at":"2026-01-29T20:44:31.902623+07:00","dependencies":[{"issue_id":"nb-1","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:15.193639+07:00","created_by":"daemon"}]} {"id":"nb-10","title":"Production Memory Adapter Warning","description":"Add runtime warning when Memory adapter is used in production Rails environments to prevent accidental production usage","design":"Add check in Memory adapter initialization that logs warning if Rails.env.production?. See roadmap item #11.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:13.087752+07:00","updated_at":"2025-12-02T17:26:17.186615+07:00"} -{"id":"nb-11","title":"Donations Resource","description":"Build Donations endpoint support with list/filter/pagination, create, show, update operations and proper handling of money values, dates, and donor relationships","design":"Create lib/nationbuilder_api/resources/donations.rb. Handle money formatting, date parsing. See roadmap item #15.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:34.642315+07:00","updated_at":"2025-12-02T17:26:17.186862+07:00","dependencies":[{"issue_id":"nb-11","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:13.279819+07:00","created_by":"daemon"}]} +{"id":"nb-11","title":"Donations Resource","description":"Build Donations endpoint support with list/filter/pagination, create, show, update operations and proper handling of money values, dates, and donor relationships","design":"Create lib/nationbuilder_api/resources/donations.rb. Handle money formatting, date parsing. See roadmap item #15.","status":"closed","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:34.642315+07:00","updated_at":"2026-01-29T20:42:17.201894+07:00","closed_at":"2026-01-29T20:42:17.201894+07:00","dependencies":[{"issue_id":"nb-11","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:13.279819+07:00","created_by":"daemon"}]} {"id":"nb-12","title":"Add Explicit Linter Configuration","description":"Create .standard.yml file for explicit StandardRB configuration and team consistency beyond defaults","design":"Add .standard.yml with project-specific linter rules. See roadmap item #12.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-02T17:09:14.308639+07:00","updated_at":"2025-12-02T17:26:17.187136+07:00"} {"id":"nb-13","title":"Enhance HTTP Error Context","description":"Add request method and path to error messages for improved debugging (e.g., 'Authentication failed for GET /people')","design":"Update error raising in http_client.rb handle_response method to include method and path context. See roadmap item #8.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:08.185816+07:00","updated_at":"2025-12-04T10:16:52.99755+07:00","closed_at":"2025-12-04T10:16:52.99755+07:00"} {"id":"nb-14","title":"Centralize OAuth HTTP Calls","description":"Refactor OAuth module to use HttpClient instead of direct HTTP.post calls for consistent logging and error handling across all HTTP operations","design":"Replace Net::HTTP direct calls in oauth.rb with HttpClient calls. Ensure token exchange and refresh operations use centralized client. See roadmap item #9.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:09.633647+07:00","updated_at":"2025-12-04T10:25:59.122885+07:00","closed_at":"2025-12-04T10:25:59.122885+07:00"} @@ -10,7 +10,7 @@ {"id":"nb-3","title":"Add Redis \u0026 ActiveRecord Adapter Tests","description":"Implement comprehensive test suites for Redis and ActiveRecord token storage adapters to match Memory adapter test coverage. This addresses 3 failing ActiveRecord tests found in verification.","design":"Add test files spec/nationbuilder_api/token_storage/redis_spec.rb and enhance active_record_spec.rb. Include store_token, retrieve_token, delete_token, serialization tests. See roadmap item #7.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:06.202895+07:00","updated_at":"2025-12-02T17:32:40.986334+07:00","closed_at":"2025-12-02T17:32:40.986334+07:00"} {"id":"nb-4","title":"Fix Token Adapter Validation","description":"Correct validate_adapter_interface! method in client.rb to use respond_to? instead of instance_methods set difference for accurate interface validation","design":"Change from instance_methods set comparison to respond_to? checks for each required method (get_token, store_token, delete_token). See roadmap item #6.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:04.540968+07:00","updated_at":"2025-12-02T17:26:17.184025+07:00","closed_at":"2025-12-02T17:18:07.625684+07:00"} {"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","design":"Create response object classes in lib/nationbuilder_api/response_objects/. Use method_missing or define_method for attribute access. See roadmap item #18.","status":"closed","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2026-01-29T20:31:08.815565+07:00","closed_at":"2026-01-29T20:31:08.815565+07:00"} -{"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","design":"Create lib/nationbuilder_api/resources/tags.rb. Support bulk operations. See roadmap item #17.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2026-01-29T20:38:09.599348+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} +{"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","design":"Create lib/nationbuilder_api/resources/tags.rb. Support bulk operations. See roadmap item #17.","status":"closed","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2026-01-29T20:39:31.50818+07:00","closed_at":"2026-01-29T20:39:31.50818+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} {"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"closed","priority":2,"issue_type":"feature","assignee":"claude","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2026-01-29T20:37:58.899562+07:00","closed_at":"2026-01-29T20:37:58.899562+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} {"id":"nb-8","title":"Fix OAuth VCR Test Failures","description":"Fix 5 failing OAuth integration tests due to VCR cassette configuration issues. Tests in oauth_flow_spec.rb and client_spec.rb fail with 'VCR does not know how to handle' errors.","design":"Review VCR cassette configuration, regenerate cassettes if needed, ensure Net::HTTP compatibility. See verification report from 2025-11-25-switch-to-net-http spec.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:18.00717+07:00","updated_at":"2025-12-02T17:26:17.186112+07:00","closed_at":"2025-12-02T17:21:53.298386+07:00"} {"id":"nb-9","title":"Add Rate Limit Monitoring","description":"Log rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) for proactive rate limit monitoring and debugging","design":"Add rate limit header logging in http_client.rb after response received. Log at info level. See roadmap item #10.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:10.928534+07:00","updated_at":"2025-12-02T17:26:17.186369+07:00"} diff --git a/lib/nationbuilder_api.rb b/lib/nationbuilder_api.rb index 996cdd2..72b9bb5 100644 --- a/lib/nationbuilder_api.rb +++ b/lib/nationbuilder_api.rb @@ -23,6 +23,8 @@ module Resources autoload :Base, "nationbuilder_api/resources/base" autoload :People, "nationbuilder_api/resources/people" autoload :Tags, "nationbuilder_api/resources/tags" + autoload :Donations, "nationbuilder_api/resources/donations" + autoload :Events, "nationbuilder_api/resources/events" end # Response objects diff --git a/lib/nationbuilder_api/client.rb b/lib/nationbuilder_api/client.rb index 6517204..a6766f9 100644 --- a/lib/nationbuilder_api/client.rb +++ b/lib/nationbuilder_api/client.rb @@ -161,6 +161,29 @@ def tags @tags ||= Resources::Tags.new(self) end + # Access Donations resource + # + # @return [Resources::Donations] Donations resource instance + # + # @example + # client.donations.list + # client.donations.show(123) + def donations + @donations ||= Resources::Donations.new(self) + end + + # Access Events resource + # + # @return [Resources::Events] Events resource instance + # + # @example + # client.events.list + # client.events.show(123) + # client.events.rsvps(123) + def events + @events ||= Resources::Events.new(self) + end + private # Extract OAuth base URL from API base URL diff --git a/lib/nationbuilder_api/resources/donations.rb b/lib/nationbuilder_api/resources/donations.rb new file mode 100644 index 0000000..b993bbe --- /dev/null +++ b/lib/nationbuilder_api/resources/donations.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module NationbuilderApi + module Resources + # Donations API resource + # Provides access to NationBuilder Donations endpoints for managing donations + class Donations < Base + # List all donations + # Uses V2 API with JSON:API format + # + # @param page [Integer, nil] Page number for pagination + # @param per_page [Integer, nil] Number of results per page + # @param filter [Hash, nil] Filter parameters (e.g., donor_id) + # @return [Hash] List of donations in JSON:API format + # + # @example + # client.donations.list + # # => { data: [{type: "donation", id: "1", ...}, ...] } + # + # @example With pagination + # client.donations.list(page: 2, per_page: 50) + # + # @example With filtering + # client.donations.list(filter: {donor_id: "123"}) + def list(page: nil, per_page: nil, filter: nil) + params = {} + params[:page] = {number: page, size: per_page} if page || per_page + params[:filter] = filter if filter + + get("/api/v2/donations", params: params) + end + + # Fetch a single donation by ID + # Uses V2 API with JSON:API format + # + # @param id [String, Integer] Donation ID + # @return [Hash] Donation data in JSON:API format + # + # @example + # client.donations.show(123) + # # => { data: { type: "donation", id: "123", attributes: { ... } } } + def show(id) + get("/api/v2/donations/#{id}") + end + + # Create a new donation + # Uses V2 API with JSON:API format + # + # @param attributes [Hash] Donation attributes (amount_in_cents, donor_id, donated_at, etc.) + # @return [Hash] Created donation data in JSON:API format + # @raise [ValidationError] If attributes are invalid + # + # @example + # client.donations.create(attributes: { + # amount_in_cents: 5000, + # donor_id: "123", + # donated_at: "2025-01-15T10:00:00Z" + # }) + def create(attributes:) + body = { + data: { + type: "donations", + attributes: attributes + } + } + post("/api/v2/donations", body: body) + end + + # Update a donation + # Uses V2 API with JSON:API format + # + # @param id [String, Integer] Donation ID + # @param attributes [Hash] Donation attributes to update + # @return [Hash] Updated donation data in JSON:API format + # @raise [ValidationError] If attributes are invalid + # @raise [NotFoundError] If donation not found + # + # @example + # client.donations.update(123, attributes: { + # amount_in_cents: 7500, + # note: "Updated donation amount" + # }) + def update(id, attributes:) + body = { + data: { + type: "donations", + id: id.to_s, + attributes: attributes + } + } + patch("/api/v2/donations/#{id}", body: body) + end + end + end +end diff --git a/lib/nationbuilder_api/resources/events.rb b/lib/nationbuilder_api/resources/events.rb new file mode 100644 index 0000000..c5b9bba --- /dev/null +++ b/lib/nationbuilder_api/resources/events.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +module NationbuilderApi + module Resources + # Events API resource + # Provides access to NationBuilder Events endpoints for managing events and RSVPs + class Events < Base + # List all events + # Uses V2 API with JSON:API format + # + # @param page [Integer, nil] Page number for pagination + # @param per_page [Integer, nil] Number of results per page + # @param filter [Hash, nil] Filter parameters (e.g., status) + # @return [Hash] List of events in JSON:API format + # + # @example + # client.events.list + # # => { data: [{type: "event", id: "1", ...}, ...] } + # + # @example With pagination + # client.events.list(page: 2, per_page: 50) + # + # @example With filtering + # client.events.list(filter: {status: "published"}) + def list(page: nil, per_page: nil, filter: nil) + params = {} + params[:page] = {number: page, size: per_page} if page || per_page + params[:filter] = filter if filter + + get("/api/v2/events", params: params) + end + + # Fetch a single event by ID + # Uses V2 API with JSON:API format + # + # @param id [String, Integer] Event ID + # @param include_rsvps [Boolean] Whether to sideload RSVPs (default: false) + # @return [Hash] Event data in JSON:API format + # + # @example + # client.events.show(123) + # # => { data: { type: "event", id: "123", attributes: { ... } } } + # + # @example With RSVPs sideloaded + # client.events.show(123, include_rsvps: true) + # # => { data: { ... }, included: [{ type: "event_rsvp", ... }] } + def show(id, include_rsvps: false) + path = "/api/v2/events/#{id}" + path += "?include=rsvps" if include_rsvps + get(path) + end + + # Create a new event + # Uses V2 API with JSON:API format + # + # @param attributes [Hash] Event attributes (name, start_time, end_time, status, etc.) + # @return [Hash] Created event data in JSON:API format + # @raise [ValidationError] If attributes are invalid + # + # @example + # client.events.create(attributes: { + # name: "Fundraising Gala", + # start_time: "2025-02-01T18:00:00Z", + # end_time: "2025-02-01T22:00:00Z", + # status: "published" + # }) + def create(attributes:) + body = { + data: { + type: "events", + attributes: attributes + } + } + post("/api/v2/events", body: body) + end + + # Update an event + # Uses V2 API with JSON:API format + # + # @param id [String, Integer] Event ID + # @param attributes [Hash] Event attributes to update + # @return [Hash] Updated event data in JSON:API format + # @raise [ValidationError] If attributes are invalid + # @raise [NotFoundError] If event not found + # + # @example + # client.events.update(123, attributes: { + # name: "Updated Event Name", + # status: "published" + # }) + def update(id, attributes:) + body = { + data: { + type: "events", + id: id.to_s, + attributes: attributes + } + } + patch("/api/v2/events/#{id}", body: body) + end + + # Delete an event + # Uses V2 API + # + # @param id [String, Integer] Event ID + # @return [Hash] Response from API + # @raise [NotFoundError] If event not found + # + # @example + # client.events.delete(123) + def delete(id) + client.delete("/api/v2/events/#{id}") + end + + # Fetch RSVPs for an event + # Uses V2 API with JSON:API format + # + # @param event_id [String, Integer] Event ID + # @param include_person [Boolean] Whether to include person data (default: false) + # @return [Hash] RSVP data in JSON:API format + # + # @example + # client.events.rsvps(123) + # # => { data: [{ type: "event_rsvp", ... }] } + # + # @example With person data + # client.events.rsvps(123, include_person: true) + # # => { data: [...], included: [{ type: "person", ... }] } + def rsvps(event_id, include_person: false) + path = "/api/v2/event_rsvps?filter[event_id]=#{event_id}" + path += "&include=person" if include_person + get(path) + end + + # Create an RSVP for an event + # Uses V2 API with JSON:API format + # + # @param event_id [String, Integer] Event ID + # @param attributes [Hash] RSVP attributes (person_id, status, guests_count, etc.) + # @return [Hash] Created RSVP data in JSON:API format + # @raise [ValidationError] If attributes are invalid + # + # @example + # client.events.create_rsvp(123, attributes: { + # person_id: "789", + # status: "accepted", + # guests_count: 2 + # }) + def create_rsvp(event_id, attributes:) + body = { + data: { + type: "event_rsvps", + attributes: attributes.merge(event_id: event_id.to_s) + } + } + post("/api/v2/event_rsvps", body: body) + end + + # Update an RSVP + # Uses V2 API with JSON:API format + # + # @param rsvp_id [String, Integer] RSVP ID + # @param attributes [Hash] RSVP attributes to update + # @return [Hash] Updated RSVP data in JSON:API format + # @raise [ValidationError] If attributes are invalid + # @raise [NotFoundError] If RSVP not found + # + # @example + # client.events.update_rsvp(999, attributes: { + # status: "declined", + # guests_count: 0 + # }) + def update_rsvp(rsvp_id, attributes:) + body = { + data: { + type: "event_rsvps", + id: rsvp_id.to_s, + attributes: attributes + } + } + patch("/api/v2/event_rsvps/#{rsvp_id}", body: body) + end + + # Delete an RSVP + # Uses V2 API + # + # @param rsvp_id [String, Integer] RSVP ID + # @return [Hash] Response from API + # @raise [NotFoundError] If RSVP not found + # + # @example + # client.events.delete_rsvp(999) + def delete_rsvp(rsvp_id) + client.delete("/api/v2/event_rsvps/#{rsvp_id}") + end + end + end +end diff --git a/spec/nationbuilder_api/resources/donations_spec.rb b/spec/nationbuilder_api/resources/donations_spec.rb new file mode 100644 index 0000000..dcefecc --- /dev/null +++ b/spec/nationbuilder_api/resources/donations_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +RSpec.describe NationbuilderApi::Resources::Donations do + let(:config) do + instance_double( + NationbuilderApi::Configuration, + wrap_responses: false + ) + end + + let(:client) do + instance_double( + NationbuilderApi::Client, + get: nil, + post: nil, + patch: nil, + config: config + ) + end + + subject(:donations) { described_class.new(client) } + + describe "#list" do + it "makes GET request to /api/v2/donations" do + expect(client).to receive(:get).with("/api/v2/donations", params: {}) + donations.list + end + + it "returns list of donations in JSON:API format" do + list_data = { + data: [ + {type: "donation", id: "1", attributes: {amount_in_cents: 5000}}, + {type: "donation", id: "2", attributes: {amount_in_cents: 10000}} + ] + } + + allow(client).to receive(:get).and_return(list_data) + result = donations.list + + expect(result).to eq(list_data) + expect(result[:data].length).to eq(2) + end + + it "supports pagination parameters" do + expected_params = {page: {number: 2, size: 50}} + expect(client).to receive(:get).with("/api/v2/donations", params: expected_params) + + donations.list(page: 2, per_page: 50) + end + + it "supports filtering parameters" do + expected_params = {filter: {donor_id: "123"}} + expect(client).to receive(:get).with("/api/v2/donations", params: expected_params) + + donations.list(filter: {donor_id: "123"}) + end + end + + describe "#show" do + it "makes GET request to /api/v2/donations/:id" do + expect(client).to receive(:get).with("/api/v2/donations/123", params: {}) + donations.show(123) + end + + it "returns donation data in JSON:API format" do + donation_data = { + data: { + type: "donation", + id: "123", + attributes: { + amount_in_cents: 5000, + donated_at: "2025-01-15T10:00:00Z" + } + } + } + + allow(client).to receive(:get).and_return(donation_data) + result = donations.show(123) + + expect(result).to eq(donation_data) + end + + it "accepts string ID" do + expect(client).to receive(:get).with("/api/v2/donations/456", params: {}) + donations.show("456") + end + end + + describe "#create" do + let(:donation_attributes) do + { + amount_in_cents: 5000, + donor_id: "123", + donated_at: "2025-01-15T10:00:00Z" + } + end + + let(:expected_body) do + { + data: { + type: "donations", + attributes: donation_attributes + } + } + end + + it "makes POST request to /api/v2/donations" do + expect(client).to receive(:post) + .with("/api/v2/donations", body: expected_body) + + donations.create(attributes: donation_attributes) + end + + it "returns created donation in JSON:API format" do + created_donation = { + data: { + type: "donation", + id: "123", + attributes: donation_attributes + } + } + + allow(client).to receive(:post).and_return(created_donation) + result = donations.create(attributes: donation_attributes) + + expect(result).to eq(created_donation) + expect(result.dig(:data, :id)).to eq("123") + end + + it "raises ValidationError for invalid attributes" do + allow(client).to receive(:post) + .and_raise(NationbuilderApi::ValidationError, "Invalid amount") + + expect { + donations.create(attributes: {amount_in_cents: -100}) + }.to raise_error(NationbuilderApi::ValidationError, "Invalid amount") + end + end + + describe "#update" do + let(:update_attributes) do + { + amount_in_cents: 7500, + note: "Updated donation amount" + } + end + + let(:expected_body) do + { + data: { + type: "donations", + id: "123", + attributes: update_attributes + } + } + end + + it "makes PATCH request to /api/v2/donations/:id" do + expect(client).to receive(:patch) + .with("/api/v2/donations/123", body: expected_body) + + donations.update(123, attributes: update_attributes) + end + + it "returns updated donation in JSON:API format" do + updated_donation = { + data: { + type: "donation", + id: "123", + attributes: update_attributes + } + } + + allow(client).to receive(:patch).and_return(updated_donation) + result = donations.update(123, attributes: update_attributes) + + expect(result).to eq(updated_donation) + end + + it "accepts string ID" do + expected_body_with_string = { + data: { + type: "donations", + id: "456", + attributes: update_attributes + } + } + + expect(client).to receive(:patch) + .with("/api/v2/donations/456", body: expected_body_with_string) + + donations.update("456", attributes: update_attributes) + end + + it "raises NotFoundError when donation not found" do + allow(client).to receive(:patch) + .and_raise(NationbuilderApi::NotFoundError, "Donation not found") + + expect { + donations.update(999, attributes: update_attributes) + }.to raise_error(NationbuilderApi::NotFoundError, "Donation not found") + end + end +end diff --git a/spec/nationbuilder_api/resources/events_spec.rb b/spec/nationbuilder_api/resources/events_spec.rb new file mode 100644 index 0000000..4af0def --- /dev/null +++ b/spec/nationbuilder_api/resources/events_spec.rb @@ -0,0 +1,431 @@ +# frozen_string_literal: true + +RSpec.describe NationbuilderApi::Resources::Events do + let(:config) do + instance_double( + NationbuilderApi::Configuration, + wrap_responses: false + ) + end + + let(:client) do + instance_double( + NationbuilderApi::Client, + get: nil, + post: nil, + patch: nil, + delete: nil, + config: config + ) + end + + subject(:events) { described_class.new(client) } + + describe "#list" do + it "makes GET request to /api/v2/events" do + expect(client).to receive(:get).with("/api/v2/events", params: {}) + events.list + end + + it "returns list of events in JSON:API format" do + list_data = { + data: [ + {type: "event", id: "1", attributes: {name: "Fundraiser", start_time: "2025-02-01T18:00:00Z"}}, + {type: "event", id: "2", attributes: {name: "Rally", start_time: "2025-02-15T14:00:00Z"}} + ] + } + + allow(client).to receive(:get).and_return(list_data) + result = events.list + + expect(result).to eq(list_data) + expect(result[:data].length).to eq(2) + end + + it "supports pagination parameters" do + expected_params = {page: {number: 2, size: 50}} + expect(client).to receive(:get).with("/api/v2/events", params: expected_params) + + events.list(page: 2, per_page: 50) + end + + it "supports filtering parameters" do + expected_params = {filter: {status: "published"}} + expect(client).to receive(:get).with("/api/v2/events", params: expected_params) + + events.list(filter: {status: "published"}) + end + end + + describe "#show" do + it "makes GET request to /api/v2/events/:id" do + expect(client).to receive(:get).with("/api/v2/events/123", params: {}) + events.show(123) + end + + it "returns event data in JSON:API format" do + event_data = { + data: { + type: "event", + id: "123", + attributes: { + name: "Fundraising Gala", + start_time: "2025-02-01T18:00:00Z", + end_time: "2025-02-01T22:00:00Z", + status: "published" + } + } + } + + allow(client).to receive(:get).and_return(event_data) + result = events.show(123) + + expect(result).to eq(event_data) + end + + it "accepts string ID" do + expect(client).to receive(:get).with("/api/v2/events/456", params: {}) + events.show("456") + end + + it "supports including RSVPs" do + expect(client).to receive(:get).with("/api/v2/events/123?include=rsvps", params: {}) + events.show(123, include_rsvps: true) + end + end + + describe "#create" do + let(:event_attributes) do + { + name: "Fundraising Gala", + start_time: "2025-02-01T18:00:00Z", + end_time: "2025-02-01T22:00:00Z", + status: "published" + } + end + + let(:expected_body) do + { + data: { + type: "events", + attributes: event_attributes + } + } + end + + it "makes POST request to /api/v2/events" do + expect(client).to receive(:post) + .with("/api/v2/events", body: expected_body) + + events.create(attributes: event_attributes) + end + + it "returns created event in JSON:API format" do + created_event = { + data: { + type: "event", + id: "123", + attributes: event_attributes + } + } + + allow(client).to receive(:post).and_return(created_event) + result = events.create(attributes: event_attributes) + + expect(result).to eq(created_event) + expect(result.dig(:data, :id)).to eq("123") + end + + it "raises ValidationError for invalid attributes" do + allow(client).to receive(:post) + .and_raise(NationbuilderApi::ValidationError, "Invalid event name") + + expect { + events.create(attributes: {name: ""}) + }.to raise_error(NationbuilderApi::ValidationError, "Invalid event name") + end + end + + describe "#update" do + let(:update_attributes) do + { + name: "Updated Event Name", + status: "published" + } + end + + let(:expected_body) do + { + data: { + type: "events", + id: "123", + attributes: update_attributes + } + } + end + + it "makes PATCH request to /api/v2/events/:id" do + expect(client).to receive(:patch) + .with("/api/v2/events/123", body: expected_body) + + events.update(123, attributes: update_attributes) + end + + it "returns updated event in JSON:API format" do + updated_event = { + data: { + type: "event", + id: "123", + attributes: update_attributes + } + } + + allow(client).to receive(:patch).and_return(updated_event) + result = events.update(123, attributes: update_attributes) + + expect(result).to eq(updated_event) + end + + it "accepts string ID" do + expected_body_with_string = { + data: { + type: "events", + id: "456", + attributes: update_attributes + } + } + + expect(client).to receive(:patch) + .with("/api/v2/events/456", body: expected_body_with_string) + + events.update("456", attributes: update_attributes) + end + + it "raises NotFoundError when event not found" do + allow(client).to receive(:patch) + .and_raise(NationbuilderApi::NotFoundError, "Event not found") + + expect { + events.update(999, attributes: update_attributes) + }.to raise_error(NationbuilderApi::NotFoundError, "Event not found") + end + end + + describe "#delete" do + it "makes DELETE request to /api/v2/events/:id" do + expect(client).to receive(:delete).with("/api/v2/events/123") + events.delete(123) + end + + it "accepts string ID" do + expect(client).to receive(:delete).with("/api/v2/events/456") + events.delete("456") + end + + it "returns response from API" do + response = {success: true} + allow(client).to receive(:delete).and_return(response) + result = events.delete(123) + + expect(result).to eq(response) + end + + it "raises NotFoundError when event not found" do + allow(client).to receive(:delete) + .and_raise(NationbuilderApi::NotFoundError, "Event not found") + + expect { + events.delete(999) + }.to raise_error(NationbuilderApi::NotFoundError, "Event not found") + end + end + + describe "#rsvps" do + it "makes GET request to /api/v2/event_rsvps with event_id filter" do + expect(client).to receive(:get).with("/api/v2/event_rsvps?filter[event_id]=123", params: {}) + events.rsvps(123) + end + + it "returns RSVP data in JSON:API format" do + rsvp_data = { + data: [ + {type: "event_rsvp", id: "1", attributes: {status: "accepted"}}, + {type: "event_rsvp", id: "2", attributes: {status: "declined"}} + ] + } + + allow(client).to receive(:get).and_return(rsvp_data) + result = events.rsvps(123) + + expect(result).to eq(rsvp_data) + end + + it "accepts string ID" do + expect(client).to receive(:get).with("/api/v2/event_rsvps?filter[event_id]=456", params: {}) + events.rsvps("456") + end + + it "supports including person data" do + expect(client).to receive(:get).with("/api/v2/event_rsvps?filter[event_id]=123&include=person", params: {}) + events.rsvps(123, include_person: true) + end + end + + describe "#create_rsvp" do + let(:rsvp_attributes) do + { + person_id: "789", + status: "accepted", + guests_count: 2 + } + end + + let(:expected_body) do + { + data: { + type: "event_rsvps", + attributes: rsvp_attributes.merge(event_id: "123") + } + } + end + + it "makes POST request to /api/v2/event_rsvps" do + expect(client).to receive(:post) + .with("/api/v2/event_rsvps", body: expected_body) + + events.create_rsvp(123, attributes: rsvp_attributes) + end + + it "returns created RSVP in JSON:API format" do + created_rsvp = { + data: { + type: "event_rsvp", + id: "999", + attributes: rsvp_attributes.merge(event_id: "123") + } + } + + allow(client).to receive(:post).and_return(created_rsvp) + result = events.create_rsvp(123, attributes: rsvp_attributes) + + expect(result).to eq(created_rsvp) + end + + it "accepts string event ID" do + expected_body_with_string = { + data: { + type: "event_rsvps", + attributes: rsvp_attributes.merge(event_id: "456") + } + } + + expect(client).to receive(:post) + .with("/api/v2/event_rsvps", body: expected_body_with_string) + + events.create_rsvp("456", attributes: rsvp_attributes) + end + + it "raises ValidationError for invalid RSVP" do + allow(client).to receive(:post) + .and_raise(NationbuilderApi::ValidationError, "Invalid person_id") + + expect { + events.create_rsvp(123, attributes: {person_id: nil}) + }.to raise_error(NationbuilderApi::ValidationError, "Invalid person_id") + end + end + + describe "#update_rsvp" do + let(:rsvp_update_attributes) do + { + status: "declined", + guests_count: 0 + } + end + + let(:expected_body) do + { + data: { + type: "event_rsvps", + id: "999", + attributes: rsvp_update_attributes + } + } + end + + it "makes PATCH request to /api/v2/event_rsvps/:rsvp_id" do + expect(client).to receive(:patch) + .with("/api/v2/event_rsvps/999", body: expected_body) + + events.update_rsvp(999, attributes: rsvp_update_attributes) + end + + it "returns updated RSVP in JSON:API format" do + updated_rsvp = { + data: { + type: "event_rsvp", + id: "999", + attributes: rsvp_update_attributes + } + } + + allow(client).to receive(:patch).and_return(updated_rsvp) + result = events.update_rsvp(999, attributes: rsvp_update_attributes) + + expect(result).to eq(updated_rsvp) + end + + it "accepts string RSVP ID" do + expected_body_with_string = { + data: { + type: "event_rsvps", + id: "888", + attributes: rsvp_update_attributes + } + } + + expect(client).to receive(:patch) + .with("/api/v2/event_rsvps/888", body: expected_body_with_string) + + events.update_rsvp("888", attributes: rsvp_update_attributes) + end + + it "raises NotFoundError when RSVP not found" do + allow(client).to receive(:patch) + .and_raise(NationbuilderApi::NotFoundError, "RSVP not found") + + expect { + events.update_rsvp(999, attributes: rsvp_update_attributes) + }.to raise_error(NationbuilderApi::NotFoundError, "RSVP not found") + end + end + + describe "#delete_rsvp" do + it "makes DELETE request to /api/v2/event_rsvps/:rsvp_id" do + expect(client).to receive(:delete).with("/api/v2/event_rsvps/999") + events.delete_rsvp(999) + end + + it "accepts string RSVP ID" do + expect(client).to receive(:delete).with("/api/v2/event_rsvps/888") + events.delete_rsvp("888") + end + + it "returns response from API" do + response = {success: true} + allow(client).to receive(:delete).and_return(response) + result = events.delete_rsvp(999) + + expect(result).to eq(response) + end + + it "raises NotFoundError when RSVP not found" do + allow(client).to receive(:delete) + .and_raise(NationbuilderApi::NotFoundError, "RSVP not found") + + expect { + events.delete_rsvp(999) + }.to raise_error(NationbuilderApi::NotFoundError, "RSVP not found") + end + end +end