The bingo host fell through to asxp.io's public/, so crawlers got asxp.io's robots.txt pointing at asxp.io's sitemap (and the keys and invoice JS were served too). Turn static files off for BingoWeb and give it its own robots.txt and a sitemap with hreflang alternates. The card loaded 15.6 MB of full-size photos as cell backgrounds. Derive 480/1200 px WebP variants with ImageMagick on store (memory/area limits, input coder pinned to the sniffed type), render cells as <img> with lazy loading past the first two rows, and add an admin action that rebuilds variants from the stored originals. Head gets per-locale titles and descriptions carrying the live score, og:image from the latest hit, Twitter card, og:locale alternates, JSON-LD WebSite + ItemList, and Vary: Accept-Language on the redirect. Assisted-by: Claude:opus-5
304 lines
12 KiB
Ruby
304 lines
12 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_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_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
|