trusty-cms 7.1.2 → 7.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +4 -3
  3. data/Gemfile.lock +58 -46
  4. data/README.md +2 -0
  5. data/app/controllers/admin/resource_controller.rb +1 -1
  6. data/app/controllers/application_controller.rb +15 -0
  7. data/app/models/asset.rb +29 -5
  8. data/app/models/standard_tags.rb +13 -7
  9. data/app/models/user.rb +13 -0
  10. data/lib/trusty_cms/site_scope_auth_reporter.rb +108 -0
  11. data/lib/trusty_cms/version.rb +1 -1
  12. data/package.json +1 -1
  13. data/spec/controllers/admin/snippets_controller_spec.rb +95 -0
  14. data/spec/controllers/site_controller_spec.rb +63 -0
  15. data/spec/factories/site.rb +8 -0
  16. data/spec/helpers/admin/references_helper_spec.rb +54 -0
  17. data/spec/helpers/admin/url_helper_spec.rb +71 -0
  18. data/spec/helpers/application_helper_spec.rb +102 -0
  19. data/spec/helpers/scoped_helper_spec.rb +60 -0
  20. data/spec/lib/active_record_extensions_spec.rb +65 -0
  21. data/spec/lib/login_system_spec.rb +61 -0
  22. data/spec/lib/ostruct_spec.rb +33 -0
  23. data/spec/lib/string_extensions_spec.rb +48 -0
  24. data/spec/lib/symbol_extensions_spec.rb +14 -0
  25. data/spec/lib/trusty_cms/config_cache_spec.rb +73 -0
  26. data/spec/models/admins_site_spec.rb +19 -0
  27. data/spec/models/asset_spec.rb +224 -0
  28. data/spec/models/asset_type_spec.rb +137 -0
  29. data/spec/models/file_not_found_page_spec.rb +33 -0
  30. data/spec/models/haml_filter_spec.rb +30 -0
  31. data/spec/models/menu_renderer_spec.rb +63 -0
  32. data/spec/models/page_attachment_spec.rb +29 -0
  33. data/spec/models/page_context_spec.rb +57 -0
  34. data/spec/models/page_field_spec.rb +15 -0
  35. data/spec/models/page_part_spec.rb +33 -0
  36. data/spec/models/page_spec.rb +184 -0
  37. data/spec/models/rails_page_spec.rb +32 -0
  38. data/spec/models/site_spec.rb +93 -0
  39. data/spec/models/snippet_finder_spec.rb +27 -0
  40. data/spec/models/snippet_tags_spec.rb +45 -0
  41. data/spec/models/standard_tags/children_spec.rb +159 -0
  42. data/spec/models/standard_tags/content_spec.rb +168 -0
  43. data/spec/models/standard_tags/meta_spec.rb +86 -0
  44. data/spec/models/standard_tags_spec.rb +32 -0
  45. data/spec/models/status_spec.rb +63 -0
  46. data/spec/models/text_filter_spec.rb +47 -0
  47. data/spec/models/trusty_cms/config_spec.rb +69 -0
  48. data/spec/models/trusty_cms/page_response_cache_director_spec.rb +72 -0
  49. data/spec/models/user_action_observer_spec.rb +39 -0
  50. data/spec/models/user_serialize_telemetry_spec.rb +124 -0
  51. data/spec/rails_helper.rb +13 -6
  52. data/spec/requests/admin/snippets_spec.rb +81 -0
  53. data/spec/spec_helper.rb +17 -3
  54. data/trusty_cms.gemspec +3 -3
  55. data/yarn.lock +273 -259
  56. metadata +91 -8
