diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ddb1cda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +*.md +LICENSE +examples/ +test/ +pkg/ +.bundle/ +.env +.env.* +*.jpg diff --git a/README.md b/README.md index 3e3b73b..85b2e18 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,40 @@ client.contact.set_do_not_text(false, phone: '+15551234567') client.contact.set_do_not_text(true, contact_id: 'contact-abc-123') ``` +### Contact Validator + +Validate email addresses and phone numbers. + +```ruby +require 'ccai' + +# Initialize the client +client = CCAI.new( + client_id: 'YOUR-CLIENT-ID', + api_key: 'YOUR-API-KEY' +) + +# Validate a single email +email_result = client.contact_validator.validate_email('user@example.com') +puts "Status: #{email_result['status']}" # "valid" | "invalid" | "risky" + +# Validate multiple emails (up to 50) +bulk_emails = client.contact_validator.validate_emails(['user@example.com', 'bad@invalid.xyz']) +puts "Total: #{bulk_emails['summary']['total']}" # 2 +puts "Valid: #{bulk_emails['summary']['valid']}" # 1 + +# Validate a single phone number +phone_result = client.contact_validator.validate_phone('+15551234567', country_code: 'US') +puts "Status: #{phone_result['status']}" # "valid" | "invalid" | "landline" + +# Validate multiple phone numbers (up to 50) +bulk_phones = client.contact_validator.validate_phones([ + { phone: '+15551234567' }, + { phone: '+15559876543', countryCode: 'US' } +]) +puts "Landline: #{bulk_phones['summary']['landline']}" # 1 +``` + ### Webhooks ```ruby @@ -356,6 +390,7 @@ ccai --type email --client-id YOUR-CLIENT-ID --api-key YOUR-API-KEY \ - Send MMS messages with images (automatic S3 upload) - Send email campaigns with HTML content - Manage contact opt-out preferences (set_do_not_text) +- Validate email addresses (valid/invalid/risky) and phone numbers (valid/invalid/landline) - Manage webhooks: register, list, update, delete - Webhook signature verification (HMAC-SHA256 with Base64 encoding) - Template variable substitution (`${firstName}`, `${lastName}`) diff --git a/integration/Dockerfile b/integration/Dockerfile new file mode 100644 index 0000000..da523b7 --- /dev/null +++ b/integration/Dockerfile @@ -0,0 +1,15 @@ +FROM ruby:3.2-slim + +RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* + +WORKDIR /sdk +COPY Gemfile Gemfile.lock ccai.gemspec ./ +COPY lib/ ./lib/ +RUN bundle install + +COPY integration/Gemfile ./integration/ +WORKDIR /sdk/integration +RUN bundle install + +COPY integration/ ./ +CMD ["bundle", "exec", "ruby", "test.rb"] diff --git a/integration/Gemfile b/integration/Gemfile new file mode 100644 index 0000000..94b8675 --- /dev/null +++ b/integration/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Load the CCAI SDK from the cloned repo +gem 'ccai', path: '..' + +# Dependencies required by the SDK +gem 'faraday', '~> 2.0' +gem 'faraday-multipart', '~> 1.0' +gem 'json', '~> 2.0' diff --git a/integration/test.rb b/integration/test.rb new file mode 100644 index 0000000..44149b4 --- /dev/null +++ b/integration/test.rb @@ -0,0 +1,560 @@ +# frozen_string_literal: true + +# Ruby SDK integration tests -- 42 tests +# Covers: SMS (1-6), MMS (7-17), Email (18-22), Webhook (23-29), Contact (30-31), +# Brands (32-36), Campaigns (37-42) + +require 'ccai' +require 'openssl' +require 'base64' +require 'json' +require 'tempfile' + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +passed = 0 +failed = 0 + +def run_test(name) + yield + puts " PASS [#{name}]" + return true +rescue => e + puts " FAIL [#{name}]: #{e.message}" + return false +end + +def must_env(key) + val = ENV[key] + if val.nil? || val.empty? + warn "ERROR: required env var #{key} is not set" + exit 2 + end + val +end + +def hmac_sha256_base64(secret, message) + digest = OpenSSL::HMAC.digest('SHA256', secret, message) + Base64.strict_encode64(digest) +end + +# Write a 1x1 PNG to a temp file and return the Tempfile object (keep reference alive) +def write_temp_png + png_b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==' + buf = Base64.decode64(png_b64) + tmp = Tempfile.new(['ccai_test', '.png']) + tmp.binmode + tmp.write(buf) + tmp.flush + tmp # return the object, not just the path — prevents GC from deleting the file +end + +# ── Setup ───────────────────────────────────────────────────────────────────── + +client_id = must_env('CCAI_CLIENT_ID') +api_key = must_env('CCAI_API_KEY') +phone1 = must_env('CCAI_TEST_PHONE') +phone2 = must_env('CCAI_TEST_PHONE_2') +phone3 = must_env('CCAI_TEST_PHONE_3') +email1 = must_env('CCAI_TEST_EMAIL') +email2 = must_env('CCAI_TEST_EMAIL_2') +email3 = must_env('CCAI_TEST_EMAIL_3') +fn1 = must_env('CCAI_TEST_FIRST_NAME') +ln1 = must_env('CCAI_TEST_LAST_NAME') +fn2 = must_env('CCAI_TEST_FIRST_NAME_2') +ln2 = must_env('CCAI_TEST_LAST_NAME_2') +fn3 = must_env('CCAI_TEST_FIRST_NAME_3') +ln3 = must_env('CCAI_TEST_LAST_NAME_3') +webhook_url = must_env('WEBHOOK_URL') + +# Use CCAI_BASE_URL if set (local dev), otherwise fall back to test environment +client = CCAI::Client.new( + CCAI::Config.new( + client_id: client_id, + api_key: api_key, + use_test_environment: ENV['CCAI_BASE_URL'].nil? + ) +) + +png_path = write_temp_png + +puts '==============================================' +puts ' CCAI Ruby SDK Integration Tests' +puts '==============================================' + +# ── SMS Tests (1-6) ─────────────────────────────────────────────────────────── +puts "\n--- SMS ---" + +# 01 -- SMS.send_single +result = run_test('01 SMS.send_single') do + client.sms.send_single(fn1, ln1, phone1, 'Hello from Ruby SDK!', 'Ruby Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 02 -- SMS.send (1 recipient) +result = run_test('02 SMS.send (1 recipient)') do + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1)] + client.sms.send(accounts, 'Hello 1 recipient!', 'Ruby Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 03 -- SMS.send (2 recipients) +result = run_test('03 SMS.send (2 recipients)') do + accounts = [ + CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1), + CCAI::SMS::Account.new(first_name: fn2, last_name: ln2, phone: phone2) + ] + client.sms.send(accounts, 'Hello 2 recipients!', 'Ruby Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 04 -- SMS.send (3 recipients) +result = run_test('04 SMS.send (3 recipients)') do + accounts = [ + CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1), + CCAI::SMS::Account.new(first_name: fn2, last_name: ln2, phone: phone2), + CCAI::SMS::Account.new(first_name: fn3, last_name: ln3, phone: phone3) + ] + client.sms.send(accounts, 'Hello 3 recipients!', 'Ruby Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 05 -- SMS.send with data +result = run_test('05 SMS.send with data') do + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1, data: { 'city' => 'Miami', 'offer' => '20% off' })] + client.sms.send(accounts, 'Hello from ${city}! Claim your ${offer}.', 'Ruby Test Data') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 06 -- SMS.send with custom_data (messageData) +result = run_test('06 SMS.send with custom_data') do + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1, custom_data: '{"trackingId":"ruby-001"}')] + client.sms.send(accounts, 'Hello with messageData!', 'Ruby Test MsgData') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# ── MMS Tests (7-17) ────────────────────────────────────────────────────────── +puts "\n--- MMS ---" + +signed_url_resp = nil +mms_dep = false + +# 07 -- MMS.get_signed_upload_url +result = run_test('07 MMS.get_signed_upload_url') do + resp = client.mms.get_signed_upload_url('test_image.png', 'image/png') + if resp.signed_s3_url.nil? || resp.signed_s3_url.empty? + mms_dep = true + raise 'signed_s3_url is empty' + end + signed_url_resp = resp +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 08 -- MMS.upload_image_to_signed_url +result = run_test('08 MMS.upload_image_to_signed_url') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + ok = client.mms.upload_image_to_signed_url(signed_url_resp.signed_s3_url, png_path.path, 'image/png') + raise 'upload returned false' unless ok +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 09 -- MMS.send_single +result = run_test('09 MMS.send_single') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + client.mms.send_single(signed_url_resp.file_key, fn1, ln1, phone1, 'MMS single!', 'Ruby MMS Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 10 -- MMS.send (1 recipient) +result = run_test('10 MMS.send (1 recipient)') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1)] + client.mms.send(signed_url_resp.file_key, accounts, 'MMS 1 recipient!', 'Ruby MMS Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 11 -- MMS.send (2 recipients) +result = run_test('11 MMS.send (2 recipients)') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + accounts = [ + CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1), + CCAI::SMS::Account.new(first_name: fn2, last_name: ln2, phone: phone2) + ] + client.mms.send(signed_url_resp.file_key, accounts, 'MMS 2 recipients!', 'Ruby MMS Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 12 -- MMS.send (3 recipients) +result = run_test('12 MMS.send (3 recipients)') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + accounts = [ + CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1), + CCAI::SMS::Account.new(first_name: fn2, last_name: ln2, phone: phone2), + CCAI::SMS::Account.new(first_name: fn3, last_name: ln3, phone: phone3) + ] + client.mms.send(signed_url_resp.file_key, accounts, 'MMS 3 recipients!', 'Ruby MMS Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 13 -- MMS.send with data +result = run_test('13 MMS.send with data') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1, data: { 'product' => 'Widget' })] + client.mms.send(signed_url_resp.file_key, accounts, 'Check out ${product}!', 'Ruby MMS Data') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 14 -- MMS.send with custom_data +result = run_test('14 MMS.send with custom_data') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1, custom_data: '{"campaignId":"mms-ruby-001"}')] + client.mms.send(signed_url_resp.file_key, accounts, 'MMS with messageData!', 'Ruby MMS MsgData') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 15 -- MMS.check_file_uploaded +result = run_test('15 MMS.check_file_uploaded') do + raise 'dependency test 07 failed' if mms_dep || signed_url_resp.nil? + client.mms.check_file_uploaded(signed_url_resp.file_key) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 16 -- MMS.send_with_image (fresh upload) +result = run_test('16 MMS.send_with_image (fresh upload)') do + raise 'dependency test 07 failed' if mms_dep + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1)] + client.mms.send_with_image(png_path.path, 'image/png', accounts, 'MMS with image!', 'Ruby MMS Image', nil, nil, true) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 17 -- MMS.send_with_image (cached) +result = run_test('17 MMS.send_with_image (cached)') do + raise 'dependency test 07 failed' if mms_dep + accounts = [CCAI::SMS::Account.new(first_name: fn1, last_name: ln1, phone: phone1)] + client.mms.send_with_image(png_path.path, 'image/png', accounts, 'MMS cached image!', 'Ruby MMS Cache', nil, nil, true) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# ── Email Tests (18-22) ─────────────────────────────────────────────────────── +puts "\n--- Email ---" + +SENDER_EMAIL = 'noreply@cloudcontactai.com' +SENDER_NAME = 'CCAI Test' +REPLY_EMAIL = 'noreply@cloudcontactai.com' + +# 18 -- Email.send_single +result = run_test('18 Email.send_single') do + client.email.send_single( + fn1, ln1, email1, + 'Ruby SDK Test Email', '
Hello from Ruby SDK!
', + nil, SENDER_EMAIL, REPLY_EMAIL, SENDER_NAME, 'Ruby Email Test' + ) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 19 -- Email.send (1 recipient) +result = run_test('19 Email.send (1 recipient)') do + accounts = [{ firstName: fn1, lastName: ln1, email: email1, phone: '' }] + client.email.send(accounts, 'Ruby SDK Email 1', 'Hello 1!
', SENDER_EMAIL, REPLY_EMAIL, SENDER_NAME, 'Ruby Email Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 20 -- Email.send (2 recipients) +result = run_test('20 Email.send (2 recipients)') do + accounts = [ + { firstName: fn1, lastName: ln1, email: email1, phone: '' }, + { firstName: fn2, lastName: ln2, email: email2, phone: '' } + ] + client.email.send(accounts, 'Ruby SDK Email 2', 'Hello 2!
', SENDER_EMAIL, REPLY_EMAIL, SENDER_NAME, 'Ruby Email Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 21 -- Email.send (3 recipients) +result = run_test('21 Email.send (3 recipients)') do + accounts = [ + { firstName: fn1, lastName: ln1, email: email1, phone: '' }, + { firstName: fn2, lastName: ln2, email: email2, phone: '' }, + { firstName: fn3, lastName: ln3, email: email3, phone: '' } + ] + client.email.send(accounts, 'Ruby SDK Email 3', 'Hello 3!
', SENDER_EMAIL, REPLY_EMAIL, SENDER_NAME, 'Ruby Email Test') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 22 -- Email.send_campaign (full campaign hash) +result = run_test('22 Email.send_campaign') do + campaign = { + subject: 'Ruby SDK Campaign Test', + title: 'Ruby Email Campaign', + message: 'Campaign email from Ruby SDK!
', + senderEmail: SENDER_EMAIL, + replyEmail: REPLY_EMAIL, + senderName: SENDER_NAME, + accounts: [ + { firstName: fn1, lastName: ln1, email: email1, phone: '' }, + { firstName: fn2, lastName: ln2, email: email2, phone: '' } + ], + campaignType: 'EMAIL', + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [] + } + client.email.send_campaign(campaign) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# ── Webhook Tests (23-29) ───────────────────────────────────────────────────── +puts "\n--- Webhook ---" + +WEBHOOK_SECRET = 'test-webhook-secret-ruby' +registered_webhook_id = nil + +# 23 -- Webhook.register +result = run_test('23 Webhook.register') do + resp = client.webhook.register(url: webhook_url, secret: WEBHOOK_SECRET) + id = resp['id'] || resp[:id] + raise 'webhook ID is empty after register' if id.nil? || id.to_s.empty? || id.to_i == 0 + registered_webhook_id = id.to_i +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 24 -- Webhook.list +result = run_test('24 Webhook.list') do + hooks = client.webhook.list + raise 'expected at least one webhook, got 0' unless hooks.is_a?(Array) && !hooks.empty? +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 25 -- Webhook.update +result = run_test('25 Webhook.update') do + raise 'no webhook ID from test 23' if registered_webhook_id.nil? + client.webhook.update(registered_webhook_id, url: "#{webhook_url}?updated=1", secret: 'updated-secret-ruby') +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 26 -- Webhook.verify_signature (valid) +result = run_test('26 Webhook.verify_signature (valid)') do + event_hash = 'abc123eventHash' + sig = hmac_sha256_base64(WEBHOOK_SECRET, "#{client_id}:#{event_hash}") + ok = client.webhook.verify_signature(sig, client_id, event_hash, WEBHOOK_SECRET) + raise 'expected valid signature to return true' unless ok +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 27 -- Webhook.verify_signature (invalid) +result = run_test('27 Webhook.verify_signature (invalid)') do + ok = client.webhook.verify_signature('invalidsig==', client_id, 'somehash', WEBHOOK_SECRET) + raise 'expected invalid signature to return false' if ok +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 28 -- Webhook.parse_event +result = run_test('28 Webhook.parse_event') do + payload = '{"eventType":"message.sent","data":{"To":"+15005550001","Message":"test","MessageStatus":"DELIVERED"}}' + evt = client.webhook.parse_event(payload) + raise 'eventType is missing after parse_event' unless evt['eventType'] +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 29 -- Webhook.delete +result = run_test('29 Webhook.delete') do + raise 'no webhook ID from test 23' if registered_webhook_id.nil? + client.webhook.delete(registered_webhook_id) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# ── Contact Tests (30-31) ───────────────────────────────────────────────────── +puts "\n--- Contact ---" + +# 30 -- Contact.set_do_not_text(true) +result = run_test('30 Contact.set_do_not_text(true)') do + client.contact.set_do_not_text(true, phone: phone1) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 31 -- Contact.set_do_not_text(false) +result = run_test('31 Contact.set_do_not_text(false)') do + client.contact.set_do_not_text(false, phone: phone1) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# ── Brands Tests (32-36) ────────────────────────────────────────────────────── +puts "\n--- Brands ---" + +created_brand_id = nil + +BRAND_PAYLOAD = { + legalCompanyName: 'Ruby SDK Test Brand LLC', + dba: 'Ruby SDK Test Brand', + entityType: 'PRIVATE_PROFIT', + taxId: '123456789', + taxIdCountry: 'US', + country: 'US', + verticalType: 'TECHNOLOGY', + websiteUrl: 'https://rubysdk.example.com', + street: '123 Test St', + city: 'Miami', + state: 'FL', + postalCode: '33101', + contactFirstName: 'Test', + contactLastName: 'User', + contactEmail: email1, + contactPhone: phone1, +}.freeze + +# 32 -- Brand.create +result = run_test('32 Brand.create') do + resp = client.brand.create(BRAND_PAYLOAD) + id = resp['id'] || resp[:id] + raise 'brand ID is empty after create' if id.nil? || id.to_s.empty? + created_brand_id = id.to_s +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 33 -- Brand.get +result = run_test('33 Brand.get') do + raise 'no brand ID from test 32' if created_brand_id.nil? + resp = client.brand.get(created_brand_id) + raise 'expected brand response to be a Hash' unless resp.is_a?(Hash) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 34 -- Brand.list +result = run_test('34 Brand.list') do + resp = client.brand.list + raise 'expected list response to be a Hash or Array' unless resp.is_a?(Hash) || resp.is_a?(Array) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 35 -- Brand.update +result = run_test('35 Brand.update') do + raise 'no brand ID from test 32' if created_brand_id.nil? + client.brand.update(created_brand_id, { city: 'Fort Lauderdale' }) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 36 -- Brand.delete +result = run_test('36 Brand.delete') do + raise 'no brand ID from test 32' if created_brand_id.nil? + client.brand.delete(created_brand_id) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# ── Campaigns Tests (37-42) ─────────────────────────────────────────────────── +puts "\n--- Campaigns ---" + +campaign_brand_id = nil +created_campaign_id = nil + +# 37 -- Campaign setup: create a brand to use +result = run_test('37 Campaign setup — Brand.create') do + resp = client.brand.create(BRAND_PAYLOAD) + id = resp['id'] || resp[:id] + raise 'brand ID is empty for campaign setup' if id.nil? || id.to_s.empty? + campaign_brand_id = id.to_s +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +CAMPAIGN_PAYLOAD_TEMPLATE = { + useCase: 'MARKETING', + description: 'Ruby SDK test campaign for integration testing', + messageFlow: 'Users opt-in via our website form.', + hasEmbeddedLinks: false, + hasEmbeddedPhone: false, + isAgeGated: false, + isDirectLending: false, + optInKeywords: %w[START YES], + optInMessage: 'You are now subscribed. Reply STOP to unsubscribe.', + optInProofUrl: 'https://rubysdk.example.com/optin', + helpKeywords: %w[HELP INFO], + helpMessage: 'For help, contact support@example.com. Reply HELP for assistance.', + optOutKeywords: %w[STOP CANCEL], + optOutMessage: 'You have been unsubscribed. Reply STOP to opt out.', + sampleMessages: [ + 'Hello! Reply STOP to opt out.', + 'Hi there! Reply HELP for assistance.' + ], +}.freeze + +# 38 -- Campaign.create +result = run_test('38 Campaign.create') do + raise 'no brand ID from test 37' if campaign_brand_id.nil? + payload = CAMPAIGN_PAYLOAD_TEMPLATE.merge(brandId: campaign_brand_id) + resp = client.campaign.create(payload) + id = resp['id'] || resp[:id] + raise 'campaign ID is empty after create' if id.nil? || id.to_s.empty? + created_campaign_id = id.to_s +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 39 -- Campaign.get +result = run_test('39 Campaign.get') do + raise 'no campaign ID from test 38' if created_campaign_id.nil? + resp = client.campaign.get(created_campaign_id) + raise 'expected campaign response to be a Hash' unless resp.is_a?(Hash) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 40 -- Campaign.list +result = run_test('40 Campaign.list') do + resp = client.campaign.list + raise 'expected list response to be a Hash or Array' unless resp.is_a?(Hash) || resp.is_a?(Array) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 41 -- Campaign.update +result = run_test('41 Campaign.update') do + raise 'no campaign ID from test 38' if created_campaign_id.nil? + client.campaign.update(created_campaign_id, { description: 'Updated Ruby SDK campaign' }) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 42 -- Campaign.delete +result = run_test('42 Campaign.delete') do + raise 'no campaign ID from test 38' if created_campaign_id.nil? + client.campaign.delete(created_campaign_id) +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# cleanup campaign brand +client.brand.delete(campaign_brand_id) if campaign_brand_id + +# 43 -- ContactValidator.validate_email +result = run_test('43 ContactValidator.validate_email') do + resp = client.contact_validator.validate_email(email1) + raise 'status is empty' if resp[:status].to_s.empty? +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 44 -- ContactValidator.validate_emails +result = run_test('44 ContactValidator.validate_emails') do + resp = client.contact_validator.validate_emails([email1, email2]) + raise "expected summary.total=2, got #{resp.dig(:summary, :total)}" unless resp.dig(:summary, :total) == 2 +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 45 -- ContactValidator.validate_phone +result = run_test('45 ContactValidator.validate_phone') do + resp = client.contact_validator.validate_phone(phone1) + raise 'status is empty' if resp[:status].to_s.empty? +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# 46 -- ContactValidator.validate_phones +result = run_test('46 ContactValidator.validate_phones') do + resp = client.contact_validator.validate_phones([{ phone: phone1 }, { phone: phone2 }]) + raise "expected summary.total=2, got #{resp.dig(:summary, :total)}" unless resp.dig(:summary, :total) == 2 +end +passed += result ? 1 : 0; failed += result ? 0 : 1 + +# ── Cleanup & Results ───────────────────────────────────────────────────────── +png_path.close! # cleanup Tempfile + +puts "\n==============================================" +puts " RESULTS: #{passed} passed, #{failed} failed" +puts "==============================================" + +summary = JSON.generate({ sdk: 'ruby', passed: passed, failed: failed, total: passed + failed }) +puts "\nSUMMARY_JSON: #{summary}" + +exit(failed > 0 ? 1 : 0) diff --git a/lib/ccai.rb b/lib/ccai.rb index 0191922..4006f38 100644 --- a/lib/ccai.rb +++ b/lib/ccai.rb @@ -11,6 +11,8 @@ require 'ccai/email/email_service' require 'ccai/webhook/webhook_service' require 'ccai/contact/contact_service' +require 'ccai/brand/brand_service' +require 'ccai/campaign/campaign_service' # Main module for the CCAI Ruby client module CCAI diff --git a/lib/ccai/brand/brand_service.rb b/lib/ccai/brand/brand_service.rb new file mode 100644 index 0000000..7f2fed6 --- /dev/null +++ b/lib/ccai/brand/brand_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 CloudContactAI LLC +# Licensed under the MIT License. See LICENSE in the project root for license information. + +module CCAI + module Brand + # Service for managing Brands (10DLC compliance) + class BrandService + # Create a new BrandService instance + # + # @param client [CCAI::Client] The parent CCAI client + def initialize(client) + @client = client + end + + # Create a new brand + # + # @param brand [Hash] Brand attributes + # @return [Hash] API response + def create(brand) + validate!(brand) + @client.compliance_request(:post, '/v1/brands', brand) + end + + # Get a brand by ID + # + # @param brand_id [String] Brand ID + # @return [Hash] API response + def get(brand_id) + raise ArgumentError, 'brandId is required' if brand_id.nil? || brand_id.to_s.empty? + + @client.compliance_request(:get, "/v1/brands/#{brand_id}") + end + + # List all brands for the account + # + # @return [Hash] API response + def list + @client.compliance_request(:get, '/v1/brands') + end + + # Update a brand + # + # @param brand_id [String] Brand ID + # @param brand [Hash] Updated brand attributes + # @return [Hash] API response + def update(brand_id, brand) + raise ArgumentError, 'brandId is required' if brand_id.nil? || brand_id.to_s.empty? + + validate!(brand, is_create: false) + @client.compliance_request(:patch, "/v1/brands/#{brand_id}", brand) + end + + # Delete a brand + # + # @param brand_id [String] Brand ID + # @return [Hash] API response + def delete(brand_id) + raise ArgumentError, 'brandId is required' if brand_id.nil? || brand_id.to_s.empty? + + @client.compliance_request(:delete, "/v1/brands/#{brand_id}") + end + + private + + REQUIRED_CREATE_FIELDS = %i[ + legalCompanyName entityType taxId taxIdCountry country + verticalType websiteUrl street city state postalCode + contactFirstName contactLastName contactEmail contactPhone + ].freeze + + def validate!(brand, is_create: true) + if is_create + REQUIRED_CREATE_FIELDS.each do |field| + val = brand[field] || brand[field.to_s] + raise ArgumentError, "#{field} is required" if val.nil? || val.to_s.strip.empty? + end + end + + entity_type = brand[:entityType] || brand['entityType'] + if entity_type == 'PUBLIC_PROFIT' + stock_symbol = brand[:stockSymbol] || brand['stockSymbol'] + stock_exchange = brand[:stockExchange] || brand['stockExchange'] + if stock_symbol.nil? || stock_symbol.to_s.empty? + raise ArgumentError, 'stockSymbol is required for PUBLIC_PROFIT entities' + end + if stock_exchange.nil? || stock_exchange.to_s.empty? + raise ArgumentError, 'stockExchange is required for PUBLIC_PROFIT entities' + end + end + + website_url = brand[:websiteUrl] || brand['websiteUrl'] + if website_url && !website_url.to_s.match?(/^https?:\/\//) + raise ArgumentError, 'websiteUrl must start with http:// or https://' + end + + contact_email = brand[:contactEmail] || brand['contactEmail'] + if contact_email && !contact_email.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/) + raise ArgumentError, 'contactEmail must be a valid email address' + end + end + end + end +end diff --git a/lib/ccai/campaign/campaign_service.rb b/lib/ccai/campaign/campaign_service.rb new file mode 100644 index 0000000..d9bb845 --- /dev/null +++ b/lib/ccai/campaign/campaign_service.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 CloudContactAI LLC +# Licensed under the MIT License. See LICENSE in the project root for license information. + +module CCAI + module Campaign + # Service for managing Campaigns (10DLC compliance) + class CampaignService + # Create a new CampaignService instance + # + # @param client [CCAI::Client] The parent CCAI client + def initialize(client) + @client = client + end + + # Create a new campaign + # + # @param campaign [Hash] Campaign attributes + # @return [Hash] API response + def create(campaign) + validate!(campaign, is_create: true) + @client.compliance_request(:post, '/v1/campaigns', campaign) + end + + # Get a campaign by ID + # + # @param campaign_id [String] Campaign ID + # @return [Hash] API response + def get(campaign_id) + raise ArgumentError, 'campaignId is required' if campaign_id.nil? || campaign_id.to_s.empty? + + @client.compliance_request(:get, "/v1/campaigns/#{campaign_id}") + end + + # List all campaigns for the account + # + # @return [Hash] API response + def list + @client.compliance_request(:get, '/v1/campaigns') + end + + # Update a campaign + # + # @param campaign_id [String] Campaign ID + # @param campaign [Hash] Updated campaign attributes + # @return [Hash] API response + def update(campaign_id, campaign) + raise ArgumentError, 'campaignId is required' if campaign_id.nil? || campaign_id.to_s.empty? + + validate!(campaign, is_create: false) + @client.compliance_request(:patch, "/v1/campaigns/#{campaign_id}", campaign) + end + + # Delete a campaign + # + # @param campaign_id [String] Campaign ID + # @return [Hash] API response + def delete(campaign_id) + raise ArgumentError, 'campaignId is required' if campaign_id.nil? || campaign_id.to_s.empty? + + @client.compliance_request(:delete, "/v1/campaigns/#{campaign_id}") + end + + private + + REQUIRED_CREATE_FIELDS = %i[ + brandId useCase description messageFlow + hasEmbeddedLinks hasEmbeddedPhone isAgeGated isDirectLending + optInKeywords optInMessage optInProofUrl + helpKeywords helpMessage + optOutKeywords optOutMessage + sampleMessages + ].freeze + + def validate!(campaign, is_create: true) + if is_create + REQUIRED_CREATE_FIELDS.each do |field| + val = campaign.key?(field) ? campaign[field] : campaign[field.to_s] + raise ArgumentError, "#{field} is required" if val.nil? + end + + brand_id = campaign.key?(:brandId) ? campaign[:brandId] : campaign['brandId'] + raise ArgumentError, 'brandId is required' if brand_id.to_s.strip.empty? + end + + sample_messages = campaign.key?(:sampleMessages) ? campaign[:sampleMessages] : campaign['sampleMessages'] + if sample_messages + unless sample_messages.is_a?(Array) && sample_messages.length.between?(2, 5) + raise ArgumentError, 'sampleMessages must have between 2 and 5 items' + end + + opt_out_keywords = (campaign[:optOutKeywords] || campaign['optOutKeywords'] || []).map(&:upcase) + help_keywords = (campaign[:helpKeywords] || campaign['helpKeywords'] || []).map(&:upcase) + + has_stop = sample_messages.any? do |m| + msg = m.to_s.upcase + msg.include?('REPLY STOP') || opt_out_keywords.any? { |kw| msg.include?("REPLY #{kw}") } + end + raise ArgumentError, 'at least one sampleMessage must contain "Reply STOP" or an optOutKeyword' unless has_stop + + has_help = sample_messages.any? do |m| + msg = m.to_s.upcase + msg.include?('REPLY HELP') || help_keywords.any? { |kw| msg.include?("REPLY #{kw}") } + end + raise ArgumentError, 'at least one sampleMessage must contain "Reply HELP" or a helpKeyword' unless has_help + end + + opt_out_msg = campaign[:optOutMessage] || campaign['optOutMessage'] + if opt_out_msg + opt_out_keywords = (campaign[:optOutKeywords] || campaign['optOutKeywords'] || []).map(&:upcase) + unless opt_out_msg.to_s.upcase.include?('STOP') || opt_out_keywords.any? { |kw| opt_out_msg.to_s.upcase.include?(kw) } + raise ArgumentError, 'optOutMessage must contain "STOP" or an optOutKeyword' + end + end + + use_case = campaign[:useCase] || campaign['useCase'] + if use_case && %w[MIXED LOW_VOLUME_MIXED].include?(use_case.to_s) + sub_use_cases = campaign[:subUseCases] || campaign['subUseCases'] + unless sub_use_cases.is_a?(Array) && sub_use_cases.length.between?(2, 3) + raise ArgumentError, 'MIXED/LOW_VOLUME_MIXED campaigns require 2-3 subUseCases' + end + end + end + end + end +end diff --git a/lib/ccai/client.rb b/lib/ccai/client.rb index 271876c..ff8074a 100644 --- a/lib/ccai/client.rb +++ b/lib/ccai/client.rb @@ -10,21 +10,26 @@ require 'ccai/email/email_service' require 'ccai/webhook/webhook_service' require 'ccai/contact/contact_service' +require 'ccai/brand/brand_service' +require 'ccai/campaign/campaign_service' +require 'ccai/contact_validator/contact_validator_service' module CCAI # Configuration for the CCAI client class Config - attr_reader :client_id, :api_key, :base_url, :email_base_url, :files_base_url, :use_test_environment + attr_reader :client_id, :api_key, :base_url, :email_base_url, :files_base_url, :compliance_base_url, :use_test_environment # Production URLs - PROD_BASE_URL = 'https://core.cloudcontactai.com/api' - PROD_EMAIL_URL = 'https://email-campaigns.cloudcontactai.com/api/v1' - PROD_FILES_URL = 'https://files.cloudcontactai.com' + PROD_BASE_URL = 'https://core.cloudcontactai.com/api' + PROD_EMAIL_URL = 'https://email-campaigns.cloudcontactai.com/api/v1' + PROD_FILES_URL = 'https://files.cloudcontactai.com' + PROD_COMPLIANCE_URL = 'https://compliance.cloudcontactai.com/api' # Test environment URLs - TEST_BASE_URL = 'https://core-test-cloudcontactai.allcode.com/api' - TEST_EMAIL_URL = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1' - TEST_FILES_URL = 'https://files-test-cloudcontactai.allcode.com' + TEST_BASE_URL = 'https://core-test-cloudcontactai.allcode.com/api' + TEST_EMAIL_URL = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1' + TEST_FILES_URL = 'https://files-test-cloudcontactai.allcode.com' + TEST_COMPLIANCE_URL = 'https://compliance-test-cloudcontactai.allcode.com/api' # Create a new configuration # @@ -34,7 +39,8 @@ class Config # @param base_url [String, nil] Override base URL for the core API # @param email_base_url [String, nil] Override base URL for the Email API # @param files_base_url [String, nil] Override base URL for the Files API - def initialize(client_id:, api_key:, use_test_environment: false, base_url: nil, email_base_url: nil, files_base_url: nil) + # @param compliance_base_url [String, nil] Override base URL for the Compliance API + def initialize(client_id:, api_key:, use_test_environment: false, base_url: nil, email_base_url: nil, files_base_url: nil, compliance_base_url: nil) @client_id = client_id @api_key = api_key @use_test_environment = use_test_environment @@ -51,12 +57,16 @@ def initialize(client_id:, api_key:, use_test_environment: false, base_url: nil, @files_base_url = files_base_url || ENV.fetch('CCAI_FILES_BASE_URL', nil) || (use_test_environment ? TEST_FILES_URL : PROD_FILES_URL) + + @compliance_base_url = compliance_base_url || + ENV.fetch('CCAI_COMPLIANCE_BASE_URL', nil) || + (use_test_environment ? TEST_COMPLIANCE_URL : PROD_COMPLIANCE_URL) end end # Main client for interacting with the CloudContactAI API class Client - attr_reader :config, :sms, :mms, :email, :webhook, :contact + attr_reader :config, :sms, :mms, :email, :webhook, :contact, :brand, :campaign, :contact_validator # Create a new CCAI client instance # @@ -88,6 +98,15 @@ def initialize(config) # Initialize the Contact service @contact = Contact::ContactService.new(self) + + # Initialize the Brand service + @brand = Brand::BrandService.new(self) + + # Initialize the Campaign service + @campaign = Campaign::CampaignService.new(self) + + # Initialize the ContactValidator service + @contact_validator = ContactValidator::ContactValidatorService.new(self) end # Get the client ID @@ -125,6 +144,13 @@ def files_base_url @config.files_base_url end + # Get the base URL for the Compliance API + # + # @return [String] Compliance base URL + def compliance_base_url + @config.compliance_base_url + end + # Whether the test environment is active # # @return [Boolean] @@ -155,8 +181,31 @@ def request(method, endpoint, data = nil, headers = nil) raise Error.new("Request failed: #{e.message}") end end + + # Make an authenticated API request to the Compliance API + # + # @param method [Symbol] HTTP method (:get, :post, etc.) + # @param endpoint [String] API endpoint + # @param data [Hash, nil] Request data + # @return [Hash] API response + # @raise [CCAI::Error] If the API returns an error + def compliance_request(method, endpoint, data = nil) + url = "#{@config.compliance_base_url}#{endpoint}" + + begin + response = @connection.run_request(method, url, data ? data.to_json : nil, nil) + + if response.success? + response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body) + else + raise Error.new("API Error: #{response.status} - #{response.body}") + end + rescue Faraday::Error => e + raise Error.new("Request failed: #{e.message}") + end + end end # Base error class for CCAI errors class Error < StandardError; end -end +end \ No newline at end of file diff --git a/lib/ccai/contact_validator/contact_validator_service.rb b/lib/ccai/contact_validator/contact_validator_service.rb new file mode 100644 index 0000000..04c862d --- /dev/null +++ b/lib/ccai/contact_validator/contact_validator_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 CloudContactAI LLC +# Licensed under the MIT License. See LICENSE in the project root for license information. + +module CCAI + module ContactValidator + # Service for validating email addresses and phone numbers + class ContactValidatorService + # Create a new ContactValidatorService instance + # + # @param client [CCAI::Client] The parent CCAI client + def initialize(client) + @client = client + end + + # Validate a single email address + # + # @param email [String] Email address to validate + # @return [Hash] Validation result with contact, type, status and metadata + def validate_email(email) + @client.request(:post, '/v1/contact-validator/email', { email: email }) + end + + # Validate multiple email addresses (up to 50) + # + # @param emails [Array