468 lines
15 KiB
Ruby
468 lines
15 KiB
Ruby
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
|
|
|
require 'rails_helper'
|
|
|
|
# NOTE: This class uses custom .add & .remove methods
|
|
# to create and destroy records.
|
|
# This pattern is a strong candidate for refactoring
|
|
# to make use of Rails' native ActiveRecord + callbacks functionality.
|
|
RSpec.describe Store, type: :model do
|
|
subject(:store) { described_class.add(**attributes) }
|
|
|
|
let(:attributes) do
|
|
{
|
|
object: 'Test',
|
|
o_id: 1,
|
|
data: data,
|
|
filename: filename,
|
|
preferences: preferences,
|
|
created_by_id: 1,
|
|
}
|
|
end
|
|
|
|
let(:data) { 'hello world' }
|
|
let(:filename) { 'test.txt' }
|
|
let(:preferences) { {} }
|
|
|
|
describe 'Class methods:' do
|
|
describe '.add' do
|
|
it 'creates a new Store record' do
|
|
expect { described_class.add(**attributes) }.to change(described_class, :count).by(1)
|
|
end
|
|
|
|
it 'returns the newly created Store record' do
|
|
expect(described_class.add(**attributes)).to eq(described_class.last)
|
|
end
|
|
|
|
it 'saves data to #content attribute' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.content }.to('hello world')
|
|
end
|
|
|
|
it 'saves filename to #filename attribute' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.filename }.to('test.txt')
|
|
end
|
|
|
|
it 'sets #provider attribute to "DB"' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.provider }.to('DB')
|
|
end
|
|
|
|
context 'with UTF-8 (non-ASCII) characters in text' do
|
|
let(:data) { 'hello world äöüß' }
|
|
|
|
it 'stores data as binary string to #content attribute' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.content }.to('hello world äöüß'.force_encoding('ASCII-8BIT'))
|
|
end
|
|
end
|
|
|
|
context 'with UTF-8 (non-ASCII) characters in filename' do
|
|
let(:filename) { 'testäöüß.txt' }
|
|
|
|
it 'stores filename verbatim to #filename attribute' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.filename }.to('testäöüß.txt')
|
|
end
|
|
end
|
|
|
|
context 'with binary data' do
|
|
let(:data) { File.binread(Rails.root.join('test/data/pdf/test1.pdf')) }
|
|
|
|
it 'stores data as binary string to #content attribute' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.content&.class }.to(String)
|
|
.and change { described_class.last&.content }.to(data)
|
|
end
|
|
|
|
it 'saves filename to #filename attribute' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.filename }.to('test.txt')
|
|
end
|
|
|
|
it 'sets #provider attribute to "DB"' do
|
|
expect { described_class.add(**attributes) }
|
|
.to change { described_class.last&.provider }.to('DB')
|
|
end
|
|
|
|
context 'when an identical file has been stored before under a different name' do
|
|
before { described_class.add(**attributes) }
|
|
|
|
it 'creates a new (duplicate) described_class record' do
|
|
expect { described_class.add(**attributes.merge(filename: 'test-again.pdf')) }
|
|
.to change(described_class, :count).by(1)
|
|
.and change { described_class.last&.filename }.to('test-again.pdf')
|
|
.and not_change { described_class.last&.content&.class }
|
|
.and not_change { described_class.last&.content }
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with an image (jpeg/jpg/png)' do
|
|
let(:data) { File.binread(Rails.root.join('test/data/upload/upload2.jpg')) }
|
|
let(:preferences) { { content_type: 'image/jpg' } }
|
|
|
|
it 'generates previews' do
|
|
described_class.add(**attributes)
|
|
|
|
expect(described_class.last.preferences)
|
|
.to include(resizable: true, content_inline: true, content_preview: true)
|
|
end
|
|
|
|
context 'when system is in import mode' do
|
|
before { Setting.set('import_mode', true) }
|
|
|
|
it 'does not generate previews' do
|
|
described_class.add(**attributes)
|
|
|
|
expect(described_class.last.preferences)
|
|
.not_to include(resizable: true, content_inline: true, content_preview: true)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.remove' do
|
|
before { described_class.add(**attributes) }
|
|
|
|
it 'destroys the specified Store record' do
|
|
expect { described_class.remove(object: 'Test', o_id: 1) }
|
|
.to change(described_class, :count).by(-1)
|
|
end
|
|
|
|
it 'destroys the associated Store::File record' do
|
|
expect { described_class.remove(object: 'Test', o_id: 1) }
|
|
.to change { described_class::File.count }.by(-1)
|
|
end
|
|
|
|
context 'with the same file stored under multiple o_ids' do
|
|
before { described_class.add(**attributes.merge(o_id: 2)) }
|
|
|
|
it 'destroys only the specified Store record' do
|
|
expect { described_class.remove(object: 'Test', o_id: 1) }
|
|
.to change(described_class, :count).by(-1)
|
|
end
|
|
|
|
it 'does not destroy the associated Store::File record (because it is referenced by another Store)' do
|
|
expect { described_class.remove(object: 'Test', o_id: 1) }
|
|
.not_to change { Store::File.count }
|
|
end
|
|
end
|
|
|
|
context 'with multiple files stored under the same o_id' do
|
|
before { described_class.add(**attributes.merge(data: 'bar')) }
|
|
|
|
it 'destroys all matching Store records' do
|
|
expect { described_class.remove(object: 'Test', o_id: 1) }
|
|
.to change(described_class, :count).by(-2)
|
|
end
|
|
|
|
it 'destroys all associated Store::File records' do
|
|
expect { described_class.remove(object: 'Test', o_id: 1) }
|
|
.to change { Store::File.count }.by(-2)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.list' do
|
|
let!(:store) do
|
|
described_class.add(
|
|
object: 'Test',
|
|
o_id: 1,
|
|
data: 'hello world',
|
|
filename: 'test.txt',
|
|
preferences: {},
|
|
created_by_id: 1,
|
|
)
|
|
end
|
|
|
|
it 'runs a Store.where query for :object / :o_id parameters (:object is Store::Object association name)' do
|
|
expect(described_class.list(object: 'Test', o_id: 1))
|
|
.to eq([store])
|
|
end
|
|
|
|
context 'without a Store::Object name' do
|
|
it 'returns an empty ActiveRecord::Relation' do
|
|
expect(described_class.list(o_id: 1))
|
|
.to be_an(ActiveRecord::Relation).and be_empty
|
|
end
|
|
end
|
|
|
|
context 'without a #o_id' do
|
|
it 'returns an empty ActiveRecord::Relation' do
|
|
expect(described_class.list(object: 'Test'))
|
|
.to be_an(ActiveRecord::Relation).and be_empty
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'Instance methods:' do
|
|
describe 'image previews (#content_inline / #content_preview)' do
|
|
let(:attributes) do
|
|
{
|
|
object: 'Test',
|
|
o_id: 1,
|
|
data: data,
|
|
filename: 'test1.pdf',
|
|
preferences: {
|
|
content_type: content_type,
|
|
content_id: 234,
|
|
},
|
|
created_by_id: 1,
|
|
}
|
|
end
|
|
|
|
let(:resized_inline_image) do
|
|
File.binwrite(temp_file, store.content_inline)
|
|
Rszr::Image.load(temp_file)
|
|
end
|
|
|
|
let(:resized_preview_image) do
|
|
File.binwrite(temp_file.next, store.content_preview)
|
|
Rszr::Image.load(temp_file.next)
|
|
end
|
|
|
|
let(:temp_file) { Tempfile.new.path }
|
|
|
|
context 'with content_type: "text/plain"' do
|
|
let(:content_type) { 'text/plain' }
|
|
|
|
context 'and text content' do
|
|
let(:data) { 'foo' }
|
|
|
|
it 'cannot be resized (neither inlined nor previewed)' do
|
|
expect { store.content_inline }
|
|
.to raise_error('Inline content could not be generated.')
|
|
|
|
expect { store.content_preview }
|
|
.to raise_error('Content preview could not be generated.')
|
|
|
|
expect(store.preferences)
|
|
.to not_include(resizable: true)
|
|
.and not_include(content_inline: true)
|
|
.and not_include(content_preview: true)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with content_type: "image/*"' do
|
|
context 'and text content' do
|
|
let(:content_type) { 'image/jpeg' }
|
|
let(:data) { 'foo' }
|
|
|
|
it 'cannot be resized (neither inlined nor previewed)' do
|
|
expect { store.content_inline }
|
|
.to raise_error('Inline content could not be generated.')
|
|
|
|
expect { store.content_preview }
|
|
.to raise_error('Content preview could not be generated.')
|
|
|
|
expect(store.preferences)
|
|
.to not_include(resizable: true)
|
|
.and not_include(content_inline: true)
|
|
.and not_include(content_preview: true)
|
|
end
|
|
end
|
|
|
|
context 'with image content (width > 1800px)' do
|
|
context 'width <= 200px' do
|
|
let(:content_type) { 'image/png' }
|
|
let(:data) { File.binread(Rails.root.join('test/data/image/1x1.png')) }
|
|
|
|
it 'cannot be resized (neither inlined nor previewed)' do
|
|
expect { store.content_inline }
|
|
.to raise_error('Inline content could not be generated.')
|
|
|
|
expect { store.content_preview }
|
|
.to raise_error('Content preview could not be generated.')
|
|
|
|
expect(store.preferences)
|
|
.to not_include(resizable: true)
|
|
.and not_include(content_inline: true)
|
|
.and not_include(content_preview: true)
|
|
end
|
|
end
|
|
|
|
context '200px < width <= 1800px)' do
|
|
let(:content_type) { 'image/png' }
|
|
let(:data) { File.binread(Rails.root.join('test/data/image/1000x1000.png')) }
|
|
|
|
it 'can be resized (previewed but not inlined)' do
|
|
expect { store.content_inline }
|
|
.to raise_error('Inline content could not be generated.')
|
|
|
|
expect(resized_preview_image.width).to eq(200)
|
|
|
|
expect(store.preferences)
|
|
.to include(resizable: true)
|
|
.and not_include(content_inline: true)
|
|
.and include(content_preview: true)
|
|
end
|
|
end
|
|
|
|
context '1800px < width' do
|
|
let(:content_type) { 'image/jpeg' }
|
|
let(:data) { File.binread(Rails.root.join('test/data/upload/upload2.jpg')) }
|
|
|
|
it 'can be resized (inlined @ 1800px wide or previewed @ 200px wide)' do
|
|
expect(resized_inline_image.width).to eq(1800)
|
|
expect(resized_preview_image.width).to eq(200)
|
|
|
|
expect(store.preferences)
|
|
.to include(resizable: true)
|
|
.and include(content_inline: true)
|
|
.and include(content_preview: true)
|
|
end
|
|
|
|
context 'kind of wide/short: 8000x300' do
|
|
let(:data) { File.binread(Rails.root.join('test/data/image/8000x300.jpg')) }
|
|
|
|
it 'can be resized (inlined @ 1800px wide or previewed @ 200px wide)' do
|
|
expect(resized_inline_image.width).to eq(1800)
|
|
expect(resized_preview_image.width).to eq(200)
|
|
|
|
expect(store.preferences)
|
|
.to include(resizable: true)
|
|
.and include(content_inline: true)
|
|
.and include(content_preview: true)
|
|
end
|
|
end
|
|
|
|
context 'very wide/short: 4000x1; i.e., <= 6px vertically per 200px (preview) or 1800px (inline) horizontally' do
|
|
let(:data) { File.binread(Rails.root.join('test/data/image/4000x1.jpg')) }
|
|
|
|
it 'cannot be resized (neither inlined nor previewed)' do
|
|
expect { store.content_inline }
|
|
.to raise_error('Inline content could not be generated.')
|
|
|
|
expect { store.content_preview }
|
|
.to raise_error('Content preview could not be generated.')
|
|
|
|
expect(store.preferences)
|
|
.to not_include(resizable: true)
|
|
.and not_include(content_inline: true)
|
|
.and not_include(content_preview: true)
|
|
end
|
|
end
|
|
|
|
context 'very wide/short: 8000x25; i.e., <= 6px vertically per 200px (preview) or 1800px (inline) horizontally' do
|
|
let(:data) { File.binread(Rails.root.join('test/data/image/8000x25.jpg')) }
|
|
|
|
it 'cannot be resized (neither inlined nor previewed)' do
|
|
expect { store.content_inline }
|
|
.to raise_error('Inline content could not be generated.')
|
|
|
|
expect { store.content_preview }
|
|
.to raise_error('Content preview could not be generated.')
|
|
|
|
expect(store.preferences)
|
|
.to not_include(resizable: true)
|
|
.and not_include(content_inline: true)
|
|
.and not_include(content_preview: true)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when preferences exceed storage size' do
|
|
|
|
let(:valid_entries) do
|
|
{
|
|
content_type: 'text/plain',
|
|
content_id: 234,
|
|
}
|
|
end
|
|
|
|
shared_examples 'keeps other entries' do
|
|
|
|
context 'when other entries are present' do
|
|
|
|
let(:preferences) do
|
|
super().merge(valid_entries)
|
|
end
|
|
|
|
it 'keeps these entries' do
|
|
expect(store.preferences).to include(valid_entries)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when single content is oversized' do
|
|
|
|
let(:preferences) do
|
|
{
|
|
oversized_content: '0' * 2500,
|
|
}
|
|
end
|
|
|
|
it 'removes that entry' do
|
|
expect(store.preferences).not_to have_key(:oversized_content)
|
|
end
|
|
|
|
include_examples 'keeps other entries'
|
|
end
|
|
|
|
context 'when the sum of multiple contents is oversized' do
|
|
|
|
let(:preferences) do
|
|
{
|
|
oversized_content1: '0' * 2000,
|
|
oversized_content2: '0' * 2000,
|
|
}
|
|
end
|
|
|
|
it 'removes first entry' do
|
|
expect(store.preferences).not_to have_key(:oversized_content1)
|
|
end
|
|
|
|
it 'keeps second entry' do
|
|
expect(store.preferences).to have_key(:oversized_content2)
|
|
end
|
|
|
|
include_examples 'keeps other entries'
|
|
end
|
|
|
|
context 'when single key is oversized' do
|
|
|
|
let(:oversized_key) { '0' * 2500 }
|
|
let(:preferences) do
|
|
{
|
|
oversized_key => 'valid content',
|
|
}
|
|
end
|
|
|
|
it 'removes that entry' do
|
|
expect(store.preferences).not_to have_key(oversized_key)
|
|
end
|
|
|
|
include_examples 'keeps other entries'
|
|
end
|
|
|
|
context 'when the sum of multiple keys is oversized' do
|
|
|
|
let(:oversized_key1) { '0' * 1500 }
|
|
let(:oversized_key2) { '1' * 1500 }
|
|
let(:preferences) do
|
|
{
|
|
oversized_key1 => 'valid content',
|
|
oversized_key2 => 'valid content',
|
|
}
|
|
end
|
|
|
|
it 'removes first entry' do
|
|
expect(store.preferences).not_to have_key(oversized_key1)
|
|
end
|
|
|
|
it 'keeps second entry' do
|
|
expect(store.preferences).to have_key(oversized_key2)
|
|
end
|
|
|
|
include_examples 'keeps other entries'
|
|
end
|
|
end
|
|
end
|