From 1122b25e84a1312e5cc2d37b79204d3b4cc5b101 Mon Sep 17 00:00:00 2001 From: Tenta Morishima Date: Sat, 5 Sep 2026 16:49:11 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E3=83=91=E3=82=B9=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=83=AA=E3=82=BB=E3=83=83=E3=83=88=E3=83=A1=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E9=80=81=E4=BF=A1=E6=89=8B=E6=AE=B5=E3=82=92?= =?UTF-8?q?Gmail=20SMTP=E3=81=8B=E3=82=89Resend=E3=81=AB=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 送信元ドメインを用意できなかったため無効化されていたパスワード再設定メールを、 Resend経由での送信に切り替えて復旧させる。 - api/drivers/mail/ を新設し、net/http でResend API(POST /emails)を叩く mail.Client インターフェースを実装(APIキー未設定時はコンソール出力にフォールバック) - password_reset_tokens.go の SendResetEmail を smtp.SendMail から mail.Client 経由の送信に置き換え - di/wire.go に ProvideMailClient を追加(wire_gen.go は make wire-gen が 現状Go 1.25.5環境で実行できないため手動で追従) Co-Authored-By: Claude Sonnet 5 --- api/drivers/mail/mail.go | 79 +++++++++++++++++++ .../repository/password_reset_tokens.go | 50 ++++-------- api/internals/di/wire.go | 7 ++ api/internals/di/wire_gen.go | 9 ++- 4 files changed, 111 insertions(+), 34 deletions(-) create mode 100644 api/drivers/mail/mail.go diff --git a/api/drivers/mail/mail.go b/api/drivers/mail/mail.go new file mode 100644 index 000000000..00fbaf269 --- /dev/null +++ b/api/drivers/mail/mail.go @@ -0,0 +1,79 @@ +package mail + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +const resendEndpoint = "https://api.resend.com/emails" + +type Client interface { + SendMail(to string, subject string, body string) error +} + +type client struct { + apiKey string + fromAddress string + httpClient *http.Client +} + +// NewMailClient - Resend経由でメールを送信するmail.Clientを生成する +func NewMailClient() client { + return client{ + apiKey: os.Getenv("RESEND_API_KEY"), + fromAddress: os.Getenv("MAIL_FROM_ADDRESS"), + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +type sendEmailRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Text string `json:"text"` +} + +// SendMail - Resend APIを叩いてメールを送信する +// RESEND_API_KEYが未設定の場合は送信を行わず、内容をコンソールに出力する +func (c client) SendMail(to string, subject string, body string) error { + if c.apiKey == "" { + fmt.Println("[mail] RESEND_API_KEY未設定のため送信をスキップし、内容をコンソールに出力します") + fmt.Printf("To: %s\nSubject: %s\n\n%s\n", to, subject, body) + return nil + } + + reqBody, err := json.Marshal(sendEmailRequest{ + From: c.fromAddress, + To: []string{to}, + Subject: subject, + Text: body, + }) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, resendEndpoint, bytes.NewReader(reqBody)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("resend api error: status=%d body=%s", resp.StatusCode, string(respBody)) + } + + return nil +} diff --git a/api/externals/repository/password_reset_tokens.go b/api/externals/repository/password_reset_tokens.go index 70209c9dd..8d6972066 100644 --- a/api/externals/repository/password_reset_tokens.go +++ b/api/externals/repository/password_reset_tokens.go @@ -3,17 +3,18 @@ package repository import ( "context" "database/sql" - "net/smtp" "os" "time" "github.com/NUTFes/FinanSu/api/drivers/db" + "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/externals/repository/abstract" ) type passwordResetTokenRepository struct { - client db.Client - crud abstract.Crud + client db.Client + crud abstract.Crud + mailClient mail.Client } type PasswordResetTokenRepository interface { @@ -29,8 +30,8 @@ type PasswordResetTokenRepository interface { SendResetEmail(context.Context, string, string, string, string) error } -func NewPasswordResetTokenRepository(c db.Client, ac abstract.Crud) PasswordResetTokenRepository { - return &passwordResetTokenRepository{c, ac} +func NewPasswordResetTokenRepository(c db.Client, ac abstract.Crud, mc mail.Client) PasswordResetTokenRepository { + return &passwordResetTokenRepository{c, ac, mc} } // 全件取得 @@ -103,35 +104,18 @@ func (pr *passwordResetTokenRepository) DestroyByUserID(c context.Context, userI // リセットメール送信 -func (pr *passwordResetTokenRepository) SendResetEmail(c context.Context,id string, name string, email string, token string) error { - mailSender := os.Getenv("NUTMEG_MAIL_SENDER") - mailPassword := os.Getenv("NUTMEG_MAIL_PASSWORD") - resetPageUrl := os.Getenv("RESET_PASSWORD_URL")+"/" + id + "/?token=" +token - - message := []byte("From: FinanSu <" + mailSender + ">\r\n" + - "Subject: 【FinanSu】パスワード再設定メール\r\n\r\n" + - name + " 様\n\n" + - "情報局 FinanSu 担当です。\r\n\r\n" + +func (pr *passwordResetTokenRepository) SendResetEmail(c context.Context, id string, name string, email string, token string) error { + resetPageUrl := os.Getenv("RESET_PASSWORD_URL") + "/" + id + "/?token=" + token + + body := name + " 様\n\n" + + "情報局 FinanSu 担当です。\n\n" + "パスワードの再設定のご依頼を受け付けました。下記の再設定ページにアクセスし、新しいパスワードを設定してください。\n" + "※パスワードリセットの申請に心当たりがない場合は、以降の対応は不要となります。\n\n" + - resetPageUrl + "\r\n\n" + - "なお、URLの有効期限は本メールが送信されてから60分間とさせていただきます。\r\n\n" + - "どうぞよろしくお願い申し上げます。\r\n\r\n" + - "情報局 FinanSu担当 \r\n\n"+ - "※このメールは送信専用です\r\n") - - smtpHost := "smtp.gmail.com" - smtpPort := "587" - - auth := smtp.PlainAuth("", mailSender, mailPassword, smtpHost) - - emails := []string{email} - - // メール送信 - err := smtp.SendMail(smtpHost + ":" + smtpPort, auth, mailSender, emails, message) - if err != nil { - return err - } + resetPageUrl + "\n\n" + + "なお、URLの有効期限は本メールが送信されてから60分間とさせていただきます。\n\n" + + "どうぞよろしくお願い申し上げます。\n\n" + + "情報局 FinanSu担当 \n\n" + + "※このメールは送信専用です\n" - return err + return pr.mailClient.SendMail(email, "【FinanSu】パスワード再設定メール", body) } diff --git a/api/internals/di/wire.go b/api/internals/di/wire.go index f47149cdc..727170ac9 100644 --- a/api/internals/di/wire.go +++ b/api/internals/di/wire.go @@ -5,6 +5,7 @@ package di import ( "github.com/NUTFes/FinanSu/api/drivers/db" + "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/drivers/mc" "github.com/NUTFes/FinanSu/api/drivers/server" "github.com/NUTFes/FinanSu/api/externals/handler" @@ -33,6 +34,11 @@ func ProvideMinioClient() (mc.Client, error) { return mc.InitMinioClient() } +// ProvideMailClient - MailClientのProvider +func ProvideMailClient() mail.Client { + return mail.NewMailClient() +} + // ProvideCrud - AbstractCrudのProvider func ProvideCrud(client db.Client) abstract.Crud { return abstract.NewCrud(client) @@ -57,6 +63,7 @@ func InitializeServer() (*ServerComponents, error) { // 基盤Provider群 ProvideDBClient, ProvideMinioClient, + ProvideMailClient, ProvideCrud, // 各層のProviderセット diff --git a/api/internals/di/wire_gen.go b/api/internals/di/wire_gen.go index 32defa469..0c97d88d7 100644 --- a/api/internals/di/wire_gen.go +++ b/api/internals/di/wire_gen.go @@ -8,6 +8,7 @@ package di import ( "github.com/NUTFes/FinanSu/api/drivers/db" + "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/drivers/mc" "github.com/NUTFes/FinanSu/api/drivers/server" "github.com/NUTFes/FinanSu/api/externals/handler" @@ -61,7 +62,8 @@ func InitializeServer() (*ServerComponents, error) { userRepository := repository.NewUserRepository(client, crud) mailAuthUseCase := usecase.NewMailAuthUseCase(mailAuthRepository, sessionRepository, userRepository, transactionRepository) objectUploadUseCase := usecase.NewObjectUploadUseCase(objectHandleRepository) - passwordResetTokenRepository := repository.NewPasswordResetTokenRepository(client, crud) + mailClient := ProvideMailClient() + passwordResetTokenRepository := repository.NewPasswordResetTokenRepository(client, crud, mailClient) passwordResetTokenUseCase := usecase.NewPasswordResetTokenUseCase(passwordResetTokenRepository, userRepository, mailAuthRepository) sponsorRepository := repository.NewSponsorRepository(client, crud) sponsorUseCase := usecase.NewSponsorUseCase(sponsorRepository) @@ -99,6 +101,11 @@ func ProvideMinioClient() (mc.Client, error) { return mc.InitMinioClient() } +// ProvideMailClient - MailClientのProvider +func ProvideMailClient() mail.Client { + return mail.NewMailClient() +} + // ProvideCrud - AbstractCrudのProvider func ProvideCrud(client db.Client) abstract.Crud { return abstract.NewCrud(client) From 1e2cbfb81b7788e404cd9805226151b15ab21442 Mon Sep 17 00:00:00 2001 From: Tenta Morishima Date: Sat, 5 Sep 2026 17:32:03 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Revert=20"fix:login=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=AE=E6=96=87=E8=A8=80=E3=81=AE=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit cdd89b4861d6db6f3c7a0828086c26ce34948cec. --- view/next-project/src/pages/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/view/next-project/src/pages/index.tsx b/view/next-project/src/pages/index.tsx index a0a66100d..e2a7de007 100644 --- a/view/next-project/src/pages/index.tsx +++ b/view/next-project/src/pages/index.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import { useState } from 'react'; import { PrimaryButton } from '@/components/common'; +import Link from '@/components/common/Link'; import SignInView from '@components/auth/SignInView'; import SignUpView from '@components/auth/SignUpView'; import LoginLayout from '@components/layout/LoginLayout'; @@ -27,7 +28,7 @@ export default function Home() {

