From e730799aa5b8946b3e7d9387af1ee560ef7ae32b Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 16 Jul 2026 11:49:58 +0800 Subject: [PATCH] refactor(e2e): organize fixtures by feature --- docs/manual-tests/chat-quantity-limit.md | 112 ----- .../email-login-from-other-options.md | 87 ---- docs/manual-tests/logout-from-sidebar.md | 63 --- docs/manual-tests/paid-image.md | 114 ----- docs/manual-tests/paid-voice.md | 105 ----- docs/manual-tests/test-site.md | 10 - e2e/fixtures/api-mocks.ts | 219 +--------- e2e/fixtures/api/auth.ts | 25 ++ e2e/fixtures/api/chat.ts | 71 +++ e2e/fixtures/api/payment.ts | 12 + e2e/fixtures/api/user.ts | 14 + e2e/fixtures/data/auth.ts | 51 +++ e2e/fixtures/data/chat.ts | 106 +++++ e2e/fixtures/data/common.ts | 8 + e2e/fixtures/data/payment.ts | 14 + e2e/fixtures/data/user.ts | 12 + e2e/fixtures/helpers/assertions.ts | 10 + e2e/fixtures/helpers/auth.ts | 65 +++ e2e/fixtures/helpers/chat.ts | 41 ++ e2e/fixtures/helpers/payment.ts | 30 ++ e2e/fixtures/test-data.ts | 406 +----------------- e2e/fixtures/test-helpers.ts | 229 +--------- .../email-login-from-other-options.spec.ts | 4 +- .../mock/auth/logout-from-sidebar.spec.ts | 7 +- .../mock/chat/chat-quantity-limit.spec.ts | 11 +- e2e/specs/mock/paid-image.spec.ts | 22 +- .../image-unlock-insufficient-credits.spec.ts | 6 +- .../voice-unlock-insufficient-credits.spec.ts | 2 +- 28 files changed, 521 insertions(+), 1335 deletions(-) delete mode 100644 docs/manual-tests/chat-quantity-limit.md delete mode 100644 docs/manual-tests/email-login-from-other-options.md delete mode 100644 docs/manual-tests/logout-from-sidebar.md delete mode 100644 docs/manual-tests/paid-image.md delete mode 100644 docs/manual-tests/paid-voice.md delete mode 100644 docs/manual-tests/test-site.md create mode 100644 e2e/fixtures/api/auth.ts create mode 100644 e2e/fixtures/api/chat.ts create mode 100644 e2e/fixtures/api/payment.ts create mode 100644 e2e/fixtures/api/user.ts create mode 100644 e2e/fixtures/data/auth.ts create mode 100644 e2e/fixtures/data/chat.ts create mode 100644 e2e/fixtures/data/common.ts create mode 100644 e2e/fixtures/data/payment.ts create mode 100644 e2e/fixtures/data/user.ts create mode 100644 e2e/fixtures/helpers/assertions.ts create mode 100644 e2e/fixtures/helpers/auth.ts create mode 100644 e2e/fixtures/helpers/chat.ts create mode 100644 e2e/fixtures/helpers/payment.ts diff --git a/docs/manual-tests/chat-quantity-limit.md b/docs/manual-tests/chat-quantity-limit.md deleted file mode 100644 index 8c46215a..00000000 --- a/docs/manual-tests/chat-quantity-limit.md +++ /dev/null @@ -1,112 +0,0 @@ -# 聊天数量限制测试 - -## 功能点 - -验证游客在聊天页连续发送消息后,是否会在限定次数内触发聊天数量限制;触发后是否能够继续完成登录、订阅、支付和返回聊天页的完整链路。 - -## 测试目标 - -1. 游客进入聊天页后,连续聊天最多 `51` 条。 -2. 若在 `51` 条以内触发聊天限制,则继续验证后续跳转和支付链路。 -3. 若发送到第 `51` 条后仍未出现目标受限界面,则判定本功能点测试失败。 - -## 测试网址 - -- 统一测试网址请查看:[test-site.md](./test-site.md) - -## 目标受限界面 - -出现以下现象时,视为命中目标界面: - -1. 聊天输入框不可见或不可继续输入。 -2. 页面出现跳转按钮,用于继续前往登录或订阅流程。 - -## 测试数据 - -### 聊天数据 - -- 消息发送上限:`51` -- 建议消息模板: - - `limit test 01` - - `limit test 02` - - `limit test 03` - - ... - - `limit test 51` - -### 登录流程引用 - -- 登录相关操作请参考:[email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式” - -## 操作流程 - -### 阶段一:进入聊天页并验证数量限制 - -1. 打开统一测试网址(见 [test-site.md](./test-site.md)) -2. 在首页点击 `Skip` -3. 进入聊天界面 -4. 以顺序消息数据持续发送聊天内容,从 `limit test 01` 开始,最多发送到 `limit test 51` -5. 在发送过程中持续观察页面状态 - -### 阶段二:数量限制判定 - -1. 若在第 `1` 至第 `51` 条消息之间出现目标受限界面: - - 继续执行后续登录与订阅流程 -2. 若发送完第 `51` 条消息后仍未出现目标受限界面: - - 直接判定该功能点测试失败 - - 停止后续登录、订阅、支付步骤 - -## 触发限制后的后续流程 - -### 阶段三:登录流程 - -1. 点击受限界面中的跳转按钮 -2. 进入登录界面 -3. 登录相关操作不在本文件内重复展开 -4. 直接参考 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式” -5. 登录成功后继续执行后续订阅流程 - -### 阶段四:订阅支付与回跳验证 - -1. 登录成功后,确认跳转到订阅界面 -2. 点击 `Pay And Activate` 按钮 -3. 等待支付成功 -4. 点击 `OK` 按钮 -5. 等待页面跳转回聊天界面 - -## 预期结果 - -### 数量限制预期 - -1. 游客在连续聊天过程中,会在第 `51` 条消息以内命中聊天数量限制。 -2. 命中限制后,聊天输入框不可继续使用。 -3. 页面出现用于继续流程的跳转按钮。 - -### 登录与支付链路预期 - -1. 点击跳转按钮后,可进入登录界面。 -2. 按照邮箱登录测试流程完成登录后,可进入订阅界面。 -3. 订阅和 Stripe 流程可正常操作。 -4. 支付成功后,点击 `OK` 可返回聊天界面。 - -## 失败判定 - -满足以下任一条件即判定本功能点失败: - -1. 发送到第 `51` 条消息后,仍未出现目标受限界面。 -2. 触发限制后,跳转按钮不存在或无法点击。 -3. 无法进入登录界面。 -4. 按照邮箱登录测试流程执行后登录失败。 -5. 无法进入订阅页。 -6. Stripe 流程无法继续。 -7. 支付成功后无法返回聊天页。 - -## 执行备注 - -1. 若执行过程中出现验证码,需要人工确认后再继续。 -2. 若支付步骤会产生真实外部副作用,应在执行支付提交前再次确认。 -3. 建议在正式执行时逐条记录: - - 实际发送到第几条时触发限制 - - 跳转按钮文案 - - 登录流程参考文件及登录跳转结果 - - 支付结果 - - 最终是否成功返回聊天页 diff --git a/docs/manual-tests/email-login-from-other-options.md b/docs/manual-tests/email-login-from-other-options.md deleted file mode 100644 index b12e2a1d..00000000 --- a/docs/manual-tests/email-login-from-other-options.md +++ /dev/null @@ -1,87 +0,0 @@ -# Other Sign In Options 邮箱登录测试 - -## 功能点 - -验证用户从 `splash` 页进入聊天页后,通过顶部 `Sign up to unlock more features` 进入登录页,再通过 `Other Sign In Options` 选择邮箱登录,最终返回聊天界面的流程是否正常。 - -## 测试目标 - -1. 从 `splash` 页点击 `Skip` 进入聊天界面。 -2. 从聊天页顶部入口进入登录界面。 -3. 点击 `Other Sign In Options` 弹出选择弹窗。 -4. 点击 `Email Sign In` 后等待界面刷新到邮箱登录表单。 -5. 点击 `Login` 按钮并等待跳转回聊天界面。 - -## 测试网址 - -- 统一测试网址请查看:[test-site.md](./test-site.md) - -## 测试数据 - -- 测试网址引用:见 [test-site.md](./test-site.md) - -## 操作流程 - -### 阶段一:进入聊天页 - -1. 打开统一测试网址(见 [test-site.md](./test-site.md)) -2. 进入 `splash` 界面 -3. 点击 `Skip` 按钮 -4. 等待页面跳转到聊天界面 - -### 阶段二:进入登录页 - -1. 在聊天页顶部点击 `Sign up to unlock more features` -2. 等待页面跳转到登录界面 - -### 阶段三:进入邮箱登录方式 - -1. 在登录界面点击 `Other Sign In Options` -2. 等待选择弹窗出现 -3. 在弹窗中点击 `Email Sign In` -4. 等待界面刷新为邮箱登录界面 - -### 阶段四:提交登录 - -1. 点击 `Login` 按钮 -2. 等待登录完成 - -### 阶段五:验证跳转结果 - -1. 确认页面从登录流程返回站内页面 -2. 等待页面跳转回聊天界面 -3. 确认聊天界面展示正常 - -## 预期结果 - -1. 点击 `Skip` 后可正常从 `splash` 页进入聊天界面。 -2. 点击顶部 `Sign up to unlock more features` 后可进入登录界面。 -3. 点击 `Other Sign In Options` 后可弹出选择弹窗。 -4. 点击 `Email Sign In` 后可切换到邮箱登录界面。 -5. 点击 `Login` 后可成功完成登录。 -6. 登录成功后可正常跳转回聊天界面。 - -## 失败判定 - -满足以下任一条件即判定本功能点失败: - -1. `Skip` 按钮不存在或无法点击。 -2. 无法从 `splash` 页进入聊天界面。 -3. 顶部 `Sign up to unlock more features` 按钮不存在或无法点击。 -4. 无法进入登录界面。 -5. `Other Sign In Options` 按钮不存在或无法点击。 -6. 未弹出选择弹窗。 -7. `Email Sign In` 按钮不存在或无法点击。 -8. 无法切换到邮箱登录界面。 -9. 点击 `Login` 后登录失败。 -10. 登录完成后未返回聊天界面。 - -## 执行备注 - -1. 如果登录后存在中间态页面或短暂回跳,应记录实际路径和停留页面。 -2. 正式执行时建议记录: - - 是否成功从 `splash` 页进入聊天页 - - 是否成功进入登录页 - - `Other Sign In Options` 弹窗是否正常出现 - - 是否成功切换到邮箱登录界面 - - 点击 `Login` 后是否成功跳回聊天页 diff --git a/docs/manual-tests/logout-from-sidebar.md b/docs/manual-tests/logout-from-sidebar.md deleted file mode 100644 index 2d3a582f..00000000 --- a/docs/manual-tests/logout-from-sidebar.md +++ /dev/null @@ -1,63 +0,0 @@ -# 侧边栏退出登录测试 - -## 功能点 - -验证用户从聊天界面进入侧边栏后,点击 `Logout` 按钮是否能够成功退出登录,并跳转回 `splash` 界面。 - -## 测试目标 - -1. 从聊天界面进入侧边栏界面。 -2. 在侧边栏中点击 `Logout` 按钮。 -3. 验证退出登录后页面是否跳转回 `splash` 界面。 - -## 测试网址 - -- 统一测试网址请查看:[test-site.md](./test-site.md) - -## 测试数据 - -- 测试网址引用:见 [test-site.md](./test-site.md) - -## 操作流程 - -### 阶段一:进入聊天界面 - -1. 打开统一测试网址(见 [test-site.md](./test-site.md)) -2. 进入聊天界面 - -### 阶段二:进入侧边栏 - -1. 点击屏幕左上角菜单按钮 -2. 等待页面跳转到侧边栏界面 - -### 阶段三:执行退出登录 - -1. 在侧边栏界面点击 `Logout` 按钮 -2. 等待退出登录完成 - -### 阶段四:验证跳转结果 - -1. 确认页面跳转到 `splash` 界面 -2. 确认 `splash` 页核心入口正常展示 - -## 预期结果 - -1. 用户可以从聊天界面正常进入侧边栏界面。 -2. 侧边栏中的 `Logout` 按钮可以正常点击。 -3. 点击 `Logout` 后可以成功退出登录。 -4. 退出登录后页面跳转回 `splash` 界面。 - -## 失败判定 - -满足以下任一条件即判定本功能点失败: - -1. 无法从聊天界面进入侧边栏界面。 -2. 左上角菜单按钮不存在或无法点击。 -3. `Logout` 按钮不存在或无法点击。 -4. 点击 `Logout` 后未成功退出登录。 -5. 退出登录后未跳转到 `splash` 界面。 - -## 执行备注 - -1. 如退出登录后存在短暂中间态页面,应记录中间跳转路径。 -2. 若页面跳转到 `splash` 后仍保留登录态相关内容,应进一步确认是否真正完成登出。 diff --git a/docs/manual-tests/paid-image.md b/docs/manual-tests/paid-image.md deleted file mode 100644 index 31ca7fc5..00000000 --- a/docs/manual-tests/paid-image.md +++ /dev/null @@ -1,114 +0,0 @@ -# 付费图片测试 - -## 功能点 - -验证游客在聊天页发送图片请求后,是否能够触发 AI 回复付费图片消息;点击图片进入大图界面后,是否能够通过登录、订阅、支付流程解锁,并在支付成功后返回图片大图界面。 - -## 测试目标 - -1. 从 `splash` 页点击 `Skip` 进入聊天界面。 -2. 在聊天页发送文字消息 `给我发图片`。 -3. 验证 AI 回复付费图片消息。 -4. 点击付费图片,跳转至图片大图界面。 -5. 点击 `Unlock high-definition large image` 按钮进入登录流程。 -6. 按照邮箱登录流程完成登录。 -7. 登录成功后进入订阅界面,完成支付。 -8. 支付成功后点击 `OK`,验证页面返回图片大图界面。 - -## 测试网址 - -- 统一测试网址请查看:[test-site.md](./test-site.md) - -## 测试数据 - -### 聊天数据 - -- 消息内容:`给我发图片` - -### 登录流程引用 - -- 登录相关操作请参考:[email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”和“阶段四:提交登录”。 - -## 操作流程 - -### 阶段一:进入聊天页 - -1. 打开统一测试网址(见 [test-site.md](./test-site.md)) -2. 进入 `splash` 界面 -3. 点击 `Skip` 按钮 -4. 等待页面跳转到聊天界面 -5. 确认聊天输入框展示正常 - -### 阶段二:触发付费图片消息 - -1. 在聊天输入框输入 `给我发图片` -2. 点击发送按钮 -3. 等待 AI 回复 -4. 确认聊天区域出现付费图片消息 - -### 阶段三:进入图片大图界面 - -1. 点击聊天区域中的付费图片 -2. 等待页面跳转到图片大图界面 -3. 确认图片大图界面展示正常 -4. 确认页面存在 `Unlock high-definition large image` 按钮 - -### 阶段四:进入登录流程 - -1. 点击 `Unlock high-definition large image` 按钮 -2. 等待页面跳转到登录界面 -3. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”执行邮箱登录入口切换 -4. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段四:提交登录”点击 `Login` 并等待登录完成 - -### 阶段五:订阅支付与回跳验证 - -1. 登录成功后,确认页面跳转到订阅界面 -2. 点击 `Pay And Activate` 按钮 -3. 等待支付成功 -4. 点击 `OK` 按钮 -5. 等待页面跳转回图片大图界面 - -## 预期结果 - -### 付费图片预期 - -1. 点击 `Skip` 后可正常进入聊天界面。 -2. 发送 `给我发图片` 后,AI 可正常回复付费图片消息。 -3. 点击付费图片后,可进入图片大图界面。 -4. 图片大图界面展示 `Unlock high-definition large image` 按钮。 - -### 登录与支付链路预期 - -1. 点击 `Unlock high-definition large image` 后可进入登录界面。 -2. 按照邮箱登录流程可成功完成登录。 -3. 登录成功后可进入订阅界面。 -4. 点击 `Pay And Activate` 后可正常完成支付流程。 -5. 支付成功后点击 `OK` 可返回图片大图界面。 - -## 失败判定 - -满足以下任一条件即判定本功能点失败: - -1. `Skip` 按钮不存在或无法点击。 -2. 无法从 `splash` 页进入聊天界面。 -3. 无法发送 `给我发图片` 消息。 -4. AI 未回复付费图片消息。 -5. 付费图片无法点击或点击后未进入图片大图界面。 -6. 图片大图界面未展示 `Unlock high-definition large image` 按钮。 -7. 点击解锁按钮后未进入登录界面。 -8. 按照邮箱登录流程执行后登录失败。 -9. 登录成功后未进入订阅界面。 -10. 点击 `Pay And Activate` 后支付流程失败。 -11. 支付成功后点击 `OK` 未返回图片大图界面。 - -## 执行备注 - -1. 若执行过程中出现验证码,需要人工确认后再继续。 -2. 若支付步骤会产生真实外部副作用,应在执行支付提交前再次确认。 -3. 正式执行时建议记录: - - 是否成功触发付费图片消息 - - 图片大图界面的实际 URL - - 解锁按钮文案 - - 登录流程参考文件及登录跳转结果 - - 支付结果 - - 最终是否成功返回图片大图界面 diff --git a/docs/manual-tests/paid-voice.md b/docs/manual-tests/paid-voice.md deleted file mode 100644 index e424aabe..00000000 --- a/docs/manual-tests/paid-voice.md +++ /dev/null @@ -1,105 +0,0 @@ -# 付费语音测试 - -## 功能点 - -验证游客在聊天页发送语音请求后,是否能够触发 AI 回复付费语音消息;点击解锁语音消息后,是否能够通过登录、订阅、支付流程解锁,并在支付成功后返回聊天界面。 - -## 测试目标 - -1. 从 `splash` 页点击 `Skip` 进入聊天界面。 -2. 在聊天页发送文字消息 `给我发语音`。 -3. 验证 AI 回复付费语音消息。 -4. 点击 `Unlock voice message` 按钮进入登录流程。 -5. 按照邮箱登录流程完成登录。 -6. 登录成功后进入订阅界面,完成支付。 -7. 支付成功后点击 `OK`,验证页面返回聊天界面。 - -## 测试网址 - -- 统一测试网址请查看:[test-site.md](./test-site.md) - -## 测试数据 - -### 聊天数据 - -- 消息内容:`给我发语音` - -### 登录流程引用 - -- 登录相关操作请参考:[email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”和“阶段四:提交登录”。 - -## 操作流程 - -### 阶段一:进入聊天页 - -1. 打开统一测试网址(见 [test-site.md](./test-site.md)) -2. 进入 `splash` 界面 -3. 点击 `Skip` 按钮 -4. 等待页面跳转到聊天界面 -5. 确认聊天输入框展示正常 - -### 阶段二:触发付费语音消息 - -1. 在聊天输入框输入 `给我发语音` -2. 点击发送按钮 -3. 等待 AI 回复 -4. 确认聊天区域出现付费语音消息 -5. 确认付费语音消息展示 `Unlock voice message` 按钮 - -### 阶段三:进入登录流程 - -1. 点击 `Unlock voice message` 按钮 -2. 等待页面跳转到登录界面 -3. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”执行邮箱登录入口切换 -4. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段四:提交登录”点击 `Login` 并等待登录完成 - -### 阶段四:订阅支付与回跳验证 - -1. 登录成功后,确认页面跳转到订阅界面 -2. 点击 `Pay And Activate` 按钮 -3. 等待支付成功 -4. 点击 `OK` 按钮 -5. 等待页面跳转回聊天界面 -6. 确认聊天界面展示正常 - -## 预期结果 - -### 付费语音预期 - -1. 点击 `Skip` 后可正常进入聊天界面。 -2. 发送 `给我发语音` 后,AI 可正常回复付费语音消息。 -3. 付费语音消息展示 `Unlock voice message` 按钮。 -4. 点击 `Unlock voice message` 后可进入登录界面。 - -### 登录与支付链路预期 - -1. 按照邮箱登录流程可成功完成登录。 -2. 登录成功后可进入订阅界面。 -3. 点击 `Pay And Activate` 后可正常完成支付流程。 -4. 支付成功后点击 `OK` 可返回聊天界面。 - -## 失败判定 - -满足以下任一条件即判定本功能点失败: - -1. `Skip` 按钮不存在或无法点击。 -2. 无法从 `splash` 页进入聊天界面。 -3. 无法发送 `给我发语音` 消息。 -4. AI 未回复付费语音消息。 -5. 付费语音消息未展示 `Unlock voice message` 按钮。 -6. 点击 `Unlock voice message` 后未进入登录界面。 -7. 按照邮箱登录流程执行后登录失败。 -8. 登录成功后未进入订阅界面。 -9. 点击 `Pay And Activate` 后支付流程失败。 -10. 支付成功后点击 `OK` 未返回聊天界面。 - -## 执行备注 - -1. 若执行过程中出现验证码,需要人工确认后再继续。 -2. 若支付步骤会产生真实外部副作用,应在执行支付提交前再次确认。 -3. 正式执行时建议记录: - - 是否成功触发付费语音消息 - - `Unlock voice message` 按钮是否展示 - - 登录流程参考文件及登录跳转结果 - - 支付结果 - - 最终是否成功返回聊天界面 diff --git a/docs/manual-tests/test-site.md b/docs/manual-tests/test-site.md deleted file mode 100644 index e8b9cba0..00000000 --- a/docs/manual-tests/test-site.md +++ /dev/null @@ -1,10 +0,0 @@ -# 统一测试网址 - -## 测试网址 - -- `https://frontend-test.banlv-ai.com` - -## 说明 - -1. `docs/manual-tests` 目录下的测试流程默认都使用这个统一测试网址。 -2. 若测试环境地址发生变化,只需更新本文件。 diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index bad002b9..8193efc3 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -1,31 +1,11 @@ import type { Page } from "@playwright/test"; -import { - apiEnvelope, - chatSendResponse, - createPaymentOrderResponse, - createChatSendResponse, - createPaidImageHistoryResponse, - createWeeklyLimitChatSendResponse, - e2eEmailUser, - emptyChatHistoryResponse, - emailLoginResponse, - guestLoginResponse, - insufficientCreditsUnlockImageResponse, - insufficientCreditsUnlockVoiceResponse, - paidImageChatSendResponse, - paidImageUnlockHistoryResponse, - paidVoiceHistoryResponse, - paidPaymentOrderStatusResponse, - paymentPlansResponse, - tipPaymentPlansResponse, - refreshedEmailLoginResponse, - refreshedGuestLoginResponse, - userEntitlementsResponse, - vipStatusResponse, -} from "./test-data"; +import { registerAuthMocks } from "./api/auth"; +import { registerChatMocks } from "./api/chat"; +import { registerPaymentMocks } from "./api/payment"; +import { registerUserMocks } from "./api/user"; -interface MockCoreApisOptions { +export interface MockCoreApisOptions { chatLimitTriggerAt?: number; chatSendTokenRefreshFlow?: boolean; paidImageFlow?: boolean; @@ -33,180 +13,21 @@ interface MockCoreApisOptions { paidVoiceInsufficientCreditsFlow?: boolean; } -export async function mockCoreApis( - page: Page, - options: MockCoreApisOptions = {}, -): Promise { - const { - chatLimitTriggerAt, - chatSendTokenRefreshFlow = false, - paidImageFlow = false, - paidImageInsufficientCreditsFlow = false, - paidVoiceInsufficientCreditsFlow = false, - } = options; - let sendCount = 0; - let hasExpiredChatSend = false; - let paidImageRequested = false; - let paidImageUnlocked = false; +export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { + const chatOptions = { + chatLimitTriggerAt: options.chatLimitTriggerAt, + chatSendTokenRefreshFlow: options.chatSendTokenRefreshFlow ?? false, + paidImageFlow: options.paidImageFlow ?? false, + paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false, + paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false, + }; + const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false }; - await page.route("**/api/auth/guest", async (route) => { - const response = - chatSendTokenRefreshFlow && hasExpiredChatSend - ? refreshedGuestLoginResponse - : guestLoginResponse; - await route.fulfill({ json: apiEnvelope(response) }); - }); - - await page.route("**/api/auth/login", async (route) => { - if (paidImageFlow) { - paidImageRequested = true; - } - - await route.fulfill({ json: apiEnvelope(emailLoginResponse) }); - }); - - await page.route("**/api/auth/refresh", async (route) => { - await route.fulfill({ json: apiEnvelope(refreshedEmailLoginResponse) }); - }); - - await page.route("**/api/auth/logout", async (route) => { - await route.fulfill({ json: apiEnvelope(null) }); - }); - - await page.route("**/api/user/profile", async (route) => { - await route.fulfill({ json: apiEnvelope(e2eEmailUser) }); - }); - - await page.route("**/api/user/entitlements", async (route) => { - await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) }); - }); - - await page.route("**/api/chat/history**", async (route) => { - const response = - paidVoiceInsufficientCreditsFlow - ? paidVoiceHistoryResponse - : paidImageInsufficientCreditsFlow - ? createPaidImageHistoryResponse(false) - : paidImageFlow && paidImageRequested - ? createPaidImageHistoryResponse(paidImageUnlocked) - : emptyChatHistoryResponse; - - await route.fulfill({ json: apiEnvelope(response) }); - }); - - await page.route("**/api/chat/send", async (route) => { - sendCount += 1; - const requestBody = route.request().postDataJSON() as - | { message?: unknown } - | undefined; - const message = - typeof requestBody?.message === "string" ? requestBody.message : ""; - - if (chatSendTokenRefreshFlow && !hasExpiredChatSend) { - hasExpiredChatSend = true; - await route.fulfill({ - status: 401, - json: { - message: "expired", - }, - }); - return; - } - - const response = - paidImageFlow && message.includes("给我发图片") - ? paidImageChatSendResponse - : chatLimitTriggerAt != null && sendCount >= chatLimitTriggerAt - ? createWeeklyLimitChatSendResponse(sendCount, chatLimitTriggerAt) - : chatLimitTriggerAt != null - ? createChatSendResponse(sendCount) - : chatSendResponse; - - if (paidImageFlow && message.includes("给我发图片")) { - paidImageRequested = true; - paidImageUnlocked = false; - } - - await route.fulfill({ json: apiEnvelope(response) }); - }); - - await page.route("**/api/chat/unlock-history", async (route) => { - if (paidImageFlow && paidImageRequested) { - paidImageUnlocked = true; - } - - const response = - paidImageFlow && paidImageRequested - ? paidImageUnlockHistoryResponse - : { - unlocked: true, - reason: "no_locked_messages", - totalLocked: 0, - unlockedCount: 0, - privateCount: 0, - imageCount: 0, - voiceCount: 0, - requiredCredits: 0, - currentCredits: 0, - remainingCredits: 0, - shortfallCredits: 0, - costsByMessage: {}, - messageIds: [], - }; - - await route.fulfill({ json: apiEnvelope(response) }); - }); - - await page.route("**/api/chat/unlock-private", async (route) => { - const response = - paidVoiceInsufficientCreditsFlow - ? insufficientCreditsUnlockVoiceResponse - : paidImageInsufficientCreditsFlow - ? insufficientCreditsUnlockImageResponse - : paidImageFlow && paidImageRequested && !paidImageUnlocked - ? insufficientCreditsUnlockImageResponse - : { - unlocked: true, - content: "Unlocked message", - reason: "ok", - creditBalance: 100, - creditsCharged: 0, - requiredCredits: 0, - shortfallCredits: 0, - lockDetail: { - locked: false, - showContent: true, - showUpgrade: false, - reason: null, - hint: null, - detail: null, - }, - }; - - await route.fulfill({ json: apiEnvelope(response) }); - }); - - await page.route("**/api/payment/plans**", async (route) => { - await route.fulfill({ json: apiEnvelope(paymentPlansResponse) }); - }); - - await page.route("**/api/payment/tip-plans**", async (route) => { - await route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }); - }); - - await page.route("**/api/payment/create-order", async (route) => { - await route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }); - }); - - await page.route("**/api/payment/order-status**", async (route) => { - if (paidImageFlow && paidImageRequested) { - paidImageUnlocked = true; - } - - await route.fulfill({ json: apiEnvelope(paidPaymentOrderStatusResponse) }); - }); - - await page.route("**/api/payment/vip-status**", async (route) => { - await route.fulfill({ json: apiEnvelope(vipStatusResponse) }); + await registerAuthMocks(page, { + chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow, + isChatSendTokenExpired: () => chatState.hasExpiredChatSend, }); + await registerUserMocks(page); + await registerChatMocks(page, chatOptions, chatState); + await registerPaymentMocks(page); } diff --git a/e2e/fixtures/api/auth.ts b/e2e/fixtures/api/auth.ts new file mode 100644 index 00000000..8aa97e41 --- /dev/null +++ b/e2e/fixtures/api/auth.ts @@ -0,0 +1,25 @@ +import type { Page } from "@playwright/test"; + +import { apiEnvelope } from "../data/common"; +import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth"; + +export interface AuthMockState { + chatSendTokenRefreshFlow: boolean; + isChatSendTokenExpired: () => boolean; +} + +export async function registerAuthMocks(page: Page, state: AuthMockState) { + await page.route("**/api/auth/guest", async (route) => { + const response = state.chatSendTokenRefreshFlow && state.isChatSendTokenExpired() ? refreshedGuestLoginResponse : guestLoginResponse; + await route.fulfill({ json: apiEnvelope(response) }); + }); + await page.route("**/api/auth/login", async (route) => { + await route.fulfill({ json: apiEnvelope(emailLoginResponse) }); + }); + await page.route("**/api/auth/refresh", async (route) => { + await route.fulfill({ json: apiEnvelope(refreshedEmailLoginResponse) }); + }); + await page.route("**/api/auth/logout", async (route) => { + await route.fulfill({ json: apiEnvelope(null) }); + }); +} diff --git a/e2e/fixtures/api/chat.ts b/e2e/fixtures/api/chat.ts new file mode 100644 index 00000000..33c1a5a8 --- /dev/null +++ b/e2e/fixtures/api/chat.ts @@ -0,0 +1,71 @@ +import type { Page } from "@playwright/test"; + +import { apiEnvelope } from "../data/common"; +import { chatSendResponse, createChatSendResponse, createPaidImageHistoryResponse, createWeeklyLimitChatSendResponse, emptyChatHistoryResponse, insufficientCreditsUnlockImageResponse, insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, paidVoiceHistoryResponse } from "../data/chat"; + +export interface ChatMockOptions { + chatLimitTriggerAt?: number; + chatSendTokenRefreshFlow: boolean; + paidImageFlow: boolean; + paidImageInsufficientCreditsFlow: boolean; + paidVoiceInsufficientCreditsFlow: boolean; +} + +export interface ChatMockState { + hasExpiredChatSend: boolean; + paidImageRequested: boolean; + paidImageUnlocked: boolean; +} + +export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) { + let sendCount = 0; + + await page.route("**/api/chat/history**", async (route) => { + const response = options.paidVoiceInsufficientCreditsFlow + ? paidVoiceHistoryResponse + : options.paidImageInsufficientCreditsFlow + ? createPaidImageHistoryResponse(false) + : options.paidImageFlow && state.paidImageRequested + ? createPaidImageHistoryResponse(state.paidImageUnlocked) + : emptyChatHistoryResponse; + await route.fulfill({ json: apiEnvelope(response) }); + }); + + await page.route("**/api/chat/send", async (route) => { + sendCount += 1; + const requestBody = route.request().postDataJSON() as { message?: unknown } | undefined; + const message = typeof requestBody?.message === "string" ? requestBody.message : ""; + if (options.chatSendTokenRefreshFlow && !state.hasExpiredChatSend) { + state.hasExpiredChatSend = true; + await route.fulfill({ status: 401, json: { message: "expired" } }); + return; + } + const response = options.paidImageFlow && message.includes("给我发图片") + ? paidImageChatSendResponse + : options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt + ? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt) + : options.chatLimitTriggerAt != null + ? createChatSendResponse(sendCount) + : chatSendResponse; + if (options.paidImageFlow && message.includes("给我发图片")) { + state.paidImageRequested = true; + state.paidImageUnlocked = false; + } + await route.fulfill({ json: apiEnvelope(response) }); + }); + + await page.route("**/api/chat/unlock-history", async (route) => { + if (options.paidImageFlow && state.paidImageRequested) state.paidImageUnlocked = true; + const response = options.paidImageFlow && state.paidImageRequested ? paidImageUnlockHistoryResponse : { unlocked: true, reason: "no_locked_messages", totalLocked: 0, unlockedCount: 0, privateCount: 0, imageCount: 0, voiceCount: 0, requiredCredits: 0, currentCredits: 0, remainingCredits: 0, shortfallCredits: 0, costsByMessage: {}, messageIds: [] }; + await route.fulfill({ json: apiEnvelope(response) }); + }); + + await page.route("**/api/chat/unlock-private", async (route) => { + const response = options.paidVoiceInsufficientCreditsFlow + ? insufficientCreditsUnlockVoiceResponse + : options.paidImageInsufficientCreditsFlow || options.paidImageFlow && state.paidImageRequested && !state.paidImageUnlocked + ? insufficientCreditsUnlockImageResponse + : { unlocked: true, content: "Unlocked message", reason: "ok", creditBalance: 100, creditsCharged: 0, requiredCredits: 0, shortfallCredits: 0, lockDetail: { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } }; + await route.fulfill({ json: apiEnvelope(response) }); + }); +} diff --git a/e2e/fixtures/api/payment.ts b/e2e/fixtures/api/payment.ts new file mode 100644 index 00000000..70325f75 --- /dev/null +++ b/e2e/fixtures/api/payment.ts @@ -0,0 +1,12 @@ +import type { Page } from "@playwright/test"; + +import { apiEnvelope } from "../data/common"; +import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment"; + +export async function registerPaymentMocks(page: Page) { + await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) })); + await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) })); + await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) })); + await page.route("**/api/payment/order-status**", async (route) => route.fulfill({ json: apiEnvelope(paidPaymentOrderStatusResponse) })); + await page.route("**/api/payment/vip-status", async (route) => route.fulfill({ json: apiEnvelope(vipStatusResponse) })); +} diff --git a/e2e/fixtures/api/user.ts b/e2e/fixtures/api/user.ts new file mode 100644 index 00000000..ba4167ff --- /dev/null +++ b/e2e/fixtures/api/user.ts @@ -0,0 +1,14 @@ +import type { Page } from "@playwright/test"; + +import { apiEnvelope } from "../data/common"; +import { e2eEmailUser } from "../data/auth"; +import { userEntitlementsResponse } from "../data/user"; + +export async function registerUserMocks(page: Page) { + await page.route("**/api/user/profile", async (route) => { + await route.fulfill({ json: apiEnvelope(e2eEmailUser) }); + }); + await page.route("**/api/user/entitlements", async (route) => { + await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) }); + }); +} diff --git a/e2e/fixtures/data/auth.ts b/e2e/fixtures/data/auth.ts new file mode 100644 index 00000000..57489b21 --- /dev/null +++ b/e2e/fixtures/data/auth.ts @@ -0,0 +1,51 @@ +export const e2eUser = { + id: "user_e2e", + username: "E2E User", + email: "e2e@example.com", + platform: "web", + intimacy: 42, + dolBalance: 0, + relationshipStage: "close_friend", + currentMood: "happy", + preferredLanguage: "en", + createdAt: "2026-06-25T00:00:00.000Z", + lastMessageAt: "", + avatarUrl: "", + loginProvider: "guest", + isGuest: true, + isVip: false, + voiceMinutesRemaining: 0, +}; + +export const e2eEmailUser = { + ...e2eUser, + id: "user_e2e_email", + username: "E2E Email User", + email: "chanwillian0@gmail.com", + isGuest: false, +}; + +export const guestLoginResponse = { + token: "e2e-guest-token", + deviceId: "e2e-device-id", + userId: e2eUser.id, + isDeviceUser: true, + expiresIn: 2_592_000, + user: e2eUser, +}; + +export const refreshedGuestLoginResponse = { + ...guestLoginResponse, + token: "e2e-refreshed-guest-token", +}; + +export const emailLoginResponse = { + token: "e2e-email-token", + refreshToken: "e2e-email-refresh-token", + user: e2eEmailUser, +}; + +export const refreshedEmailLoginResponse = { + token: "e2e-refreshed-email-token", + refreshToken: "e2e-refreshed-email-refresh-token", +}; diff --git a/e2e/fixtures/data/chat.ts b/e2e/fixtures/data/chat.ts new file mode 100644 index 00000000..c007342a --- /dev/null +++ b/e2e/fixtures/data/chat.ts @@ -0,0 +1,106 @@ +export const emptyChatHistoryResponse = { + messages: [], + total: 0, + limit: 50, + offset: 0, + isVip: false, + privateFreeLimit: 0, + privateUsedToday: 0, + privateCanViewFree: false, +}; + +export const chatSendResponse = { + reply: "Hello from the E2E boyfriend.", + audioUrl: "", + messageId: "msg_e2e_reply_001", + isGuest: true, + timestamp: 1_782_356_425_363, + image: { type: null, url: null }, + lockDetail: { + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }, +}; + +export function createChatSendResponse(sendCount: number) { + return { + ...chatSendResponse, + reply: `Hello from the E2E boyfriend. Reply ${sendCount}.`, + messageId: `msg_e2e_reply_${String(sendCount).padStart(3, "0")}`, + timestamp: chatSendResponse.timestamp + sendCount, + }; +} + +export function createWeeklyLimitChatSendResponse( + usedMessageCount: number, + limit: number, +) { + return { + reply: "", + audioUrl: "", + messageId: "", + isGuest: true, + canSendMessage: false, + cannotSendReason: "insufficient_credits", + creditBalance: 0, + creditsCharged: 0, + requiredCredits: limit, + shortfallCredits: Math.max(0, limit - usedMessageCount), + timestamp: chatSendResponse.timestamp + usedMessageCount, + image: { type: null, url: null }, + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "weekly_limit", + hint: "Free message limit reached.", + detail: { type: "weekly_msg_limit", usedThisWeek: usedMessageCount, limit }, + }, + }; +} + +export const paidImageMessageId = "msg_photo_paywall_001"; +export const paidImageUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; +export const paidImageChatSendResponse = { + reply: "I can show you that photo after you activate VIP.", + audioUrl: "", + messageId: paidImageMessageId, + isGuest: true, + timestamp: 1_782_180_725_000, + image: { type: "elio_schedule", url: paidImageUrl }, + lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null }, +}; + +export function createPaidImageHistoryResponse(unlocked: boolean) { + return { + messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: paidImageUrl }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }], + total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false, + }; +} + +export const paidImageUnlockHistoryResponse = { + unlocked: true, reason: "ok", totalLocked: 1, unlockedCount: 1, privateCount: 0, imageCount: 1, voiceCount: 0, + requiredCredits: 0, currentCredits: 0, remainingCredits: 0, shortfallCredits: 0, + costsByMessage: { [paidImageMessageId]: 0 }, messageIds: [paidImageMessageId], +}; + +export const paidVoiceMessageId = "msg_voice_paywall_001"; +export const paidVoiceAudioUrl = "data:audio/mpeg;base64,//uQZAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAACcQCA"; +export const paidVoiceHistoryResponse = { + messages: [{ role: "assistant", type: "voice", content: "", id: paidVoiceMessageId, created_at: "2026-07-01T00:00:00.000Z", audioUrl: paidVoiceAudioUrl, image: { type: null, url: null }, lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "voice_message", hint: "Unlock this voice message with credits.", detail: null } }], + total: 1, limit: 50, offset: 0, isVip: false, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false, +}; + +export const insufficientCreditsUnlockVoiceResponse = { + unlocked: false, content: "", reason: "insufficient_balance", creditBalance: 3, creditsCharged: 0, requiredCredits: 20, shortfallCredits: 17, + lockDetail: paidVoiceHistoryResponse.messages[0].lockDetail, +}; +export const insufficientCreditsUnlockImageResponse = { + unlocked: false, content: "", reason: "insufficient_balance", creditBalance: 3, creditsCharged: 0, requiredCredits: 30, shortfallCredits: 27, + lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Unlock this high-definition image with credits.", detail: null }, +}; diff --git a/e2e/fixtures/data/common.ts b/e2e/fixtures/data/common.ts new file mode 100644 index 00000000..aadba368 --- /dev/null +++ b/e2e/fixtures/data/common.ts @@ -0,0 +1,8 @@ +export function apiEnvelope(data: T) { + return { + code: 200, + message: "success", + success: true, + data, + }; +} diff --git a/e2e/fixtures/data/payment.ts b/e2e/fixtures/data/payment.ts new file mode 100644 index 00000000..e2653357 --- /dev/null +++ b/e2e/fixtures/data/payment.ts @@ -0,0 +1,14 @@ +export const paymentPlansResponse = { plans: [ + { planId: "vip_monthly", planName: "Monthly", orderType: "vip_monthly", amountCents: 999, originalAmountCents: 1999, dailyPriceCents: 33, currency: "USD", vipDays: 30, dolAmount: null, creditBalance: 300 }, + { planId: "vip_quarterly", planName: "Quarterly", orderType: "vip_quarterly", amountCents: 2499, originalAmountCents: 5999, dailyPriceCents: 28, currency: "USD", vipDays: 90, dolAmount: null, creditBalance: 1000 }, + { planId: "dol_voice_30", planName: "Voice Pack", orderType: "dol_voice", amountCents: 499, originalAmountCents: null, dailyPriceCents: null, currency: "USD", vipDays: null, dolAmount: 30, creditBalance: 30 }, +] }; +export const tipPaymentPlansResponse = { plans: [ + { planId: "tip_coffee_usd_4_99", planName: "Small Coffee", orderType: "tip", tipType: "coffee_small", description: "Buy Elio a small coffee", amountCents: 499, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 }, + { planId: "tip_coffee_usd_9_99", planName: "Medium Coffee", orderType: "tip", tipType: "coffee_medium", description: "Buy Elio a medium coffee", amountCents: 999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 }, + { planId: "tip_coffee_usd_19_99", planName: "Large Coffee", orderType: "tip", tipType: "coffee_large", description: "Buy Elio a large coffee", amountCents: 1999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 }, +] }; +export const paymentOrderId = "order_e2e_vip_monthly"; +export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } }; +export const paidPaymentOrderStatusResponse = { orderId: paymentOrderId, status: "paid", orderType: "vip_monthly", planId: "vip_monthly" }; +export const vipStatusResponse = { isVip: false, expiresAt: null }; diff --git a/e2e/fixtures/data/user.ts b/e2e/fixtures/data/user.ts new file mode 100644 index 00000000..f1ed1e19 --- /dev/null +++ b/e2e/fixtures/data/user.ts @@ -0,0 +1,12 @@ +import { e2eEmailUser } from "./auth"; + +export { e2eEmailUser } from "./auth"; + +export const userEntitlementsResponse = { + userId: e2eEmailUser.id, + isGuest: false, + isVip: false, + vipExpiresAt: null, + creditBalance: 0, + dolBalance: 0, +}; diff --git a/e2e/fixtures/helpers/assertions.ts b/e2e/fixtures/helpers/assertions.ts new file mode 100644 index 00000000..e305f99f --- /dev/null +++ b/e2e/fixtures/helpers/assertions.ts @@ -0,0 +1,10 @@ +import { expect, type Page } from "@playwright/test"; + +export async function expectInsufficientCreditsDialog(page: Page) { + const dialog = page.getByRole("dialog", { name: "Not enough credits" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Balance")).toBeVisible(); + await expect(dialog.getByText("Required")).toBeVisible(); + await expect(dialog.getByText("Still needed")).toBeVisible(); + return dialog; +} diff --git a/e2e/fixtures/helpers/auth.ts b/e2e/fixtures/helpers/auth.ts new file mode 100644 index 00000000..07ac2ee3 --- /dev/null +++ b/e2e/fixtures/helpers/auth.ts @@ -0,0 +1,65 @@ +import { expect, type Page } from "@playwright/test"; + +import { e2eEmailUser } from "../data/auth"; + +export const e2eEmailCredentials = { + email: "chanwillian0@gmail.com", + password: "12345678", +}; + +export const splashStartChatButtonName = "Start Chatting"; + +export function getSplashStartChatButton(page: Page) { + return page.getByRole("button", { name: splashStartChatButtonName }); +} + +export async function setEmailSessionStorage(page: Page) { + await page.evaluate((user) => { + localStorage.setItem("cozsweet:login_token", "e2e-email-token"); + localStorage.setItem("cozsweet:refresh_token", "e2e-email-refresh-token"); + localStorage.setItem("cozsweet:login_provider", "email"); + localStorage.setItem("cozsweet:user_id", user.id); + localStorage.setItem("cozsweet:user", JSON.stringify(user)); + }, e2eEmailUser); +} + +export async function seedEmailSession(page: Page) { + await page.goto("/splash"); + await setEmailSessionStorage(page); +} + +export async function switchToEmailSignIn(page: Page) { + const otherOptionsButton = page.getByRole("button", { name: /Other Sign In Options/i }); + const otherOptionsDialog = page.getByRole("dialog", { name: /Other sign-in options/i }); + await expect(otherOptionsButton).toBeVisible({ timeout: 10_000 }); + await expect(otherOptionsButton).toBeEnabled(); + for (let attempt = 0; attempt < 3; attempt += 1) { + await otherOptionsButton.click(); + try { + await expect(otherOptionsDialog).toBeVisible({ timeout: 2_000 }); + break; + } catch (error) { + if (attempt === 2) throw error; + await otherOptionsButton.evaluate((button) => (button as HTMLButtonElement).click()); + await page.waitForTimeout(500); + } + } + await page.getByRole("button", { name: "Email Sign In" }).click(); + await expect(page.getByPlaceholder("Email")).toHaveValue(e2eEmailCredentials.email); + await expect(page.getByPlaceholder("Password")).toHaveValue(e2eEmailCredentials.password); +} + +export async function submitEmailLogin(page: Page, options: { expectedUrl?: RegExp; waitForRequest?: boolean; urlTimeout?: number } = {}) { + const { expectedUrl, waitForRequest = true, urlTimeout = 10_000 } = options; + const loginButton = page.getByRole("button", { name: "Login" }); + if (!waitForRequest) { + await Promise.all([expectedUrl ? page.waitForURL(expectedUrl, { timeout: urlTimeout }) : Promise.resolve(), loginButton.click()]); + return null; + } + const loginRequestPromise = page.waitForRequest("**/api/auth/login"); + await loginButton.click(); + const loginRequest = await loginRequestPromise; + expect(loginRequest.postDataJSON()).toMatchObject({ email: e2eEmailCredentials.email, password: e2eEmailCredentials.password, isTestAccount: true }); + if (expectedUrl) await expect(page).toHaveURL(expectedUrl, { timeout: urlTimeout }); + return loginRequest; +} diff --git a/e2e/fixtures/helpers/chat.ts b/e2e/fixtures/helpers/chat.ts new file mode 100644 index 00000000..a5b3988e --- /dev/null +++ b/e2e/fixtures/helpers/chat.ts @@ -0,0 +1,41 @@ +import { expect, type BrowserContext, type Page } from "@playwright/test"; + +import { seedEmailSession } from "./auth"; + +export async function clearBrowserState(context: BrowserContext, page: Page, baseURL?: string) { + await context.clearCookies(); + const client = await context.newCDPSession(page); + const origins = new Set([new URL(baseURL ?? "http://127.0.0.1:3000").origin, "http://127.0.0.1:3000", "http://localhost:3000"]); + for (const origin of origins) { + await client.send("Storage.clearDataForOrigin", { origin, storageTypes: "all" }).catch(() => {}); + } +} + +export async function enterChatFromSplash(page: Page, options: { expectedUrl?: RegExp; timeout?: number; waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle" } = {}) { + const { expectedUrl = /\/chat$/, timeout = 10_000, waitUntil } = options; + await page.goto("/splash", { waitUntil }); + const startChatButton = page.getByRole("button", { name: "Start Chatting" }); + await expect(startChatButton).toBeVisible({ timeout }); + await expect(startChatButton).toBeEnabled(); + for (let attempt = 0; attempt < 3; attempt += 1) { + await startChatButton.click(); + try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); } + } + await expect(page).toHaveURL(expectedUrl, { timeout }); +} + +export async function dismissChatInterruptions(page: Page) { + for (const buttonName of ["Later", "Cancel", "OK"]) { + const button = page.getByRole("button", { name: buttonName }).first(); + if (await button.isVisible().catch(() => false)) await button.click(); + } +} + +export async function signInWithEmailAndOpenChat(page: Page) { + await seedEmailSession(page); + const historyResponsePromise = page.waitForResponse("**/api/chat/history**"); + await page.goto("/chat"); + await historyResponsePromise; + await dismissChatInterruptions(page); + await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 }); +} diff --git a/e2e/fixtures/helpers/payment.ts b/e2e/fixtures/helpers/payment.ts new file mode 100644 index 00000000..cc813859 --- /dev/null +++ b/e2e/fixtures/helpers/payment.ts @@ -0,0 +1,30 @@ +import { expect, type Page } from "@playwright/test"; + +export async function completeVipPayment(page: Page) { + const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order"); + const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**"); + await page.getByRole("button", { name: /Pay and Activ/i }).click(); + const createOrderRequest = await createOrderRequestPromise; + expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" }); + await orderStatusRequestPromise; + const paymentSuccessDialog = page.getByRole("alertdialog", { name: "Payment successful" }); + await expect(paymentSuccessDialog).toBeVisible(); + await paymentSuccessDialog.getByRole("button", { name: "OK" }).click(); +} + +export async function expectVipChatSubscriptionUrl(page: Page) { + await expect(page).toHaveURL(/\/subscription\?/); + const url = new URL(page.url()); + expect(url.pathname).toBe("/subscription"); + expect(url.searchParams.get("type")).toBe("vip"); + expect(url.searchParams.get("returnTo")).toBe("chat"); +} + +export function expectAuthRedirectToVipChatSubscription(page: Page) { + const redirect = new URL(page.url()).searchParams.get("redirect"); + expect(redirect).not.toBeNull(); + const redirectUrl = new URL(redirect ?? "", "http://e2e.local"); + expect(redirectUrl.pathname).toBe("/subscription"); + expect(redirectUrl.searchParams.get("type")).toBe("vip"); + expect(redirectUrl.searchParams.get("returnTo")).toBe("chat"); +} diff --git a/e2e/fixtures/test-data.ts b/e2e/fixtures/test-data.ts index 183cfc27..f4600bda 100644 --- a/e2e/fixtures/test-data.ts +++ b/e2e/fixtures/test-data.ts @@ -1,400 +1,6 @@ -export function apiEnvelope(data: T) { - return { - code: 200, - message: "success", - success: true, - data, - }; -} - -export const e2eUser = { - id: "user_e2e", - username: "E2E User", - email: "e2e@example.com", - platform: "web", - intimacy: 42, - dolBalance: 0, - relationshipStage: "close_friend", - currentMood: "happy", - preferredLanguage: "en", - createdAt: "2026-06-25T00:00:00.000Z", - lastMessageAt: "", - avatarUrl: "", - loginProvider: "guest", - isGuest: true, - isVip: false, - voiceMinutesRemaining: 0, -}; - -export const e2eEmailUser = { - ...e2eUser, - id: "user_e2e_email", - username: "E2E Email User", - email: "chanwillian0@gmail.com", - isGuest: false, -}; - -export const guestLoginResponse = { - token: "e2e-guest-token", - deviceId: "e2e-device-id", - userId: e2eUser.id, - isDeviceUser: true, - expiresIn: 2_592_000, - user: e2eUser, -}; - -export const refreshedGuestLoginResponse = { - ...guestLoginResponse, - token: "e2e-refreshed-guest-token", -}; - -export const emailLoginResponse = { - token: "e2e-email-token", - refreshToken: "e2e-email-refresh-token", - user: e2eEmailUser, -}; - -export const refreshedEmailLoginResponse = { - token: "e2e-refreshed-email-token", - refreshToken: "e2e-refreshed-email-refresh-token", -}; - -export const userEntitlementsResponse = { - userId: e2eEmailUser.id, - isGuest: false, - isVip: false, - vipExpiresAt: null, - creditBalance: 0, - dolBalance: 0, -}; - -export const emptyChatHistoryResponse = { - messages: [], - total: 0, - limit: 50, - offset: 0, - isVip: false, - privateFreeLimit: 0, - privateUsedToday: 0, - privateCanViewFree: false, -}; - -export const chatSendResponse = { - reply: "Hello from the E2E boyfriend.", - audioUrl: "", - messageId: "msg_e2e_reply_001", - isGuest: true, - timestamp: 1_782_356_425_363, - image: { - type: null, - url: null, - }, - lockDetail: { - locked: false, - showContent: true, - showUpgrade: false, - reason: null, - hint: null, - detail: null, - }, -}; - -export function createChatSendResponse(sendCount: number) { - return { - ...chatSendResponse, - reply: `Hello from the E2E boyfriend. Reply ${sendCount}.`, - messageId: `msg_e2e_reply_${String(sendCount).padStart(3, "0")}`, - timestamp: chatSendResponse.timestamp + sendCount, - }; -} - -export function createWeeklyLimitChatSendResponse( - usedMessageCount: number, - limit: number, -) { - return { - reply: "", - audioUrl: "", - messageId: "", - isGuest: true, - canSendMessage: false, - cannotSendReason: "insufficient_credits", - creditBalance: 0, - creditsCharged: 0, - requiredCredits: limit, - shortfallCredits: Math.max(0, limit - usedMessageCount), - timestamp: chatSendResponse.timestamp + usedMessageCount, - image: { - type: null, - url: null, - }, - lockDetail: { - locked: true, - showContent: false, - showUpgrade: true, - reason: "weekly_limit", - hint: "Free message limit reached.", - detail: { - type: "weekly_msg_limit", - usedThisWeek: usedMessageCount, - limit, - }, - }, - }; -} - -export const paidImageMessageId = "msg_photo_paywall_001"; -export const paidImageUrl = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; - -export const paidImageChatSendResponse = { - reply: "I can show you that photo after you activate VIP.", - audioUrl: "", - messageId: paidImageMessageId, - isGuest: true, - timestamp: 1_782_180_725_000, - image: { - type: "elio_schedule", - url: paidImageUrl, - }, - lockDetail: { - locked: true, - showContent: true, - showUpgrade: true, - reason: "image", - hint: "Activate VIP to unlock the full photo.", - detail: null, - }, -}; - -export function createPaidImageHistoryResponse(unlocked: boolean) { - return { - messages: [ - { - role: "assistant", - type: "image", - content: unlocked - ? "" - : "I can show you that photo after you activate VIP.", - id: paidImageMessageId, - created_at: "2026-06-30T00:00:00.000Z", - audioUrl: null, - image: { - type: "elio_schedule", - url: paidImageUrl, - }, - lockDetail: unlocked - ? { - locked: false, - showContent: true, - showUpgrade: false, - reason: null, - hint: null, - detail: null, - } - : paidImageChatSendResponse.lockDetail, - }, - ], - total: 1, - limit: 50, - offset: 0, - isVip: unlocked, - privateFreeLimit: 0, - privateUsedToday: 0, - privateCanViewFree: false, - }; -} - -export const paidImageUnlockHistoryResponse = { - unlocked: true, - reason: "ok", - totalLocked: 1, - unlockedCount: 1, - privateCount: 0, - imageCount: 1, - voiceCount: 0, - requiredCredits: 0, - currentCredits: 0, - remainingCredits: 0, - shortfallCredits: 0, - costsByMessage: { - [paidImageMessageId]: 0, - }, - messageIds: [paidImageMessageId], -}; - -export const paidVoiceMessageId = "msg_voice_paywall_001"; -export const paidVoiceAudioUrl = - "data:audio/mpeg;base64,//uQZAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAACcQCA"; - -export const paidVoiceHistoryResponse = { - messages: [ - { - role: "assistant", - type: "voice", - content: "", - id: paidVoiceMessageId, - created_at: "2026-07-01T00:00:00.000Z", - audioUrl: paidVoiceAudioUrl, - image: { - type: null, - url: null, - }, - lockDetail: { - locked: true, - showContent: true, - showUpgrade: true, - reason: "voice_message", - hint: "Unlock this voice message with credits.", - detail: null, - }, - }, - ], - total: 1, - limit: 50, - offset: 0, - isVip: false, - privateFreeLimit: 0, - privateUsedToday: 0, - privateCanViewFree: false, -}; - -export const insufficientCreditsUnlockVoiceResponse = { - unlocked: false, - content: "", - reason: "insufficient_balance", - creditBalance: 3, - creditsCharged: 0, - requiredCredits: 20, - shortfallCredits: 17, - lockDetail: { - locked: true, - showContent: true, - showUpgrade: true, - reason: "voice_message", - hint: "Unlock this voice message with credits.", - detail: null, - }, -}; - -export const insufficientCreditsUnlockImageResponse = { - unlocked: false, - content: "", - reason: "insufficient_balance", - creditBalance: 3, - creditsCharged: 0, - requiredCredits: 30, - shortfallCredits: 27, - lockDetail: { - locked: true, - showContent: true, - showUpgrade: true, - reason: "image", - hint: "Unlock this high-definition image with credits.", - detail: null, - }, -}; - -export const paymentPlansResponse = { - plans: [ - { - planId: "vip_monthly", - planName: "Monthly", - orderType: "vip_monthly", - amountCents: 999, - originalAmountCents: 1999, - dailyPriceCents: 33, - currency: "USD", - vipDays: 30, - dolAmount: null, - creditBalance: 300, - }, - { - planId: "vip_quarterly", - planName: "Quarterly", - orderType: "vip_quarterly", - amountCents: 2499, - originalAmountCents: 5999, - dailyPriceCents: 28, - currency: "USD", - vipDays: 90, - dolAmount: null, - creditBalance: 1000, - }, - { - planId: "dol_voice_30", - planName: "Voice Pack", - orderType: "dol_voice", - amountCents: 499, - originalAmountCents: null, - dailyPriceCents: null, - currency: "USD", - vipDays: null, - dolAmount: 30, - creditBalance: 30, - }, - ], -}; - -export const tipPaymentPlansResponse = { - plans: [ - { - planId: "tip_coffee_usd_4_99", - planName: "Small Coffee", - orderType: "tip", - tipType: "coffee_small", - description: "Buy Elio a small coffee", - amountCents: 499, - currency: "USD", - autoRenew: false, - isFirstRechargeOffer: false, - firstRechargeDiscountPercent: 0, - }, - { - planId: "tip_coffee_usd_9_99", - planName: "Medium Coffee", - orderType: "tip", - tipType: "coffee_medium", - description: "Buy Elio a medium coffee", - amountCents: 999, - currency: "USD", - autoRenew: false, - isFirstRechargeOffer: false, - firstRechargeDiscountPercent: 0, - }, - { - planId: "tip_coffee_usd_19_99", - planName: "Large Coffee", - orderType: "tip", - tipType: "coffee_large", - description: "Buy Elio a large coffee", - amountCents: 1999, - currency: "USD", - autoRenew: false, - isFirstRechargeOffer: false, - firstRechargeDiscountPercent: 0, - }, - ], -}; - -export const paymentOrderId = "order_e2e_vip_monthly"; - -export const createPaymentOrderResponse = { - orderId: paymentOrderId, - payParams: { - provider: "stripe", - clientSecret: "pi_e2e_secret_mock", - }, -}; - -export const paidPaymentOrderStatusResponse = { - orderId: paymentOrderId, - status: "paid", - orderType: "vip_monthly", - planId: "vip_monthly", -}; - -export const vipStatusResponse = { - isVip: false, - expiresAt: null, -}; +// Backward-compatible aggregate export. Prefer importing from data/ in new tests. +export * from "./data/auth"; +export * from "./data/chat"; +export * from "./data/common"; +export * from "./data/payment"; +export * from "./data/user"; diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 46b79bc4..cfc80a7a 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -1,224 +1,5 @@ -import { expect, type BrowserContext, type Page } from "@playwright/test"; - -export const e2eEmailCredentials = { - email: "chanwillian0@gmail.com", - password: "12345678", -}; - -export const splashStartChatButtonName = "Start Chatting"; - -export function getSplashStartChatButton(page: Page) { - return page.getByRole("button", { name: splashStartChatButtonName }); -} - -export async function clearBrowserState( - context: BrowserContext, - page: Page, - baseURL?: string, -) { - await context.clearCookies(); - - const client = await context.newCDPSession(page); - const origins = new Set([ - new URL(baseURL ?? "http://127.0.0.1:3000").origin, - "http://127.0.0.1:3000", - "http://localhost:3000", - ]); - - for (const origin of origins) { - await client - .send("Storage.clearDataForOrigin", { - origin, - storageTypes: "all", - }) - .catch(() => {}); - } -} - -export async function enterChatFromSplash( - page: Page, - options: { - expectedUrl?: RegExp; - timeout?: number; - waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle"; - } = {}, -) { - const { - expectedUrl = /\/chat$/, - timeout = 10_000, - waitUntil, - } = options; - - await page.goto("/splash", { waitUntil }); - - const startChatButton = getSplashStartChatButton(page); - await expect(startChatButton).toBeVisible({ timeout }); - await expect(startChatButton).toBeEnabled(); - - for (let attempt = 0; attempt < 3; attempt += 1) { - await startChatButton.click(); - try { - await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); - return; - } catch { - await page.waitForTimeout(500); - } - } - - await expect(page).toHaveURL(expectedUrl, { timeout }); -} - -export async function dismissChatInterruptions(page: Page) { - for (const buttonName of ["Later", "Cancel", "OK"]) { - const button = page.getByRole("button", { name: buttonName }).first(); - if (await button.isVisible().catch(() => false)) { - await button.click(); - } - } -} - -export async function switchToEmailSignIn(page: Page) { - const otherOptionsButton = page.getByRole("button", { - name: /Other Sign In Options/i, - }); - const otherOptionsDialog = page.getByRole("dialog", { - name: /Other sign-in options/i, - }); - - await expect(otherOptionsButton).toBeVisible({ timeout: 10_000 }); - await expect(otherOptionsButton).toBeEnabled(); - - for (let attempt = 0; attempt < 3; attempt += 1) { - await otherOptionsButton.click(); - try { - await expect(otherOptionsDialog).toBeVisible({ timeout: 2_000 }); - break; - } catch (error) { - if (attempt === 2) throw error; - await otherOptionsButton.evaluate((button) => - (button as HTMLButtonElement).click(), - ); - if (await otherOptionsDialog.isVisible().catch(() => false)) { - break; - } - await page.waitForTimeout(500); - } - } - - await page.getByRole("button", { name: "Email Sign In" }).click(); - await expect(page.getByPlaceholder("Email")).toHaveValue( - e2eEmailCredentials.email, - ); - await expect(page.getByPlaceholder("Password")).toHaveValue( - e2eEmailCredentials.password, - ); -} - -export async function submitEmailLogin( - page: Page, - options: { - expectedUrl?: RegExp; - waitForRequest?: boolean; - urlTimeout?: number; - } = {}, -) { - const { - expectedUrl, - waitForRequest = true, - urlTimeout = 10_000, - } = options; - const loginButton = page.getByRole("button", { name: "Login" }); - - if (!waitForRequest) { - await Promise.all([ - expectedUrl - ? page.waitForURL(expectedUrl, { timeout: urlTimeout }) - : Promise.resolve(), - loginButton.click(), - ]); - return null; - } - - const loginRequestPromise = page.waitForRequest("**/api/auth/login"); - await loginButton.click(); - - const loginRequest = await loginRequestPromise; - expect(loginRequest.postDataJSON()).toMatchObject({ - email: e2eEmailCredentials.email, - password: e2eEmailCredentials.password, - isTestAccount: true, - }); - - if (expectedUrl) { - await expect(page).toHaveURL(expectedUrl, { timeout: urlTimeout }); - } - - return loginRequest; -} - -export async function signInWithEmailAndOpenChat(page: Page) { - await page.goto("/auth"); - await switchToEmailSignIn(page); - const historyResponsePromise = page - .waitForResponse("**/api/chat/history**", { timeout: 10_000 }) - .catch(() => null); - await submitEmailLogin(page, { expectedUrl: /\/chat$/ }); - await historyResponsePromise; - await dismissChatInterruptions(page); -} - -export async function completeVipPayment(page: Page) { - const createOrderRequestPromise = page.waitForRequest( - "**/api/payment/create-order", - ); - const orderStatusRequestPromise = page.waitForRequest( - "**/api/payment/order-status**", - ); - - await page.getByRole("button", { name: /Pay and Activ/i }).click(); - - const createOrderRequest = await createOrderRequestPromise; - expect(createOrderRequest.postDataJSON()).toMatchObject({ - planId: "vip_monthly", - payChannel: "stripe", - }); - await orderStatusRequestPromise; - - const paymentSuccessDialog = page.getByRole("alertdialog", { - name: "Payment successful", - }); - await expect(paymentSuccessDialog).toBeVisible(); - await paymentSuccessDialog.getByRole("button", { name: "OK" }).click(); -} - -export async function expectInsufficientCreditsDialog(page: Page) { - const insufficientCreditsDialog = page.getByRole("dialog", { - name: "Not enough credits", - }); - await expect(insufficientCreditsDialog).toBeVisible(); - await expect(insufficientCreditsDialog.getByText("Balance")).toBeVisible(); - await expect(insufficientCreditsDialog.getByText("Required")).toBeVisible(); - await expect( - insufficientCreditsDialog.getByText("Still needed"), - ).toBeVisible(); - - return insufficientCreditsDialog; -} - -export async function expectVipChatSubscriptionUrl(page: Page) { - await expect(page).toHaveURL(/\/subscription\?/); - const url = new URL(page.url()); - expect(url.pathname).toBe("/subscription"); - expect(url.searchParams.get("type")).toBe("vip"); - expect(url.searchParams.get("returnTo")).toBe("chat"); -} - -export function expectAuthRedirectToVipChatSubscription(page: Page) { - const redirect = new URL(page.url()).searchParams.get("redirect"); - expect(redirect).not.toBeNull(); - - const redirectUrl = new URL(redirect ?? "", "http://e2e.local"); - expect(redirectUrl.pathname).toBe("/subscription"); - expect(redirectUrl.searchParams.get("type")).toBe("vip"); - expect(redirectUrl.searchParams.get("returnTo")).toBe("chat"); -} +// Backward-compatible aggregate export. Prefer importing from helpers/ in new tests. +export * from "./helpers/assertions"; +export * from "./helpers/auth"; +export * from "./helpers/chat"; +export * from "./helpers/payment"; diff --git a/e2e/specs/mock/auth/email-login-from-other-options.spec.ts b/e2e/specs/mock/auth/email-login-from-other-options.spec.ts index 6ed4e657..504fd21a 100644 --- a/e2e/specs/mock/auth/email-login-from-other-options.spec.ts +++ b/e2e/specs/mock/auth/email-login-from-other-options.spec.ts @@ -25,9 +25,7 @@ test("user can email login from other sign-in options and return to chat", async await switchToEmailSignIn(page); const loginRequest = await submitEmailLogin(page, { expectedUrl: /\/chat$/ }); - expect(loginRequest?.postDataJSON()).toMatchObject({ - platform: "desktop", - }); + expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); diff --git a/e2e/specs/mock/auth/logout-from-sidebar.spec.ts b/e2e/specs/mock/auth/logout-from-sidebar.spec.ts index ab81f019..e9dabf6c 100644 --- a/e2e/specs/mock/auth/logout-from-sidebar.spec.ts +++ b/e2e/specs/mock/auth/logout-from-sidebar.spec.ts @@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { clearBrowserState, + getSplashStartChatButton, signInWithEmailAndOpenChat, } from "@e2e/fixtures/test-helpers"; @@ -26,8 +27,6 @@ test("user can log out from the sidebar after email login", async ({ const logoutRequest = await logoutRequestPromise; expect(logoutRequest.method()).toBe("POST"); - await expect(page).toHaveURL(/\/chat$/); - await expect( - page.getByRole("button", { name: "Sign up to unlock more features" }), - ).toBeVisible(); + await expect(page).toHaveURL(/\/splash$/); + await expect(getSplashStartChatButton(page)).toBeVisible(); }); diff --git a/e2e/specs/mock/chat/chat-quantity-limit.spec.ts b/e2e/specs/mock/chat/chat-quantity-limit.spec.ts index 4bf5e10f..bf236e44 100644 --- a/e2e/specs/mock/chat/chat-quantity-limit.spec.ts +++ b/e2e/specs/mock/chat/chat-quantity-limit.spec.ts @@ -24,9 +24,7 @@ test.beforeEach(async ({ baseURL, context, page }) => { }); }); -test("guest hits the chat quantity limit and can continue to login and subscription", async ({ - page, -}) => { +test("guest uses the shared chat limit top-up flow", async ({ page }) => { await enterChatFromSplash(page); await dismissChatInterruptions(page); @@ -81,9 +79,10 @@ test("guest hits the chat quantity limit and can continue to login and subscript expect(limitTriggeredAt).toBe(mockedLimitTriggerAt); await expect(quantityLimitBanner).toBeVisible(); - await expect(messageInput).toHaveCount(0); - - const topUpButton = page.getByRole("button", { + await expect( + quantityLimitBanner.getByText("Free chats refresh every day."), + ).toBeVisible(); + const topUpButton = quantityLimitBanner.getByRole("button", { name: "Top up credits to continue", }); await expect(topUpButton).toBeVisible(); diff --git a/e2e/specs/mock/paid-image.spec.ts b/e2e/specs/mock/paid-image.spec.ts index 12f7ca7b..5602450c 100644 --- a/e2e/specs/mock/paid-image.spec.ts +++ b/e2e/specs/mock/paid-image.spec.ts @@ -9,10 +9,15 @@ import { enterChatFromSplash, expectInsufficientCreditsDialog, expectVipChatSubscriptionUrl, + setEmailSessionStorage, submitEmailLogin, switchToEmailSignIn, } from "@e2e/fixtures/test-helpers"; +const paidImageOverlayUrl = new RegExp( + `/chat\\?image=${paidImageMessageId}$`, +); + test.beforeEach(async ({ baseURL, context, page }) => { await clearBrowserState(context, page, baseURL); await mockCoreApis(page, { @@ -44,7 +49,7 @@ test("guest can unlock a paid image after email login and subscription payment", await expect(paidImageButton).toBeVisible(); await paidImageButton.click(); - await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`)); + await expect(page).toHaveURL(paidImageOverlayUrl); const lockedImageDialog = page.getByRole("dialog", { name: "Locked fullscreen image", @@ -57,16 +62,19 @@ test("guest can unlock a paid image after email login and subscription payment", await expect(page).toHaveURL(/\/auth\?redirect=/); expect(new URL(page.url()).searchParams.get("redirect")).toBe( - `/chat/image/${paidImageMessageId}`, + `/chat?image=${paidImageMessageId}`, ); await switchToEmailSignIn(page); + await submitEmailLogin(page, { + expectedUrl: paidImageOverlayUrl, + }); + + await setEmailSessionStorage(page); const unlockRequestPromise = page.waitForRequest( "**/api/chat/unlock-private", ); - await submitEmailLogin(page, { - expectedUrl: new RegExp(`/chat/image/${paidImageMessageId}$`), - }); + await page.goto(`/chat?image=${paidImageMessageId}`); const unlockRequest = await unlockRequestPromise; expect(unlockRequest.postDataJSON()).toMatchObject({ @@ -81,5 +89,7 @@ test("guest can unlock a paid image after email login and subscription payment", await expectVipChatSubscriptionUrl(page); await completeVipPayment(page); - await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`)); + await expect(page).toHaveURL(/\/chat(?:\?image=msg_photo_paywall_001)?$/); + expect(new URL(page.url()).pathname).toBe("/chat"); + await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); }); diff --git a/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts b/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts index 82acc264..921906fb 100644 --- a/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts +++ b/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts @@ -10,6 +10,10 @@ import { signInWithEmailAndOpenChat, } from "@e2e/fixtures/test-helpers"; +const paidImageOverlayUrl = new RegExp( + `/chat\\?image=${paidImageMessageId}$`, +); + test.beforeEach(async ({ baseURL, context, page }) => { await clearBrowserState(context, page, baseURL); await mockCoreApis(page, { @@ -29,7 +33,7 @@ test("user sees insufficient credits when unlocking a paid image from fullscreen await expect(paidImageButton).toBeVisible({ timeout: 10_000 }); await paidImageButton.click(); - await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`)); + await expect(page).toHaveURL(paidImageOverlayUrl); const lockedImageDialog = page.getByRole("dialog", { name: "Locked fullscreen image", diff --git a/e2e/specs/mock/unlock-message/voice-unlock-insufficient-credits.spec.ts b/e2e/specs/mock/unlock-message/voice-unlock-insufficient-credits.spec.ts index a9f759b9..64f29a20 100644 --- a/e2e/specs/mock/unlock-message/voice-unlock-insufficient-credits.spec.ts +++ b/e2e/specs/mock/unlock-message/voice-unlock-insufficient-credits.spec.ts @@ -21,7 +21,7 @@ test("user sees insufficient credits when unlocking a paid voice message and can }) => { await signInWithEmailAndOpenChat(page); await expect( - page.getByText("Unlock this voice message with credits."), + page.getByText("Elio has a locked voice message for you."), ).toBeVisible({ timeout: 10_000 }); const unlockButton = page.getByRole("button", {