The card mixes facilities in service with construction projects (Sber Balakovo at #1, MOS7, MOS11, Yandex Vladimir-2), which is fine for a bingo but not when you want to compare what actually runs today. Add a toggle that builds the card from operational rows only, in the same ranking, with bingo lines recomputed for that card. The filtered view is noindex with canonical to the plain locale URL, so it does not compete with the main page in search. Assisted-by: Claude:opus-5
351 lines
14 KiB
Ruby
351 lines
14 KiB
Ruby
require_relative 'test_helper'
|
||
|
||
class BingoTest < Minitest::Test
|
||
include Rack::Test::Methods
|
||
|
||
# Minimal well-formed JPEG: SOI, start-of-scan, data, EOI.
|
||
JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze
|
||
|
||
def app
|
||
@app || BingoWeb
|
||
end
|
||
|
||
def setup
|
||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||
DB.connection[:bingo_incidents].delete
|
||
DB.connection[:bingo_datacenters].delete
|
||
@dcs = (1..25).map do |n|
|
||
Bingo::Datacenter.create(slug: "dc-#{n}", position: n, name_ru: "ЦОД #{n}", name_en: ("DC #{n}" if n == 1),
|
||
city_ru: 'Москва', city_en: 'Moscow', operator: 'Op', mw: n.to_s)
|
||
end
|
||
end
|
||
|
||
def admin_env
|
||
{ 'HTTP_AUTHORIZATION' =>
|
||
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }
|
||
end
|
||
|
||
def hit!(idx, kind: 'outage')
|
||
Bingo::Incident.create(datacenter_id: @dcs[idx].id, occurred_on: Date.today, kind: kind,
|
||
title_ru: "Авария #{idx}", title_uk: "Аварія #{idx}")
|
||
end
|
||
|
||
def test_root_redirects_by_accept_language
|
||
get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'uk-UA,uk;q=0.9,en;q=0.8'
|
||
assert_equal 302, last_response.status
|
||
assert_match %r{/uk\z}, last_response.location
|
||
get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'de-DE'
|
||
assert_match %r{/en\z}, last_response.location
|
||
end
|
||
|
||
def test_locales_render_with_ru_fallback
|
||
get '/en'
|
||
assert last_response.ok?
|
||
assert_includes last_response.body, 'DC 1' # localized
|
||
assert_includes last_response.body, 'ЦОД 2' # falls back to ru
|
||
assert_includes last_response.body, 'Moscow'
|
||
get '/uk'
|
||
assert_includes last_response.body, 'Бінго'
|
||
assert_includes last_response.body, '<a href="https://asxp.io">asxp.io</a>'
|
||
get '/xx'
|
||
assert_equal 404, last_response.status
|
||
end
|
||
|
||
def test_board_detects_lines
|
||
(0..4).each { |i| hit!(i) } # top row
|
||
[6, 12, 18, 24].each { |i| hit!(i) } # + main diagonal (0 already hit)
|
||
board = Bingo::Board.load
|
||
assert_equal 2, board.wins.size
|
||
get '/ru'
|
||
assert_includes last_response.body, 'БИНГО ×2!'
|
||
assert_includes last_response.body, 'Авария 24'
|
||
get '/uk'
|
||
assert_includes last_response.body, 'Аварія 24'
|
||
end
|
||
|
||
def test_no_banner_without_line
|
||
hit!(0)
|
||
get '/ru'
|
||
refute_includes last_response.body, 'class="banner"'
|
||
assert_includes last_response.body, 'Авария 0'
|
||
end
|
||
|
||
def test_photo_store_and_serve
|
||
Aws.config[:s3] = { stub_responses: { get_object: { body: JPEG } } }
|
||
S3.instance_variable_set(:@client, nil)
|
||
key = Bingo::Photo.store('dc-1', JPEG)
|
||
assert_match %r{\Abingo/dc-1-[0-9a-f]{8}\.jpg\z}, key
|
||
@dcs[0].update(photo_key: key)
|
||
get "/p/#{@dcs[0].photo_file}"
|
||
assert last_response.ok?
|
||
assert_equal 'image/jpeg', last_response.content_type
|
||
get '/p/..%2Finvoices%2Fx.pdf'
|
||
assert_equal 404, last_response.status
|
||
ensure
|
||
Aws.config[:s3] = { stub_responses: true }
|
||
S3.instance_variable_set(:@client, nil)
|
||
end
|
||
|
||
def test_strips_jpeg_exif
|
||
exif = "\xFF\xE1".b + [17].pack('n') + "Exif\0\0GPSDATA!!".b
|
||
jfif = "\xFF\xE0".b + [16].pack('n') + "JFIF\0".b + ("\0" * 9).b
|
||
scan = "\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6pixels\xFF\xD9".b
|
||
out = Bingo::Photo.strip_metadata("\xFF\xD8".b + jfif + exif + scan, 'jpg')
|
||
refute_includes out, 'GPSDATA'
|
||
assert_equal "\xFF\xD8".b + jfif + scan, out
|
||
end
|
||
|
||
def test_strips_png_text_chunks
|
||
chunk = ->(type, data) { [data.bytesize].pack('N') + type + data + "\0\0\0\0".b }
|
||
sig = "\x89PNG\r\n\x1A\n".b
|
||
ihdr = chunk.('IHDR', "\0" * 13)
|
||
out = Bingo::Photo.strip_metadata(sig + ihdr + chunk.('eXIf', 'GPS') + chunk.('tEXt', 'k\0v') + chunk.('IEND', ''), 'png')
|
||
assert_equal sig + ihdr + chunk.('IEND', ''), out
|
||
end
|
||
|
||
def test_strips_webp_exif
|
||
vp8x = 'VP8X'.b + [10].pack('V') + [0x08 | 0x10].pack('C') + ("\0" * 9).b # EXIF + alpha flags
|
||
img = 'VP8L'.b + [5].pack('V') + "abcde\0".b
|
||
exif = 'EXIF'.b + [3].pack('V') + "GPS\0".b
|
||
body = vp8x + img + exif
|
||
out = Bingo::Photo.strip_metadata('RIFF'.b + [body.bytesize + 4].pack('V') + 'WEBPh'.b[0, 4] + body, 'webp')
|
||
refute_includes out, 'GPS'
|
||
assert_equal 0x10, out.getbyte(20) # alpha kept, EXIF flag cleared
|
||
assert_equal out.bytesize - 8, out.byteslice(4, 4).unpack1('V')
|
||
end
|
||
|
||
def test_operational_view_skips_construction_projects
|
||
@dcs[0].update(status: 'building')
|
||
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op', mw: '1')
|
||
all = Bingo::Board.load
|
||
op = Bingo::Board.load('operational')
|
||
assert_includes all.cells.map(&:slug), 'dc-1'
|
||
refute_includes op.cells.map(&:slug), 'dc-1'
|
||
assert_equal 'dc-26', op.cells.last.slug # reserve moves up to fill the card
|
||
assert_equal 25, op.cells.size
|
||
end
|
||
|
||
def test_operational_view_recomputes_lines
|
||
(1..4).each { |i| hit!(i) } # row 1 minus dc-1
|
||
@dcs[0].update(status: 'building')
|
||
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op')
|
||
assert_equal 0, Bingo::Board.load.wins.size # dc-1 not hit
|
||
hit!(5) # dc-6 now sits at index 4 in operational view
|
||
assert_equal 1, Bingo::Board.load('operational').wins.size
|
||
end
|
||
|
||
def test_view_toggle_page
|
||
get '/ru?view=operational'
|
||
assert last_response.ok?
|
||
assert_includes last_response.body, 'noindex, follow'
|
||
assert_includes last_response.body, 'href="/en?view=operational"' # language links keep the view
|
||
assert_includes last_response.body, 'rel="canonical" href="https://example.org/ru"'
|
||
get '/ru?view=bogus'
|
||
assert_includes last_response.body, 'index, follow, max-image-preview'
|
||
end
|
||
|
||
def test_robots_and_sitemap_are_the_bingos_own
|
||
get '/robots.txt'
|
||
assert last_response.ok?
|
||
assert_includes last_response.body, 'Sitemap: https://example.org/sitemap.xml'
|
||
refute_includes last_response.body, 'asxp.io'
|
||
get '/sitemap.xml'
|
||
assert last_response.ok?
|
||
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" }
|
||
assert_includes last_response.body, 'hreflang="x-default"'
|
||
end
|
||
|
||
def test_asxpio_static_files_not_served
|
||
get '/style.css'
|
||
assert_equal 404, last_response.status
|
||
get '/pgp.pub'
|
||
assert_equal 404, last_response.status
|
||
end
|
||
|
||
def test_root_varies_on_language
|
||
get '/'
|
||
assert_equal 'Accept-Language', last_response.headers['Vary']
|
||
end
|
||
|
||
def test_seo_head
|
||
hit!(3)
|
||
@dcs[3].update(photo_key: 'bingo/dc-4-0123abcd.jpg', photo_variants: true)
|
||
get '/en'
|
||
body = last_response.body
|
||
assert_includes body, '<title>Datacenter Bingo — strikes'
|
||
assert_includes body, '1 of Russia's 25 largest'
|
||
assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-1200.webp">'
|
||
assert_includes body, 'src="/p/dc-4-0123abcd-480.webp"'
|
||
assert_includes body, 'og:locale:alternate" content="uk_UA"'
|
||
ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}m, 1])
|
||
list = ld['@graph'].find { |n| n['@type'] == 'ItemList' }
|
||
assert_equal 25, list['itemListElement'].size
|
||
assert_equal 'DC 1', list['itemListElement'][0]['item']['name']
|
||
end
|
||
|
||
def test_photo_falls_back_to_original_without_variants
|
||
@dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.jpg', photo_variants: false)
|
||
assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file(480)
|
||
@dcs[0].update(photo_variants: true)
|
||
assert_equal 'dc-1-0123abcd-480.webp', @dcs[0].photo_file(480)
|
||
assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file
|
||
end
|
||
|
||
def test_resize_produces_small_webp
|
||
skip 'ImageMagick not available' unless system(Bingo::Photo::CONVERT, '-version', out: File::NULL, err: File::NULL)
|
||
png, = Open3.capture2(Bingo::Photo::CONVERT, '-size', '2000x1000', 'gradient:red-blue', 'png:-', binmode: true)
|
||
out = Bingo::Photo.resize(png.b, 'png', 480)
|
||
assert_equal 'webp', Bingo::Photo.sniff(out)
|
||
dims, = Open3.capture2(Bingo::Photo::CONVERT, 'webp:-', '-format', '%wx%h', 'info:', stdin_data: out, binmode: true)
|
||
assert_equal '480x240', dims
|
||
end
|
||
|
||
def test_sniff_accepts_utf8_tagged_bytes
|
||
assert_equal 'jpg', Bingo::Photo.sniff(JPEG.dup.force_encoding('UTF-8'))
|
||
end
|
||
|
||
def test_rebuild_from_utf8_s3_body
|
||
skip 'ImageMagick not available' unless system(Bingo::Photo::CONVERT, '-version', out: File::NULL, err: File::NULL)
|
||
png, = Open3.capture2(Bingo::Photo::CONVERT, '-size', '800x600', 'gradient:red-blue', 'png:-', binmode: true)
|
||
Aws.config[:s3] = { stub_responses: { get_object: { body: png.dup.force_encoding('UTF-8') } } }
|
||
S3.instance_variable_set(:@client, nil)
|
||
@dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.png')
|
||
Bingo::Photo.rebuild(@dcs[0])
|
||
assert @dcs[0].refresh.photo_variants
|
||
ensure
|
||
Aws.config[:s3] = { stub_responses: true }
|
||
S3.instance_variable_set(:@client, nil)
|
||
end
|
||
|
||
def test_photo_rejects_non_images
|
||
assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') }
|
||
end
|
||
|
||
# --- admin (served by AsxpioWeb) -------------------------------------------
|
||
|
||
def test_admin_requires_auth
|
||
@app = AsxpioWeb
|
||
get '/admin/bingo'
|
||
assert_equal 401, last_response.status
|
||
end
|
||
|
||
def test_admin_records_and_deletes_incident
|
||
@app = AsxpioWeb
|
||
get '/admin/bingo', {}, admin_env
|
||
assert last_response.ok?
|
||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||
|
||
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dcs[3].id,
|
||
occurred_on: '2026-10-01', kind: 'fire', title_ru: 'Пожар',
|
||
url: 'https://example.com/news' }, admin_env
|
||
assert_equal 302, last_response.status
|
||
inc = Bingo::Incident.first
|
||
assert_equal 'fire', inc.kind
|
||
assert_equal @dcs[3].id, inc.datacenter_id
|
||
|
||
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dcs[3].id,
|
||
occurred_on: '2026-10-01', kind: 'fire', title_ru: 'x',
|
||
url: 'javascript:alert(1)' }, admin_env
|
||
assert_equal 1, Bingo::Incident.count
|
||
|
||
post "/admin/bingo/incidents/#{inc.id}/delete", { authenticity_token: token }, admin_env
|
||
assert_equal 0, Bingo::Incident.count
|
||
end
|
||
|
||
def test_admin_edits_datacenter
|
||
@app = AsxpioWeb
|
||
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
||
assert last_response.ok?
|
||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||
post "/admin/bingo/dc/#{@dcs[0].id}", { authenticity_token: token, name_ru: 'Новое', city_ru: 'Калуга',
|
||
name_uk: 'Нове', operator: 'Yandex', mw: '63', status: 'building',
|
||
year: '2027', position: '1' }, admin_env
|
||
@dcs[0].refresh
|
||
assert_equal 'Нове', @dcs[0].name_uk
|
||
assert @dcs[0].building?
|
||
assert_equal 2027, @dcs[0].year
|
||
end
|
||
|
||
def admin_token(path = '/admin/bingo')
|
||
get path, {}, admin_env
|
||
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||
end
|
||
|
||
def dc_params(extra = {})
|
||
{ name_ru: 'IXcellerate MOS7', city_ru: 'Москва', operator: 'IXcellerate', mw: '34', status: 'building' }.merge(extra)
|
||
end
|
||
|
||
def test_mw_value
|
||
assert_equal 64.0, Bingo.mw_value('64')
|
||
assert_equal 30.0, Bingo.mw_value('25–30')
|
||
assert_equal 13.7, Bingo.mw_value('13,7')
|
||
assert_equal 40.0, Bingo.mw_value('>40')
|
||
assert_nil Bingo.mw_value('')
|
||
end
|
||
|
||
def test_admin_adds_datacenter_at_position
|
||
@app = AsxpioWeb
|
||
token = admin_token('/admin/bingo/dc/new')
|
||
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'ix-mos7', position: '9'), admin_env
|
||
assert_equal 302, last_response.status, last_response.body
|
||
mos7 = Bingo::Datacenter[slug: 'ix-mos7']
|
||
assert_equal 9, mos7.position
|
||
assert_equal 10, @dcs[8].refresh.position # old #9 shifted down
|
||
assert_equal 26, @dcs[24].refresh.position # old #25 now in reserve
|
||
assert_equal (1..26).to_a, Bingo::Datacenter.order(:position).select_map(:position)
|
||
|
||
board = Bingo::Board.load
|
||
assert_equal 25, board.cells.size
|
||
refute_includes board.cells.map(&:slug), 'dc-25'
|
||
assert_includes board.cells.map(&:slug), 'ix-mos7'
|
||
end
|
||
|
||
def test_admin_rejects_bad_or_duplicate_slug
|
||
@app = AsxpioWeb
|
||
token = admin_token('/admin/bingo/dc/new')
|
||
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'Bad Slug'), admin_env
|
||
assert_equal 422, last_response.status
|
||
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'dc-1'), admin_env
|
||
assert_equal 422, last_response.status
|
||
assert_equal 25, Bingo::Datacenter.count
|
||
end
|
||
|
||
def test_admin_moves_datacenter
|
||
@app = AsxpioWeb
|
||
token = admin_token("/admin/bingo/dc/#{@dcs[24].id}")
|
||
post "/admin/bingo/dc/#{@dcs[24].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 25', position: '1'), admin_env
|
||
assert_equal 1, @dcs[24].refresh.position
|
||
assert_equal 2, @dcs[0].refresh.position
|
||
assert_equal (1..25).to_a, Bingo::Datacenter.order(:position).select_map(:position)
|
||
end
|
||
|
||
def test_admin_deletes_datacenter_with_incidents
|
||
@app = AsxpioWeb
|
||
hit!(4)
|
||
token = admin_token("/admin/bingo/dc/#{@dcs[4].id}")
|
||
post "/admin/bingo/dc/#{@dcs[4].id}/delete", { authenticity_token: token }, admin_env
|
||
assert_equal 302, last_response.status
|
||
assert_nil Bingo::Datacenter[@dcs[4].id]
|
||
assert_equal 0, Bingo::Incident.count
|
||
assert_equal (1..24).to_a, Bingo::Datacenter.order(:position).select_map(:position)
|
||
end
|
||
|
||
def test_sort_by_mw
|
||
@dcs[0].update(mw: '') # unknown → last
|
||
@dcs[1].update(mw: '25–30')
|
||
Bingo.sort_by_mw!
|
||
order = Bingo::Datacenter.order(:position).select_map(:slug)
|
||
assert_equal %w[dc-2 dc-25 dc-24], order.first(3) # "25–30" ranks as 30
|
||
assert_equal 'dc-1', order.last
|
||
end
|
||
|
||
def test_upload_photo_via_admin
|
||
@app = AsxpioWeb
|
||
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||
file = Rack::Test::UploadedFile.new(StringIO.new(JPEG), 'image/jpeg', original_filename: 'a.jpg')
|
||
post "/admin/bingo/dc/#{@dcs[0].id}/photo", { authenticity_token: token, photo: file }, admin_env
|
||
assert_equal 302, last_response.status
|
||
assert_match %r{\Abingo/dc-1-}, @dcs[0].refresh.photo_key
|
||
end
|
||
end
|