Skip to content

Instantly share code, notes, and snippets.

@peteonrails
Created April 17, 2026 11:13
Show Gist options
  • Select an option

  • Save peteonrails/ce92cd9ad7487cc5fb8cb68fe2a52129 to your computer and use it in GitHub Desktop.

Select an option

Save peteonrails/ce92cd9ad7487cc5fb8cb68fe2a52129 to your computer and use it in GitHub Desktop.
ENG1S-712: Test artifacts — seed task, Playwright scripts, before/after proof
/home/pete/.local/share/mise/installs/ruby/3.4.7/lib/ruby/3.4.0/bundled_gems.rb:82: warning: ⛔️ WARNING: Sidekiq testing API enabled, but this is not the test environment. Your jobs will not go to Redis.
⛔️ `require "sidekiq/testing"` is deprecated and will be removed in Sidekiq 9.0. See https://sidekiq.org/wiki/Testing#new-api
======================================================================
ENG1S-712: Before/After Proof — SubscriptionSeats::Assign
======================================================================
--- BEFORE (main's code) ---
User: 32 | Subscription: active | Membership: 38
ReplaceIndividualWithAgencyJob enqueued: 0
RESULT: NO JOB — subscription keeps running until period end
INFO  2026-04-16T23:42:19.203Z pid=3703396 tid=27foc: Sidekiq 8.1.2 connecting to Redis with options {size: 5, pool_name: "internal", url: "redis://localhost:6379/"}
--- AFTER (our fix) ---
User: 33 | Subscription: active | Membership: 39
Assign result: success
ReplaceIndividualWithAgencyJob enqueued: 1
Job args: [33]
RESULT: JOB FIRED — subscription will be cancelled and refunded
======================================================================
# ENG1S-712 Before/After Proof
# Exercises SubscriptionSeats::Assign with and without the fix,
# showing that ReplaceIndividualWithAgencyJob is only enqueued WITH the fix.
#
# Usage: bin/rails runner /tmp/eng1s712-before-after.rb
require "sidekiq/testing"
agency = Agency.find_by!(name: "Duck Travel Worldwide")
sub = agency.subscription
puts "=" * 70
puts "ENG1S-712: Before/After Proof — SubscriptionSeats::Assign"
puts "=" * 70
# --- Setup a fresh user for each test ---
def setup_test_user(agency, sub, label)
email = "eng1s712-#{label}@proof.test"
User.find_by(email:)&.destroy
user = User.create!(
email:,
first_name: label.capitalize,
last_name: "Proof",
password: "abc12345!1!1",
password_confirmation: "abc12345!1!1",
signup_type: "travel_advisor",
signup_status: "active",
agreed_to_terms_of_service: true,
member_of_host_agency: false,
member_of_consortium: false,
confirmed_at: Time.current
)
user.create_subscription!(
status: "active",
stripe_subscription_id: "sub_proof_#{label}",
current_period_end_at: 30.days.from_now
)
membership = agency.memberships.create!(user:, role: "agent", status: "active")
seat = sub.subscription_seats.create!
[user, membership, seat]
end
# -------------------------------------------------------
# BEFORE: main's version (no subscription cancellation)
# -------------------------------------------------------
user_before, membership_before, seat_before = setup_test_user(agency, sub, "before")
puts "\n--- BEFORE (main's code) ---"
puts "User: #{user_before.id} | Subscription: active | Membership: #{membership_before.id}"
Sidekiq::Testing.fake! do
Sidekiq::Worker.clear_all
# Simulate main's Assign — just assigns the seat, no cancellation check
seat_before.update!(agency_membership: membership_before)
# That's all main does — no cancel_personal_subscription_if_needed call
jobs = Sidekiq::Queues["less_than_five_minutes"].select { |j| j["class"] == "Subscriptions::ReplaceIndividualWithAgencyJob" }
puts "ReplaceIndividualWithAgencyJob enqueued: #{jobs.count}"
puts "RESULT: #{jobs.count > 0 ? 'JOB FIRED (unexpected)' : 'NO JOB — subscription keeps running until period end'}"
end
# Cleanup
seat_before.update!(agency_membership: nil)
membership_before.destroy
user_before.subscription.destroy
user_before.destroy
seat_before.destroy
# -------------------------------------------------------
# AFTER: our fix (with subscription cancellation)
# -------------------------------------------------------
user_after, membership_after, seat_after = setup_test_user(agency, sub, "after")
puts "\n--- AFTER (our fix) ---"
puts "User: #{user_after.id} | Subscription: active | Membership: #{membership_after.id}"
Sidekiq::Testing.fake! do
Sidekiq::Worker.clear_all
# Use our service which includes cancel_personal_subscription_if_needed
result = AgencyMemberships::SubscriptionSeats::Assign.call(
membership: membership_after,
subscription_seat: seat_after
)
puts "Assign result: #{result.success? ? 'success' : 'failure'}"
jobs = Sidekiq::Queues["less_than_five_minutes"].select { |j| j["class"] == "Subscriptions::ReplaceIndividualWithAgencyJob" }
puts "ReplaceIndividualWithAgencyJob enqueued: #{jobs.count}"
if jobs.any?
puts "Job args: #{jobs.first['args'].inspect}"
puts "RESULT: JOB FIRED — subscription will be cancelled and refunded"
else
puts "RESULT: NO JOB (unexpected)"
end
end
# Cleanup
seat_after.update!(agency_membership: nil)
membership_after.destroy
user_after.subscription.destroy
user_after.destroy
seat_after.destroy
puts "\n" + "=" * 70
// ENG1S-712 Path A: Manual seat assignment via Team & Billing.
// Proves SubscriptionSeats::Assign fires ReplaceIndividualWithAgencyJob.
const { chromium } = require("playwright")
const path = require("path")
const BASE = process.env.TERN_URL || "http://localhost:3000"
const DIR = "/tmp/eng1s712-screenshots"
async function shoot(page, name) {
const file = path.join(DIR, name + ".png")
await page.screenshot({ path: file, fullPage: true })
console.log(" screenshot:", file)
}
async function login(page, email, password) {
await page.goto(BASE + "/session/new")
await page.fill("#email", email)
await page.fill("#password", password)
await page.getByRole("button", { name: "Sign In", exact: true }).click()
await page.waitForURL((url) => !url.pathname.startsWith("/session"))
}
async function run() {
const browser = await chromium.launch({ headless: true })
const page = await browser.newContext({ viewport: { width: 1400, height: 900 } }).then(c => c.newPage())
try {
console.log("PATH A: Manual Seat Assignment")
console.log(" 1. Login as agency owner")
await login(page, "john@ducktravel.com", "abc12345!1!1")
console.log(" 2. Navigate to Team & Billing")
await page.goto(BASE + "/agencies/1/team")
await page.waitForLoadState("networkidle")
await shoot(page, "path-a-01-before")
console.log(" 3. Open more-actions menu for PathA Advisor")
const row = page.locator(".table-row", { hasText: "eng1s712-path-a@example.com" })
await row.waitFor({ state: "visible", timeout: 10000 })
// Click the three-dot menu button scoped to this row
const menuBtn = row.locator("[aria-haspopup='menu']")
await menuBtn.click()
// Wait for a visible dropdown, then click Assign paid seat
await page.waitForTimeout(500) // let dropdown animation finish
await shoot(page, "path-a-02-menu-open")
console.log(" 4. Click 'Assign paid seat'")
// Menu items with method: :post render as button_to forms with role="menuitem"
await page.evaluate(() => {
const items = [...document.querySelectorAll("[role='menuitem']")]
const assign = items.find(el => el.textContent.includes("Assign paid seat") && !el.closest(".hidden"))
if (assign) assign.click()
else throw new Error("Could not find 'Assign paid seat' menu item")
})
await page.waitForTimeout(1500)
await shoot(page, "path-a-03-after-click")
console.log(" 5. Reload to confirm state change")
await page.goto(BASE + "/agencies/1/team")
await page.waitForLoadState("networkidle")
await shoot(page, "path-a-04-after-reload")
console.log(" 6. Check Sidekiq")
await page.goto(BASE + "/admin/sidekiq")
await page.waitForLoadState("networkidle")
await shoot(page, "path-a-05-sidekiq")
console.log("PATH A: SUCCESS")
} catch (err) {
await shoot(page, "path-a-99-error")
console.error("PATH A FAILED:", err.message)
process.exitCode = 1
} finally {
await browser.close()
}
}
run()
// ENG1S-712 Path B: Subagency seat transfer via parent agency invitation.
// Proves transfer_memberships! fires ReplaceIndividualWithAgencyJob for
// transferred members with active personal subscriptions.
const { chromium } = require("playwright")
const path = require("path")
const { execSync } = require("child_process")
const BASE = process.env.TERN_URL || "http://localhost:3000"
const DIR = "/tmp/eng1s712-screenshots"
async function shoot(page, name) {
const file = path.join(DIR, name + ".png")
await page.screenshot({ path: file, fullPage: true })
console.log(" screenshot:", file)
}
async function login(page, email, password) {
await page.goto(BASE + "/session/new")
await page.fill("#email", email)
await page.fill("#password", password)
await page.getByRole("button", { name: "Sign In", exact: true }).click()
await page.waitForURL((url) => !url.pathname.startsWith("/session"))
}
function getInvitationToken() {
const cmd = `cd /home/pete/workspace/tern/.worktrees/eng1s-712-fix-agency-conversion-refunds/rails && eval "$(mise activate bash)" && bin/rails runner "inv = ParentAgencyInvitation.find_by(email: 'eng1s712-path-b-owner@example.com', status: 'pending'); puts inv&.to_sgid(purpose: :parent_agency_invitation)&.to_s || 'NONE'" 2>/dev/null | tail -1`
return execSync(cmd, { encoding: "utf-8" }).trim()
}
async function run() {
const browser = await chromium.launch({ headless: true })
const page = await browser.newContext({ viewport: { width: 1400, height: 900 } }).then(c => c.newPage())
try {
console.log("PATH B: Subagency Seat Transfer")
console.log(" 1. Login as subagency owner")
await login(page, "eng1s712-path-b-owner@example.com", "abc12345!1!1")
console.log(" 2. Visit invitation URL")
const token = getInvitationToken()
if (!token || token === "NONE") throw new Error("No pending invitation found")
// The invitation URL redirects to /users/:id/agency_memberships with the
// acceptance modal auto-opened
await page.goto(BASE + "/parent_agency_invitations/" + token)
await page.waitForLoadState("networkidle")
await page.waitForTimeout(2000) // let modal render
await shoot(page, "path-b-01-invitation-page")
console.log(" 3. Select 'Affiliate existing agency' and choose acting agency")
// Radio button for "existing" should be present if the user has admin agencies
const existingRadio = page.locator("input[value='existing']")
if (await existingRadio.count() > 0) {
await existingRadio.click()
await page.waitForTimeout(500)
}
// Select the acting agency from the dropdown
const agencySelect = page.locator("select[name*='agency_id']")
if (await agencySelect.count() > 0) {
// Find the option with the acting agency name
await agencySelect.selectOption({ label: "ENG1S-712 Acting Agency" })
await page.waitForTimeout(500)
}
await shoot(page, "path-b-02-form-filled")
console.log(" 4. Submit acceptance")
// The submit button text comes from t(".continue")
const submitBtn = page.getByRole("button", { name: /continue/i }).first()
await submitBtn.click()
// Wait for turbo redirect to the new team page
await page.waitForTimeout(3000)
await page.waitForLoadState("networkidle")
await shoot(page, "path-b-03-after-acceptance")
console.log(" 5. Check Sidekiq")
await page.goto(BASE + "/admin/sidekiq")
await page.waitForLoadState("networkidle")
await shoot(page, "path-b-04-sidekiq")
console.log("PATH B: SUCCESS")
} catch (err) {
await shoot(page, "path-b-99-error")
console.error("PATH B FAILED:", err.message)
process.exitCode = 1
} finally {
await browser.close()
}
}
run()
require_relative "../../data_migration"
# Seed data for manual testing of ENG1S-712.
# Creates two test scenarios and prints step-by-step instructions.
#
# Usage: bin/rails eng1s_712:seed
#
# To reset and re-run: bin/rails eng1s_712:reset eng1s_712:seed
namespace :eng1s_712 do
PASSWORD = ENV["SEED_USER_PASSWORD"] || "abc12345!1!1"
desc "Seed test data for ENG1S-712 manual testing"
task seed: :environment do
puts "\n=== ENG1S-712: Seeding test data ===\n\n"
# --- Shared: host agency with subscription + available seats ---
agency = Agency.find_by(name: "Duck Travel Worldwide")
raise "Run db:seed first — Duck Travel Worldwide must exist" unless agency
raise "Duck Travel needs an active subscription" unless agency.subscription&.active?
owner = User.find_by!(email: "john@ducktravel.com")
# ----------------------------------------------------------------
# PATH A: Manual seat assignment (SubscriptionSeats::Assign)
# ----------------------------------------------------------------
path_a_user = User.find_or_create_by!(email: "eng1s712-path-a@example.com") do |u|
u.first_name = "PathA"
u.last_name = "Advisor"
u.password = PASSWORD
u.password_confirmation = PASSWORD
u.signup_type = "travel_advisor"
u.signup_status = "active"
u.agreed_to_terms_of_service = true
u.member_of_host_agency = false
u.member_of_consortium = false
u.confirmed_at = Time.current
end
path_a_user.authentication_settings.update!(prompted_for_two_factor: true)
# Active personal subscription (fake Stripe ID — job will fail gracefully)
path_a_user.subscription&.destroy
path_a_user.create_subscription!(
status: "active",
stripe_subscription_id: "sub_eng1s712_path_a_test",
current_period_end_at: 30.days.from_now
)
# Active unpaid membership
path_a_membership = agency.memberships.find_or_create_by!(user: path_a_user) do |m|
m.role = "agent"
m.status = "active"
end
path_a_membership.update!(status: "active", role: "agent")
path_a_membership.subscription_seats.each { |s| s.update!(agency_membership: nil) }
# Ensure there are at least 2 unoccupied seats (Path A needs 1, Path B needs 1)
sub = agency.subscription
while sub.subscription_seats.unoccupied.count < 2
sub.subscription_seats.create!
sub.update!(seats: sub.subscription_seats.count)
end
# ----------------------------------------------------------------
# PATH B: Subagency seat transfer (ParentAgencyInvitations::Accept)
# ----------------------------------------------------------------
# "Acting agency" — the subagency being absorbed into Duck Travel
acting_agency = Agency.find_by(name: "ENG1S-712 Acting Agency")
unless acting_agency
acting_agency = Agency.new(
name: "ENG1S-712 Acting Agency",
default_billing: "agency_pays",
estimated_advisor_count: 3,
website: "https://eng1s712-acting.example.com"
)
acting_agency.accreditations.build(value: "712712", agency_accreditation_type: "IATA")
acting_agency.save!
end
# Acting agency subscription with a paid seat
acting_sub = acting_agency.subscription || AgencySubscription.create!(
agency: acting_agency,
amount_cents: 3900,
amount_currency: "USD",
cancel_at_period_end: false,
current_period_end_at: 1.month.from_now,
interval: "month",
seats: 2,
status: "active"
)
acting_sub.update!(status: "active") unless acting_sub.active?
# Subagency owner — will accept the invitation
path_b_owner = User.find_or_create_by!(email: "eng1s712-path-b-owner@example.com") do |u|
u.first_name = "PathB"
u.last_name = "Owner"
u.password = PASSWORD
u.password_confirmation = PASSWORD
u.signup_type = "admin"
u.signup_status = "active"
u.agreed_to_terms_of_service = true
u.member_of_host_agency = false
u.member_of_consortium = false
u.confirmed_at = Time.current
end
path_b_owner.authentication_settings.update!(prompted_for_two_factor: true)
# Owner membership in acting agency
acting_agency.memberships.find_or_create_by!(user: path_b_owner) do |m|
m.role = "owner"
m.status = "active"
end
# Transferred member — has personal subscription + paid seat in acting agency
path_b_member = User.find_or_create_by!(email: "eng1s712-path-b-member@example.com") do |u|
u.first_name = "PathB"
u.last_name = "PaidMember"
u.password = PASSWORD
u.password_confirmation = PASSWORD
u.signup_type = "travel_advisor"
u.signup_status = "active"
u.agreed_to_terms_of_service = true
u.member_of_host_agency = false
u.member_of_consortium = false
u.confirmed_at = Time.current
end
path_b_member.authentication_settings.update!(prompted_for_two_factor: true)
# Active personal subscription on the transferred member
path_b_member.subscription&.destroy
path_b_member.create_subscription!(
status: "active",
stripe_subscription_id: "sub_eng1s712_path_b_test",
current_period_end_at: 30.days.from_now
)
# Member of acting agency with a paid seat
member_membership = acting_agency.memberships.find_or_create_by!(user: path_b_member) do |m|
m.role = "agent"
m.status = "active"
end
member_membership.update!(status: "active")
# Assign a paid seat in acting agency to the member
acting_seat = acting_sub.subscription_seats.find_or_create_by!(agency_membership: member_membership)
# Also make the member a member of the subagency owner's future subagency
# (they need to exist in both agencies for transfer to work)
# Create the parent agency invitation (Duck Travel invites acting agency)
# Reset any prior invitation first
ParentAgencyInvitation.where(
agency: agency,
email: path_b_owner.email
).where.not(status: "accepted").destroy_all
invitation = ParentAgencyInvitation.find_by(
agency: agency,
email: path_b_owner.email,
status: "pending"
)
unless invitation
seat = sub.subscription_seats.unoccupied.first
raise "No unoccupied seats in #{agency.name} for the invitation" unless seat
invitation = ParentAgencyInvitation.create!(
agency: agency,
acting_agency: acting_agency,
email: path_b_owner.email,
invited_by: owner,
paid: true,
status: "pending"
)
seat.update!(parent_agency_invitation: invitation)
end
acceptance_token = invitation.to_sgid(purpose: :parent_agency_invitation).to_s
invitation_url = "/parent_agency_invitations/#{acceptance_token}"
# ----------------------------------------------------------------
# Print instructions
# ----------------------------------------------------------------
puts <<~INSTRUCTIONS
============================================================
PATH A: Manual Seat Assignment
============================================================
This tests SubscriptionSeats::Assign — the fix that fires
ReplaceIndividualWithAgencyJob when an owner manually assigns
a paid seat to a member with an active personal subscription.
Login: john@ducktravel.com / #{PASSWORD}
Navigate: /agencies/#{agency.id}/team
Steps:
1. Find "PathA Advisor" (eng1s712-path-a@example.com) in the table
2. Confirm "Seat paid by" column is empty (no paid seat)
3. Click the three-dot menu → "Assign paid seat"
4. Verify success flash and "Seat paid by" now shows the agency name
5. Check Sidekiq at /admin/sidekiq — ReplaceIndividualWithAgencyJob
should have been enqueued with user_id=#{path_a_user.id}
IDs:
Agency: #{agency.id} (#{agency.name})
User: #{path_a_user.id}
Membership: #{path_a_membership.id}
Subscription: #{path_a_user.subscription.id} (stripe: #{path_a_user.subscription.stripe_subscription_id})
============================================================
PATH B: Subagency Seat Transfer
============================================================
This tests ParentAgencyInvitations::Accept#transfer_memberships!
— the fix that fires ReplaceIndividualWithAgencyJob for each
transferred member who has an active personal subscription.
Login: eng1s712-path-b-owner@example.com / #{PASSWORD}
Navigate: #{invitation_url}
Steps:
1. Log in as the subagency owner (PathB Owner)
2. Visit the invitation URL above
3. Choose "Use existing agency" and select "ENG1S-712 Acting Agency"
4. Submit the acceptance form
5. After redirect, check Sidekiq — ReplaceIndividualWithAgencyJob
should have been enqueued with user_id=#{path_b_member.id}
(the transferred member with the personal subscription)
IDs:
Host agency: #{agency.id} (#{agency.name})
Acting agency: #{acting_agency.id} (#{acting_agency.name})
Subagency owner: #{path_b_owner.id}
Transferred member: #{path_b_member.id}
Member subscription: #{path_b_member.subscription.id} (stripe: #{path_b_member.subscription.stripe_subscription_id})
Invitation: #{invitation.id}
============================================================
INSTRUCTIONS
end
desc "Reset ENG1S-712 test data"
task reset: :environment do
puts "Resetting ENG1S-712 test data..."
emails = %w[
eng1s712-path-a@example.com
eng1s712-path-b-owner@example.com
eng1s712-path-b-member@example.com
eng1s712-target@example.com
]
emails.each do |email|
user = User.find_by(email: email)
next unless user
user.subscription&.destroy
user.agency_memberships.each do |m|
m.subscription_seats.update_all(agency_membership_id: nil)
m.destroy
end
end
Agency.find_by(name: "ENG1S-712 Acting Agency")&.destroy
ParentAgencyInvitation.where(email: "eng1s712-path-b-owner@example.com").destroy_all
puts "Done."
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment