diff --git a/e2e-backend/docker-compose.yml b/e2e-backend/docker-compose.yml index 7c4266b9c..fd0c2d2c9 100644 --- a/e2e-backend/docker-compose.yml +++ b/e2e-backend/docker-compose.yml @@ -75,6 +75,12 @@ services: # never run — fine here, since the checkout golden path doesn't depend on # any job output (see note above), and the tests never assert on images. SOLID_QUEUE_IN_PUMA: "false" + # Spree throttles sign-ups (3/min) and sign-ins (5/min) per client IP. + # Every test signs a fresh customer up from the same IP, so the + # defaults lock the suite out after its first few tests. + SPREE_RATE_LIMIT_REGISTER: "1000" + SPREE_RATE_LIMIT_LOGIN: "1000" + SPREE_RATE_LIMIT_REFRESH: "1000" ports: - "4000:3000" healthcheck: diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts new file mode 100644 index 000000000..2400111ee --- /dev/null +++ b/e2e/account.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from "@playwright/test"; +import { signUp, waitForHydration } from "./support"; + +test.describe("customer account", () => { + test.beforeEach(async ({ page }) => { + await signUp(page, { firstName: "Grace", lastName: "Hopper" }); + }); + + test("the account navigation reaches every section", async ({ page }) => { + const nav = page.getByRole("complementary").getByRole("navigation"); + const sections = [ + { link: "Orders", heading: "Order History", empty: "No orders yet" }, + { + link: "Payment Methods", + heading: "Payment Methods", + empty: "No payment methods saved", + }, + { link: "Gift Cards", heading: "Gift Cards", empty: "No gift cards" }, + { link: "Addresses", heading: "Addresses", empty: "No saved addresses" }, + ]; + + for (const section of sections) { + const link = nav.getByRole("link", { name: section.link }); + await waitForHydration(link); + await link.click(); + await expect( + page.getByRole("heading", { level: 1, name: section.heading }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByRole("heading", { name: section.empty }), + ).toBeVisible(); + } + }); + + test("a customer adds and deletes an address", async ({ page }) => { + await page + .getByRole("complementary") + .getByRole("link", { name: "Addresses" }) + .click(); + const addFirst = page.getByRole("button", { + name: "Add Your First Address", + }); + await waitForHydration(addFirst); + await addFirst.click(); + + const dialog = page.getByRole("dialog", { name: "Add New Address" }); + await dialog + .getByRole("combobox", { name: "Country" }) + .selectOption({ label: "United States" }); + await dialog + .getByRole("textbox", { name: "Address", exact: true }) + .fill("42 Analytical Engine Way"); + await dialog.getByRole("textbox", { name: "City" }).fill("New York"); + await dialog + .getByRole("textbox", { name: "ZIP / Postal code" }) + .fill("10001"); + // With the United States selected the state becomes a list, enabled once + // the country's states have loaded. + await dialog + .getByRole("combobox", { name: /state/i }) + .selectOption({ label: "New York" }); + await dialog.getByRole("button", { name: "Save Address" }).click(); + + await expect(dialog).toBeHidden({ timeout: 30_000 }); + await expect(page.getByText("42 Analytical Engine Way")).toBeVisible(); + + // The list refreshes once more after the save, and that closes a confirm + // dialog opened just before it. + const confirmDelete = page + .getByRole("alertdialog") + .getByRole("button", { name: "Delete" }); + await expect(async () => { + if (!(await confirmDelete.isVisible())) { + await page + .getByRole("button", { name: "Delete" }) + .first() + .click({ timeout: 3_000 }); + } + await confirmDelete.click({ timeout: 3_000 }); + }).toPass({ timeout: 30_000 }); + await expect( + page.getByRole("heading", { name: "No saved addresses" }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("a customer updates their name", async ({ page }) => { + await page + .getByRole("complementary") + .getByRole("link", { name: "Profile" }) + .click(); + const firstName = page.getByRole("textbox", { name: "First Name" }); + await expect(firstName).toHaveValue("Grace", { timeout: 30_000 }); + await waitForHydration(firstName); + + await firstName.fill("Amazing Grace"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await expect(page.getByText("Profile updated successfully!")).toBeVisible({ + timeout: 30_000, + }); + + await page.reload(); + await expect(page.getByRole("textbox", { name: "First Name" })).toHaveValue( + "Amazing Grace", + { timeout: 30_000 }, + ); + }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 000000000..ba6137349 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { + BASE_PATH, + formWithButton, + signIn, + signUp, + waitForHydration, +} from "./support"; + +test.describe("sign up and sign in", () => { + test("a new customer signs up, signs out and signs back in", async ({ + page, + }) => { + const { email, password } = await signUp(page, { + firstName: "Ada", + lastName: "Lovelace", + }); + const sidebar = page.getByRole("complementary"); + await expect(sidebar.getByText("Ada Lovelace")).toBeVisible(); + await expect(sidebar.getByText(email)).toBeVisible(); + + const signOut = page.getByRole("button", { name: "Sign Out" }); + await waitForHydration(signOut); + await signOut.click(); + await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible({ + timeout: 30_000, + }); + + await page.goto(`${BASE_PATH}/account`); + await signIn(page, email, password); + await expect( + page.getByRole("heading", { name: "Account Overview" }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("a wrong password is refused", async ({ page }) => { + const { email } = await signUp(page); + await page.context().clearCookies(); + + await page.goto(`${BASE_PATH}/account`); + await signIn(page, email, "not-the-password"); + await expect(page.getByText("Invalid email or password")).toBeVisible({ + timeout: 30_000, + }); + }); + + test("an account page sends a guest to sign in and back", async ({ + page, + }) => { + const { email, password } = await signUp(page); + await page.context().clearCookies(); + + await page.goto(`${BASE_PATH}/account/orders`); + await expect(page).toHaveURL(/\/account\?redirect=/); + await signIn(page, email, password); + await expect( + page.getByRole("heading", { level: 1, name: "Order History" }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("sign up refuses passwords that do not match", async ({ page }) => { + await page.goto(`${BASE_PATH}/account/register`); + await waitForHydration(formWithButton(page, "Create Account")); + await page.getByRole("textbox", { name: "First name" }).fill("E2E"); + await page.getByRole("textbox", { name: "Last name" }).fill("Customer"); + await page + .getByRole("textbox", { name: "Email" }) + .fill(`e2e-mismatch-${Date.now()}@example.com`); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill("spree123!"); + await page + .getByRole("textbox", { name: "Confirm Password" }) + .fill("different123!"); + await page.getByRole("checkbox", { name: /i agree/i }).check(); + await page.getByRole("button", { name: "Create Account" }).click(); + + await expect(page.getByText("Passwords do not match")).toBeVisible(); + await expect(page).toHaveURL(/\/account\/register$/); + }); +}); diff --git a/e2e/cart.spec.ts b/e2e/cart.spec.ts new file mode 100644 index 000000000..812f1b4a2 --- /dev/null +++ b/e2e/cart.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; +import { + addToCart, + BASE_PATH, + openFirstProduct, + waitForHydration, +} from "./support"; + +test.describe("cart page", () => { + test("an empty cart points back to the catalog", async ({ page }) => { + await page.goto(`${BASE_PATH}/cart`); + await expect( + page.getByRole("heading", { name: "Your cart is empty" }), + ).toBeVisible({ timeout: 30_000 }); + + const continueShopping = page.getByRole("link", { + name: "Continue Shopping", + }); + await waitForHydration(continueShopping); + await continueShopping.click(); + await expect(page).toHaveURL(/\/products$/); + }); + + test("a customer changes the quantity and removes the item", async ({ + page, + }) => { + const name = await openFirstProduct(page, `${BASE_PATH}/products`); + await addToCart(page, name); + + await page.goto(`${BASE_PATH}/cart`); + await expect( + page.getByRole("heading", { name: "Shopping Cart" }), + ).toBeVisible({ timeout: 30_000 }); + const main = page.getByRole("main"); + await expect(main.getByRole("heading", { name })).toBeVisible(); + + const subtotal = main + .getByRole("term") + .filter({ hasText: /^Subtotal$/ }) + .locator("xpath=following-sibling::*[1]"); + const initialSubtotal = (await subtotal.textContent())?.trim() ?? ""; + + const increase = main.getByRole("button", { name: "Increase quantity" }); + await waitForHydration(increase); + await increase.click(); + await expect(main.getByRole("textbox", { name: "Quantity" })).toHaveValue( + "2", + { timeout: 30_000 }, + ); + await expect(subtotal).not.toHaveText(initialSubtotal, { timeout: 30_000 }); + + await expect( + main.getByRole("link", { name: "Proceed to Checkout" }), + ).toBeVisible(); + + await main.getByRole("button", { name: `Remove ${name}` }).click(); + await expect( + page.getByRole("heading", { name: "Your cart is empty" }), + ).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/e2e/catalog.spec.ts b/e2e/catalog.spec.ts new file mode 100644 index 000000000..8d9cc65fb --- /dev/null +++ b/e2e/catalog.spec.ts @@ -0,0 +1,95 @@ +import { expect, type Page, test } from "@playwright/test"; +import { + BASE_PATH, + openCategoryFromMenu, + openFirstCategory, + openFirstProduct, + waitForHydration, +} from "./support"; + +function productNames(page: Page) { + return page + .getByRole("main") + .getByRole("heading", { level: 3 }) + .allTextContents(); +} + +test.describe("home page", () => { + test("shows featured products that open their product page", async ({ + page, + }) => { + await page.goto(BASE_PATH); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Featured Products" }), + ).toBeVisible(); + + await openFirstProduct(page, BASE_PATH); + }); + + test("the header menu opens a category", async ({ page }) => { + await openCategoryFromMenu(page); + }); +}); + +test.describe("category page", () => { + test("lists the category's products with a count and breadcrumb", async ({ + page, + }) => { + const name = await openFirstCategory(page); + const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(breadcrumb.getByRole("link", { name: "Home" })).toBeVisible(); + await expect(breadcrumb.getByText(name)).toBeVisible(); + // The count briefly renders twice while the page streams in. + await expect( + page.getByText(/\d+ products?/).filter({ visible: true }), + ).toBeVisible(); + expect((await productNames(page)).length).toBeGreaterThan(0); + }); + + test("a subcategory narrows the listing", async ({ page }) => { + const parent = await openFirstCategory(page); + const subcategory = page + .getByRole("main") + .getByRole("link") + .filter({ hasNot: page.getByRole("heading") }) + .and(page.locator('a[href*="/c/"]')) + .first(); + await waitForHydration(subcategory); + const name = (await subcategory.textContent())?.trim() ?? ""; + await subcategory.click(); + + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + await expect( + page + .getByRole("navigation", { name: "Breadcrumb" }) + .getByRole("link", { name: parent }), + ).toBeVisible(); + }); + + test("sorts products by name", async ({ page }) => { + await openFirstCategory(page); + const sort = page.getByRole("button", { name: "Sort" }); + await waitForHydration(sort); + await sort.click(); + await page.getByRole("menuitemradio", { name: "Name (A-Z)" }).click(); + + await expect(page).toHaveURL(/sort=/, { timeout: 30_000 }); + await expect + .poll(async () => { + const names = await productNames(page); + const sorted = [...names].sort((a, b) => + a.localeCompare(b, "en", { sensitivity: "base" }), + ); + return names.length > 1 && names.join("|") === sorted.join("|"); + }) + .toBe(true); + }); + + test("a product card opens its product page", async ({ page }) => { + await openFirstCategory(page); + await openFirstProduct(page, page.url()); + }); +}); diff --git a/e2e/product.spec.ts b/e2e/product.spec.ts new file mode 100644 index 000000000..6b7086e43 --- /dev/null +++ b/e2e/product.spec.ts @@ -0,0 +1,143 @@ +import { expect, type Page, test } from "@playwright/test"; +import { + addToCart, + BASE_PATH, + openFirstProduct, + waitForHydration, +} from "./support"; + +test.describe("product page", () => { + test("shows the product's price, availability and details", async ({ + page, + }) => { + await openFirstProduct(page, `${BASE_PATH}/products`); + + await expect(page.getByText(/\$\d/).first()).toBeVisible(); + await expect(page.getByText(/in stock/i)).toBeVisible(); + await expect(page.getByRole("heading", { name: "Details" })).toBeVisible(); + await expect( + page.getByRole("navigation", { name: "Breadcrumb" }), + ).toBeVisible(); + }); + + test("adds the chosen quantity to the cart", async ({ page }) => { + const name = await openFirstProduct(page, `${BASE_PATH}/products`); + const quantity = page.getByRole("textbox", { name: "Quantity" }); + await page.getByRole("button", { name: "Increase quantity" }).click(); + await expect(quantity).toHaveValue("2"); + + const drawer = await addToCart(page, name); + await expect(drawer.getByRole("textbox", { name: "Quantity" })).toHaveValue( + "2", + ); + await expect( + drawer.getByRole("heading", { name: /2 items/ }), + ).toBeVisible(); + }); +}); + +// Created by scripts/e2e/bootstrap-spree.sh: black in S, M and L, red in S and +// M only. Everything is $20 except red M, on sale at $15 instead of $25, and +// black L is sold out. +test.describe("product page with color and size options", () => { + const name = "E2E Two-Option Tee"; + + function detail(page: Page, term: string) { + return page + .getByRole("main") + .getByRole("term") + .filter({ hasText: new RegExp(`^${term}$`) }) + .locator("xpath=following-sibling::*[1]"); + } + + // A sold-out size carries "(Out of stock)" in its name. + function size(page: Page, label: string) { + return page + .getByRole("main") + .getByRole("button", { name: new RegExp(`^${label}\\b`) }); + } + + test.beforeEach(async ({ page }) => { + await page.goto(`${BASE_PATH}/products/e2e-two-option-tee`); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + await expect(detail(page, "Options")).toHaveText("Color: Black, Size: S"); + await waitForHydration(size(page, "M")); + }); + + test("picking a color and a size adds that combination to the cart", async ({ + page, + }) => { + await expect(size(page, "L")).toBeEnabled(); + + await page.getByRole("main").getByTitle("Red", { exact: true }).click(); + await expect(detail(page, "Options")).toHaveText("Color: Red, Size: S", { + timeout: 30_000, + }); + await expect(detail(page, "SKU")).toHaveText("E2E-TEE-RED-S"); + // Red does not come in L. + await expect(size(page, "L")).toBeDisabled(); + + await size(page, "M").click(); + await expect(detail(page, "Options")).toHaveText("Color: Red, Size: M"); + await expect(detail(page, "SKU")).toHaveText("E2E-TEE-RED-M"); + + const drawer = await addToCart(page, name); + await expect(drawer.getByText("Color: Red, Size: M")).toBeVisible(); + }); + + test("a discounted variant shows its sale price, and the cart charges it", async ({ + page, + }) => { + const main = page.getByRole("main"); + await expect(main.getByText("$20.00", { exact: true })).toBeVisible(); + await expect(main.getByText("Sale", { exact: true })).toBeHidden(); + + await main.getByTitle("Red", { exact: true }).click(); + await size(page, "M").click(); + await expect(detail(page, "Options")).toHaveText("Color: Red, Size: M", { + timeout: 30_000, + }); + await expect(main.getByText("$15.00", { exact: true })).toBeVisible(); + await expect(main.getByText("$25.00", { exact: true })).toBeVisible(); + await expect(main.getByText("Sale", { exact: true })).toBeVisible(); + + await size(page, "S").click(); + await expect(main.getByText("$20.00", { exact: true })).toBeVisible(); + await expect(main.getByText("Sale", { exact: true })).toBeHidden(); + await expect(main.getByText("$25.00", { exact: true })).toBeHidden(); + + await size(page, "M").click(); + const drawer = await addToCart(page, name); + await expect(drawer.getByText("$15.00").first()).toBeVisible(); + }); + + test("a sold-out variant can be picked but not added to the cart", async ({ + page, + }) => { + const main = page.getByRole("main"); + const addButton = main.getByRole("button", { name: /add to cart/i }); + await expect(addButton).toBeEnabled(); + await expect(size(page, "L")).toHaveText(/out of stock/i); + await expect(size(page, "M")).not.toHaveText(/out of stock/i); + + await size(page, "L").click(); + await expect(detail(page, "Options")).toHaveText("Color: Black, Size: L", { + timeout: 30_000, + }); + await expect(main.getByText("Out of Stock", { exact: true })).toHaveCount( + 2, + ); + await expect( + main.getByRole("button", { name: "Out of Stock", exact: true }), + ).toBeDisabled(); + await expect(addButton).toBeHidden(); + // Red does not come in L, so it cannot be combined with this size. + await expect(main.getByTitle("Red", { exact: true })).toBeDisabled(); + + await size(page, "M").click(); + await expect(main.getByText("In Stock", { exact: true })).toBeVisible(); + await expect(addButton).toBeEnabled(); + }); +}); diff --git a/e2e/support.ts b/e2e/support.ts new file mode 100644 index 000000000..2eeb63a14 --- /dev/null +++ b/e2e/support.ts @@ -0,0 +1,176 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export const BASE_PATH = "/us/en"; +export const PASSWORD = "spree123!"; + +/** + * Waits until React has hydrated an element. A click or submit that lands + * before hydration is lost (a button does nothing) or, for a form, falls back + * to a native GET submit that reloads the page with the fields in the URL. + * React tags every hydrated DOM node with a `__reactFiber$…` key. + */ +export async function waitForHydration(locator: Locator): Promise { + await expect(locator).toBeVisible({ timeout: 30_000 }); + await expect + .poll( + () => + locator.evaluate((element) => + Object.keys(element).some((key) => key.startsWith("__reactFiber")), + ), + { timeout: 30_000 }, + ) + .toBe(true); +} + +/** The form submitted by the named button (the header holds a search form too). */ +export function formWithButton(page: Page, button: string): Locator { + return page + .locator("form") + .filter({ has: page.getByRole("button", { name: button }) }); +} + +/** The first top-level category in the category navigation. */ +async function firstCategory( + page: Page, +): Promise<{ name: string; href: string }> { + await page.goto(BASE_PATH); + const link = page + .getByRole("navigation", { name: "Category navigation" }) + .getByRole("link") + .first(); + return { + name: (await link.textContent())?.trim() ?? "", + href: (await link.getAttribute("href")) ?? BASE_PATH, + }; +} + +/** Opens the first top-level category page and returns its name. */ +export async function openFirstCategory(page: Page): Promise { + const { name, href } = await firstCategory(page); + await page.goto(href); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + return name; +} + +/** + * Opens a category through the header menu: the first top-level category, or + * its first subcategory when it has a panel of them. Returns the name of the + * category opened. + */ +export async function openCategoryFromMenu(page: Page): Promise { + const { name: topLevel } = await firstCategory(page); + const menuButton = page.getByRole("button", { name: "Open menu" }); + await waitForHydration(menuButton); + await menuButton.click(); + const menu = page.getByRole("dialog", { name: "Menu" }); + // Scoped to the menu's list: a category's panel has a back button that + // carries the category's name too. + const list = menu.getByRole("navigation").first(); + const categoryButton = list.getByRole("button", { + name: topLevel, + exact: true, + }); + + let target = list.getByRole("link", { name: topLevel, exact: true }); + await expect(categoryButton.or(target)).toBeVisible({ timeout: 10_000 }); + if (await categoryButton.count()) { + // The subcategory panel is the menu list without the "All Products" link. + // A click landing while the menu is still sliding in is lost. + const subcategory = menu + .getByRole("navigation") + .filter({ hasNot: page.getByRole("link", { name: "All Products" }) }) + .getByRole("link") + .first(); + await expect(async () => { + if (!(await subcategory.isVisible())) { + await categoryButton.click({ timeout: 3_000 }); + } + await expect(subcategory).toBeVisible({ timeout: 3_000 }); + }).toPass({ timeout: 30_000 }); + target = subcategory; + } + + const name = (await target.textContent())?.trim() ?? ""; + await target.click(); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + return name; +} + +/** Opens the first product listed on a listing page and returns its name. */ +export async function openFirstProduct( + page: Page, + listingPath: string, +): Promise { + await page.goto(listingPath); + const firstProduct = page + .getByRole("heading", { level: 3 }) + .getByRole("link") + .first(); + await waitForHydration(firstProduct); + const name = (await firstProduct.textContent())?.trim() ?? ""; + await firstProduct.click(); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + return name; +} + +/** + * Adds the product on the current product page to the cart and waits for the + * cart drawer to list it. Returns the drawer. + */ +export async function addToCart(page: Page, name: string): Promise { + const addButton = page.getByRole("button", { name: /add to cart/i }); + await waitForHydration(addButton); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + + const drawer = page.getByRole("dialog", { name: /^cart/i }); + await expect(drawer.getByRole("link", { name })).toBeVisible({ + timeout: 30_000, + }); + return drawer; +} + +/** Signs a new customer up through the registration form. */ +export async function signUp( + page: Page, + { firstName = "E2E", lastName = "Customer" } = {}, +): Promise<{ email: string; password: string }> { + const email = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.com`; + + await page.goto(`${BASE_PATH}/account/register`); + await waitForHydration(formWithButton(page, "Create Account")); + await page.getByRole("textbox", { name: "First name" }).fill(firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(lastName); + await page.getByRole("textbox", { name: "Email" }).fill(email); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await page.getByRole("textbox", { name: "Confirm Password" }).fill(PASSWORD); + await page.getByRole("checkbox", { name: /i agree/i }).check(); + await page.getByRole("button", { name: "Create Account" }).click(); + + await expect( + page.getByRole("heading", { name: "Account Overview" }), + ).toBeVisible({ timeout: 30_000 }); + return { email, password: PASSWORD }; +} + +/** Fills the sign-in form on the current page and submits it. */ +export async function signIn( + page: Page, + email: string, + password: string, +): Promise { + await waitForHydration(formWithButton(page, "Sign In")); + await page.getByRole("textbox", { name: "Email" }).fill(email); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(password); + await page.getByRole("button", { name: "Sign In" }).click(); +} diff --git a/scripts/e2e/bootstrap-spree.sh b/scripts/e2e/bootstrap-spree.sh index a41db629a..d14c63614 100755 --- a/scripts/e2e/bootstrap-spree.sh +++ b/scripts/e2e/bootstrap-spree.sh @@ -4,7 +4,8 @@ # Steps: # 1. `spree seed` — seed the default store, roles, countries. # 2. `spree sample-data` — load sample products, categories, images. -# 3. mint a publishable API key bound to a public channel, and capture +# 3. add a product that has both color and size options. +# 4. mint a publishable API key bound to a public channel, and capture # its pk_… token. # # Idempotent: the CLI's seed/sample-data tasks are no-ops on already-seeded @@ -107,6 +108,51 @@ gateway.save!(validate: false) puts "OK: gateway #{gateway.id} (#{gateway.name})" RUBY +# The sample catalog's products vary by color only and are all in stock at +# full price, so the product page tests for picking a color and a size, a +# discounted variant and a sold-out one need a product of their own. +echo "==> Creating a product with color and size options" +docker compose exec -T web bin/rails runner - <<'RUBY' +store = Spree::Store.default +color = Spree::OptionType.for_store(store).find_by!(name: 'color') +size = Spree::OptionType.for_store(store).find_or_create_by!(name: 'size') do |option_type| + option_type.label = 'Size' + option_type.kind = 'buttons' +end +colors = { 'black' => ['Black', '#000000'], 'red' => ['Red', '#cc0000'] }.to_h do |name, (label, code)| + [name, color.option_values.find_or_create_by!(name: name) { |ov| ov.label = label; ov.color_code = code }] +end +sizes = %w[S M L].each_with_index.to_h do |label, index| + [label, size.option_values.find_or_create_by!(name: label.downcase) { |ov| ov.label = label; ov.position = index + 1 }] +end + +product = store.products.find_or_initialize_by(slug: 'e2e-two-option-tee') +product.assign_attributes(name: 'E2E Two-Option Tee', status: 'active', description: 'A product with color and size options.') +product.save! +product.option_types = [color, size] +location = Spree::StockLocation.order_default.first + +# Black in S, M and L, red in S and M only. Red M is on sale and black L is +# sold out. +[ + ['black', 'S'], ['black', 'M'], ['black', 'L', { stock: 0 }], + ['red', 'S'], ['red', 'M', { price: 15, compare_at: 25 }] +].each do |color_name, size_label, overrides = {}| + sku = "E2E-TEE-#{color_name}-#{size_label}".upcase + variant = product.variants.find_or_initialize_by(sku: sku) + variant.option_values = [colors.fetch(color_name), sizes.fetch(size_label)] + variant.set_price('USD', overrides.fetch(:price, 20), overrides[:compare_at]) + variant.save! + stock_item = variant.stock_items.find_or_create_by!(stock_location: location) + stock_item.update!(backorderable: false) + stock_item.set_count_on_hand(overrides.fetch(:stock, 100)) +end +# Saving the product creates a variant without options; the page would open on it. +product.variants.reject { |variant| variant.option_values.any? }.each(&:destroy!) +store.channels.each { |channel| product.product_publications.find_or_create_by!(channel: channel) } +puts "OK: #{product.slug} (#{product.variants.count} variants)" +RUBY + # Created via `bin/rails runner` rather than `spree api-key create`, because the # key must be bound to a channel and the CLI has no --channel flag. Sample data # seeds three channels ("Online Store", "Wholesale", "Point of Sale"), and a key