diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..1df6f24 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,157 @@ +name: E2E + +on: + push: + branches: + - '*' + tags: + - '*' + paths-ignore: + - '**/*.md' + pull_request: + paths-ignore: + - '**/*.md' + +jobs: + e2e: + name: Playwright E2E Tests + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: eccube_db + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + mailhog: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + + steps: + - name: Checkout plugin repository + uses: actions/checkout@v6 + with: + path: plugin + + - name: Checkout EC-CUBE + uses: actions/checkout@v6 + with: + repository: EC-CUBE/ec-cube + ref: '4.3' + path: eccube + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, pdo_mysql, curl, zip, opcache + coverage: none + + - name: Install EC-CUBE dependencies + working-directory: eccube + run: composer install --no-progress --prefer-dist --optimize-autoloader --no-interaction + + - name: Install plugin + run: | + mkdir -p eccube/app/Plugin/StockAlertMail + find plugin -mindepth 1 -maxdepth 1 ! -name '.github' ! -name '.git' -exec cp -r {} eccube/app/Plugin/StockAlertMail/ \; + + - name: Setup EC-CUBE + working-directory: eccube + env: + APP_ENV: 'dev' + APP_DEBUG: '1' + DATABASE_URL: 'mysql://root:root@127.0.0.1:3306/eccube_db' + DATABASE_SERVER_VERSION: '8.0' + DATABASE_CHARSET: 'utf8mb4' + MAILER_DSN: 'smtp://127.0.0.1:1025' + ECCUBE_AUTH_MAGIC: 'e2etestmagic' + run: | + cat > .env.local << 'ENVEOF' + APP_ENV=dev + APP_DEBUG=1 + DATABASE_URL=mysql://root:root@127.0.0.1:3306/eccube_db + DATABASE_SERVER_VERSION=8.0 + DATABASE_CHARSET=utf8mb4 + MAILER_DSN=smtp://127.0.0.1:1025 + ECCUBE_AUTH_MAGIC=e2etestmagic + ENVEOF + php bin/console doctrine:database:create --if-not-exists + php bin/console doctrine:schema:create + php bin/console eccube:fixtures:load + php bin/console eccube:plugin:install --code=StockAlertMail + php bin/console eccube:plugin:enable --code=StockAlertMail + php bin/console eccube:plugin:schema-update StockAlertMail + php bin/console cache:warmup + + - name: Install Symfony CLI + run: | + curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | sudo -E bash + sudo apt install symfony-cli + + - name: Start Symfony server + working-directory: eccube + env: + APP_ENV: 'dev' + APP_DEBUG: '1' + DATABASE_URL: 'mysql://root:root@127.0.0.1:3306/eccube_db' + DATABASE_SERVER_VERSION: '8.0' + DATABASE_CHARSET: 'utf8mb4' + MAILER_DSN: 'smtp://127.0.0.1:1025' + ECCUBE_AUTH_MAGIC: 'e2etestmagic' + run: | + symfony serve -d --port=8000 --no-tls + sleep 3 + curl -sf http://127.0.0.1:8000/admin/login > /dev/null + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Playwright + working-directory: eccube/app/Plugin/StockAlertMail/e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: eccube/app/Plugin/StockAlertMail/e2e + env: + ECCUBE_URL: 'http://127.0.0.1:8000' + ECCUBE_ROOT: ${{ github.workspace }}/eccube + MAILHOG_API: 'http://localhost:8025/api' + APP_ENV: 'dev' + DATABASE_URL: 'mysql://root:root@127.0.0.1:3306/eccube_db' + DATABASE_SERVER_VERSION: '8.0' + DATABASE_CHARSET: 'utf8mb4' + MAILER_DSN: 'smtp://127.0.0.1:1025' + run: npx playwright test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v6 + with: + name: playwright-report + path: eccube/app/Plugin/StockAlertMail/e2e/playwright-report/ + retention-days: 7 + if-no-files-found: ignore + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v6 + with: + name: playwright-test-results + path: eccube/app/Plugin/StockAlertMail/e2e/test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..90234a1 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,59 @@ +name: Lint + +on: + push: + branches: + - '*' + tags: + - '*' + paths-ignore: + - '**/*.md' + pull_request: + paths-ignore: + - '**/*.md' + +jobs: + lint: + name: PHP-CS-Fixer & PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout plugin repository + uses: actions/checkout@v6 + with: + path: plugin + + - name: Checkout EC-CUBE + uses: actions/checkout@v6 + with: + repository: EC-CUBE/ec-cube + ref: '4.3' + path: eccube + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, pdo_mysql, pdo_pgsql, curl, zip, opcache + coverage: none + + - name: Install EC-CUBE dependencies + working-directory: eccube + run: composer install --no-progress --prefer-dist --optimize-autoloader --no-interaction + + - name: Install plugin + run: | + mkdir -p eccube/app/Plugin/StockAlertMail + find plugin -mindepth 1 -maxdepth 1 ! -name '.github' ! -name '.git' -exec cp -r {} eccube/app/Plugin/StockAlertMail/ \; + + - name: Clear cache + working-directory: eccube + run: php bin/console cache:clear --env=dev --no-warmup + + - name: PHP-CS-Fixer (dry-run) + working-directory: eccube + run: vendor/bin/php-cs-fixer fix app/Plugin/StockAlertMail/ --dry-run --diff --no-interaction + + - name: PHPStan + working-directory: eccube + run: vendor/bin/phpstan analyse app/Plugin/StockAlertMail/ --level=1 --no-progress diff --git a/.github/workflows/ci.yml b/.github/workflows/test.yml similarity index 68% rename from .github/workflows/ci.yml rename to .github/workflows/test.yml index 2b0d270..dc1c5ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: CI +name: PHPUnit on: push: @@ -6,63 +6,17 @@ on: - '*' tags: - '*' - paths: - - '**' - - '!*.md' + paths-ignore: + - '**/*.md' + - 'e2e/**' pull_request: - paths: - - '**' - - '!*.md' + paths-ignore: + - '**/*.md' + - 'e2e/**' jobs: - lint: - name: PHP-CS-Fixer & PHPStan - runs-on: ubuntu-latest - - steps: - - name: Checkout plugin repository - uses: actions/checkout@v6 - with: - path: plugin - - - name: Checkout EC-CUBE - uses: actions/checkout@v6 - with: - repository: EC-CUBE/ec-cube - ref: '4.3' - path: eccube - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - extensions: mbstring, intl, pdo_mysql, pdo_pgsql, curl, zip, opcache - coverage: none - - - name: Install EC-CUBE dependencies - working-directory: eccube - run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader --no-interaction - - - name: Install plugin - run: | - mkdir -p eccube/app/Plugin/StockAlertMail - find plugin -mindepth 1 -maxdepth 1 ! -name '.github' ! -name '.git' -exec cp -r {} eccube/app/Plugin/StockAlertMail/ \; - - - name: Clear cache - working-directory: eccube - run: php bin/console cache:clear --env=dev --no-warmup - - - name: PHP-CS-Fixer (dry-run) - working-directory: eccube - run: vendor/bin/php-cs-fixer fix app/Plugin/StockAlertMail/ --dry-run --diff --no-interaction - - - name: PHPStan - working-directory: eccube - run: vendor/bin/phpstan analyse app/Plugin/StockAlertMail/ --level=1 --no-progress - test: - name: PHPUnit Tests (EC-CUBE ${{ matrix.ec-cube-version }}, PHP ${{ matrix.php-version }}, ${{ matrix.database }}) - needs: lint + name: EC-CUBE ${{ matrix.ec-cube-version }} / PHP ${{ matrix.php-version }} / ${{ matrix.database }} runs-on: ubuntu-latest strategy: @@ -132,7 +86,7 @@ jobs: - name: Install EC-CUBE dependencies working-directory: eccube run: | - composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader --no-interaction + composer install --no-progress --prefer-dist --optimize-autoloader --no-interaction - name: Install plugin run: | diff --git a/Service/StockAlertMailBuilder.php b/Service/StockAlertMailBuilder.php index 5a0cf71..81dd7f1 100644 --- a/Service/StockAlertMailBuilder.php +++ b/Service/StockAlertMailBuilder.php @@ -110,6 +110,7 @@ public function createDummyItems(): array // ダミー商品A(規格なし) $productA = new Product(); $productA->setName($this->translator->trans('stock_alert_mail.test.dummy_product_a')); + $this->setEntityId($productA, 1); $pcA = new ProductClass(); $pcA->setProduct($productA); @@ -118,6 +119,7 @@ public function createDummyItems(): array // ダミー商品B(規格あり) $productB = new Product(); $productB->setName($this->translator->trans('stock_alert_mail.test.dummy_product_b')); + $this->setEntityId($productB, 2); $cc1 = new ClassCategory(); $cc1->setName($this->translator->trans('stock_alert_mail.test.dummy_class1')); @@ -133,6 +135,16 @@ public function createDummyItems(): array return [$pcA, $pcB]; } + /** + * エンティティに仮IDを設定する(テスト用ダミーデータ向け) + */ + private function setEntityId(object $entity, int $id): void + { + $ref = new \ReflectionProperty($entity, 'id'); + $ref->setAccessible(true); + $ref->setValue($entity, $id); + } + private function findMailTemplate(): ?MailTemplate { return $this->mailTemplateRepository->findOneBy([ diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..323669f --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +auth.json +test-results/ +playwright-report/ diff --git a/e2e/helpers/command.ts b/e2e/helpers/command.ts new file mode 100644 index 0000000..7c96692 --- /dev/null +++ b/e2e/helpers/command.ts @@ -0,0 +1,28 @@ +import { execSync } from 'child_process'; +import path from 'path'; + +const ECCUBE_ROOT = process.env.ECCUBE_ROOT + || path.resolve(__dirname, '../../../../../..'); + +/** EC-CUBE の bin/console コマンドを実行する */ +export function execConsole(command: string): string { + return execSync(`php bin/console ${command}`, { + cwd: ECCUBE_ROOT, + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env }, + }).trim(); +} + +/** 在庫アラートコマンドを実行する */ +export function execStockAlert(): { exitCode: number; output: string } { + try { + const output = execConsole('eccube:plugin:stock-alert-mail'); + return { exitCode: 0, output }; + } catch (e: any) { + return { + exitCode: e.status ?? 1, + output: e.stdout?.toString() ?? e.message, + }; + } +} diff --git a/e2e/helpers/mailhog.ts b/e2e/helpers/mailhog.ts new file mode 100644 index 0000000..19b70f4 --- /dev/null +++ b/e2e/helpers/mailhog.ts @@ -0,0 +1,100 @@ +const MAILHOG_API = process.env.MAILHOG_API || 'http://localhost:8025/api'; + +export interface MailhogMessage { + ID: string; + Content: { + Headers: { + Subject: string[]; + To: string[]; + From: string[]; + 'Content-Transfer-Encoding'?: string[]; + }; + Body: string; + }; +} + +interface MailhogResponse { + total: number; + count: number; + items: MailhogMessage[]; +} + +/** 全メールを削除する */ +export async function deleteAllMessages(): Promise { + await fetch(`${MAILHOG_API}/v1/messages`, { method: 'DELETE' }); +} + +/** 全メールを取得する(新しい順) */ +export async function getMessages(): Promise { + const res = await fetch(`${MAILHOG_API}/v2/messages`); + const data: MailhogResponse = await res.json(); + return data.items; +} + +/** 条件を満たすメールが届くまで待機する(最大 timeout ミリ秒) */ +export async function waitForMessage( + predicate: (msg: MailhogMessage) => boolean, + timeout = 10_000, + interval = 500, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const messages = await getMessages(); + const found = messages.find(predicate); + if (found) return found; + await new Promise(r => setTimeout(r, interval)); + } + throw new Error(`メールが ${timeout}ms 以内に届きませんでした`); +} + +/** メールの件名を取得する(MIME エンコード済みの場合はデコード) */ +export function getSubject(msg: MailhogMessage): string { + const raw = msg.Content.Headers.Subject?.[0] ?? ''; + return decodeMimeEncoded(raw); +} + +/** メールの本文を取得する(quoted-printable UTF-8 をデコード) */ +export function getBody(msg: MailhogMessage): string { + return decodeQuotedPrintableUtf8(msg.Content.Body); +} + +/** メールの To を取得する */ +export function getTo(msg: MailhogMessage): string[] { + return msg.Content.Headers.To ?? []; +} + +/** MIME encoded-word (=?utf-8?Q?...?= / =?utf-8?B?...?=) をデコード */ +function decodeMimeEncoded(str: string): string { + // RFC 2047: 隣接する encoded-word 間の空白は無視する + const collapsed = str.replace(/\?=\s+=\?/g, '?==?'); + return collapsed.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, _charset, encoding, encoded) => { + if (encoding.toUpperCase() === 'B') { + return Buffer.from(encoded, 'base64').toString('utf-8'); + } + // Q encoding + const qpStr = encoded.replace(/_/g, ' '); + return decodeQuotedPrintableUtf8(qpStr); + }); +} + +/** Quoted-Printable でエンコードされた UTF-8 バイト列をデコード */ +function decodeQuotedPrintableUtf8(str: string): string { + // ソフト改行を除去 + const joined = str.replace(/=\r?\n/g, ''); + + // =XX をバイトに変換してから UTF-8 としてデコード + const bytes: number[] = []; + for (let i = 0; i < joined.length; i++) { + if (joined[i] === '=' && i + 2 < joined.length) { + const hex = joined.substring(i + 1, i + 3); + if (/^[0-9A-Fa-f]{2}$/.test(hex)) { + bytes.push(parseInt(hex, 16)); + i += 2; + continue; + } + } + bytes.push(joined.charCodeAt(i)); + } + + return Buffer.from(bytes).toString('utf-8'); +} diff --git a/e2e/helpers/pages.ts b/e2e/helpers/pages.ts new file mode 100644 index 0000000..37358c2 --- /dev/null +++ b/e2e/helpers/pages.ts @@ -0,0 +1,92 @@ +import { Page, expect } from '@playwright/test'; + +/** プラグイン設定画面を開く */ +export async function gotoConfig(page: Page): Promise { + await page.goto('/admin/plugin/stock-alert/config'); + await expect(page.locator('h2.head-title')).toContainText('在庫アラート設定'); +} + +/** 送信履歴画面を開く */ +export async function gotoLog(page: Page): Promise { + await page.goto('/admin/plugin/stock-alert/log'); + await expect(page.locator('h2.head-title')).toContainText('送信履歴'); +} + +/** 閾値を変更して保存する */ +export async function setThreshold(page: Page, value: number): Promise { + await gotoConfig(page); + await page.locator('#stock_alert_config_threshold').fill(String(value)); + await page.locator('button[type="submit"].btn-ec-conversion').click(); + await expect(page.locator('.alert-success')).toBeVisible(); +} + +/** 通知先メールアドレスを設定して保存する */ +export async function setAlertEmails(page: Page, emails: string): Promise { + await gotoConfig(page); + await page.locator('#stock_alert_config_alertEmails').fill(emails); + await page.locator('button[type="submit"].btn-ec-conversion').click(); + await expect(page.locator('.alert-success')).toBeVisible(); +} + +/** テストメール送信ボタンをクリックして確認ダイアログを承認する */ +export async function sendTestMail(page: Page): Promise { + await gotoConfig(page); + + // confirm ダイアログを自動承認 + page.once('dialog', dialog => dialog.accept()); + + await page.locator('button.btn-outline-primary').click(); + + // リダイレクト後のフラッシュメッセージを待機 + await page.waitForURL(/\/admin\/plugin\/stock-alert\/config/); +} + +/** 商品の在庫数を管理画面から変更する */ +export async function setProductStock( + page: Page, + productId: number, + stock: number, +): Promise { + await page.goto(`/admin/product/product/${productId}/edit`); + + // 在庫数フィールドを探して更新(規格なし商品の場合) + const stockInput = page.locator('input[name*="stock"]').first(); + await stockInput.fill(String(stock)); + + // 保存 + await page.locator('#aside_column button[type="submit"]').first().click(); + await expect(page.locator('.alert-success')).toBeVisible(); +} + +/** メール設定画面で在庫アラートメールテンプレートを開く */ +export async function gotoMailTemplate(page: Page): Promise { + await page.goto('/admin/setting/shop/mail'); + + // テンプレート選択ドロップダウンで「在庫アラートメール」を選択 + // 選択すると location.href でフルページ遷移が発生する + const select = page.locator('#mail_template'); + const options = select.locator('option'); + const count = await options.count(); + + let targetIndex = -1; + for (let i = 0; i < count; i++) { + const text = await options.nth(i).textContent(); + if (text?.includes('在庫アラートメール')) { + targetIndex = i; + break; + } + } + + if (targetIndex < 0) { + throw new Error('在庫アラートメールテンプレートが見つかりません'); + } + + // selectOption でページ遷移が発生するので waitForURL で待機 + await Promise.all([ + page.waitForURL(/\/admin\/setting\/shop\/mail\/\d+/), + select.selectOption({ index: targetIndex }), + ]); + + // 件名フィールドが表示されるまで待機 + await expect(page.locator('#mail_mail_subject')).toBeVisible(); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..f0e6a96 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "stock-alert-mail-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stock-alert-mail-e2e", + "devDependencies": { + "@playwright/test": "^1.50.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..8b2bd8e --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "stock-alert-mail-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.50.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..5254d5a --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: 'html', + use: { + baseURL: process.env.ECCUBE_URL || 'http://127.0.0.1:8000', + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'setup', + testMatch: /global-setup\.ts/, + }, + { + name: 'e2e', + dependencies: ['setup'], + testMatch: /.*\.spec\.ts/, + use: { storageState: './auth.json' }, + }, + ], +}); diff --git a/e2e/tests/command.spec.ts b/e2e/tests/command.spec.ts new file mode 100644 index 0000000..269ad81 --- /dev/null +++ b/e2e/tests/command.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; +import { execStockAlert } from '../helpers/command'; +import { setThreshold } from '../helpers/pages'; +import { + deleteAllMessages, + waitForMessage, + getMessages, + getSubject, + getBody, +} from '../helpers/mailhog'; + +test.describe('コマンド実行', () => { + test.beforeEach(async () => { + await deleteAllMessages(); + }); + + test('TC-5.1: アラート対象商品ありでメール送信される', async ({ page }) => { + // 閾値を高く設定して既存商品をアラート対象にする + await setThreshold(page, 9999); + + const result = execStockAlert(); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('送信しました'); + + // MailHog でメールを確認([TEST] プレフィックスがないアラートメール) + const msg = await waitForMessage( + m => !getSubject(m).includes('[TEST]') && getSubject(m).startsWith('['), + 30_000, + ); + + const subject = getSubject(msg); + expect(subject).toMatch(/\[.+\] .+/); + + const body = getBody(msg); + expect(body).toContain('管理者様'); + expect(body).toContain('在庫'); + }); + + test('TC-5.2: アラート対象商品なしでメール送信されない', async ({ page }) => { + // 閾値を-1にして対象商品をなくす + await setThreshold(page, 0); + + const result = execStockAlert(); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('新規の在庫アラート対象商品はありません'); + + // メールが送信されていないことを確認 + await new Promise(r => setTimeout(r, 2000)); + const messages = await getMessages(); + expect(messages.length).toBe(0); + }); + + test('TC-5.3: 同じ商品に対して重複送信されない', async ({ page }) => { + // 閾値を高く設定 + await setThreshold(page, 9999); + + // 1回目実行 + const result1 = execStockAlert(); + expect(result1.exitCode).toBe(0); + + await deleteAllMessages(); + + // 2回目実行 — 同じ商品は送信済みなのでメールなし + const result2 = execStockAlert(); + expect(result2.exitCode).toBe(0); + expect(result2.output).toContain('新規の在庫アラート対象商品はありません'); + + await new Promise(r => setTimeout(r, 2000)); + const messages = await getMessages(); + expect(messages.length).toBe(0); + }); + + test('TC-5.4: 在庫回復後に再アラートされる', async ({ page }) => { + // 1. 閾値を高くしてアラート送信 + await setThreshold(page, 9999); + execStockAlert(); + await deleteAllMessages(); + + // 2. 閾値を0にして在庫回復扱い→ログリセット + await setThreshold(page, 0); + execStockAlert(); + + // 3. 再度閾値を高くして再アラート + await setThreshold(page, 9999); + const result = execStockAlert(); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('送信しました'); + + const msg = await waitForMessage( + m => !getSubject(m).includes('[TEST]') && getSubject(m).startsWith('['), + 30_000, + ); + expect(getSubject(msg)).toBeTruthy(); + }); +}); + +test.afterAll(async ({ browser }) => { + // 閾値をデフォルトに戻す + const context = await browser.newContext({ storageState: './auth.json' }); + const page = await context.newPage(); + await setThreshold(page, 5); + await context.close(); +}); diff --git a/e2e/tests/config.spec.ts b/e2e/tests/config.spec.ts new file mode 100644 index 0000000..51fca2f --- /dev/null +++ b/e2e/tests/config.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; +import { gotoConfig, setThreshold, setAlertEmails } from '../helpers/pages'; + +test.describe('設定画面', () => { + test('TC-2.1: 設定画面が正常に表示される', async ({ page }) => { + await gotoConfig(page); + + // アラート設定セクション(card-header 内の span で完全一致) + await expect(page.locator('.card-header span').filter({ hasText: /^アラート設定$/ })).toBeVisible(); + await expect(page.locator('#stock_alert_config_threshold')).toBeVisible(); + await expect(page.locator('#stock_alert_config_alertEmails')).toBeVisible(); + + // メールテンプレートセクション + await expect(page.locator('.card-header span').filter({ hasText: /^メールテンプレート$/ })).toBeVisible(); + await expect(page.locator('.card-body a[href*="setting/shop/mail"]')).toBeVisible(); + + // cron設定例セクション + await expect(page.locator('.card-header span').filter({ hasText: /^cron 設定例$/ })).toBeVisible(); + await expect(page.locator('code')).toContainText('eccube:plugin:stock-alert-mail'); + + // ボタン + await expect(page.locator('button.btn-outline-primary')).toBeVisible(); + await expect(page.locator('button.btn-ec-conversion')).toBeVisible(); + }); + + test('TC-2.2: 閾値を変更して保存できる', async ({ page }) => { + await setThreshold(page, 10); + + // リロードして値が保持されていることを確認 + await page.reload(); + const value = await page.locator('#stock_alert_config_threshold').inputValue(); + expect(value).toBe('10'); + + // 元に戻す + await setThreshold(page, 5); + }); + + test('TC-2.3: 閾値のバリデーション — 空欄', async ({ page }) => { + await gotoConfig(page); + await page.locator('#stock_alert_config_threshold').fill(''); + await page.locator('button[type="submit"].btn-ec-conversion').click(); + + // バリデーションエラーが表示される(成功メッセージが出ない) + const hasSuccess = await page.locator('.alert-success').isVisible().catch(() => false); + expect(hasSuccess).toBeFalsy(); + }); + + test('TC-2.4: 通知先メールアドレスを設定できる', async ({ page }) => { + await setAlertEmails(page, 'test1@example.com, test2@example.com'); + + // リロードして値が保持されていることを確認 + await page.reload(); + const value = await page.locator('#stock_alert_config_alertEmails').inputValue(); + expect(value).toContain('test1@example.com'); + expect(value).toContain('test2@example.com'); + + // 元に戻す(空欄=デフォルト使用) + await setAlertEmails(page, ''); + }); + + test('TC-2.5: 通知先が空欄の場合、ヘルプテキストに店舗メールが表示される', async ({ page }) => { + await gotoConfig(page); + + // ヘルプテキストに店舗メールアドレスが含まれること + const helpText = page.locator('#stock_alert_config_alertEmails') + .locator('..').locator('.form-text'); + await expect(helpText).toContainText('空欄の場合は店舗設定のメールアドレスを使用します'); + }); + + test('TC-2.6: メール設定リンクが正しく遷移する', async ({ page }) => { + await gotoConfig(page); + + const link = page.locator('.card-body a[href*="setting/shop/mail"]'); + await expect(link).toBeVisible(); + + await link.click(); + await expect(page).toHaveURL(/\/admin\/setting\/shop\/mail/); + }); +}); diff --git a/e2e/tests/error-cases.spec.ts b/e2e/tests/error-cases.spec.ts new file mode 100644 index 0000000..ae33308 --- /dev/null +++ b/e2e/tests/error-cases.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { gotoConfig, sendTestMail } from '../helpers/pages'; +import { deleteAllMessages } from '../helpers/mailhog'; + +test.describe('異常系・エッジケース', () => { + test('TC-7.2: テストメール送信失敗時にエラーメッセージが表示される(SMTP障害)', async ({ page }) => { + // 注意: このテストは SMTP サーバが停止している場合にのみ有効 + // MailHog が稼働中の場合はスキップされる + await deleteAllMessages(); + await sendTestMail(page); + + // 成功 or エラーのフラッシュメッセージが表示されること(500エラーにならない) + const hasSuccess = await page.locator('.alert-success').isVisible().catch(() => false); + const hasError = await page.locator('.alert-danger').isVisible().catch(() => false); + + // どちらかのフラッシュメッセージが表示される(500 ページではない) + expect(hasSuccess || hasError).toBeTruthy(); + }); + + test('TC-7.3: テストメール送信後に設定画面にリダイレクトされる', async ({ page }) => { + await sendTestMail(page); + + // 500 エラーにならず設定画面にいること + await expect(page).toHaveURL(/\/admin\/plugin\/stock-alert\/config/); + await expect(page.locator('h2.head-title')).toContainText('在庫アラート設定'); + }); + + test('CSRF トークンなしのテストメール送信は拒否される', async ({ page }) => { + // 直接 POST(CSRF トークンなし) + const response = await page.request.post('/admin/plugin/stock-alert/config/send-test'); + + // リダイレクト or エラーになること(200 で処理が通らない) + expect([301, 302, 403, 422].some(code => + response.status() === code || response.url().includes('/config'), + )).toBeTruthy(); + }); +}); diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts new file mode 100644 index 0000000..eaecbc0 --- /dev/null +++ b/e2e/tests/global-setup.ts @@ -0,0 +1,13 @@ +import { test as setup, expect } from '@playwright/test'; + +setup('管理画面にログインしてセッションを保存', async ({ page }) => { + await page.goto('/admin/login'); + await page.locator('#login_id').fill('admin'); + await page.locator('#password').fill('password'); + await page.locator('button[type="submit"]').click(); + + // ダッシュボードに遷移するまで待機 + await expect(page).toHaveURL(/\/admin\/?$/); + + await page.context().storageState({ path: './auth.json' }); +}); diff --git a/e2e/tests/log.spec.ts b/e2e/tests/log.spec.ts new file mode 100644 index 0000000..0769395 --- /dev/null +++ b/e2e/tests/log.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { gotoLog, setThreshold } from '../helpers/pages'; +import { execStockAlert } from '../helpers/command'; +import { deleteAllMessages } from '../helpers/mailhog'; + +test.describe('送信履歴画面', () => { + test('TC-6.1: 送信履歴画面が正常に表示される', async ({ page }) => { + await gotoLog(page); + await expect(page.locator('h2.head-title')).toContainText('送信履歴'); + }); + + test('TC-6.2: アラート送信後に送信履歴にログが表示される', async ({ page }) => { + await deleteAllMessages(); + + // 閾値を高くしてアラート送信 + await setThreshold(page, 9999); + execStockAlert(); + + // 送信履歴画面を確認 + await gotoLog(page); + + // テーブルにカラムヘッダーが表示されている + const cardBody = page.locator('.card-body'); + await expect(cardBody).toContainText('商品名'); + await expect(cardBody).toContainText('送信日時'); + + // 商品リンクが存在する + const productLink = cardBody.locator('a[href*="admin/product/product"]').first(); + await expect(productLink).toBeVisible(); + }); + + test('TC-6.3: 商品リンクから商品編集ページに遷移できる', async ({ page }) => { + // アラート送信済みの状態を確保 + await setThreshold(page, 9999); + execStockAlert(); + + await gotoLog(page); + + const productLink = page.locator('table a[href*="admin/product/product"]').first(); + const isVisible = await productLink.isVisible().catch(() => false); + + if (isVisible) { + await productLink.click(); + await expect(page).toHaveURL(/\/admin\/product\/product\/\d+\/edit/); + } else { + test.skip(); + } + }); +}); + +test.afterAll(async ({ browser }) => { + // 閾値をデフォルトに戻す + const context = await browser.newContext({ storageState: './auth.json' }); + const page = await context.newPage(); + await setThreshold(page, 5); + await context.close(); +}); diff --git a/e2e/tests/mail-template.spec.ts b/e2e/tests/mail-template.spec.ts new file mode 100644 index 0000000..4897088 --- /dev/null +++ b/e2e/tests/mail-template.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { gotoMailTemplate, sendTestMail } from '../helpers/pages'; +import { + deleteAllMessages, + waitForMessage, + getSubject, +} from '../helpers/mailhog'; + +test.describe('メールテンプレート', () => { + test.beforeEach(async () => { + await deleteAllMessages(); + }); + + test('TC-4.1: メールテンプレートの件名を編集してテスト送信', async ({ page }) => { + // メール設定画面で件名を変更 + await gotoMailTemplate(page); + + const subjectInput = page.locator('#mail_mail_subject'); + const originalSubject = await subjectInput.inputValue(); + + await subjectInput.fill('カスタム在庫通知'); + + // フォーム送信後にリダイレクトされるので waitForURL で待機 + await Promise.all([ + page.waitForURL(/\/admin\/setting\/shop\/mail\/\d+/), + page.locator('button.btn-ec-conversion[type="submit"]').click(), + ]); + await expect(page.locator('.alert-success')).toBeVisible(); + + // テストメール送信 + await sendTestMail(page); + + // MailHog で件名を確認 + const msg = await waitForMessage(m => getSubject(m).includes('[TEST]')); + const subject = getSubject(msg); + expect(subject).toContain('カスタム在庫通知'); + + // 件名を元に戻す + await gotoMailTemplate(page); + await page.locator('#mail_mail_subject').fill(originalSubject); + await Promise.all([ + page.waitForURL(/\/admin\/setting\/shop\/mail\/\d+/), + page.locator('button.btn-ec-conversion[type="submit"]').click(), + ]); + }); + + test('TC-4.2: 件名を空白にすると保存時にバリデーションエラーになる', async ({ page }) => { + // メール設定画面で件名を空白に変更 + await gotoMailTemplate(page); + + const subjectInput = page.locator('#mail_mail_subject'); + await subjectInput.fill(''); + + // 送信ボタンをクリック(HTML5 required バリデーションで送信されない場合もある) + await page.locator('button.btn-ec-conversion[type="submit"]').click(); + + // 成功メッセージが表示されないことを確認 + await page.waitForTimeout(1000); + await expect(page.locator('.alert-success')).not.toBeVisible(); + }); +}); diff --git a/e2e/tests/navigation.spec.ts b/e2e/tests/navigation.spec.ts new file mode 100644 index 0000000..eb61136 --- /dev/null +++ b/e2e/tests/navigation.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; + +test.describe('ナビゲーション', () => { + test('TC-1.1: サイドバーに在庫アラートメニューが表示される', async ({ page }) => { + await page.goto('/admin'); + + // サイドバーに「在庫アラート」が存在する + const sidebar = page.locator('#side_menu, .c-mainNavArea'); + await expect(sidebar).toContainText('在庫アラート'); + }); + + test('TC-1.2: サブメニュー「設定」から設定画面に遷移する', async ({ page }) => { + await page.goto('/admin'); + + // 「在庫アラート」メニューを開く + const menuItem = page.locator('text=在庫アラート').first(); + await menuItem.click(); + + // 「設定」サブメニューをクリック + const configLink = page.locator('a[href*="stock-alert/config"]').first(); + await configLink.click(); + + await expect(page).toHaveURL(/\/admin\/plugin\/stock-alert\/config/); + await expect(page.locator('h2.head-title')).toContainText('在庫アラート設定'); + }); + + test('TC-1.3: サブメニュー「送信履歴」から送信履歴画面に遷移する', async ({ page }) => { + await page.goto('/admin'); + + const menuItem = page.locator('text=在庫アラート').first(); + await menuItem.click(); + + const logLink = page.locator('a[href*="stock-alert/log"]').first(); + await logLink.click(); + + await expect(page).toHaveURL(/\/admin\/plugin\/stock-alert\/log/); + await expect(page.locator('h2.head-title')).toContainText('送信履歴'); + }); +}); diff --git a/e2e/tests/test-mail.spec.ts b/e2e/tests/test-mail.spec.ts new file mode 100644 index 0000000..0b4677e --- /dev/null +++ b/e2e/tests/test-mail.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; +import { gotoConfig, sendTestMail, setAlertEmails } from '../helpers/pages'; +import { + deleteAllMessages, + waitForMessage, + getSubject, + getBody, + getTo, +} from '../helpers/mailhog'; + +test.describe('テストメール送信', () => { + test.beforeEach(async () => { + await deleteAllMessages(); + }); + + test('TC-3.1: テストメールが正常に送信される', async ({ page }) => { + await sendTestMail(page); + + // 成功メッセージ + await expect(page.locator('.alert-success')).toContainText('テストメールを'); + await expect(page.locator('.alert-success')).toContainText('送信しました'); + + // MailHog でメールを確認 + const msg = await waitForMessage(m => getSubject(m).includes('[TEST]')); + + const subject = getSubject(msg); + expect(subject).toMatch(/\[TEST\] \[.+\] .+/); + + const body = getBody(msg); + expect(body).toContain('管理者様'); + expect(body).toContain('サンプル商品A'); + expect(body).toContain('サンプル商品B'); + }); + + test('TC-3.2: 確認ダイアログでキャンセルするとメールが送信されない', async ({ page }) => { + await gotoConfig(page); + + // confirm ダイアログをキャンセル + page.on('dialog', dialog => dialog.dismiss()); + + await page.locator('button.btn-outline-primary').click(); + + // 少し待ってもメールが届かないことを確認 + await page.waitForTimeout(2000); + try { + await waitForMessage(() => true, 1000); + // ここに到達したらメールが届いてしまっている + expect(true).toBe(false); + } catch { + // メールが届かない = 期待通り + } + }); + + test('TC-3.3: カスタム通知先にテストメールが送信される', async ({ page }) => { + // 通知先を設定 + await setAlertEmails(page, 'test-e2e@example.com'); + + await sendTestMail(page); + + // 成功メッセージに通知先が含まれる + await expect(page.locator('.alert-success')).toContainText('test-e2e@example.com'); + + // MailHog で宛先を確認 + const msg = await waitForMessage(m => getSubject(m).includes('[TEST]')); + const to = getTo(msg); + expect(to.join(',')).toContain('test-e2e@example.com'); + + // 通知先を元に戻す + await setAlertEmails(page, ''); + }); +});