登録がまだの方はこちら

setIsMember(!isMember)}>新規登録 -

パスワードを忘れた方はSlackまで

+ パスワードを忘れた
); From 78204f19c042c3024571e5dd7880dc81b2cc3b95 Mon Sep 17 00:00:00 2001 From: Tenta Morishima Date: Sat, 5 Sep 2026 18:35:20 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=E3=83=91=E3=82=B9=E3=83=AF?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=83=AA=E3=82=BB=E3=83=83=E3=83=88=E7=94=B3?= =?UTF-8?q?=E8=AB=8B=E3=81=AEE2E=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reset_password/requestページでメールアドレスを送信すると 再設定メール送信の完了表示が出ることを確認する Co-Authored-By: Claude Sonnet 5 --- .../e2e/tests/password_reset.spec.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 view/next-project/e2e/tests/password_reset.spec.ts diff --git a/view/next-project/e2e/tests/password_reset.spec.ts b/view/next-project/e2e/tests/password_reset.spec.ts new file mode 100644 index 000000000..1ce2d0035 --- /dev/null +++ b/view/next-project/e2e/tests/password_reset.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; + +const apiURL = process.env.API_URL || 'http://api:1323'; + +async function waitForService(url: string) { + const deadline = Date.now() + 120_000; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) { + return; + } + lastError = new Error(`HTTP ${res.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error(`Service did not become ready: ${url}. Last error: ${String(lastError)}`); +} + +test.beforeAll(async () => { + await waitForService(`${apiURL}/`); + await waitForService(`${process.env.BASE_URL || 'http://view:3000'}/`); +}, 120_000); + +test('パスワードリセット申請後に再設定メール送信の完了表示が出る', async ({ page }) => { + const email = `e2e-reset-${Date.now()}@example.com`; + + await page.goto('/reset_password/request'); + + const requestPromise = page.waitForRequest( + (request) => + request.url().includes('/password_reset/request') && request.method() === 'POST', + ); + + await page.getByPlaceholder('test@example.com').fill(email); + await page.getByRole('button', { name: '再設定メールの送信' }).click(); + + const resetRequest = await requestPromise; + const resetRequestURL = new URL(resetRequest.url()); + expect(resetRequestURL.searchParams.get('email')).toBe(email); + + await expect(page.getByText('再設定メールを送信しました')).toBeVisible(); +}); From 6ad8d78265ff465bdf12c21d4f1e5ea949406f42 Mon Sep 17 00:00:00 2001 From: fore0111 <157373664+fore0111@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:39:07 +0000 Subject: [PATCH 4/5] formatted by workflow --- view/next-project/e2e/tests/password_reset.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/view/next-project/e2e/tests/password_reset.spec.ts b/view/next-project/e2e/tests/password_reset.spec.ts index 1ce2d0035..8ba0e229d 100644 --- a/view/next-project/e2e/tests/password_reset.spec.ts +++ b/view/next-project/e2e/tests/password_reset.spec.ts @@ -33,8 +33,7 @@ test('パスワードリセット申請後に再設定メール送信の完了 await page.goto('/reset_password/request'); const requestPromise = page.waitForRequest( - (request) => - request.url().includes('/password_reset/request') && request.method() === 'POST', + (request) => request.url().includes('/password_reset/request') && request.method() === 'POST', ); await page.getByPlaceholder('test@example.com').fill(email); From 37d6fa7e5e3bd5d644b80a067717479522102776 Mon Sep 17 00:00:00 2001 From: Tenta Morishima Date: Thu, 24 Sep 2026 13:29:50 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20PR#1122=E3=81=AE=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mail.Clientの環境変数読み取り・検証をProvideMailClientに移し、未設定時は起動失敗させる - RESEND_API_KEY未設定時のconsoleフォールバックを削除 - NewMailClientがClient interfaceを返すように変更 - SendMailにcontextを追加し、NewRequestWithContextを使用 - ステータス判定を!= 200にし、Resendのエラーからname/messageを抽出 - リセットURLをnet/urlで組み立て、件名を変数化 - E2E: seedユーザー(delivered@resend.dev)で申請し、レスポンスまで検証 - E2E: beforeAllのタイムアウトをtest.setTimeoutで指定 - index.tsxのLink importをbarrelに統一 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e.yml | 3 ++ api/drivers/mail/mail.go | 34 ++++++++++--------- .../repository/password_reset_tokens.go | 15 +++++--- api/internals/di/wire.go | 15 ++++++-- api/internals/di/wire_gen.go | 20 +++++++++-- compose.e2e.yml | 2 ++ mysql/e2e_seed.sql | 8 +++++ .../e2e/tests/password_reset.spec.ts | 17 ++++++---- view/next-project/e2e/tests/signup.spec.ts | 3 +- view/next-project/src/pages/index.tsx | 3 +- 10 files changed, 86 insertions(+), 34 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1b7c07b08..c6139c5ad 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,6 +30,9 @@ jobs: - name: Run E2E run: make run-e2e + env: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + MAIL_FROM_ADDRESS: ${{ secrets.MAIL_FROM_ADDRESS }} - name: Cleanup E2E containers if: always() diff --git a/api/drivers/mail/mail.go b/api/drivers/mail/mail.go index 00fbaf269..5ac836653 100644 --- a/api/drivers/mail/mail.go +++ b/api/drivers/mail/mail.go @@ -2,18 +2,18 @@ package mail import ( "bytes" + "context" "encoding/json" "fmt" "io" "net/http" - "os" "time" ) const resendEndpoint = "https://api.resend.com/emails" type Client interface { - SendMail(to string, subject string, body string) error + SendMail(ctx context.Context, to string, subject string, body string) error } type client struct { @@ -23,10 +23,10 @@ type client struct { } // NewMailClient - Resend経由でメールを送信するmail.Clientを生成する -func NewMailClient() client { +func NewMailClient(apiKey, fromAddress string) Client { return client{ - apiKey: os.Getenv("RESEND_API_KEY"), - fromAddress: os.Getenv("MAIL_FROM_ADDRESS"), + apiKey: apiKey, + fromAddress: fromAddress, httpClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -38,15 +38,13 @@ type sendEmailRequest struct { Text string `json:"text"` } -// SendMail - Resend APIを叩いてメールを送信する -// RESEND_API_KEYが未設定の場合は送信を行わず、内容をコンソールに出力する -func (c client) SendMail(to string, subject string, body string) error { - if c.apiKey == "" { - fmt.Println("[mail] RESEND_API_KEY未設定のため送信をスキップし、内容をコンソールに出力します") - fmt.Printf("To: %s\nSubject: %s\n\n%s\n", to, subject, body) - return nil - } +type resendErrorResponse struct { + Name string `json:"name"` + Message string `json:"message"` +} +// SendMail - Resend APIを叩いてメールを送信する +func (c client) SendMail(ctx context.Context, to string, subject string, body string) error { reqBody, err := json.Marshal(sendEmailRequest{ From: c.fromAddress, To: []string{to}, @@ -57,7 +55,7 @@ func (c client) SendMail(to string, subject string, body string) error { return err } - req, err := http.NewRequest(http.MethodPost, resendEndpoint, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(reqBody)) if err != nil { return err } @@ -70,10 +68,14 @@ func (c client) SendMail(to string, subject string, body string) error { } defer resp.Body.Close() - if resp.StatusCode >= 300 { + if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("resend api error: status=%d body=%s", resp.StatusCode, string(respBody)) + var resendErr resendErrorResponse + _ = json.Unmarshal(respBody, &resendErr) + return fmt.Errorf("resend api error: status=%d name=%s message=%s", resp.StatusCode, resendErr.Name, resendErr.Message) } + _, _ = io.Copy(io.Discard, resp.Body) + return nil } diff --git a/api/externals/repository/password_reset_tokens.go b/api/externals/repository/password_reset_tokens.go index 8d6972066..f443064b8 100644 --- a/api/externals/repository/password_reset_tokens.go +++ b/api/externals/repository/password_reset_tokens.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "net/url" "os" "time" @@ -105,17 +106,23 @@ func (pr *passwordResetTokenRepository) DestroyByUserID(c context.Context, userI // リセットメール送信 func (pr *passwordResetTokenRepository) SendResetEmail(c context.Context, id string, name string, email string, token string) error { - resetPageUrl := os.Getenv("RESET_PASSWORD_URL") + "/" + id + "/?token=" + token - + resetPageURL, err := url.Parse(os.Getenv("RESET_PASSWORD_URL")) + if err != nil { + return err + } + resetPageURL = resetPageURL.JoinPath(id) + resetPageURL.RawQuery = url.Values{"token": {token}}.Encode() + + title := "【FinanSu】パスワード再設定メール" body := name + " 様\n\n" + "情報局 FinanSu 担当です。\n\n" + "パスワードの再設定のご依頼を受け付けました。下記の再設定ページにアクセスし、新しいパスワードを設定してください。\n" + "※パスワードリセットの申請に心当たりがない場合は、以降の対応は不要となります。\n\n" + - resetPageUrl + "\n\n" + + resetPageURL.String() + "\n\n" + "なお、URLの有効期限は本メールが送信されてから60分間とさせていただきます。\n\n" + "どうぞよろしくお願い申し上げます。\n\n" + "情報局 FinanSu担当 \n\n" + "※このメールは送信専用です\n" - return pr.mailClient.SendMail(email, "【FinanSu】パスワード再設定メール", body) + return pr.mailClient.SendMail(c, email, title, body) } diff --git a/api/internals/di/wire.go b/api/internals/di/wire.go index 727170ac9..6e72d7701 100644 --- a/api/internals/di/wire.go +++ b/api/internals/di/wire.go @@ -4,6 +4,9 @@ package di import ( + "errors" + "os" + "github.com/NUTFes/FinanSu/api/drivers/db" "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/drivers/mc" @@ -35,8 +38,16 @@ func ProvideMinioClient() (mc.Client, error) { } // ProvideMailClient - MailClientのProvider -func ProvideMailClient() mail.Client { - return mail.NewMailClient() +func ProvideMailClient() (mail.Client, error) { + apiKey := os.Getenv("RESEND_API_KEY") + if apiKey == "" { + return nil, errors.New("RESEND_API_KEY is not set") + } + from := os.Getenv("MAIL_FROM_ADDRESS") + if from == "" { + return nil, errors.New("MAIL_FROM_ADDRESS is not set") + } + return mail.NewMailClient(apiKey, from), nil } // ProvideCrud - AbstractCrudのProvider diff --git a/api/internals/di/wire_gen.go b/api/internals/di/wire_gen.go index 0c97d88d7..a41fd3d03 100644 --- a/api/internals/di/wire_gen.go +++ b/api/internals/di/wire_gen.go @@ -7,6 +7,9 @@ package di import ( + "errors" + "os" + "github.com/NUTFes/FinanSu/api/drivers/db" "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/drivers/mc" @@ -62,7 +65,10 @@ func InitializeServer() (*ServerComponents, error) { userRepository := repository.NewUserRepository(client, crud) mailAuthUseCase := usecase.NewMailAuthUseCase(mailAuthRepository, sessionRepository, userRepository, transactionRepository) objectUploadUseCase := usecase.NewObjectUploadUseCase(objectHandleRepository) - mailClient := ProvideMailClient() + mailClient, err := ProvideMailClient() + if err != nil { + return nil, err + } passwordResetTokenRepository := repository.NewPasswordResetTokenRepository(client, crud, mailClient) passwordResetTokenUseCase := usecase.NewPasswordResetTokenUseCase(passwordResetTokenRepository, userRepository, mailAuthRepository) sponsorRepository := repository.NewSponsorRepository(client, crud) @@ -102,8 +108,16 @@ func ProvideMinioClient() (mc.Client, error) { } // ProvideMailClient - MailClientのProvider -func ProvideMailClient() mail.Client { - return mail.NewMailClient() +func ProvideMailClient() (mail.Client, error) { + apiKey := os.Getenv("RESEND_API_KEY") + if apiKey == "" { + return nil, errors.New("RESEND_API_KEY is not set") + } + from := os.Getenv("MAIL_FROM_ADDRESS") + if from == "" { + return nil, errors.New("MAIL_FROM_ADDRESS is not set") + } + return mail.NewMailClient(apiKey, from), nil } // ProvideCrud - AbstractCrudのProvider diff --git a/compose.e2e.yml b/compose.e2e.yml index 7b4bc9f75..5a7411c30 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -72,6 +72,8 @@ services: MINIO_SECRET_KEY: password MINIO_USE_SSL: "false" RESET_PASSWORD_URL: "http://view:3000/reset_password" + RESEND_API_KEY: "${RESEND_API_KEY}" + MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}" command: go run main.go depends_on: seed: diff --git a/mysql/e2e_seed.sql b/mysql/e2e_seed.sql index 4b2a11139..a9af9185b 100644 --- a/mysql/e2e_seed.sql +++ b/mysql/e2e_seed.sql @@ -11,3 +11,11 @@ VALUES (1, 2025); INSERT INTO year_periods (year_id, started_at, ended_at) VALUES (1, '2024-11-15 00:00:00', '2025-11-15 00:00:00'); + +INSERT INTO users (id, name, bureau_id, role_id, is_deleted) +VALUES (1, 'E2E Reset User', 1, 1, false); + +-- password: e2e-reset-password (bcrypt hash, ログインテストでは使用しない) +-- email は Resend のシミュレーションアドレス(実配送されず必ず成功レスポンスを返す) +INSERT INTO mail_auth (id, email, password, user_id) +VALUES (1, 'delivered@resend.dev', '$2a$10$L8XaQthkF.YbgJHQSEIsSOntPbtBGp1q1lkv33qZbtuMT0C8h4OPS', 1); diff --git a/view/next-project/e2e/tests/password_reset.spec.ts b/view/next-project/e2e/tests/password_reset.spec.ts index 8ba0e229d..f98db600e 100644 --- a/view/next-project/e2e/tests/password_reset.spec.ts +++ b/view/next-project/e2e/tests/password_reset.spec.ts @@ -23,25 +23,30 @@ async function waitForService(url: string) { } test.beforeAll(async () => { + test.setTimeout(120_000); await waitForService(`${apiURL}/`); await waitForService(`${process.env.BASE_URL || 'http://view:3000'}/`); -}, 120_000); +}); test('パスワードリセット申請後に再設定メール送信の完了表示が出る', async ({ page }) => { - const email = `e2e-reset-${Date.now()}@example.com`; + // mysql/e2e_seed.sql で登録済みのユーザーのメールアドレス(Resendのシミュレーションアドレス) + const email = 'delivered@resend.dev'; await page.goto('/reset_password/request'); - const requestPromise = page.waitForRequest( - (request) => request.url().includes('/password_reset/request') && request.method() === 'POST', + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/password_reset/request') && response.request().method() === 'POST', ); await page.getByPlaceholder('test@example.com').fill(email); await page.getByRole('button', { name: '再設定メールの送信' }).click(); - const resetRequest = await requestPromise; - const resetRequestURL = new URL(resetRequest.url()); + const resetResponse = await responsePromise; + const resetRequestURL = new URL(resetResponse.url()); expect(resetRequestURL.searchParams.get('email')).toBe(email); + expect(resetResponse.status()).toBe(200); + expect(await resetResponse.text()).toBe('PasswordResetTokenを送信しました'); await expect(page.getByText('再設定メールを送信しました')).toBeVisible(); }); diff --git a/view/next-project/e2e/tests/signup.spec.ts b/view/next-project/e2e/tests/signup.spec.ts index 732c8090d..c55bdc939 100644 --- a/view/next-project/e2e/tests/signup.spec.ts +++ b/view/next-project/e2e/tests/signup.spec.ts @@ -23,9 +23,10 @@ async function waitForService(url: string) { } test.beforeAll(async () => { + test.setTimeout(120_000); await waitForService(`${apiURL}/`); await waitForService(`${process.env.BASE_URL || 'http://view:3000'}/`); -}, 120_000); +}); test('新規登録後に current_user が 404 にならず My Page に遷移する', async ({ page }) => { const currentUserStatuses: number[] = []; diff --git a/view/next-project/src/pages/index.tsx b/view/next-project/src/pages/index.tsx index e2a7de007..cdfa6fd61 100644 --- a/view/next-project/src/pages/index.tsx +++ b/view/next-project/src/pages/index.tsx @@ -1,8 +1,7 @@ import Image from 'next/image'; import { useState } from 'react'; -import { PrimaryButton } from '@/components/common'; -import Link from '@/components/common/Link'; +import { Link, PrimaryButton } from '@/components/common'; import SignInView from '@components/auth/SignInView'; import SignUpView from '@components/auth/SignUpView'; import LoginLayout from '@components/layout/LoginLayout';