diff --git a/app/assets/images/og/sharable-purchase/hack-club-flag.png b/app/assets/images/og/sharable-purchase/hack-club-flag.png new file mode 100644 index 000000000..fc2cc8631 Binary files /dev/null and b/app/assets/images/og/sharable-purchase/hack-club-flag.png differ diff --git a/app/assets/images/og/sharable-purchase/moon.png b/app/assets/images/og/sharable-purchase/moon.png new file mode 100644 index 000000000..d8cafa9be Binary files /dev/null and b/app/assets/images/og/sharable-purchase/moon.png differ diff --git a/app/assets/images/og/sharable-purchase/star-mascot.png b/app/assets/images/og/sharable-purchase/star-mascot.png new file mode 100644 index 000000000..ffc4980b6 Binary files /dev/null and b/app/assets/images/og/sharable-purchase/star-mascot.png differ diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 185b02b5b..e7546fea0 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -71,6 +71,7 @@ @use "pages/shop/checkout"; @use "pages/shop/order"; @use "pages/shop/my_orders"; +@use "pages/shop/sharable_purchase"; @use "pages/shop/admin_order_show"; @use "pages/user/show" as users_show; @use "pages/profile_placeholder"; diff --git a/app/assets/stylesheets/pages/shop/_sharable_purchase.scss b/app/assets/stylesheets/pages/shop/_sharable_purchase.scss new file mode 100644 index 000000000..2675aaf54 --- /dev/null +++ b/app/assets/stylesheets/pages/shop/_sharable_purchase.scss @@ -0,0 +1,68 @@ +.sharable-purchase { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 3rem; + padding: 1.5rem; + background: var(--color-space-card, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--color-space-border, rgba(255, 255, 255, 0.12)); + border-radius: 14px; + + &__preview { + flex: none; + width: min(260px, 40%); + } + + &__image { + display: block; + width: 100%; + height: auto; + border-radius: 10px; + border: 1px solid var(--color-space-border, rgba(255, 255, 255, 0.12)); + } + + &__content { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; + } + + &__title { + margin: 0; + font-size: var(--font-size-xl); + color: var(--color-space-text); + + em { + font-family: var(--font-family-subtitle); + color: var(--color-brand-yellow); + } + } + + &__hint { + margin: 0; + color: var(--color-space-text-muted, var(--color-space-text)); + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + &__status { + margin: 0; + min-height: 1.2em; + font-size: var(--font-size-s); + color: var(--color-brand-mint); + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: stretch; + + &__preview { + width: 100%; + } + } +} diff --git a/app/controllers/shop/orders/flex_images_controller.rb b/app/controllers/shop/orders/flex_images_controller.rb new file mode 100644 index 000000000..91645cdfa --- /dev/null +++ b/app/controllers/shop/orders/flex_images_controller.rb @@ -0,0 +1,17 @@ +class Shop::Orders::FlexImagesController < Shop::BaseController + def show + head :not_found and return unless Flipper.enabled?(:sharable_purchase, current_user) + + authorize :shop, :flex_image? + order = current_user.shop_orders.find(params[:order_id]) + + png_data = OgImage::ShopOrderFlex.new(order).to_png + + expires_in 30.minutes, public: false + if params[:download].present? + send_data png_data, type: "image/png", disposition: "attachment", filename: "stardance-order-#{order.id}.png" + else + send_data png_data, type: "image/png", disposition: "inline" + end + end +end diff --git a/app/controllers/shop/orders_controller.rb b/app/controllers/shop/orders_controller.rb index 11b914eb0..ed9c2edb6 100644 --- a/app/controllers/shop/orders_controller.rb +++ b/app/controllers/shop/orders_controller.rb @@ -6,6 +6,7 @@ def index .where(parent_order_id: nil) .includes(accessory_orders: { shop_item: { image_attachment: :blob } }, shop_item: { image_attachment: :blob }) .order(id: :desc) + @sharable_order = find_sharable_order end def create @@ -195,6 +196,16 @@ def cancel private + def find_sharable_order + return nil unless Flipper.enabled?(:sharable_purchase, current_user) + + latest = @orders.worth_counting.first + return nil unless latest && latest.created_at > 10.minutes.ago + return nil if latest.shop_item.is_a?(ShopItem::TutorialNothing) + + latest + end + def fulfill_free_stickers! @shop_item.fulfill!(@order) @order.mark_stickers_received diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 43e27b2a8..5beec6583 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -316,6 +316,9 @@ application.register("scroll-restore", ScrollRestoreController); import SearchableSelectController from "./searchable_select_controller"; application.register("searchable-select", SearchableSelectController); +import SharePurchaseController from "./share_purchase_controller"; +application.register("share-purchase", SharePurchaseController); + import ShopController from "./shop_controller"; application.register("shop", ShopController); diff --git a/app/javascript/controllers/share_purchase_controller.js b/app/javascript/controllers/share_purchase_controller.js new file mode 100644 index 000000000..304eaf2db --- /dev/null +++ b/app/javascript/controllers/share_purchase_controller.js @@ -0,0 +1,44 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static values = { url: String }; + static targets = ["status"]; + + async copy(event) { + event.preventDefault(); + try { + if ( + !navigator.clipboard || + !window.ClipboardItem || + !window.isSecureContext + ) { + throw new Error("clipboard unsupported"); + } + // Pass the promise straight to ClipboardItem so Safari keeps the + // user-gesture context while the PNG downloads. + const item = new ClipboardItem({ + "image/png": fetch(this.urlValue).then((response) => { + if (!response.ok) throw new Error("image fetch failed"); + return response.blob(); + }), + }); + await navigator.clipboard.write([item]); + this.#status("Copied! Paste it anywhere."); + } catch { + this.#status("Couldn't copy on this browser — use Download PNG instead."); + } + } + + #status(message) { + if (!this.hasStatusTarget) return; + this.statusTarget.textContent = message; + clearTimeout(this.statusTimeout); + this.statusTimeout = setTimeout(() => { + this.statusTarget.textContent = ""; + }, 4000); + } + + disconnect() { + clearTimeout(this.statusTimeout); + } +} diff --git a/app/policies/shop_policy.rb b/app/policies/shop_policy.rb index f033b9d82..5b32865f5 100644 --- a/app/policies/shop_policy.rb +++ b/app/policies/shop_policy.rb @@ -15,6 +15,10 @@ def cancel? signed_in_any? end + def flex_image? + signed_in_any? + end + def destroy? signed_in_any? end diff --git a/app/services/og_image/base.rb b/app/services/og_image/base.rb index 298502cbf..d253ba0a8 100644 --- a/app/services/og_image/base.rb +++ b/app/services/og_image/base.rb @@ -61,6 +61,16 @@ def to_png protected + # Canvas dimensions resolve through the subclass so a renderer can + # declare its own WIDTH/HEIGHT constants (e.g. a square canvas). + def canvas_width + self.class::WIDTH + end + + def canvas_height + self.class::HEIGHT + end + def draw_rounded_rect(x:, y:, width:, height:, radius: 24, fill: "#ffffff", fill_opacity: 1.0, stroke: nil, stroke_width: 0) r, g, b = hex_to_rgb(fill) rect = rounded_rect_mask(width, height, radius) @@ -85,10 +95,10 @@ def create_patterned_canvas( fr, fg, fb = hex_to_rgb(frame_color) cr, cg, cb = hex_to_rgb(card_color) - canvas = solid_rgba(WIDTH, HEIGHT, fr, fg, fb) + canvas = solid_rgba(canvas_width, canvas_height, fr, fg, fb) - cw = WIDTH - inset * 2 - ch = HEIGHT - inset * 2 + cw = canvas_width - inset * 2 + ch = canvas_height - inset * 2 card_mask = rounded_rect_mask(cw, ch, card_radius) card = solid_rgba(cw, ch, cr, cg, cb) card = card.extract_band(0, n: 3).bandjoin(card_mask) @@ -97,10 +107,10 @@ def create_patterned_canvas( pattern_path = Rails.root.join("app", "assets", "images", "mask", "pattern.png").to_s if File.exist?(pattern_path) pattern = Vips::Image.new_from_file(pattern_path) - pattern = pattern.resize(WIDTH.to_f / pattern.width, vscale: HEIGHT.to_f / pattern.height) + pattern = pattern.resize(canvas_width.to_f / pattern.width, vscale: canvas_height.to_f / pattern.height) pat_rgb = pattern.extract_band(0, n: 3) - pat_alpha = pattern.bands >= 4 ? pattern.extract_band(3) : Vips::Image.black(WIDTH, HEIGHT).new_from_image(255).cast(:uchar) + pat_alpha = pattern.bands >= 4 ? pattern.extract_band(3) : Vips::Image.black(canvas_width, canvas_height).new_from_image(255).cast(:uchar) canvas_rgb = canvas.extract_band(0, n: 3) canvas_alpha = canvas.extract_band(3) @@ -169,10 +179,10 @@ def create_stardance_canvas( br, bg, bb = hex_to_rgb(bg_color) cr, cg, cb = hex_to_rgb(card_color) - canvas = solid_rgba(WIDTH, HEIGHT, br, bg, bb) + canvas = solid_rgba(canvas_width, canvas_height, br, bg, bb) - cw = WIDTH - inset * 2 - ch = HEIGHT - inset * 2 + cw = canvas_width - inset * 2 + ch = canvas_height - inset * 2 card_mask = rounded_rect_mask(cw, ch, card_radius) card = solid_rgba(cw, ch, cr, cg, cb) card = card.extract_band(0, n: 3).bandjoin(card_mask) @@ -181,7 +191,7 @@ def create_stardance_canvas( nebula_path = Rails.root.join("app", "assets", "images", "landing", "how-this-works", "nebula-bg.png").to_s if File.exist?(nebula_path) nebula = Vips::Image.new_from_file(nebula_path) - nebula = nebula.resize(WIDTH.to_f / nebula.width, vscale: HEIGHT.to_f / nebula.height) + nebula = nebula.resize(canvas_width.to_f / nebula.width, vscale: canvas_height.to_f / nebula.height) nebula = ensure_four_bands(nebula) neb_rgb = nebula.extract_band(0, n: 3) neb_alpha = nebula.extract_band(3) @@ -283,14 +293,14 @@ def place_star_character(x: 60, y: 60, width: 220, height: 220, gravity: "SouthE def draw_diagonal_scrim(opacity: 0.55) r, g, b = hex_to_rgb("#08061e") - h_ramp = Vips::Image.identity(bands: 1).resize(WIDTH / 256.0, vscale: 1.0) - h_ramp = h_ramp.linear(-1.0, 255.0).resize(1, vscale: HEIGHT.to_f) + h_ramp = Vips::Image.identity(bands: 1).resize(canvas_width / 256.0, vscale: 1.0) + h_ramp = h_ramp.linear(-1.0, 255.0).resize(1, vscale: canvas_height.to_f) - v_ramp = Vips::Image.identity(bands: 1).resize(1, vscale: HEIGHT / 256.0) - v_ramp = v_ramp.resize(WIDTH.to_f, vscale: 1.0) + v_ramp = Vips::Image.identity(bands: 1).resize(1, vscale: canvas_height / 256.0) + v_ramp = v_ramp.resize(canvas_width.to_f, vscale: 1.0) diag = ((h_ramp + v_ramp) / 2.0 * opacity).cast(:uchar) - scrim = solid_rgba(WIDTH, HEIGHT, r, g, b).extract_band(0, n: 3).bandjoin(diag).copy(interpretation: :srgb) + scrim = solid_rgba(canvas_width, canvas_height, r, g, b).extract_band(0, n: 3).bandjoin(diag).copy(interpretation: :srgb) @image = image.composite(scrim, :over, x: [ 0 ], y: [ 0 ]) end @@ -384,13 +394,13 @@ def apply_gravity(gravity, x, y, obj_width, obj_height) when "NorthWest" [ x, y ] when "NorthEast" - [ WIDTH - x - obj_width, y ] + [ canvas_width - x - obj_width, y ] when "SouthWest" - [ x, HEIGHT - y - obj_height ] + [ x, canvas_height - y - obj_height ] when "SouthEast" - [ WIDTH - x - obj_width, HEIGHT - y - obj_height ] + [ canvas_width - x - obj_width, canvas_height - y - obj_height ] when "Center" - [ (WIDTH - obj_width) / 2 + x, (HEIGHT - obj_height) / 2 + y ] + [ (canvas_width - obj_width) / 2 + x, (canvas_height - obj_height) / 2 + y ] else [ x, y ] end diff --git a/app/services/og_image/preview.rb b/app/services/og_image/preview.rb index cfa17f3c4..abb21442d 100644 --- a/app/services/og_image/preview.rb +++ b/app/services/og_image/preview.rb @@ -6,7 +6,8 @@ module Preview OgImage::User, OgImage::Devlog, OgImage::Missions, - OgImage::Shop + OgImage::Shop, + OgImage::ShopOrderFlex ].freeze class << self diff --git a/app/services/og_image/shop_order_flex.rb b/app/services/og_image/shop_order_flex.rb new file mode 100644 index 000000000..90615d066 --- /dev/null +++ b/app/services/og_image/shop_order_flex.rb @@ -0,0 +1,181 @@ +module OgImage + class MockShopOrder + MockShopItem = Struct.new(:name, :image) + + def initialize(item_name:, user_name:) + @shop_item = MockShopItem.new(item_name, MockAttachment.new) + @user = MockUser.new(display_name: user_name) + end + + attr_reader :shop_item, :user + end + + # The "sharable purchase" flex card: a square social image the buyer can + # copy/download after checkout. Layout mirrors the brand carousel art + # (Figma "the empty space above", node 5790:29325). + class ShopOrderFlex < Base + WIDTH = 1200 + HEIGHT = 1200 + + BG_TOP = "#040208" + BG_BOTTOM = "#160f39" + TEXT_COLOR = "#fefdfb" + LINK_COLOR = "#81ffff" + + ART_DIR = Rails.root.join("app", "assets", "images", "og", "sharable-purchase") + MOON_PATH = ART_DIR.join("moon.png").to_s + MASCOT_PATH = ART_DIR.join("star-mascot.png").to_s + FLAG_PATH = ART_DIR.join("hack-club-flag.png").to_s + + PREVIEWS = { + "default" => -> { new(MockShopOrder.new(item_name: "Hardware Build Fund", user_name: "orpheus")) }, + "short_item" => -> { new(MockShopOrder.new(item_name: "Pile of Stickers", user_name: "orpheus")) }, + "long_item" => -> { new(MockShopOrder.new(item_name: "Bambu Lab A1 Mini 3D Printer Combo", user_name: "superlongusername123")) } + }.freeze + + PREVIEW_META = { + "default" => { + title: "I bought a Hardware Build Fund on Stardance", + description: "Earned in the Stardance shop", + url: "https://stardance.hackclub.com/@orpheus", + site_name: "Stardance - Hack Club", + twitter_card: "summary_large_image" + }, + "short_item" => { + title: "I bought a Pile of Stickers on Stardance", + description: "Earned in the Stardance shop", + url: "https://stardance.hackclub.com/@orpheus", + site_name: "Stardance - Hack Club", + twitter_card: "summary_large_image" + }, + "long_item" => { + title: "I bought a Bambu Lab A1 Mini 3D Printer Combo on Stardance", + description: "Earned in the Stardance shop", + url: "https://stardance.hackclub.com/@superlongusername123", + site_name: "Stardance - Hack Club", + twitter_card: "summary_large_image" + } + }.freeze + + def initialize(shop_order) + super() + @shop_item = shop_order.shop_item + @user = shop_order.user + end + + def render + draw_vertical_gradient(BG_TOP, BG_BOTTOM) + place_moon + place_screen_blended(MASCOT_PATH, x: 744, y: 276) + place_screen_blended(FLAG_PATH, x: 924, y: 294) + place_stardance_logo(x: 60, y: 60, width: 375, height: 140) + draw_flex_text + place_item_art + end + + private + + def draw_vertical_gradient(top_hex, bottom_hex) + tr, tg, tb = hex_to_rgb(top_hex) + br, bg, bb = hex_to_rgb(bottom_hex) + + mix = Vips::Image.xyz(canvas_width, canvas_height).extract_band(1) / (canvas_height - 1).to_f + rgb = mix.linear([ br - tr, bg - tg, bb - tb ], [ tr, tg, tb ]).cast(:uchar) + alpha = Vips::Image.black(canvas_width, canvas_height).new_from_image(255).cast(:uchar) + @image = rgb.bandjoin(alpha).copy(interpretation: :srgb) + end + + # The moon export carries its own baked-in background, so fade its left + # edge into the canvas gradient instead of compositing a hard seam. + def place_moon(fade_width: 190) + return unless File.exist?(MOON_PATH) + + moon = ensure_four_bands(Vips::Image.new_from_file(MOON_PATH)) + + mix = Vips::Image.xyz(moon.width, moon.height).extract_band(0) / fade_width.to_f + mix = (mix > 1.0).ifthenelse(1.0, mix) + faded_alpha = (moon.extract_band(3) * mix).cast(:uchar) + moon = moon.extract_band(0, n: 3).bandjoin(faded_alpha).copy(interpretation: :srgb) + + x = canvas_width - moon.width + y = canvas_height - moon.height + @image = image.composite(moon, :over, x: [ x ], y: [ y ]) + rescue StandardError => e + Rails.logger.warn("OgImage::ShopOrderFlex: failed to place moon: #{e.message}") + end + + # Mascot/flag exports sit on a dark navy field; screen-blending drops the + # field into the background while keeping the glow. + def place_screen_blended(path, x:, y:) + return unless File.exist?(path) + + overlay = ensure_four_bands(Vips::Image.new_from_file(path)).copy(interpretation: :srgb) + @image = image.composite(overlay, :screen, x: [ x ], y: [ y ]) + rescue StandardError => e + Rails.logger.warn("OgImage::ShopOrderFlex: failed to place #{File.basename(path)}: #{e.message}") + end + + def draw_flex_text + username = pango_escape("@#{@user.display_name}") + # Size off the human-visible (pre-escape) length so names with & < > " + # don't get bumped into a smaller bucket by their escaped expansion. + truncated_name = truncate_text(@shop_item.name, 28) + item_name = pango_escape(truncated_name) + item_size = item_font_size(truncated_name) + + draw_shadowed_text(username, x: 60, y: 316, size: 54) + + draw_shadowed_text("I bought a", x: 60, y: 402, size: 88) + draw_shadowed_text(item_name, x: 60, y: 506, size: item_size, font: title_font_name) + third_line_y = 506 + item_size + 24 + draw_shadowed_text("on Stardance", x: 60, y: third_line_y, size: 88) + + profile_url = pango_escape("stardance.hackclub.com/@#{@user.display_name}") + draw_shadowed_text(profile_url, x: 60, y: third_line_y + 88 + 42, size: 36, color: LINK_COLOR) + rescue StandardError => e + Rails.logger.warn("OgImage::ShopOrderFlex: failed to draw text: #{e.message}") + end + + def draw_shadowed_text(text, x:, y:, size:, color: TEXT_COLOR, font: nil) + face = font || heading_font_name + draw_soft_shadow(text, x: x, y: y, size: size, font: face, radius: 10, opacity: 0.5, offset: 4) + draw_text(text, x: x, y: y, size: size, color: color, font: face) + end + + def item_font_size(name) + case name.length + when 0..14 then 100 + when 15..20 then 80 + else 64 + end + end + + def place_item_art(box: 360, angle: -5) + thumb = load_source_image(@shop_item.image, box, box, cover: false) + return unless thumb + + thumb = ensure_four_bands(thumb) + rotated = thumb.similarity(angle: angle, background: [ 0, 0, 0, 0 ]).copy(interpretation: :srgb) + + x = canvas_width - 96 - rotated.width + y = canvas_height - 72 - rotated.height + + shadow_alpha = (rotated.extract_band(3).gaussblur(12) * 0.55).cast(:uchar) + shadow = Vips::Image.black(rotated.width, rotated.height) + .new_from_image([ 0, 0, 0 ]) + .cast(:uchar) + .bandjoin(shadow_alpha) + .copy(interpretation: :srgb) + @image = image.composite(shadow, :over, x: [ x + 8 ], y: [ y + 18 ]) + @image = image.composite(rotated, :over, x: [ x ], y: [ y ]) + rescue StandardError => e + Rails.logger.warn("OgImage::ShopOrderFlex: failed to place item art: #{e.message}") + end + + # Vips::Image.text parses Pango markup, so user-provided strings must be + # escaped or names containing & / < break the render. + def pango_escape(text) + CGI.escapeHTML(text.to_s) + end + end +end diff --git a/app/views/shop/orders/_sharable_purchase.html.erb b/app/views/shop/orders/_sharable_purchase.html.erb new file mode 100644 index 000000000..a7c45fe45 --- /dev/null +++ b/app/views/shop/orders/_sharable_purchase.html.erb @@ -0,0 +1,19 @@ +
+
+ <%= image_tag shop_order_flex_image_path(order), + alt: "I bought a #{order.shop_item.name} on Stardance", + class: "sharable-purchase__image", + loading: "lazy" %> +
+
+

You earned this. Flex it.

+

Copy your card and post it anywhere — it points people at your profile.

+
+ <%= render ActionButtonComponent.new(text: "Copy image", type: :button, size: :small, data: { action: "share-purchase#copy" }) %> + <%= render ActionButtonComponent.new(text: "Download PNG", size: :small, variant: :secondary, href: shop_order_flex_image_path(order, download: 1)) %> +
+

+
+
diff --git a/app/views/shop/orders/index.html.erb b/app/views/shop/orders/index.html.erb index 712e54995..1f5164803 100644 --- a/app/views/shop/orders/index.html.erb +++ b/app/views/shop/orders/index.html.erb @@ -6,6 +6,10 @@ <%= render ActionButtonComponent.new(text: "Back to the Shop", href: shop_path, size: :small, variant: :secondary) %> + <% if @sharable_order %> + <%= render "sharable_purchase", order: @sharable_order %> + <% end %> + <% if @orders.empty? %>

You haven't placed any orders yet.

diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 6c8c5825d..2c704c88b 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -43,6 +43,7 @@ lookout payout_recommendations hardware_to_outpost + sharable_purchase ].each { |flag| Flipper.add(flag) } end rescue StandardError => e diff --git a/config/routes.rb b/config/routes.rb index 73391b3ab..58bf95f96 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -464,6 +464,7 @@ member do delete :cancel end + resource :flex_image, only: [ :show ], module: :orders, defaults: { format: :png } end resource :region, only: [ :update ] get "category/:slug", to: "items#category", as: :category diff --git a/test/controllers/shop/orders/flex_images_controller_test.rb b/test/controllers/shop/orders/flex_images_controller_test.rb new file mode 100644 index 000000000..22d28d337 --- /dev/null +++ b/test/controllers/shop/orders/flex_images_controller_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class Shop::Orders::FlexImagesControllerTest < ActionDispatch::IntegrationTest + include UserFactory + + PIXEL_PNG = Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=") + + setup do + @owner = create_user(slack_id: "u-flex-owner", display_name: "flexowner") + @owner.update!(has_gotten_free_stickers: true) + @other = create_user(slack_id: "u-flex-other", display_name: "flexother") + + item = ShopItem.new( + name: "Hardware Build Fund", + description: "Flex card test item", + ticket_cost: 0, + type: "ShopItem::ThirdPartyPhysical", + enabled: true + ) + item.image.attach(io: StringIO.new(PIXEL_PNG), filename: "px.png", content_type: "image/png") + item.save! + + @order = @owner.shop_orders.new( + shop_item: item, + quantity: 1, + frozen_address: { "country" => "US", "phone_number" => "+15555550123", "primary" => true } + ) + @order.aasm_state = "pending" + @order.save! + end + + teardown do + Flipper.disable(:sharable_purchase) + end + + test "returns 404 when the flag is off" do + sign_in @owner + get shop_order_flex_image_path(@order) + assert_response :not_found + end + + test "owner gets their flex image when the flag is on" do + Flipper.enable(:sharable_purchase) + sign_in @owner + + get shop_order_flex_image_path(@order) + + assert_response :success + assert_equal "image/png", response.media_type + assert response.body.start_with?("\x89PNG".b), "body must be a PNG" + end + + test "download param serves the png as an attachment" do + Flipper.enable(:sharable_purchase) + sign_in @owner + + get shop_order_flex_image_path(@order, download: 1) + + assert_response :success + assert_match(/attachment/, response.headers["Content-Disposition"]) + assert_match(/stardance-order-#{@order.id}\.png/, response.headers["Content-Disposition"]) + end + + test "another user cannot fetch someone else's flex image" do + Flipper.enable(:sharable_purchase) + sign_in @other + + get shop_order_flex_image_path(@order) + + assert_response :not_found + end +end diff --git a/test/services/og_image/shop_order_flex_test.rb b/test/services/og_image/shop_order_flex_test.rb new file mode 100644 index 000000000..83750c9f4 --- /dev/null +++ b/test/services/og_image/shop_order_flex_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class OgImageShopOrderFlexTest < ActiveSupport::TestCase + include UserFactory + + PIXEL_PNG = Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=") + + setup do + @user = create_user(slack_id: "u-flex", display_name: "flexer") + @item = build_item(name: "Hardware Build Fund") + @order = ShopOrder.new(shop_item: @item, user: @user, quantity: 1) + end + + test "renders a 1200x1200 png" do + png = OgImage::ShopOrderFlex.new(@order).to_png + + assert png.start_with?("\x89PNG".b), "output must be a PNG" + image = Vips::Image.new_from_buffer(png, "") + assert_equal 1200, image.width + assert_equal 1200, image.height + end + + test "survives item names with pango markup characters" do + @item.update!(name: "Solder & Kit") + + assert_nothing_raised do + OgImage::ShopOrderFlex.new(@order).to_png + end + end + + private + + def build_item(name:) + item = ShopItem.new( + name: name, + description: "Flex card test item", + ticket_cost: 0, + type: "ShopItem::ThirdPartyPhysical", + enabled: true + ) + item.image.attach(io: StringIO.new(PIXEL_PNG), filename: "px.png", content_type: "image/png") + item.save! + item + end +end