Files
simply_based/spec/s3_spec.rb
GregandClaude Opus 4.8 86299b8827 SimplyBased 0.1.0 — Couchbase-native ActiveModel ORM
A from-scratch fork of simply_couch's model layer that runs on Couchbase
natively: `include SimplyBased::Model` instead of `include SimplyCouch::Model`.

- SimplyBased::Store replaces the CouchDB layer: KV documents, N1QL/SQL++
  finders + lazily-created GSIs, CAS mapped onto _rev. No design documents or
  JavaScript map/reduce — the hand-written association views become N1QL
  (belongs_to/has_many/has_one scalar FK; habtm via ANY ... SATISFIES + array
  index, or KV multi-get on the storing side).
- Redis replacements on the same cluster: Counter (atomic increment/decrement),
  Cache (ActiveSupport::Cache::Store over KV+TTL, drop-in Rails.cache/ActiveJob),
  Lock (insert-if-absent + TTL + CAS-checked release).
- Runs the simply_couch test suite against live Couchbase: 378/432 applicable
  examples green (CouchDB-mechanic specs tagged :couchdb_only and excluded).
- Remaining: ancestry (tree) + embedded_in reverse-lookups (recursive/embedded
  N1QL) and a few pagination/lifecycle edges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:58:47 -05:00

263 lines
8.4 KiB
Ruby