@@ -0,0 +1,73 @@
1
+ require 'spec_helper'
2
+
3
+ # The cache layer of TrustyCms::Config leans on Rails.cache and File.mtime,
4
+ # both of which can shift across Rails upgrades. Pin the round-trip.
5
+ describe TrustyCms::Config, 'caching' do
6
+ # Seed a known key so the read/round-trip assertions can't pass vacuously on
7
+ # an empty config table. The config table is exempt from truncation, so this
8
+ # probe row is removed explicitly after each example.
9
+ let(:probe_key) { 'spec.cache_probe' }
10
+ let(:probe_value) { 'probe-value' }
11
+
12
+ before { TrustyCms::Config[probe_key] = probe_value }
13
+
14
+ after do
15
+ TrustyCms::Config.where(key: probe_key).delete_all
16
+ TrustyCms::Config.initialize_cache # leave the cache good for whatever runs next
17
+ end
18
+
19
+ describe '.ensure_cache_file' do
20
+ it 'creates the cache file' do
21
+ File.delete(TrustyCms::Config.cache_file) if File.exist?(TrustyCms::Config.cache_file)
22
+ expect(TrustyCms::Config.cache_file_exists?).to be(false)
23
+
24
+ TrustyCms::Config.ensure_cache_file
25
+ expect(TrustyCms::Config.cache_file_exists?).to be(true)
26
+ expect(File.file?(TrustyCms::Config.cache_file)).to be(true)
27
+ end
28
+ end
29
+
30
+ describe '.initialize_cache' do
31
+ it 'writes the full config hash into Rails.cache' do
32
+ TrustyCms::Config.initialize_cache
33
+ expect(Rails.cache.read('TrustyCms::Config')).to eq(TrustyCms::Config.to_hash)
34
+ end
35
+
36
+ it 'leaves the cache non-stale' do
37
+ TrustyCms::Config.initialize_cache
38
+ expect(TrustyCms::Config.stale_cache?).to be(false)
39
+ end
40
+ end
41
+
42
+ describe '.stale_cache?' do
43
+ it 'is true when the stored mtime no longer matches the cache file' do
44
+ TrustyCms::Config.initialize_cache
45
+ Rails.cache.write('TrustyCms.cache_mtime', Time.at(0))
46
+ expect(TrustyCms::Config.stale_cache?).to be(true)
47
+ end
48
+ end
49
+
50
+ describe '.[]' do
51
+ it 'reads a value through the cache' do
52
+ TrustyCms::Config.initialize_cache
53
+ expect(TrustyCms::Config[probe_key]).to eq(probe_value)
54
+ end
55
+
56
+ it 'rebuilds a stale cache before returning a value' do
57
+ TrustyCms::Config.initialize_cache
58
+ Rails.cache.write('TrustyCms.cache_mtime', Time.at(0)) # force staleness
59
+
60
+ expect(TrustyCms::Config[probe_key]).to eq(probe_value)
61
+ expect(TrustyCms::Config.stale_cache?).to be(false)
62
+ end
63
+ end
64
+
65
+ describe '.to_hash' do
66
+ it 'returns a hash keyed by config key, including seeded entries' do
67
+ hash = TrustyCms::Config.to_hash
68
+ expect(hash).to be_a(Hash)
69
+ expect(hash).to include(probe_key => probe_value)
70
+ expect(hash.keys).to all(be_a(String))
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,19 @@
1
+ require 'spec_helper'
2
+
3
+ describe AdminsSite do
4
+ it 'uses the admins_sites table' do
5
+ expect(AdminsSite.table_name).to eq('admins_sites')
6
+ end
7
+
8
+ describe 'associations' do
9
+ it 'belongs to an admin that is a User' do
10
+ association = AdminsSite.reflect_on_association(:admin)
11
+ expect(association.macro).to eq(:belongs_to)
12
+ expect(association.options[:class_name]).to eq('User')
13
+ end
14
+
15
+ it 'belongs to a site' do
16
+ expect(AdminsSite.reflect_on_association(:site).macro).to eq(:belongs_to)
17
+ end
18
+ end
19
+ end
@@ -259,4 +259,228 @@ RSpec.describe Asset, type: :model do
259
259
  expect(asset.public_url('normal')).to eq('https://s3.amazonaws.com/bucket/randomlegacykey')
260
260
  end
261
261
  end
