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>
263 lines
8.4 KiB
Ruby
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
|