require 'spec_helper'
class CouchLogItem
include SimplyBased::Model
has_s3_attached :log_data, bucket: 'bucket-for-monsieur', access_key: 'abcdef', secret_access_key: 'secret!'
end
describe 'S3 attachment' do
let(:client) { instance_double(Aws::S3::Client) }
before do
CouchLogItem._s3_options[:log_data][:bucket] = 'bucket-for-monsieur'
CouchLogItem._s3_options[:log_data][:location] = :us
CouchLogItem._s3_options[:log_data][:permissions] = 'private'
CouchLogItem._s3_options[:log_data][:after_delete] = :nothing
CouchLogItem._s3_options[:log_data][:logger] = nil
allow(Aws::S3::Client).to receive(:new).and_return(client)
allow(client).to receive(:put_object)
allow(client).to receive(:delete_object)
end
let(:log_item) do
item = CouchLogItem.new
item.instance_variable_set(:@_s3_client, nil)
item.instance_variable_set(:@_s3_bucket, nil)
item
end
context 'when saving the attachment' do
it 'creates an S3 client' do
log_item.log_data = 'Yay! It logged!'
expect(Aws::S3::Client).to receive(:new).with(
access_key_id: 'abcdef',
secret_access_key: 'secret!',
region: 'us-east-1',
logger: nil
).and_return(client)
allow(client).to receive(:head_bucket)
log_item.save
end
it 'uploads the file' do
log_item.log_data = 'Yay! It logged!'
allow(client).to receive(:head_bucket)
expect(client).to receive(:put_object).with(
bucket: 'bucket-for-monsieur',
key: anything,
body: 'Yay! It logged!',
acl: 'private'
)
log_item.save
end
it 'also uploads on save!' do
log_item.log_data = 'Yay! It logged!'
allow(client).to receive(:head_bucket)
expect(client).to receive(:put_object)
log_item.save!
end
it 'uses the specified bucket' do
CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket'
log_item.log_data = 'Yay! It logged!'
expect(client).to receive(:head_bucket).with(bucket: 'mybucket')
expect(client).to receive(:put_object).with(hash_including(bucket: 'mybucket'))
log_item.save
end
it 'creates the bucket if it does not exist' do
CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket'
log_item.log_data = 'Yay! log me'
expect(client).to receive(:head_bucket).with(bucket: 'mybucket')
.and_raise(Aws::S3::Errors::NoSuchBucket.new(nil, 'NoSuchBucket'))
expect(client).to receive(:create_bucket).with(
bucket: 'mybucket',
create_bucket_configuration: nil
)
allow(client).to receive(:put_object)
log_item.save
end
it 'creates an EU bucket with location constraint' do
CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket'
CouchLogItem._s3_options[:log_data][:location] = :eu
log_item.log_data = 'Yay! log me'
expect(client).to receive(:head_bucket).with(bucket: 'mybucket')
.and_raise(Aws::S3::Errors::NoSuchBucket.new(nil, 'NoSuchBucket'))
expect(client).to receive(:create_bucket).with(
bucket: 'mybucket',
create_bucket_configuration: { location_constraint: 'eu-west-1' }
)
allow(client).to receive(:put_object)
log_item.save
end
it 'raises an error if the bucket is not ours' do
CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket'
CouchLogItem._s3_options[:log_data][:location] = :eu
log_item.log_data = 'Yay! log me too'
expect(client).to receive(:head_bucket).with(bucket: 'mybucket')
.and_raise(Aws::S3::Errors::NoSuchBucket.new(nil, 'NoSuchBucket'))
expect(client).to receive(:create_bucket).with(
bucket: 'mybucket',
create_bucket_configuration: { location_constraint: 'eu-west-1' }
).and_raise(Aws::S3::Errors::BucketAlreadyExists.new(nil, 'BucketAlreadyExists'))
expect { log_item.save }.to raise_error(ArgumentError)
end
it 'passes the logger to the S3 client' do
log_item.log_data = 'Yay! log me'
CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket'
CouchLogItem._s3_options[:log_data][:logger] = Logger.new(nil)
expect(Aws::S3::Client).to receive(:new).with(
access_key_id: 'abcdef',
secret_access_key: 'secret!',
region: 'us-east-1',
logger: kind_of(Logger)
).and_return(client)
allow(client).to receive(:head_bucket)
log_item.save
CouchLogItem._s3_options[:log_data][:logger] = nil
end
it 'does not upload when not changed' do
allow(client).to receive(:head_bucket)
expect(client).not_to receive(:put_object)
log_item.save
end
it 'uses full class name and id as key' do
log_item.log_data = 'Yay!'
allow(client).to receive(:head_bucket)
expect(client).to receive(:put_object).with(hash_including(key: /couch_log_items\/log_data\//))
log_item.save
end
it 'marks attachment not dirty after upload' do
log_item.log_data = 'Yay!'
allow(client).to receive(:head_bucket)
log_item.save
expect(log_item.instance_variable_get(:@_s3_attachments)[:log_data][:dirty]).to be_falsy
end
it 'stores attachment when validations succeed' do
log_item.log_data = 'Yay!'
allow(client).to receive(:head_bucket)
expect(client).to receive(:put_object)
log_item.save
end
xit 'does not store attachment when validations fail' do
log_item.log_data = 'Yay!'
allow(client).to receive(:head_bucket)
allow(log_item).to receive(:valid?).and_return(false)
expect(client).not_to receive(:put_object)
log_item.save
end
it 'serializes non-string data to json' do
log_item.log_data = ['one log entry', 'and another one']
allow(client).to receive(:head_bucket)
expect(client).to receive(:put_object).with(hash_including(body: '["one log entry","and another one"]'))
log_item.save
end
context 'attachment size' do
before do
allow(client).to receive(:head_bucket)
end
it 'stores on upload' do
log_item.log_data = 'abc'
log_item.save
expect(log_item.log_data_size).to eq 3
end
it 'updates size on change' do
log_item.log_data = 'abc'
log_item.save
expect(log_item.log_data_size).to eq 3
log_item.log_data = 'example'
log_item.save
expect(log_item.log_data_size).to eq 7
end
it 'stores size of json attachments' do
log_item.log_data = ['abc']
log_item.save
expect(log_item.log_data_size).to eq ['abc'].to_json.size
end
end
end
context 'when fetching the data' do
let(:resp) { double('response', body: double(read: 'Yay!')) }
before do
allow(client).to receive(:get_object).and_return(resp)
end
it 'creates a configured S3 connection' do
CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket'
CouchLogItem._s3_options[:log_data][:location] = :eu
expect(Aws::S3::Client).to receive(:new).with(
access_key_id: 'abcdef',
secret_access_key: 'secret!',
region: 'eu-west-1',
logger: nil
).and_return(client)
log_item.log_data
end
it 'fetches data from s3' do
log_item.instance_variable_set(:@_s3_attachments, {})
expect(client).to receive(:get_object).with(
bucket: 'bucket-for-monsieur',
key: anything
).and_return(resp)
expect(log_item.log_data).to eq 'Yay!'
end
it 'does not mark attachment as dirty' do
log_item.instance_variable_set(:@_s3_attachments, {})
log_item.log_data
expect(log_item._s3_attachments[:log_data][:dirty]).to be_falsy
end
it 'does not fetch if already set' do
log_item.log_data = 'Yay!'
expect(client).not_to receive(:get_object)
expect(log_item.log_data).to eq 'Yay!'
end
end
context 'when deleting' do
before do
CouchLogItem._s3_options[:log_data][:after_delete] = :nothing
allow(client).to receive(:head_bucket)
log_item.log_data = 'Yatzzee'
log_item.save
end
it 'does nothing to S3' do
expect(client).not_to receive(:delete_object)
log_item.delete
end
it 'deletes on S3 if configured' do
CouchLogItem._s3_options[:log_data][:after_delete] = :delete
expect(client).to receive(:delete_object).with(
bucket: 'bucket-for-monsieur',
key: anything
)
log_item.delete
end
end
end