262
+
263
+ # Shared helpers for the blocks below.
264
+ def register_image_and_other_types
265
+ AssetType.new(:image, styles: :standard, extensions: %w[png], mime_types: %w[image/png]) unless AssetType.known?(:image)
266
+ AssetType.new(:other, icon: 'unknown') unless AssetType.known?(:other)
267
+ end
268
+
269
+ def png_bytes
270
+ Base64.decode64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==')
271
+ end
272
+
273
+ def attach_png(filename: 'pixel.png', **attrs)
274
+ io = StringIO.new(png_bytes)
275
+ io.set_encoding(Encoding::BINARY)
276
+ described_class.new(attrs).tap do |asset|
277
+ asset.asset.attach(io: io, filename: filename, content_type: 'image/png')
278
+ asset.save!
279
+ end
280
+ end
281
+
282
+ describe 'unattached accessors' do
283
+ before { register_image_and_other_types }
284
+
285
+ it 'falls back to the stored columns when nothing is attached' do
286
+ asset = described_class.new(asset_file_name: 'legacy.PNG', asset_content_type: 'image/png', asset_file_size: 42)
287
+
288
+ expect(asset.filename).to eq('legacy.PNG')
289
+ expect(asset.content_type).to eq('image/png')
290
+ expect(asset.byte_size).to eq(42)
291
+ end
292
+
293
+ it 'derives basename and extension from the stored filename' do
294
+ asset = described_class.new(asset_file_name: 'legacy.PNG')
295
+
296
+ expect(asset.basename).to eq('legacy')
297
+ expect(asset.original_extension).to eq('png')
298
+ expect(asset.extension('original')).to eq('png')
299
+ end
300
+
301
+ it 'returns the original extension for a style with no defined format' do
302
+ asset = described_class.new(asset_file_name: 'legacy.png')
303
+
304
+ expect(asset.extension('no_such_style')).to eq('png')
305
+ end
306
+ end
307
+
308
+ describe 'geometry' do
309
+ before { register_image_and_other_types }
310
+
311
+ it 'reports width, height and aspect from the original dimensions' do
312
+ asset = described_class.new(original_width: 100, original_height: 50)
313
+
314
+ expect(asset.width).to eq(100)
315
+ expect(asset.height).to eq(50)
316
+ expect(asset.aspect).to eq(2.0)
317
+ expect(asset.dimensions_known?).to be(true)
318
+ end
319
+
320
+ it 'reports a horizontal orientation for a wide asset' do
321
+ asset = described_class.new(original_width: 100, original_height: 50)
322
+
323
+ expect(asset.orientation).to eq('horizontal')
324
+ expect(asset.horizontal?).to be(true)
325
+ expect(asset.vertical?).to be(false)
326
+ expect(asset.square?).to be(false)
327
+ end
328
+
329
+ it 'reports a vertical orientation for a tall asset' do
330
+ asset = described_class.new(original_width: 50, original_height: 100)
331
+
332
+ expect(asset.orientation).to eq('vertical')
333
+ expect(asset.vertical?).to be(true)
334
+ end
335
+
336
+ it 'reports a square orientation for an equal-sided asset' do
337
+ asset = described_class.new(original_width: 100, original_height: 100)
338
+
339
+ expect(asset.orientation).to eq('square')
340
+ expect(asset.square?).to be(true)
341
+ end
342
+
343
+ it 'is not dimensions_known? without stored dimensions' do
344
+ expect(described_class.new.dimensions_known?).to be(false)
345
+ end
346
+
347
+ it 'raises a StyleError for a style that is not defined' do
348
+ asset = described_class.new(original_width: 100, original_height: 50)
349
+
350
+ expect { asset.geometry('no_such_style') }.to raise_error(TrustyCms::StyleError)
351
+ end
352
+ end
353
+
354
+ describe '#active_storage_transformations' do
355
+ subject(:asset) { described_class.new }
356
+
357
+ it 'resizes to fill for cropping modifiers' do
358
+ expect(asset.send(:active_storage_transformations, '100x80#')).to eq(resize_to_fill: [100, 80])
359
+ end
360
+
361
+ it 'resizes to limit for the shrink modifier' do
362
+ expect(asset.send(:active_storage_transformations, '100x80>')).to eq(resize_to_limit: [100, 80])
363
+ end
364
+
365
+ it 'resizes to limit when there is no modifier' do
366
+ expect(asset.send(:active_storage_transformations, '100x80')).to eq(resize_to_limit: [100, 80])
367
+ end
368
+
369
+ it 'returns no transformations for a blank geometry' do
370
+ expect(asset.send(:active_storage_transformations, '')).to eq({})
371
+ end
372
+ end
373
+
374
+ describe 'scopes' do
375
+ before { register_image_and_other_types }
376
+
377
+ let!(:image) { attach_png(caption: 'a picture', title: 'Pixel') }
378
+
379
+ describe '.latest' do
380
+ it 'includes recently created assets up to the limit' do
381
+ expect(described_class.latest(5)).to include(image)
382
+ end
383
+ end
384
+
385
+ describe '.of_types' do
386
+ it 'includes assets whose blob content type matches the given types' do
387
+ expect(described_class.of_types([:image])).to include(image)
388
+ end
389
+
390
+ it 'returns none when the given types have no mime types' do
391
+ expect(described_class.of_types([:no_such_type])).to be_empty
392
+ end
393
+ end
394
+
395
+ describe '.matching' do
396
+ it 'matches on filename, title or caption, case-insensitively' do
397
+ expect(described_class.matching('PIXEL')).to include(image)
398
+ expect(described_class.matching('picture')).to include(image)
399
+ end
400
+ end
401
+
402
+ describe '.excepting' do
403
+ it 'excludes the given asset ids' do
404
+ expect(described_class.excepting([image.id]).to_sql).to match(/NOT IN/)
405
+ expect(described_class.excepting([image.id])).not_to include(image)
406
+ end
407
+
408
+ it 'adds no exclusion when given an empty list' do
409
+ expect(described_class.excepting([])).to eq({})
410
+ end
411
+ end
412
+ end
413
+
414
+ describe '#attached_to?' do
415
+ before { register_image_and_other_types }
416
+
417
+ it 'is true for a page the asset is attached to, false otherwise' do
418
+ asset = attach_png
419
+ page = FactoryBot.create(:page, title: 'Attached')
420
+ other_page = FactoryBot.create(:page, title: 'Unattached')
421
+ asset.pages << page
422
+
423
+ expect(asset.attached_to?(page)).to be(true)
424
+ expect(asset.attached_to?(other_page)).to be(false)
425
+ end
426
+ end
427
+
428
+ describe 'class helpers' do
429
+ before { register_image_and_other_types }
430
+
431
+ it 'exposes the known asset type names' do
432
+ expect(described_class.known_types).to eq(AssetType.known_types)
433
+ end
434
+
435
+ it 'lists ransackable attributes' do
436
+ expect(described_class.ransackable_attributes).to include('title', 'caption', 'uuid')
437
+ end
438
+
439
+ it 'exposes the image thumbnail sizes and names' do
440
+ expect(described_class.thumbnail_sizes).to eq(AssetType.find(:image).active_storage_styles)
441
+ expect(described_class.thumbnail_names).to eq(described_class.thumbnail_sizes.keys)
442
+ end
443
+
444
+ it 'builds thumbnail options with an Original entry first' do
445
+ expect(described_class.thumbnail_options.first).to eq(['Original (as uploaded)', 'original'])
446
+ end
447
+
448
+ it 'describes hash-style thumbnails as "id: geometry as format"' do
449
+ options = described_class.thumbnail_options
450
+
451
+ expect(options).to include(['icon: 50x50# as png', :icon])
452
+ # Every option is a [description, id] pair with a string description.
453
+ expect(options).to all(satisfy { |desc, _id| desc.is_a?(String) })
454
+ end
455
+
456
+ it 'represents "original" only once, via the explicit entry (no blank :original option)' do
457
+ ids = described_class.thumbnail_options.map(&:last)
458
+
459
+ expect(ids).to include('original') # the explicit "Original (as uploaded)" entry
460
+ expect(ids).not_to include(:original) # not the blank one built from the :original style
461
+ expect(ids.count { |id| id.to_s == 'original' }).to eq(1)
462
+ end
463
+ end
464
+
465
+ describe '.describe_style' do
466
+ it 'renders a hash style as geometry and format' do
467
+ expect(described_class.describe_style(geometry: '100x100#', format: :png)).to eq('100x100# as png')
468
+ end
469
+
470
+ it 'omits a missing format' do
471
+ expect(described_class.describe_style(geometry: '640x640>')).to eq('640x640>')
472
+ end
473
+
474
+ it 'renders an empty hash as a blank string' do
475
+ expect(described_class.describe_style({})).to eq('')
476
+ end
477
+
478
+ it 'joins an array style with " as "' do
479
+ expect(described_class.describe_style(['100x100', 'png'])).to eq('100x100 as png')
480
+ end
481
+
482
+ it 'stringifies a plain style' do
483
+ expect(described_class.describe_style('100x100#')).to eq('100x100#')
484
+ end
485
+ end
262
486
  end
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+
3
+ describe AssetType do
4
+ # AssetType keeps its registry in class variables, so registrations persist
5
+ # for the whole suite. Register uniquely-named types (guarded by .known?) so
6
+ # these examples don't depend on what other specs have registered.
7
+ before(:all) do
8
+ AssetType.new(:other, icon: 'unknown') unless AssetType.known?(:other)
9
+ unless AssetType.known?(:spec_image)
10
+ AssetType.new(:spec_image,
11
+ icon: 'image',
12
+ styles: :standard,
13
+ extensions: %w[spx],
14
+ mime_types: %w[image/spectest])
15
+ end
16
+ end
17
+
18
+ let(:image) { AssetType.find(:spec_image) }
19
+
20
+ describe '#plural' do
21
+ it 'pluralizes the name' do
22
+ expect(image.plural).to eq('spec_images')
23
+ end
24
+ end
25
+
26
+ describe '#mime_types' do
27
+ it 'returns the configured mime types' do
28
+ expect(image.mime_types).to eq(%w[image/spectest])
29
+ end
30
+ end
31
+
32
+ describe '#condition' do
33
+ it 'builds an IN clause over its mime types' do
34
+ sql, *values = image.condition
35
+ expect(sql).to match(/asset_content_type IN/)
36
+ expect(values).to eq(%w[image/spectest])
37
+ end
38
+ end
39
+
40
+ describe '#non_condition' do
41
+ it 'builds a NOT IN clause over its mime types' do
42
+ sql, *values = image.non_condition
43
+ expect(sql).to match(/NOT asset_content_type IN/)
44
+ expect(values).to eq(%w[image/spectest])
45
+ end
46
+ end
47
+
48
+ describe '#standard_styles' do
49
+ it 'defines icon, thumbnail and original styles' do
50
+ expect(image.standard_styles.keys).to match_array(%i[icon thumbnail original])
51
+ end
52
+ end
53
+
54
+ describe '#normalize_style_rules' do
55
+ it 'wraps a bare geometry string in a hash' do
56
+ expect(image.normalize_style_rules(small: '100x100')).to eq(small: { geometry: '100x100' })
57
+ end
58
+
59
+ it 'parses a key=value rule string into a hash' do
60
+ result = image.normalize_style_rules(big: 'geometry=640x640,format=jpg')
61
+ expect(result[:big]).to eq(geometry: '640x640', format: 'jpg')
62
+ end
63
+ end
64
+
65
+ describe '.known?' do
66
+ it 'is true for a registered type' do
67
+ expect(AssetType.known?(:spec_image)).to be(true)
68
+ end
69
+
70
+ it 'is false for an unregistered type' do
71
+ expect(AssetType.known?(:nonexistent)).to be(false)
72
+ end
73
+ end
74
+
75
+ describe '.find and .[]' do
76
+ it 'looks up a type by name' do
77
+ expect(AssetType.find(:spec_image)).to eq(image)
78
+ end
79
+
80
+ it 'aliases .[] to .find' do
81
+ expect(AssetType[:spec_image]).to eq(image)
82
+ end
83
+
84
+ it 'returns nil for an unknown type' do
85
+ expect(AssetType.find(:nonexistent)).to be_nil
86
+ end
87
+ end
88
+
89
+ describe '.from_extension' do
90
+ it 'finds a type by a registered extension' do
91
+ expect(AssetType.from_extension('spx')).to eq(image)
92
+ end
93
+ end
94
+
95
+ describe '.from_mimetype' do
96
+ it 'finds a type by a registered mime type' do
97
+ expect(AssetType.from_mimetype('image/spectest')).to eq(image)
98
+ end
99
+ end
100
+
101
+ describe '.for' do
102
+ it 'returns the catchall type when there is no attachment' do
103
+ expect(AssetType.for(nil)).to eq(AssetType.catchall)
104
+ end
105
+ end
106
+
107
+ describe '.catchall' do
108
+ it 'is the :other type' do
109
+ expect(AssetType.catchall).to eq(AssetType.find(:other))
110
+ end
111
+ end
112
+
113
+ describe '.all and .known_types' do
114
+ it 'includes registered types' do
115
+ expect(AssetType.known_types).to include(:spec_image)
116
+ expect(AssetType.all).to include(image)
117
+ end
118
+ end
119
+
120
+ describe '.known_mimetypes' do
121
+ it 'includes registered mime types' do
122
+ expect(AssetType.known_mimetypes).to include('image/spectest')
123
+ end
124
+ end
125
+
126
+ describe '.mime_types_for' do
127
+ it 'returns the mime types for the named types' do
128
+ expect(AssetType.mime_types_for(:spec_image)).to eq(%w[image/spectest])
129
+ end
130
+ end
131
+
132
+ describe '.slice' do
133
+ it 'returns the named types' do
134
+ expect(AssetType.slice(:spec_image)).to eq([image])
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,33 @@
1
+ require 'spec_helper'
2
+
3
+ describe FileNotFoundPage do
4
+ subject(:page) { FileNotFoundPage.new }
5
+
6
+ it 'is a Page' do
7
+ expect(page).to be_a(Page)
8
+ end
9
+
10
+ describe '#cache_timeout' do
11
+ it 'is five minutes' do
12
+ expect(page.cache_timeout).to eq(5.minutes)
13
+ end
14
+ end
15
+
16
+ describe '#allowed_children' do
17
+ it 'has none' do
18
+ expect(page.allowed_children).to eq([])
19
+ end
20
+ end
21
+
22
+ describe '#virtual?' do
23
+ it 'is true' do
24
+ expect(page.virtual?).to be(true)
25
+ end
26
+ end
27
+
28
+ describe '#response_code' do
29
+ it 'is 404' do
30
+ expect(page.response_code).to eq(404)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ require 'spec_helper'
2
+
3
+ describe HamlFilter do
4
+ subject(:filter) { HamlFilter.new }
5
+
6
+ it 'is a TextFilter' do
7
+ expect(filter).to be_a(TextFilter)
8
+ end
9
+
10
+ describe '.filter_name' do
11
+ it 'is derived from the class name' do
12
+ expect(HamlFilter.filter_name).to eq('Haml')
13
+ end
14
+ end
15
+
16
+ describe '#filter' do
17
+ it 'renders Haml markup to HTML' do
18
+ expect(filter.filter('%p Hello world')).to eq("<p>Hello world</p>\n")
19
+ end
20
+
21
+ it 'un-escapes radius tags that Haml would otherwise escape' do
22
+ # Haml escapes the `=` output to &lt;r:title /&gt;; the filter restores it.
23
+ expect(filter.filter('= "<r:title />"')).to eq("<r:title/>\n")
24
+ end
25
+
26
+ it 'leaves radius tags in plain text intact' do
27
+ expect(filter.filter("%p\n <r:title />")).to eq("<p>\n<r:title />\n</p>\n")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,63 @@
1
+ require 'spec_helper'
2
+
3
+ describe MenuRenderer do
4
+ # MenuRenderer is extended onto a Page instance (see Admin::NodeHelper).
5
+ let(:page) { Page.new.tap { |p| p.extend(MenuRenderer) } }
6
+
7
+ # MenuRenderer keeps its exclusion list in a module-level ivar. Isolate it so
8
+ # the .exclude example doesn't leak into others regardless of run order.
9
+ before(:all) { @orig_excluded = MenuRenderer.instance_variable_get(:@excluded_class_names) }
10
+ after(:all) { MenuRenderer.instance_variable_set(:@excluded_class_names, @orig_excluded) }
11
+ before { MenuRenderer.instance_variable_set(:@excluded_class_names, []) }
12
+
13
+ describe '#view' do
14
+ it 'reads back an assigned view' do
15
+ view = Object.new
16
+ page.view = view
17
+ expect(page.view).to eq(view)
18
+ end
19
+ end
20
+
21
+ describe '#menu_renderer_module_name' do
22
+ it 'is MenuRenderer for a plain page' do
23
+ expect(page.menu_renderer_module_name).to eq('MenuRenderer')
24
+ end
25
+ end
26
+
27
+ describe '#additional_menu_features?' do
28
+ it 'is false when no page-specific renderer module exists' do
29
+ expect(page.additional_menu_features?).to be_falsey
30
+ end
31
+ end
32
+
33
+ describe '#allowed_child_classes' do
34
+ it 'constantizes the names from the allowed-children cache' do
35
+ page.allowed_children_cache = 'Page,FileNotFoundPage'
36
+ expect(page.allowed_child_classes).to eq([Page, FileNotFoundPage])
37
+ end
38
+
39
+ it 'skips names that cannot be constantized' do
40
+ page.allowed_children_cache = 'Page,NotARealClassName'
41
+ expect(page.allowed_child_classes).to eq([Page])
42
+ end
43
+
44
+ it 'excludes names registered via .exclude' do
45
+ MenuRenderer.exclude('FileNotFoundPage')
46
+ page.allowed_children_cache = 'Page,FileNotFoundPage'
47
+ expect(page.allowed_child_classes).to eq([Page])
48
+ expect(MenuRenderer.excluded_class_names).to include('FileNotFoundPage')
49
+ end
50
+ end
51
+
52
+ describe '#add_child_disabled?' do
53
+ it 'is true when there are no allowed child classes' do
54
+ page.allowed_children_cache = ''
55
+ expect(page.add_child_disabled?).to be(true)
56
+ end
57
+
58
+ it 'is false when there is at least one allowed child class' do
59
+ page.allowed_children_cache = 'Page'
60
+ expect(page.add_child_disabled?).to be(false)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ describe PageAttachment do
4
+ describe '#selected?' do
5
+ it 'is false by default' do
6
+ expect(PageAttachment.new.selected?).to be(false)
7
+ end
8
+
9
+ it 'is true when selected is set to a truthy value' do
10
+ attachment = PageAttachment.new
11
+ attachment.selected = '1'
12
+ expect(attachment.selected?).to be(true)
13
+ end
14
+
15
+ it 'is false when selected is set to nil' do
16
+ attachment = PageAttachment.new
17
+ attachment.selected = nil
18
+ expect(attachment.selected?).to be(false)
19
+ end
20
+ end
21
+
22
+ describe '#add_to_list_bottom' do
23
+ it 'keeps an already-assigned position' do
24
+ attachment = PageAttachment.new(position: 5)
25
+ attachment.add_to_list_bottom
26
+ expect(attachment.position).to eq(5)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ require 'spec_helper'
2
+
3
+ describe PageContext do
4
+ let(:page) { FactoryBot.build(:page, title: 'My Page') }
5
+ let(:context) { PageContext.new(page) }
6
+
7
+ describe '#initialize' do
8
+ it 'exposes the page it was built for' do
9
+ expect(context.page).to eq(page)
10
+ end
11
+
12
+ it 'registers the page as a global' do
13
+ expect(context.globals.page).to eq(page)
14
+ end
15
+
16
+ it 'defines a tag for each of the page tags' do
17
+ expect(context.definitions.keys).to include('title')
18
+ end
19
+ end
20
+
21
+ describe '#dup' do
22
+ it 'returns an independent PageContext for the same page' do
23
+ copy = context.dup
24
+ expect(copy).to be_a(PageContext)
25
+ expect(copy).not_to equal(context)
26
+ expect(copy.page).to eq(page)
27
+ expect(copy.globals.page).to eq(page)
28
+ end
29
+
30
+ it 'copies the definitions rather than sharing them' do
31
+ copy = context.dup
32
+ expect(copy.definitions).not_to equal(context.definitions)
33
+ expect(copy.definitions.keys).to eq(context.definitions.keys)
34
+ end
35
+ end
36
+
37
+ describe 'rendering through Page#parse' do
38
+ it 'renders a standard tag' do
39
+ expect(page.send(:parse, '<r:title />')).to eq('My Page')
40
+ end
41
+
42
+ context 'when a tag raises and errors are suppressed (production-like)' do
43
+ before { allow_any_instance_of(PageContext).to receive(:raise_errors?).and_return(false) }
44
+
45
+ it 'renders the error message inside a div' do
46
+ output = page.send(:parse, '<r:find />')
47
+ expect(output).to match(%r{<div><strong>.*find.*</strong></div>})
48
+ end
49
+ end
50
+
51
+ context 'when errors are raised (development/test)' do
52
+ it 'propagates the exception' do
53
+ expect { page.send(:parse, '<r:find />') }.to raise_error(StandardTags::TagError)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,15 @@
1
+ require 'spec_helper'
2
+
3
+ describe PageField do
4
+ describe 'validations' do
5
+ it 'requires a name' do
6
+ field = PageField.new(content: 'value')
7
+ expect(field).not_to be_valid
8
+ expect(field.errors[:name]).to be_present
9
+ end
10
+
11
+ it 'is valid with a name' do
12
+ expect(PageField.new(name: 'keywords', content: 'value')).to be_valid
13
+ end
14
+ end
15
+ end