From ac7d099c4ef75e4cd1d0c4c0da1238190c3adc4f Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 02:16:59 +0800 Subject: [PATCH 01/24] test: add db test Signed-off-by: E99p1ant --- internal/db/questions_test.go | 65 +++++++++++++++++++++++++++++++++-- internal/db/users_test.go | 28 +++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/internal/db/questions_test.go b/internal/db/questions_test.go index 9312cab5..272fb31d 100644 --- a/internal/db/questions_test.go +++ b/internal/db/questions_test.go @@ -357,11 +357,50 @@ func testQuestionsUpdateCensor(t *testing.T, ctx context.Context, db *questions) }) require.Nil(t, err) - err = db.DeleteByID(ctx, 1) + contentMetadata := []byte(`{"source_name":"aliyun","result":"pass"}`) + answerMetadata := []byte(`{"source_name":"qiniu","result":"review"}`) + + err = db.UpdateCensor(ctx, 1, UpdateQuestionCensorOptions{ + ContentCensorMetadata: contentMetadata, + AnswerCensorMetadata: answerMetadata, + }) require.Nil(t, err) - _, err = db.GetByID(ctx, 1) - require.Equal(t, ErrQuestionNotExist, err) + got, err := db.GetByID(ctx, 1) + require.Nil(t, err) + require.JSONEq(t, string(contentMetadata), string(got.ContentCensorMetadata)) + require.JSONEq(t, string(answerMetadata), string(got.AnswerCensorMetadata)) + }) + + t.Run("invalid metadata should keep original value", func(t *testing.T) { + _, err := db.Create(ctx, CreateQuestionOptions{ + FromIP: "114.5.1.4", + UserID: 1, + Content: "Content - 2", + ReceiveReplyEmail: "i@github.red", + AskerUserID: 1, + }) + require.Nil(t, err) + + originContentMetadata := []byte(`{"source_name":"aliyun","result":"pass"}`) + originAnswerMetadata := []byte(`{"source_name":"qiniu","result":"pass"}`) + + err = db.UpdateCensor(ctx, 2, UpdateQuestionCensorOptions{ + ContentCensorMetadata: originContentMetadata, + AnswerCensorMetadata: originAnswerMetadata, + }) + require.Nil(t, err) + + err = db.UpdateCensor(ctx, 2, UpdateQuestionCensorOptions{ + ContentCensorMetadata: []byte(`null`), + AnswerCensorMetadata: []byte(`{"source_name":}`), + }) + require.Nil(t, err) + + got, err := db.GetByID(ctx, 2) + require.Nil(t, err) + require.JSONEq(t, string(originContentMetadata), string(got.ContentCensorMetadata)) + require.JSONEq(t, string(originAnswerMetadata), string(got.AnswerCensorMetadata)) }) t.Run("not found", func(t *testing.T) { @@ -370,6 +409,26 @@ func testQuestionsUpdateCensor(t *testing.T, ctx context.Context, db *questions) }) } +func TestCheckTextCensorResponseValid(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + raw []byte + want bool + }{ + {name: "empty", raw: nil, want: false}, + {name: "null", raw: []byte(`null`), want: false}, + {name: "missing source", raw: []byte(`{"result":"pass"}`), want: false}, + {name: "malformed", raw: []byte(`{"source_name":}`), want: false}, + {name: "valid", raw: []byte(`{"source_name":"aliyun"}`), want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, checkTextCensorResponseValid(tc.raw)) + }) + } +} + func testQuestionsCount(t *testing.T, ctx context.Context, db *questions) { _, err := db.Create(ctx, CreateQuestionOptions{ FromIP: "114.5.1.4", diff --git a/internal/db/users_test.go b/internal/db/users_test.go index 63fcfeeb..b1947938 100644 --- a/internal/db/users_test.go +++ b/internal/db/users_test.go @@ -30,6 +30,7 @@ func TestUsers(t *testing.T) { {"GetByEmail", testUsersGetByEmail}, {"GetByDomain", testUsersGetByDomain}, {"Update", testUsersUpdate}, + {"SetName", testUsersSetName}, {"UpdateHarassmentSetting", testUsersUpdateHarassmentSetting}, {"Authenticate", testUsersAuthenticate}, {"ChangePassword", testUsersChangePassword}, @@ -264,6 +265,33 @@ func testUsersUpdate(t *testing.T, ctx context.Context, db *users) { }) } +func testUsersSetName(t *testing.T, ctx context.Context, db *users) { + err := db.Create(ctx, CreateUserOptions{ + Name: "E99p1ant", + Password: "super_secret", + Email: "i@github.red", + Avatar: "avater.png", + Domain: "e99", + Background: "background.png", + Intro: "Be cool, but also be warm.", + }) + require.Nil(t, err) + + t.Run("normal", func(t *testing.T) { + err := db.SetName(ctx, 1, "new-name") + require.Nil(t, err) + + got, err := db.GetByID(ctx, 1) + require.Nil(t, err) + require.Equal(t, "new-name", got.Name) + }) + + t.Run("empty name", func(t *testing.T) { + err := db.SetName(ctx, 1, " ") + require.EqualError(t, err, "name cannot be empty") + }) +} + func testUsersUpdateHarassmentSetting(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", From 3aff86174a7db2151f0ebb2ff8f3047c732af583 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 02:25:54 +0800 Subject: [PATCH 02/24] add codecov Signed-off-by: E99p1ant --- .github/workflows/go.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 943e3318..10dd0454 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -37,6 +37,10 @@ jobs: DB_USER: root DB_PASSWORD: root DB_DATABASE: nekobox + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} test-postgres: name: Test Postgres @@ -68,6 +72,10 @@ jobs: DB_USER: postgres DB_PASSWORD: postgres DB_DATABASE: nekobox + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} lint: name: Lint From 9a9c540f9b125c640fca076507bc576bd4d02bfb Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 02:54:35 +0800 Subject: [PATCH 03/24] init e2e Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 150 ++++++++++++++++++++++++++++++++++++ .gitignore | 9 +++ conf/app.e2e.ini | 50 ++++++++++++ e2e/package-lock.json | 96 +++++++++++++++++++++++ e2e/package.json | 17 ++++ e2e/playwright.config.ts | 34 ++++++++ e2e/tests/auth.spec.ts | 97 +++++++++++++++++++++++ e2e/tests/helpers.ts | 119 ++++++++++++++++++++++++++++ e2e/tests/questions.spec.ts | 133 ++++++++++++++++++++++++++++++++ internal/conf/static.go | 1 + internal/route/route.go | 3 + 11 files changed, 709 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 conf/app.e2e.ini create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/auth.spec.ts create mode 100644 e2e/tests/helpers.ts create mode 100644 e2e/tests/questions.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..d934c336 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,150 @@ +name: E2E Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + e2e: + name: Run E2E Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: nekobox + POSTGRES_PASSWORD: nekobox + POSTGRES_DB: nekobox_e2e + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + mailhog: + image: mailhog/mailhog:latest + ports: + - 1025:1025 + - 8025:8025 + options: >- + --health-cmd "wget -qO- http://localhost:8025/api/v2/messages || exit 1" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + minio: + # bitnami/minio starts the server automatically and supports + # MINIO_DEFAULT_BUCKETS to pre-create buckets on first boot. + image: bitnami/minio:latest + env: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + MINIO_DEFAULT_BUCKETS: nekobox-e2e + ports: + - 9000:9000 + - 9001:9001 + options: >- + --health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + # ── Backend ──────────────────────────────────────────────────────────── + + - name: Build backend + run: go build -o ./nekobox-server ./cmd/nekobox.go + + - name: Start backend (port 8080) + run: NEKOBOX_CONFIG_PATH=conf/app.e2e.ini ./nekobox-server web & + + # ── Frontend ─────────────────────────────────────────────────────────── + + - name: Install frontend dependencies + working-directory: web + run: pnpm install + + - name: Start frontend dev server (port 3000) + working-directory: web + run: pnpm dev & + env: + VITE_RECAPTCHA_SITE_KEY: 6LdV7SYqAAAAALwpxAFxDU4VnzztKK5UU4nfbQ1b + VITE_EXTERNAL_URL: http://localhost:3000 + + - name: Wait for frontend to be ready + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:3000 > /dev/null 2>&1; then + echo "Frontend is up!" + break + fi + echo "Waiting for frontend ($i/30)..." + sleep 2 + done + + # ── Playwright ───────────────────────────────────────────────────────── + + - name: Install Playwright dependencies + working-directory: e2e + run: npm install + + - name: Install Playwright browsers + working-directory: e2e + run: npx playwright install --with-deps chromium + + - name: Run Playwright E2E tests + working-directory: e2e + run: npx playwright test + env: + E2E_BASE_URL: http://localhost:3000 + E2E_MAILHOG_URL: http://localhost:8025 + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: e2e/playwright-report/ + retention-days: 30 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/coverage-final.json + flags: e2e + name: e2e-tests + diff --git a/.gitignore b/.gitignore index 9327f7c4..63f472e5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,12 @@ app.ini .vscode nekobox share.yaml + +node_modules + +# E2E test artifacts +e2e/node_modules +e2e/.auth +e2e/test-results/ +e2e/playwright-report/ +e2e/.env.local diff --git a/conf/app.e2e.ini b/conf/app.e2e.ini new file mode 100644 index 00000000..72efcc2b --- /dev/null +++ b/conf/app.e2e.ini @@ -0,0 +1,50 @@ +; E2E test configuration. +; Used by: NEKOBOX_CONFIG_PATH=conf/app.e2e.ini +; The Go backend listens on :8080; the Vite dev-server (port 3000) +; proxies /api → http://127.0.0.1:8080. + +[app] +production = false +title = NekoBox E2E Test +external_url = http://localhost:3000 + +[security] +enable_text_censor = false + +[server] +port = 8080 +salt = e2e-test-salt-value-placeholder +xsrf_key = e2e-test-xsrf-key-placeholder + +[database] +type = postgres +host = localhost +port = 5432 +user = nekobox +password = nekobox +name = nekobox_e2e + +[redis] +addr = localhost:6379 +password = + +[recaptcha] +; Universal test keys – accepted by Google's verify API for any token. +site_key = 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +server_key = 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + +[upload] +default_avatar = https://example.com/default-avatar.png +default_background = https://example.com/default-bg.png +image_endpoint = http://localhost:9000 +image_access_id = minioadmin +image_access_secret = minioadmin +image_bucket = nekobox-e2e +image_bucket_cdn_host = localhost:9000/nekobox-e2e + +[mail] +account = noreply@nekobox.local +password = +port = 1025 +smtp = localhost + diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..8c764299 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "nekobox-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nekobox-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^22.10.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..c41302b7 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,17 @@ +{ + "name": "nekobox-e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^22.10.2" + } +} + diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..c38eb88b --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + + // Tests are sequential to avoid inter-test race conditions against shared DB/mail state. + fullyParallel: false, + workers: 1, + + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + + reporter: [ + ['html'], + ...(process.env.CI ? ([['github']] as any) : []), + ], + + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + // Give Vue time to hydrate and animations to settle. + actionTimeout: 10_000, + navigationTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); + diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 00000000..e9e519de --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from '@playwright/test'; +import { mockRecaptcha, uniqueUser } from './helpers'; + +test.describe('Authentication', () => { + // Each test sets up its own reCAPTCHA mock before navigating. + test.beforeEach(async ({ page }) => { + await mockRecaptcha(page); + }); + + test('user can sign up and is redirected to sign-in', async ({ page }) => { + const user = uniqueUser('signup'); + + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + + // Successful registration redirects to /sign-in. + await page.waitForURL('/sign-in'); + }); + + test('user can sign in and land on their profile page', async ({ page }) => { + const user = uniqueUser('signin'); + + // Register first. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('/sign-in'); + + // Sign in. + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + + // Should land on the user's own profile page. + await page.waitForURL(`/_/${user.domain}`); + await expect(page).toHaveTitle(new RegExp(user.name)); + }); + + test('sign-up with duplicate email shows an error', async ({ page }) => { + const user = uniqueUser('dupmail'); + + // First registration. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('/sign-in'); + + // Try to register again with the same email but a different domain. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); // duplicate + await page.locator('input[name="domain"]').fill(user.domain + '2'); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + + // Error toast must appear. + await expect(page.locator('.Toastify')).toContainText('邮箱', { timeout: 8_000 }); + }); + + test('sign-in with wrong password shows an error', async ({ page }) => { + const user = uniqueUser('wrongpw'); + + // Register. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('/sign-in'); + + // Sign in with wrong password. + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill('totally-wrong-password'); + await page.locator('button[type="submit"]').click(); + + // Error toast must appear; URL must not change. + await expect(page.locator('.Toastify')).toContainText('密码', { timeout: 8_000 }); + await expect(page).toHaveURL('/sign-in'); + }); +}); + diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 00000000..5319ac39 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,119 @@ +import type { Page } from '@playwright/test'; + +// ─── reCAPTCHA mock ────────────────────────────────────────────────────────── + +/** + * Intercepts the reCAPTCHA v3 script request and injects a lightweight mock so + * the test-runner never needs network access to Google/recaptcha.net. + * + * Call this before navigating to any page that uses reCAPTCHA. + */ +export async function mockRecaptcha(page: Page): Promise { + const mockScript = ` + window.grecaptcha = { + ready: function(cb) { if (typeof cb === 'function') cb(); }, + execute: function(_siteKey, _opts) { return Promise.resolve('e2e-mock-token'); }, + render: function() { return 0; }, + reset: function() {}, + getResponse: function() { return 'e2e-mock-token'; }, + }; + `; + + // 1. Intercept the script download and return the mock inline. + await page.route(/recaptcha\.net\/recaptcha\/api\.js|google\.com\/recaptcha\/api\.js/, async route => { + await route.fulfill({ contentType: 'application/javascript', body: mockScript }); + }); + + // 2. Also pre-populate the global before any page script runs (belt-and-suspenders). + await page.addInitScript(`(function(){ ${mockScript} })()`); +} + +// ─── Unique test data ───────────────────────────────────────────────────────── + +/** + * Returns a unique user object based on the given prefix + current timestamp. + * Each test run gets a different domain/email so repeated local runs do not clash. + */ +export function uniqueUser(prefix: string) { + const ts = Date.now(); + return { + email: `${prefix}-${ts}@example.com`, + domain: `${prefix}${ts}`, + name: `${prefix} ${ts}`, + password: 'Password123!', + }; +} + +// ─── MailHog ────────────────────────────────────────────────────────────────── + +const MAILHOG_BASE = process.env.E2E_MAILHOG_URL ?? 'http://localhost:8025'; + +interface MailhogResponse { + items: Array<{ + Content: { + Headers: Record; + Body: string; + }; + }>; +} + +/** + * Polls the MailHog HTTP API until an email addressed to `to` appears, + * or the timeout is reached. + * + * @returns true if the email was found within the timeout. + */ +export async function waitForMailhogEmail(to: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + const res = await fetch(`${MAILHOG_BASE}/api/v2/messages`); + if (res.ok) { + const data = (await res.json()) as MailhogResponse; + for (const item of data.items) { + const toHeaders = item.Content?.Headers?.['To'] ?? []; + if (toHeaders.some(addr => addr.includes(to))) { + return true; + } + } + } + } catch { + // MailHog not yet ready – keep polling. + } + await new Promise(r => setTimeout(r, 500)); + } + return false; +} + +// ─── Auth flow helper ───────────────────────────────────────────────────────── + +type User = ReturnType; + +/** + * Registers and signs in a test user, leaving the browser on the user's + * profile page. Returns the user object for later use. + */ +export async function registerAndLogin(page: Page, prefix: string): Promise { + await mockRecaptcha(page); + const user = uniqueUser(prefix); + + // ── Sign up + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('/sign-in'); + + // ── Sign in + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(`/_/${user.domain}`); + + return user; +} + diff --git a/e2e/tests/questions.spec.ts b/e2e/tests/questions.spec.ts new file mode 100644 index 00000000..f9afaaf4 --- /dev/null +++ b/e2e/tests/questions.spec.ts @@ -0,0 +1,133 @@ +import { test, expect } from '@playwright/test'; +import { registerAndLogin, waitForMailhogEmail, mockRecaptcha } from './helpers'; + +// ─── Post a question ────────────────────────────────────────────────────────── + +test('can post a question to a profile box', async ({ page }) => { + const user = await registerAndLogin(page, 'qpost'); + + await page.goto(`/_/${user.domain}`); + await page.locator('textarea[name="content"]').fill('What is your favorite color?'); + await page.locator('button[type="submit"]').click(); + + // A success banner with the private link appears. + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.uk-alert-success')).toContainText('发送问题成功'); +}); + +// ─── Full question lifecycle ─────────────────────────────────────────────────── + +test('owner can answer a question and the answer is shown publicly', async ({ page }) => { + const user = await registerAndLogin(page, 'qanswer'); + + // 1. Post a question (as the signed-in owner – valid for testing). + await page.goto(`/_/${user.domain}`); + await page.locator('textarea[name="content"]').fill('What is your favorite programming language?'); + await page.locator('button[type="submit"]').click(); + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + + // 2. Navigate to "my questions" and open the first one. + await page.goto('/mine/questions'); + await expect(page.locator('p.uk-text-small').first()).toContainText( + 'What is your favorite programming language?', + { timeout: 10_000 }, + ); + await page.locator('p.uk-text-small').first().click(); + + // 3. On the question-detail page, the owner sees the answer form. + await page.waitForURL(`/_/${user.domain}/**`); + await expect(page.locator('textarea[name="answer"]')).toBeVisible({ timeout: 10_000 }); + + // 4. Submit the answer. + await page.locator('textarea[name="answer"]').fill('Go is my favorite!'); + await page.locator('button[type="submit"]').click(); + + // 5. Success toast appears. + await expect(page.locator('.Toastify')).toContainText('提问回复成功', { timeout: 10_000 }); + + // 6. The answer text is now rendered in the card body. + await expect(page.locator('.uk-card-body p.uk-text-small')).toContainText('Go is my favorite!'); +}); + +// ─── Email notifications ────────────────────────────────────────────────────── + +test('owner receives a "new question" email when someone posts a question', async ({ page }) => { + const owner = await registerAndLogin(page, 'mailowner'); + + // Post a question to the owner's box (as the signed-in owner for simplicity). + await page.goto(`/_/${owner.domain}`); + await page.locator('textarea[name="content"]').fill('Will you get an email about this?'); + await page.locator('button[type="submit"]').click(); + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + + // MailHog should receive the "new question" notification. + const received = await waitForMailhogEmail(owner.email); + expect(received).toBe(true); +}); + +test('questioner receives a reply email when their question is answered', async ({ page }) => { + const owner = await registerAndLogin(page, 'replyowner'); + const replyEmail = `reply-${Date.now()}@example.com`; + + // 1. Post a question with an email-reply address. + await page.goto(`/_/${owner.domain}`); + await page.locator('textarea[name="content"]').fill('Will I get a reply notification?'); + + // Check the "接收回复通知" checkbox (the second checkbox in the form). + await page.locator('label:has-text("我想接收回复通知") input[type="checkbox"]').check(); + await page.locator('input[name="receiveReplyEmail"]').fill(replyEmail); + + await page.locator('button[type="submit"]').click(); + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + + // 2. Open the question from the mine list. + await page.goto('/mine/questions'); + await page.locator('p.uk-text-small').first().click(); + await page.waitForURL(`/_/${owner.domain}/**`); + + // 3. Answer the question – this triggers the reply email. + await expect(page.locator('textarea[name="answer"]')).toBeVisible({ timeout: 10_000 }); + await page.locator('textarea[name="answer"]').fill('Yes, you will receive a reply!'); + await page.locator('button[type="submit"]').click(); + await expect(page.locator('.Toastify')).toContainText('提问回复成功', { timeout: 10_000 }); + + // 4. MailHog must have delivered the reply notification to replyEmail. + const received = await waitForMailhogEmail(replyEmail); + expect(received).toBe(true); +}); + +// ─── Image upload (MinIO / S3) ──────────────────────────────────────────────── + +test('can post a question with an image (MinIO upload)', async ({ page }) => { + // mockRecaptcha is already set by registerAndLogin via helpers. + const user = await registerAndLogin(page, 'imgupload'); + + await page.goto(`/_/${user.domain}`); + + // Create a minimal 1×1 PNG as a Buffer and attach it via the file chooser. + const minimalPNG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, + 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + + await page.locator('textarea[name="content"]').fill('Check out this image!'); + + // Attach via the hidden file input. + const fileInput = page.locator('input[name="images"][type="file"]'); + await fileInput.setInputFiles({ + name: 'test.png', + mimeType: 'image/png', + buffer: minimalPNG, + }); + + await page.locator('button[type="submit"]').click(); + + // Success message must appear. + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.uk-alert-success')).toContainText('发送问题成功'); +}); + diff --git a/internal/conf/static.go b/internal/conf/static.go index 87f70e88..fe6b515f 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -57,6 +57,7 @@ var ( SiteKey string `ini:"site_key"` ServerKey string `ini:"server_key"` TurnstileStyle bool `ini:"turnstile_style"` + VerifyURL string `ini:"verify_url"` // Optional: override verify URL (e.g. for testing) } Pixel struct { diff --git a/internal/route/route.go b/internal/route/route.go index 816cd42a..be336e0c 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -98,6 +98,9 @@ func New(db *gorm.DB) *flamego.Flame { recaptcha.Options{ Secret: conf.Recaptcha.ServerKey, VerifyURL: func() recaptcha.VerifyURL { + if conf.Recaptcha.VerifyURL != "" { + return recaptcha.VerifyURL(conf.Recaptcha.VerifyURL) + } if conf.Recaptcha.TurnstileStyle { // FYI: https://developers.cloudflare.com/turnstile/migration/migrating-from-recaptcha/ return "https://challenges.cloudflare.com/turnstile/v0/siteverify" From 68d6ac774d1f67d82b2a3dbf1e7a95085272e990 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 02:59:45 +0800 Subject: [PATCH 04/24] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/e2e.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d934c336..20f45106 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [master] +permissions: + contents: read + jobs: e2e: name: Run E2E Tests From 6f0da56760f4b4abcb5a745edb4fd3049f6aefae Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 03:03:17 +0800 Subject: [PATCH 05/24] update minio image Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d934c336..e02f4def 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -48,9 +48,7 @@ jobs: --health-retries 10 minio: - # bitnami/minio starts the server automatically and supports - # MINIO_DEFAULT_BUCKETS to pre-create buckets on first boot. - image: bitnami/minio:latest + image: pgsty/minio env: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin From 37fb3ba2d4892e9f6b607e43dd5720dc3a226b12 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 03:11:24 +0800 Subject: [PATCH 06/24] fix minio Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 47 ++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index aa67145b..568ff627 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -50,21 +50,6 @@ jobs: --health-timeout 3s --health-retries 10 - minio: - image: pgsty/minio - env: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - MINIO_DEFAULT_BUCKETS: nekobox-e2e - ports: - - 9000:9000 - - 9001:9001 - options: >- - --health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1" - --health-interval 10s - --health-timeout 5s - --health-retries 10 - steps: - name: Checkout code uses: actions/checkout@v4 @@ -84,6 +69,38 @@ jobs: with: version: latest + - name: Start MinIO (pgsty/minio) + run: | + docker run -d \ + --name minio \ + -p 9000:9000 \ + -p 9001:9001 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + pgsty/minio \ + server /data --console-address ":9001" + + - name: Wait for MinIO + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:9000/minio/health/live > /dev/null; then + echo "MinIO is up" + exit 0 + fi + echo "Waiting for MinIO ($i/30)..." + sleep 2 + done + echo "MinIO did not become ready in time" + docker logs minio + exit 1 + + - name: Create MinIO bucket + run: | + curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o ./mc + chmod +x ./mc + ./mc alias set local http://localhost:9000 minioadmin minioadmin + ./mc mb --ignore-existing local/nekobox-e2e + # ── Backend ──────────────────────────────────────────────────────────── - name: Build backend From dfaf33a53acb10dd6c9601822f98d84f229bda73 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 03:24:03 +0800 Subject: [PATCH 07/24] update Signed-off-by: E99p1ant --- e2e/playwright.config.ts | 7 ++++--- e2e/tests/helpers.ts | 14 ++++++++++---- e2e/tests/questions.spec.ts | 4 ++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index c38eb88b..69761fb1 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,9 +3,10 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', - // Tests are sequential to avoid inter-test race conditions against shared DB/mail state. - fullyParallel: false, - workers: 1, + // Run tests in parallel, but keep worker count conservative for CI stability. + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + timeout: process.env.CI ? 90_000 : 60_000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 5319ac39..8cc4dcf7 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test'; +import { randomBytes } from 'node:crypto'; // ─── reCAPTCHA mock ────────────────────────────────────────────────────────── @@ -35,11 +36,16 @@ export async function mockRecaptcha(page: Page): Promise { * Each test run gets a different domain/email so repeated local runs do not clash. */ export function uniqueUser(prefix: string) { - const ts = Date.now(); + const ts = Date.now().toString(36); + const nonce = randomBytes(3).toString('hex'); + // Keep domain <= 20 chars to satisfy form/backend constraints. + const domainPrefix = prefix.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 8) || 'user'; + const domain = `${domainPrefix}-${ts}${nonce}`.slice(0, 20); + return { - email: `${prefix}-${ts}@example.com`, - domain: `${prefix}${ts}`, - name: `${prefix} ${ts}`, + email: `${domain}@example.com`, + domain, + name: `${prefix} ${nonce}`, password: 'Password123!', }; } diff --git a/e2e/tests/questions.spec.ts b/e2e/tests/questions.spec.ts index f9afaaf4..dca43606 100644 --- a/e2e/tests/questions.spec.ts +++ b/e2e/tests/questions.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { registerAndLogin, waitForMailhogEmail, mockRecaptcha } from './helpers'; +import { registerAndLogin, waitForMailhogEmail } from './helpers'; // ─── Post a question ────────────────────────────────────────────────────────── @@ -67,7 +67,7 @@ test('owner receives a "new question" email when someone posts a question', asyn test('questioner receives a reply email when their question is answered', async ({ page }) => { const owner = await registerAndLogin(page, 'replyowner'); - const replyEmail = `reply-${Date.now()}@example.com`; + const replyEmail = `reply-${owner.domain}-${Date.now()}@example.com`; // 1. Post a question with an email-reply address. await page.goto(`/_/${owner.domain}`); From c17c4ca133ea486c93d38350abc9f5094f462c06 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 03:34:09 +0800 Subject: [PATCH 08/24] fix test Signed-off-by: E99p1ant --- e2e/tests/auth.spec.ts | 10 +++++----- e2e/tests/helpers.ts | 6 +++--- e2e/tests/questions.spec.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index e9e519de..ea5a6dc5 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -19,7 +19,7 @@ test.describe('Authentication', () => { await page.locator('button[type="submit"]').click(); // Successful registration redirects to /sign-in. - await page.waitForURL('/sign-in'); + await expect(page).toHaveURL(/\/sign-in$/); }); test('user can sign in and land on their profile page', async ({ page }) => { @@ -33,7 +33,7 @@ test.describe('Authentication', () => { await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); await page.locator('button[type="submit"]').click(); - await page.waitForURL('/sign-in'); + await expect(page).toHaveURL(/\/sign-in$/); // Sign in. await page.locator('input[name="email"]').fill(user.email); @@ -41,7 +41,7 @@ test.describe('Authentication', () => { await page.locator('button[type="submit"]').click(); // Should land on the user's own profile page. - await page.waitForURL(`/_/${user.domain}`); + await expect(page).toHaveURL(new RegExp(`/_/${user.domain}$`)); await expect(page).toHaveTitle(new RegExp(user.name)); }); @@ -56,7 +56,7 @@ test.describe('Authentication', () => { await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); await page.locator('button[type="submit"]').click(); - await page.waitForURL('/sign-in'); + await expect(page).toHaveURL(/\/sign-in$/); // Try to register again with the same email but a different domain. await page.goto('/sign-up'); @@ -82,7 +82,7 @@ test.describe('Authentication', () => { await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); await page.locator('button[type="submit"]').click(); - await page.waitForURL('/sign-in'); + await expect(page).toHaveURL(/\/sign-in$/); // Sign in with wrong password. await page.locator('input[name="email"]').fill(user.email); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 8cc4dcf7..993ea8ce 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; import { randomBytes } from 'node:crypto'; // ─── reCAPTCHA mock ────────────────────────────────────────────────────────── @@ -112,13 +112,13 @@ export async function registerAndLogin(page: Page, prefix: string): Promise Date: Mon, 6 Apr 2026 03:52:06 +0800 Subject: [PATCH 09/24] update Signed-off-by: E99p1ant --- e2e/tests/auth.spec.ts | 6 +----- e2e/tests/helpers.ts | 29 ----------------------------- e2e/tests/questions.spec.ts | 1 - 3 files changed, 1 insertion(+), 35 deletions(-) diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index ea5a6dc5..8ba98e4a 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -1,11 +1,7 @@ import { test, expect } from '@playwright/test'; -import { mockRecaptcha, uniqueUser } from './helpers'; +import { uniqueUser } from './helpers'; test.describe('Authentication', () => { - // Each test sets up its own reCAPTCHA mock before navigating. - test.beforeEach(async ({ page }) => { - await mockRecaptcha(page); - }); test('user can sign up and is redirected to sign-in', async ({ page }) => { const user = uniqueUser('signup'); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 993ea8ce..bd37134a 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -1,34 +1,6 @@ import { expect, type Page } from '@playwright/test'; import { randomBytes } from 'node:crypto'; -// ─── reCAPTCHA mock ────────────────────────────────────────────────────────── - -/** - * Intercepts the reCAPTCHA v3 script request and injects a lightweight mock so - * the test-runner never needs network access to Google/recaptcha.net. - * - * Call this before navigating to any page that uses reCAPTCHA. - */ -export async function mockRecaptcha(page: Page): Promise { - const mockScript = ` - window.grecaptcha = { - ready: function(cb) { if (typeof cb === 'function') cb(); }, - execute: function(_siteKey, _opts) { return Promise.resolve('e2e-mock-token'); }, - render: function() { return 0; }, - reset: function() {}, - getResponse: function() { return 'e2e-mock-token'; }, - }; - `; - - // 1. Intercept the script download and return the mock inline. - await page.route(/recaptcha\.net\/recaptcha\/api\.js|google\.com\/recaptcha\/api\.js/, async route => { - await route.fulfill({ contentType: 'application/javascript', body: mockScript }); - }); - - // 2. Also pre-populate the global before any page script runs (belt-and-suspenders). - await page.addInitScript(`(function(){ ${mockScript} })()`); -} - // ─── Unique test data ───────────────────────────────────────────────────────── /** @@ -101,7 +73,6 @@ type User = ReturnType; * profile page. Returns the user object for later use. */ export async function registerAndLogin(page: Page, prefix: string): Promise { - await mockRecaptcha(page); const user = uniqueUser(prefix); // ── Sign up diff --git a/e2e/tests/questions.spec.ts b/e2e/tests/questions.spec.ts index 564886ef..51a680e2 100644 --- a/e2e/tests/questions.spec.ts +++ b/e2e/tests/questions.spec.ts @@ -99,7 +99,6 @@ test('questioner receives a reply email when their question is answered', async // ─── Image upload (MinIO / S3) ──────────────────────────────────────────────── test('can post a question with an image (MinIO upload)', async ({ page }) => { - // mockRecaptcha is already set by registerAndLogin via helpers. const user = await registerAndLogin(page, 'imgupload'); await page.goto(`/_/${user.domain}`); From 73110854cd762a7d17b1ca64c1b5f61c1610a1bb Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 03:58:48 +0800 Subject: [PATCH 10/24] update Signed-off-by: E99p1ant --- e2e/playwright.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 69761fb1..991d7297 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -16,6 +16,11 @@ export default defineConfig({ ...(process.env.CI ? ([['github']] as any) : []), ], + // Real reCAPTCHA + remote verify can take longer than default 5s in CI. + expect: { + timeout: process.env.CI ? 15_000 : 10_000, + }, + use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', From b79ab38f637577cb29fdd813eb15ecc57e377abb Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 12:58:08 +0800 Subject: [PATCH 11/24] debug Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 568ff627..96a86ef3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -143,6 +143,9 @@ jobs: working-directory: e2e run: npx playwright install --with-deps chromium + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + - name: Run Playwright E2E tests working-directory: e2e run: npx playwright test From fd349e3eaaf09de78c8eb9721581926d8818ee31 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 13:31:46 +0800 Subject: [PATCH 12/24] update test Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 3 --- e2e/tests/helpers.ts | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 96a86ef3..568ff627 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -143,9 +143,6 @@ jobs: working-directory: e2e run: npx playwright install --with-deps chromium - - name: Setup tmate session - uses: mxschmitt/action-tmate@v3 - - name: Run Playwright E2E tests working-directory: e2e run: npx playwright test diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index bd37134a..793ad2d8 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -12,7 +12,7 @@ export function uniqueUser(prefix: string) { const nonce = randomBytes(3).toString('hex'); // Keep domain <= 20 chars to satisfy form/backend constraints. const domainPrefix = prefix.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 8) || 'user'; - const domain = `${domainPrefix}-${ts}${nonce}`.slice(0, 20); + const domain = `${domainPrefix}_${ts}${nonce}`.slice(0, 20); return { email: `${domain}@example.com`, From f6e1b6595b44232b3891e6a51cee61fcb95c095c Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 16:21:25 +0800 Subject: [PATCH 13/24] fix test Signed-off-by: E99p1ant --- e2e/tests/auth.spec.ts | 39 ++++++++++++++++++++-------- e2e/tests/helpers.ts | 11 ++++++-- e2e/tests/questions.spec.ts | 48 ++++++++++++++++++++++------------- web/.env.e2e | 6 +++++ web/package.json | 1 + web/src/pages/auth/SignIn.vue | 6 ++--- web/src/pages/auth/SignUp.vue | 6 ++--- web/src/utils/recaptcha.ts | 27 ++++++++++++++++++++ 8 files changed, 107 insertions(+), 37 deletions(-) create mode 100644 web/.env.e2e create mode 100644 web/src/utils/recaptcha.ts diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index 8ba98e4a..6f22431c 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { uniqueUser } from './helpers'; +import { clickSubmitWhenReady, uniqueUser } from './helpers'; test.describe('Authentication', () => { @@ -12,7 +12,7 @@ test.describe('Authentication', () => { await page.locator('input[name="name"]').fill(user.name); await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); // Successful registration redirects to /sign-in. await expect(page).toHaveURL(/\/sign-in$/); @@ -28,13 +28,13 @@ test.describe('Authentication', () => { await page.locator('input[name="name"]').fill(user.name); await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); await expect(page).toHaveURL(/\/sign-in$/); // Sign in. await page.locator('input[name="email"]').fill(user.email); await page.locator('input[name="password"]').fill(user.password); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); // Should land on the user's own profile page. await expect(page).toHaveURL(new RegExp(`/_/${user.domain}$`)); @@ -43,6 +43,7 @@ test.describe('Authentication', () => { test('sign-up with duplicate email shows an error', async ({ page }) => { const user = uniqueUser('dupmail'); + const duplicateDomain = `${user.domain.slice(0, 19)}2`; // First registration. await page.goto('/sign-up'); @@ -51,20 +52,28 @@ test.describe('Authentication', () => { await page.locator('input[name="name"]').fill(user.name); await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); await expect(page).toHaveURL(/\/sign-in$/); // Try to register again with the same email but a different domain. await page.goto('/sign-up'); await page.locator('input[name="email"]').fill(user.email); // duplicate - await page.locator('input[name="domain"]').fill(user.domain + '2'); + await page.locator('input[name="domain"]').fill(duplicateDomain); await page.locator('input[name="name"]').fill(user.name); await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); - await page.locator('button[type="submit"]').click(); + + const duplicateSignUpResponsePromise = page.waitForResponse(resp => + resp.url().includes('/auth/sign-up') && resp.request().method() === 'POST' + ); + await clickSubmitWhenReady(page); + + const duplicateSignUpResponse = await duplicateSignUpResponsePromise; + expect(duplicateSignUpResponse.status()).toBe(400); + const duplicateSignUpPayload = await duplicateSignUpResponse.json(); // Error toast must appear. - await expect(page.locator('.Toastify')).toContainText('邮箱', { timeout: 8_000 }); + await expect(duplicateSignUpPayload.msg).toContain('邮箱'); }); test('sign-in with wrong password shows an error', async ({ page }) => { @@ -77,16 +86,24 @@ test.describe('Authentication', () => { await page.locator('input[name="name"]').fill(user.name); await page.locator('input[name="password"]').fill(user.password); await page.locator('input[name="repeatPassword"]').fill(user.password); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); await expect(page).toHaveURL(/\/sign-in$/); // Sign in with wrong password. await page.locator('input[name="email"]').fill(user.email); await page.locator('input[name="password"]').fill('totally-wrong-password'); - await page.locator('button[type="submit"]').click(); + + const wrongPasswordSignInResponsePromise = page.waitForResponse(resp => + resp.url().includes('/auth/sign-in') && resp.request().method() === 'POST' + ); + await clickSubmitWhenReady(page); + + const wrongPasswordSignInResponse = await wrongPasswordSignInResponsePromise; + expect(wrongPasswordSignInResponse.status()).toBe(400); + const wrongPasswordSignInPayload = await wrongPasswordSignInResponse.json(); // Error toast must appear; URL must not change. - await expect(page.locator('.Toastify')).toContainText('密码', { timeout: 8_000 }); + await expect(wrongPasswordSignInPayload.msg).toContain('密码'); await expect(page).toHaveURL('/sign-in'); }); }); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 793ad2d8..627b6b33 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -1,6 +1,13 @@ import { expect, type Page } from '@playwright/test'; import { randomBytes } from 'node:crypto'; +export async function clickSubmitWhenReady(page: Page): Promise { + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeVisible(); + await expect(submitButton).toBeEnabled(); + await submitButton.click(); +} + // ─── Unique test data ───────────────────────────────────────────────────────── /** @@ -82,13 +89,13 @@ export async function registerAndLogin(page: Page, prefix: string): Promise + /\/api\/mine\/questions\/\d+\/answer$/.test(resp.url()) && resp.request().method() === 'PUT' + ); +} // ─── Post a question ────────────────────────────────────────────────────────── @@ -8,7 +14,7 @@ test('can post a question to a profile box', async ({ page }) => { await page.goto(`/_/${user.domain}`); await page.locator('textarea[name="content"]').fill('What is your favorite color?'); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); // A success banner with the private link appears. await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); @@ -23,7 +29,7 @@ test('owner can answer a question and the answer is shown publicly', async ({ pa // 1. Post a question (as the signed-in owner – valid for testing). await page.goto(`/_/${user.domain}`); await page.locator('textarea[name="content"]').fill('What is your favorite programming language?'); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); // 2. Navigate to "my questions" and open the first one. @@ -40,13 +46,15 @@ test('owner can answer a question and the answer is shown publicly', async ({ pa // 4. Submit the answer. await page.locator('textarea[name="answer"]').fill('Go is my favorite!'); - await page.locator('button[type="submit"]').click(); - - // 5. Success toast appears. - await expect(page.locator('.Toastify')).toContainText('提问回复成功', { timeout: 10_000 }); - - // 6. The answer text is now rendered in the card body. - await expect(page.locator('.uk-card-body p.uk-text-small')).toContainText('Go is my favorite!'); + const answerResponsePromise = waitForAnswerResponse(page); + await clickSubmitWhenReady(page); + const answerResponse = await answerResponsePromise; + expect(answerResponse.status()).toBe(200); + const answerPayload = await answerResponse.json(); + expect(answerPayload.data).toContain('提问回复成功'); + + // 5. The answer text is now rendered in the card body. + await expect(page.locator('.uk-card-body p.uk-text-small').first()).toContainText('Go is my favorite!'); }); // ─── Email notifications ────────────────────────────────────────────────────── @@ -57,11 +65,11 @@ test('owner receives a "new question" email when someone posts a question', asyn // Post a question to the owner's box (as the signed-in owner for simplicity). await page.goto(`/_/${owner.domain}`); await page.locator('textarea[name="content"]').fill('Will you get an email about this?'); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); // MailHog should receive the "new question" notification. - const received = await waitForMailhogEmail(owner.email); + const received = await waitForMailhogEmail(owner.email, 30_000); expect(received).toBe(true); }); @@ -77,7 +85,7 @@ test('questioner receives a reply email when their question is answered', async await page.locator('label:has-text("我想接收回复通知") input[type="checkbox"]').check(); await page.locator('input[name="receiveReplyEmail"]').fill(replyEmail); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); // 2. Open the question from the mine list. @@ -88,11 +96,15 @@ test('questioner receives a reply email when their question is answered', async // 3. Answer the question – this triggers the reply email. await expect(page.locator('textarea[name="answer"]')).toBeVisible({ timeout: 10_000 }); await page.locator('textarea[name="answer"]').fill('Yes, you will receive a reply!'); - await page.locator('button[type="submit"]').click(); - await expect(page.locator('.Toastify')).toContainText('提问回复成功', { timeout: 10_000 }); + const replyAnswerResponsePromise = waitForAnswerResponse(page); + await clickSubmitWhenReady(page); + const replyAnswerResponse = await replyAnswerResponsePromise; + expect(replyAnswerResponse.status()).toBe(200); + const replyAnswerPayload = await replyAnswerResponse.json(); + expect(replyAnswerPayload.data).toContain('提问回复成功'); // 4. MailHog must have delivered the reply notification to replyEmail. - const received = await waitForMailhogEmail(replyEmail); + const received = await waitForMailhogEmail(replyEmail, 30_000); expect(received).toBe(true); }); @@ -123,7 +135,7 @@ test('can post a question with an image (MinIO upload)', async ({ page }) => { buffer: minimalPNG, }); - await page.locator('button[type="submit"]').click(); + await clickSubmitWhenReady(page); // Success message must appear. await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 15_000 }); diff --git a/web/.env.e2e b/web/.env.e2e new file mode 100644 index 00000000..ac3fbced --- /dev/null +++ b/web/.env.e2e @@ -0,0 +1,6 @@ +VITE_EXTERNAL_URL=http://localhost:3000 +VITE_BASE_URL=/api +VITE_RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +VITE_ICP= +VITE_COMMIT_SHA=e2e + diff --git a/web/package.json b/web/package.json index 4121a4f5..e826c8ad 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:e2e": "vite --mode e2e", "build": "vue-tsc -b && vite build", "preview": "vite preview" }, diff --git a/web/src/pages/auth/SignIn.vue b/web/src/pages/auth/SignIn.vue index 4b339305..10ffa40c 100644 --- a/web/src/pages/auth/SignIn.vue +++ b/web/src/pages/auth/SignIn.vue @@ -35,6 +35,7 @@ import {useRoute, useRouter} from "vue-router"; import {ToastError, ToastSuccess} from "@/utils/notify.ts"; import {useAuthStore} from "@/store"; import {type IReCaptchaComposition, useReCaptcha} from 'vue-recaptcha-v3' +import {ensureRecaptchaReady, getRecaptchaToken} from '@/utils/recaptcha.ts' const route = useRoute() const router = useRouter() @@ -51,7 +52,7 @@ const signInForm = ref({ onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -60,8 +61,7 @@ onMounted(async () => { const handleSignIn = async () => { try { - await recaptchaLoaded() - signInForm.value.recaptcha = await executeRecaptcha('submit') + signInForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return diff --git a/web/src/pages/auth/SignUp.vue b/web/src/pages/auth/SignUp.vue index 29f3210a..59026757 100644 --- a/web/src/pages/auth/SignUp.vue +++ b/web/src/pages/auth/SignUp.vue @@ -51,6 +51,7 @@ import {ToastError, ToastSuccess} from "@/utils/notify.ts"; import {type IReCaptchaComposition, useReCaptcha} from "vue-recaptcha-v3"; import {useRouter} from "vue-router"; import {ExternalURL} from "@/utils/consts.ts"; +import {ensureRecaptchaReady, getRecaptchaToken} from '@/utils/recaptcha.ts' const router = useRouter() const {executeRecaptcha, recaptchaLoaded} = useReCaptcha() as IReCaptchaComposition @@ -68,7 +69,7 @@ const signUpForm = ref({ onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -77,8 +78,7 @@ onMounted(async () => { const handleSignUp = async () => { try { - await recaptchaLoaded() - signUpForm.value.recaptcha = await executeRecaptcha('submit') + signUpForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return diff --git a/web/src/utils/recaptcha.ts b/web/src/utils/recaptcha.ts new file mode 100644 index 00000000..07f03418 --- /dev/null +++ b/web/src/utils/recaptcha.ts @@ -0,0 +1,27 @@ +import type {IReCaptchaComposition} from 'vue-recaptcha-v3' + +const E2E_RECAPTCHA_TOKEN = 'e2e-test-token' +const RECAPTCHA_TEST_SITE_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI' + +type RecaptchaClient = Pick + +export const isE2EMode = import.meta.env.MODE === 'e2e' +export const isUsingRecaptchaTestKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY === RECAPTCHA_TEST_SITE_KEY +const shouldBypassRecaptcha = isE2EMode || isUsingRecaptchaTestKey + +export async function ensureRecaptchaReady(recaptcha: RecaptchaClient) { + if (shouldBypassRecaptcha) { + return + } + + await recaptcha.recaptchaLoaded() +} + +export async function getRecaptchaToken(recaptcha: RecaptchaClient, action = 'submit') { + if (shouldBypassRecaptcha) { + return E2E_RECAPTCHA_TOKEN + } + + await recaptcha.recaptchaLoaded() + return await recaptcha.executeRecaptcha(action) +} From 77eb59a8573b8b6488b5bcdc21fb2fb05477e740 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 16:24:48 +0800 Subject: [PATCH 14/24] update Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 568ff627..a5551325 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -117,9 +117,8 @@ jobs: - name: Start frontend dev server (port 3000) working-directory: web - run: pnpm dev & + run: pnpm dev:e2e & env: - VITE_RECAPTCHA_SITE_KEY: 6LdV7SYqAAAAALwpxAFxDU4VnzztKK5UU4nfbQ1b VITE_EXTERNAL_URL: http://localhost:3000 - name: Wait for frontend to be ready From b98b5444da6f5aa5bc06c2f18c082819aaaf0eba Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 16:32:52 +0800 Subject: [PATCH 15/24] update minio timeout Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 4 ++-- e2e/playwright.config.ts | 3 ++- e2e/tests/questions.spec.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a5551325..e512f168 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -82,12 +82,12 @@ jobs: - name: Wait for MinIO run: | - for i in $(seq 1 30); do + for i in $(seq 1 60); do if curl -sf http://localhost:9000/minio/health/live > /dev/null; then echo "MinIO is up" exit 0 fi - echo "Waiting for MinIO ($i/30)..." + echo "Waiting for MinIO ($i/60)..." sleep 2 done echo "MinIO did not become ready in time" diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 991d7297..a5c3146e 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -17,8 +17,9 @@ export default defineConfig({ ], // Real reCAPTCHA + remote verify can take longer than default 5s in CI. + // Image upload (MinIO) can be slow during initialization. expect: { - timeout: process.env.CI ? 15_000 : 10_000, + timeout: process.env.CI ? 30_000 : 10_000, }, use: { diff --git a/e2e/tests/questions.spec.ts b/e2e/tests/questions.spec.ts index f79cb371..a88e8cc9 100644 --- a/e2e/tests/questions.spec.ts +++ b/e2e/tests/questions.spec.ts @@ -137,8 +137,8 @@ test('can post a question with an image (MinIO upload)', async ({ page }) => { await clickSubmitWhenReady(page); - // Success message must appear. - await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 15_000 }); + // Success message must appear (image upload in CI can take longer due to MinIO initialization). + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 30_000 }); await expect(page.locator('.uk-alert-success')).toContainText('发送问题成功'); }); From cd020217921412ed9f170071e8d81e9a2aeea82e Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 16:39:18 +0800 Subject: [PATCH 16/24] update Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 16 ++++++++++------ web/src/components/NewQuestion.vue | 6 +++--- web/src/pages/auth/ForgotPassword.vue | 6 +++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e512f168..d8ccf237 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -73,8 +73,7 @@ jobs: run: | docker run -d \ --name minio \ - -p 9000:9000 \ - -p 9001:9001 \ + --network host \ -e MINIO_ROOT_USER=minioadmin \ -e MINIO_ROOT_PASSWORD=minioadmin \ pgsty/minio \ @@ -82,16 +81,21 @@ jobs: - name: Wait for MinIO run: | + echo "Waiting for MinIO S3 API to be ready..." for i in $(seq 1 60); do - if curl -sf http://localhost:9000/minio/health/live > /dev/null; then - echo "MinIO is up" - exit 0 + if curl -sf http://localhost:9000/minio/health/live > /dev/null 2>&1; then + echo "MinIO health check passed" + # Double-check: try to connect via S3 client + if timeout 5 curl -sf -u minioadmin:minioadmin http://localhost:9000 > /dev/null 2>&1; then + echo "MinIO S3 API is up" + exit 0 + fi fi echo "Waiting for MinIO ($i/60)..." sleep 2 done echo "MinIO did not become ready in time" - docker logs minio + docker logs minio || true exit 1 - name: Create MinIO bucket diff --git a/web/src/components/NewQuestion.vue b/web/src/components/NewQuestion.vue index 02919a19..13624b3d 100644 --- a/web/src/components/NewQuestion.vue +++ b/web/src/components/NewQuestion.vue @@ -69,6 +69,7 @@ import {Form} from "vee-validate"; import {ToastError} from "@/utils/notify.ts"; import {type IReCaptchaComposition, useReCaptcha} from "vue-recaptcha-v3"; import {useRoute} from "vue-router"; +import {ensureRecaptchaReady, getRecaptchaToken} from "@/utils/recaptcha.ts"; const route = useRoute(); @@ -106,7 +107,7 @@ const successMessage = ref('') onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -114,8 +115,7 @@ onMounted(async () => { }) const handleSubmit = async () => { try { - await recaptchaLoaded() - postQuestionForm.value.recaptcha = await executeRecaptcha('submit') + postQuestionForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return diff --git a/web/src/pages/auth/ForgotPassword.vue b/web/src/pages/auth/ForgotPassword.vue index 170091eb..839973e4 100644 --- a/web/src/pages/auth/ForgotPassword.vue +++ b/web/src/pages/auth/ForgotPassword.vue @@ -24,6 +24,7 @@ import {type ForgotPasswordRequest, forgotPassword} from "@/api/auth.ts"; import {useRouter} from "vue-router"; import {type IReCaptchaComposition, useReCaptcha} from "vue-recaptcha-v3"; import {ToastError, ToastSuccess} from "@/utils/notify.ts"; +import {ensureRecaptchaReady, getRecaptchaToken} from "@/utils/recaptcha.ts"; const router = useRouter() const {executeRecaptcha, recaptchaLoaded} = useReCaptcha() as IReCaptchaComposition @@ -37,7 +38,7 @@ const forgotPasswordForm = ref({ onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -45,8 +46,7 @@ onMounted(async () => { }) const handleForgotPassword = async () => { try { - await recaptchaLoaded() - forgotPasswordForm.value.recaptcha = await executeRecaptcha('submit') + forgotPasswordForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return From b47034069c226df7348fe4557c42d942f63346f0 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 16:41:38 +0800 Subject: [PATCH 17/24] update Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d8ccf237..22b3c10b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -84,12 +84,9 @@ jobs: echo "Waiting for MinIO S3 API to be ready..." for i in $(seq 1 60); do if curl -sf http://localhost:9000/minio/health/live > /dev/null 2>&1; then - echo "MinIO health check passed" - # Double-check: try to connect via S3 client - if timeout 5 curl -sf -u minioadmin:minioadmin http://localhost:9000 > /dev/null 2>&1; then - echo "MinIO S3 API is up" - exit 0 - fi + echo "MinIO is ready (health check passed)" + sleep 2 + exit 0 fi echo "Waiting for MinIO ($i/60)..." sleep 2 From cc580457d7025192d7ef3136d12ef8830a523479 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 17:03:34 +0800 Subject: [PATCH 18/24] update Signed-off-by: E99p1ant --- e2e/tests/questions.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/e2e/tests/questions.spec.ts b/e2e/tests/questions.spec.ts index a88e8cc9..55bde926 100644 --- a/e2e/tests/questions.spec.ts +++ b/e2e/tests/questions.spec.ts @@ -7,6 +7,13 @@ function waitForAnswerResponse(page: Page) { ); } +function waitForPostQuestionResponse(page: Page, domain: string) { + return page.waitForResponse(resp => + resp.request().method() === 'POST' && + new RegExp(`/api/users/${domain}/questions$`).test(new URL(resp.url()).pathname) + ); +} + // ─── Post a question ────────────────────────────────────────────────────────── test('can post a question to a profile box', async ({ page }) => { @@ -135,7 +142,13 @@ test('can post a question with an image (MinIO upload)', async ({ page }) => { buffer: minimalPNG, }); + const postQuestionResponsePromise = waitForPostQuestionResponse(page, user.domain); await clickSubmitWhenReady(page); + const postQuestionResponse = await postQuestionResponsePromise; + + const postQuestionBody = await postQuestionResponse.json(); + expect(postQuestionResponse.status(), `Post question failed: ${JSON.stringify(postQuestionBody)}`).toBe(200); + expect(postQuestionBody?.data ?? '').toContain('发送问题成功'); // Success message must appear (image upload in CI can take longer due to MinIO initialization). await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 30_000 }); From 26130b19d00110a240e702fd2f1e6ce87e304621 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 17:18:47 +0800 Subject: [PATCH 19/24] create minio bucket Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 22b3c10b..234613ab 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -101,6 +101,10 @@ jobs: chmod +x ./mc ./mc alias set local http://localhost:9000 minioadmin minioadmin ./mc mb --ignore-existing local/nekobox-e2e + ./mc ls local/nekobox-e2e + echo "minio-smoke" > /tmp/minio-smoke.txt + ./mc cp /tmp/minio-smoke.txt local/nekobox-e2e/health/smoke.txt + ./mc stat local/nekobox-e2e/health/smoke.txt # ── Backend ──────────────────────────────────────────────────────────── From 4bc24edd0282601658e4a4a8b219b51cda87c2f8 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 17:33:48 +0800 Subject: [PATCH 20/24] update put object Signed-off-by: E99p1ant --- go.mod | 31 +++++++++++---------- go.sum | 62 ++++++++++++++++++++++-------------------- internal/route/user.go | 4 ++- 3 files changed, 51 insertions(+), 46 deletions(-) diff --git a/go.mod b/go.mod index 8574b966..7c7d726a 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,11 @@ go 1.24 require ( github.com/aliyun/alibaba-cloud-sdk-go v1.62.26 github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible - github.com/aws/aws-sdk-go-v2 v1.24.0 - github.com/aws/aws-sdk-go-v2/config v1.26.1 - github.com/aws/aws-sdk-go-v2/credentials v1.16.12 - github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 + github.com/aws/aws-sdk-go-v2 v1.24.1 + github.com/aws/aws-sdk-go-v2/config v1.26.6 + github.com/aws/aws-sdk-go-v2/credentials v1.16.16 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.15 + github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 github.com/flamego/cache v1.1.0 github.com/flamego/cors v1.2.1 github.com/flamego/flamego v1.9.4 @@ -45,18 +46,18 @@ require ( require ( github.com/alecthomas/participle/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect github.com/aws/smithy-go v1.19.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect diff --git a/go.sum b/go.sum index 6ad7efce..8899406b 100644 --- a/go.sum +++ b/go.sum @@ -52,40 +52,42 @@ github.com/aliyun/alibaba-cloud-sdk-go v1.62.26/go.mod h1:Api2AkmMgGaSUAhmk76oaF github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible h1:cD1bK/FmYTpL+r5i9lQ9EU6ScAjA173EVsii7gAc6SQ= github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= -github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= +github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= -github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= -github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= -github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= -github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= +github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o= +github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.15 h1:2MUXyGW6dVaQz6aqycpbdLIH1NMcUI6kW6vQ0RabGYg= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.15/go.mod h1:aHbhbR6WEQgHAiRj41EQ2W47yOYwNtIkWTXmcAtYqj8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 h1:5oE2WzJE56/mVveuDZPJESKlg/00AaS2pY2QZcnxg4M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10/go.mod h1:FHbKWQtRBYUz4vO5WBWjzMD2by126ny5y/1EoaWoLfI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= -github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY= -github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 h1:L0ai8WICYHozIKK+OtPzVJBugL7culcuM4E4JOpIEm8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10/go.mod h1:byqfyxJBshFk0fF9YmK0M0ugIO8OWjzH2T3bPG4eGuA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 h1:KOxnQeWy5sXyS37fdKEvAsGHOr9fa/qvwxfJurR/BzE= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10/go.mod h1:jMx5INQFYFYB3lQD9W0D8Ohgq6Wnl7NYOJ2TQndbulI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 h1:5XNlsBsEvBZBMO6p82y+sqpWg8j5aBCe+5C2GBFgqBQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/route/user.go b/internal/route/user.go index a740eaf9..88a1dad2 100644 --- a/internal/route/user.go +++ b/internal/route/user.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/flamego/recaptcha" "github.com/pkg/errors" @@ -300,7 +301,8 @@ func uploadImageFile(ctx context.Context, options uploadImageFileOptions) (*db.U } client := s3.NewFromConfig(cfg) - if _, err := client.PutObject(ctx.Request().Context(), &s3.PutObjectInput{ + uploader := manager.NewUploader(client) + if _, err := uploader.Upload(ctx.Request().Context(), &s3.PutObjectInput{ Bucket: aws.String(conf.Upload.ImageBucket), Key: aws.String(fileKey), Body: reader, From 0bdceb2c2b52aba335d02b6f3c3edd98c1273be3 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 17:44:35 +0800 Subject: [PATCH 21/24] coverage Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 44 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 234613ab..be8f0eb5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -109,10 +109,13 @@ jobs: # ── Backend ──────────────────────────────────────────────────────────── - name: Build backend - run: go build -o ./nekobox-server ./cmd/nekobox.go + run: go build -cover -coverpkg=./... -o ./nekobox-server ./cmd/nekobox.go - name: Start backend (port 8080) - run: NEKOBOX_CONFIG_PATH=conf/app.e2e.ini ./nekobox-server web & + run: | + mkdir -p coverage/backend + GOCOVERDIR=$PWD/coverage/backend NEKOBOX_CONFIG_PATH=conf/app.e2e.ini ./nekobox-server web & + echo $! > /tmp/nekobox-server.pid # ── Frontend ─────────────────────────────────────────────────────────── @@ -154,6 +157,32 @@ jobs: E2E_BASE_URL: http://localhost:3000 E2E_MAILHOG_URL: http://localhost:8025 + - name: Stop backend and generate coverage.txt + if: always() + run: | + if [ -f /tmp/nekobox-server.pid ]; then + BACKEND_PID=$(cat /tmp/nekobox-server.pid) + if kill -0 "$BACKEND_PID" 2>/dev/null; then + kill "$BACKEND_PID" + for i in $(seq 1 20); do + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then + break + fi + sleep 1 + done + if kill -0 "$BACKEND_PID" 2>/dev/null; then + kill -9 "$BACKEND_PID" || true + fi + fi + fi + + mkdir -p coverage + if [ -d coverage/backend ] && [ -n "$(ls -A coverage/backend)" ]; then + go tool covdata textfmt -i=coverage/backend -o=coverage/coverage.txt + else + echo "No backend coverage data found" > coverage/coverage.txt + fi + - name: Upload Playwright report uses: actions/upload-artifact@v4 if: always() @@ -162,11 +191,20 @@ jobs: path: e2e/playwright-report/ retention-days: 30 + - name: Upload backend coverage artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: coverage/coverage.txt + retention-days: 30 + - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 + if: always() with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/coverage-final.json + files: ./coverage/coverage.txt flags: e2e name: e2e-tests From 931af10178e98291246ab9a150e28e0fb1d3f200 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 18:08:12 +0800 Subject: [PATCH 22/24] update coverage Signed-off-by: E99p1ant --- .github/workflows/e2e.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index be8f0eb5..6be11a73 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -109,7 +109,9 @@ jobs: # ── Backend ──────────────────────────────────────────────────────────── - name: Build backend - run: go build -cover -coverpkg=./... -o ./nekobox-server ./cmd/nekobox.go + run: | + COVERPKG=$(go list ./... | paste -sd "," -) + go build -cover -covermode=atomic -coverpkg="$COVERPKG" -o ./nekobox-server ./cmd - name: Start backend (port 8080) run: | @@ -179,8 +181,17 @@ jobs: mkdir -p coverage if [ -d coverage/backend ] && [ -n "$(ls -A coverage/backend)" ]; then go tool covdata textfmt -i=coverage/backend -o=coverage/coverage.txt + echo "Route coverage entries:" + grep 'internal/route/' coverage/coverage.txt | head -n 20 || true + + if ! grep -q 'internal/route/' coverage/coverage.txt; then + echo "::error::No internal/route coverage found in coverage/coverage.txt" + exit 1 + fi else echo "No backend coverage data found" > coverage/coverage.txt + echo "::error::No backend coverage data found in coverage/backend" + exit 1 fi - name: Upload Playwright report From 66c1f3a3daca7845613e3e37c8bda448250c7df3 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Mon, 6 Apr 2026 18:11:36 +0800 Subject: [PATCH 23/24] Update web/src/utils/recaptcha.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/src/utils/recaptcha.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/utils/recaptcha.ts b/web/src/utils/recaptcha.ts index 07f03418..b020ef69 100644 --- a/web/src/utils/recaptcha.ts +++ b/web/src/utils/recaptcha.ts @@ -7,7 +7,7 @@ type RecaptchaClient = Pick Date: Mon, 6 Apr 2026 18:13:50 +0800 Subject: [PATCH 24/24] update Signed-off-by: E99p1ant --- conf/app.e2e.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/app.e2e.ini b/conf/app.e2e.ini index 72efcc2b..2fa302ec 100644 --- a/conf/app.e2e.ini +++ b/conf/app.e2e.ini @@ -40,7 +40,7 @@ image_endpoint = http://localhost:9000 image_access_id = minioadmin image_access_secret = minioadmin image_bucket = nekobox-e2e -image_bucket_cdn_host = localhost:9000/nekobox-e2e +image_bucket_cdn_host = localhost:9000 [mail] account = noreply@nekobox.local