Merge pull request #270 from vernu/dev

Dev
This commit is contained in:
vernu
2026-08-03 13:20:28 +03:00
committed by GitHub
17 changed files with 635 additions and 209 deletions
+109
View File
@@ -0,0 +1,109 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: en-US
tone_instructions: >-
Be direct and concise. Never use em dashes or en dashes in any comment,
summary, or suggested code. Use periods, commas, colons, or parentheses.
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: true
review_status: true
poem: false
auto_review:
enabled: true
drafts: false
# main is the default branch and is always reviewed implicitly.
# dev has to be listed here or feature PRs get no review at all.
base_branches:
- dev
# Label the dev to main roll-up PRs to skip a duplicate review of code
# that was already reviewed on the way into dev.
labels:
- '!skip-ai-review'
ignore_title_keywords:
- WIP
- DO NOT MERGE
path_filters:
- '!**/node_modules/**'
- '!**/pnpm-lock.yaml'
- '!**/.DS_Store'
- '!api/dist/**'
- '!api/coverage/**'
- '!web/.next/**'
- '!web/out/**'
- '!web/test-results/**'
- '!web/playwright-report/**'
- '!web/next-env.d.ts'
- '!android/build/**'
- '!android/app/build/**'
- '!android/.gradle/**'
- '!android/local.properties'
- '!**/google-services.json'
- '!**/*.apk'
path_instructions:
- path: '**'
instructions: |
Never use em dashes or en dashes anywhere, including code comments,
JSDoc, alt text, metadata, and commit messages. Use a period, comma,
colon, parentheses, or a plain hyphen instead.
This project uses pnpm only. Flag npm or yarn commands in scripts,
docs, CI, and Dockerfiles.
Keep functions small and single-purpose.
- path: 'api/src/**/*.ts'
instructions: |
NestJS and Mongoose service. Focus on authentication, authorization,
tenant scoping, and input validation. Flag any query that can read or
write another user's data without an owner or user filter. Flag secrets,
API keys, or tokens that get logged or returned in responses.
Note that tsconfig has strictNullChecks and noImplicitAny disabled, so
null and undefined handling is not compiler-enforced here.
- path: 'web/**/*.{ts,tsx}'
instructions: |
Next.js 16 App Router with React 19. Flag server-only data or secrets
leaking into client components, missing loading and error states, and
useEffect dependency mistakes. eslint downgrades several react-hooks
rules to warn, so call those out in review instead.
- path: 'web/components/ui/**'
instructions: |
Vendored shadcn/ui primitives. Only comment on deliberate local
modifications, not on generator-produced style.
- path: 'android/**/*.{kt,java}'
instructions: |
Android SMS gateway. Focus on permission handling, leaked Context or
Activity references, work done on the main thread, and SMS send or
receive edge cases. There is no ktlint or detekt in CI, so style issues
are worth flagging here.
- path: '.github/workflows/**'
instructions: |
Flag unpinned action versions, secrets echoed into logs or files, and
steps that would run untrusted code from fork pull requests.
tools:
# Waits for ALL GitHub Checks, then comments on failures.
github-checks:
enabled: true
chat:
auto_reply: true
# Public repo, so outside contributors can spend the chat quota.
# Set to false if that becomes a problem.
allow_non_org_members: true
knowledge_base:
learnings:
scope: auto
issues:
scope: auto
pull_requests:
scope: auto
+103
View File
@@ -0,0 +1,103 @@
name: Android
on:
push:
branches:
- main
- dev
paths:
- 'android/**'
- '.github/workflows/android.yaml'
pull_request:
branches:
- main
- dev
paths:
- 'android/**'
- '.github/workflows/android.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Android
runs-on: ubuntu-latest
timeout-minutes: 30
# google-services.json is written from repository secrets, and GitHub
# withholds secrets from fork pull requests, so skip on forks rather than
# fail every outside contribution.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false
matrix:
variant: [dev, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
gradle-version: '7.2'
- name: Grant execute permission for gradlew
run: chmod +x android/gradlew
- name: Create debug keystore
run: |
mkdir -p ~/.android
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
- name: Create google-services.json
env:
GOOGLE_SERVICES_JSON: ${{ matrix.variant == 'dev' && secrets.GOOGLE_SERVICES_JSON_DEV || secrets.GOOGLE_SERVICES_JSON_PROD }}
run: |
mkdir -p android/app/src/${{ matrix.variant }}
printf '%s' "$GOOGLE_SERVICES_JSON" > android/app/src/${{ matrix.variant }}/google-services.json
- name: Build
run: |
cd android
./gradlew assemble${{ matrix.variant }}Debug
# - name: Run Android tests
# run: |
# cd android
# ./gradlew test${{ matrix.variant }}DebugUnitTest
- name: Sanitize ref name for artifact
shell: bash
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
retention-days: 7
+74
View File
@@ -0,0 +1,74 @@
name: API
on:
push:
branches:
- main
- dev
paths:
- 'api/**'
- '.github/workflows/api.yaml'
pull_request:
branches:
- main
- dev
paths:
- 'api/**'
- '.github/workflows/api.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
permissions:
contents: read
# Superseded runs on the same branch or PR are cancelled.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
name: Build and test API
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: api
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
# pnpm has to be on PATH before setup-node, or its pnpm cache resolution
# has no package manager to query.
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 9
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
cache: pnpm
cache-dependency-path: api/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm run build
- name: Unit test
run: pnpm test
-179
View File
@@ -1,179 +0,0 @@
name: Build and Test
on:
push:
paths:
- 'api/**'
- 'web/**'
- 'android/**'
- '.github/workflows/build-and-test.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
android_variant:
description: 'Android build variant'
required: true
default: 'dev'
type: choice
options:
- dev
- prod
jobs:
build-and-test-web-and-api:
name: Build and Test web and api
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Build and test API
run: |
cd api
pnpm install
pnpm run build
pnpm test
# Web previously ran `build` only, so its unit and e2e suites never
# gated a merge. Lint, typecheck and unit tests are fast and
# deterministic, so they block.
- name: Install web dependencies
run: |
cd web
pnpm install
- name: Lint web
run: |
cd web
pnpm lint
- name: Typecheck web
run: |
cd web
pnpm typecheck
- name: Unit test web
run: |
cd web
pnpm test
- name: Build web
run: |
cd web
pnpm run build
# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call),
# but it drives a real browser, so it is reported without blocking until
# it has proven stable across a few merges.
#
# TODO: remove continue-on-error once that is established. It is here to
# avoid wedging the merge queue on a browser flake, not because e2e
# failures are acceptable.
- name: Install Playwright browser
run: |
cd web
pnpm exec playwright install --with-deps chromium
- name: E2e test web (non-blocking for now)
continue-on-error: true
run: |
cd web
pnpm test:e2e
build-and-test-android:
name: Build and Test Android
runs-on: ubuntu-latest
strategy:
matrix:
variant: [dev, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
gradle-version: '7.2'
- name: Grant execute permission for gradlew
run: chmod +x android/gradlew
- name: Create debug keystore
run: |
mkdir -p ~/.android
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
- name: Create google-services.json for dev
if: matrix.variant == 'dev'
run: |
mkdir -p android/app/src/dev
echo '${{ secrets.GOOGLE_SERVICES_JSON_DEV }}' > android/app/src/dev/google-services.json
- name: Create google-services.json for prod
if: matrix.variant == 'prod'
run: |
mkdir -p android/app/src/prod
echo '${{ secrets.GOOGLE_SERVICES_JSON_PROD }}' > android/app/src/prod/google-services.json
- name: Build Android app
run: |
cd android
./gradlew assemble${{ matrix.variant }}Debug
# - name: Run Android tests
# run: |
# cd android
# ./gradlew test${{ matrix.variant }}DebugUnitTest
- name: Sanitize ref name for artifact
shell: bash
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
retention-days: 7
+136
View File
@@ -0,0 +1,136 @@
name: Web
on:
push:
branches:
- main
- dev
paths:
- 'web/**'
- '.github/workflows/web.yaml'
pull_request:
branches:
- main
- dev
paths:
- 'web/**'
- '.github/workflows/web.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
checks:
name: Lint, typecheck, unit test, build
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
# pnpm has to be on PATH before setup-node, or its pnpm cache resolution
# has no package manager to query.
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 9
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Unit test
run: pnpm test
- name: Build
run: pnpm run build
# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call), but it
# drives a real browser and playwright's webServer does its own production
# build, so it ran about three times longer than everything else combined.
# It sits in its own job so it never delays the blocking signal above.
#
# TODO: remove continue-on-error once e2e has proven stable across a few
# merges. It is here to avoid wedging on a browser flake, not because e2e
# failures are acceptable.
e2e:
name: e2e
runs-on: ubuntu-latest
timeout-minutes: 30
continue-on-error: true
defaults:
run:
working-directory: web
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 9
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium
# No build step: playwright.config.ts runs `pnpm build && pnpm start` as
# its webServer, and the checks job above already gates the build.
- name: Run e2e
run: pnpm test:e2e
- name: Upload playwright report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-${{ github.run_id }}
path: web/playwright-report/
retention-days: 7
if-no-files-found: ignore
+17 -1
View File
@@ -12,7 +12,13 @@ import {
Request,
UseGuards,
} from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger'
import {
LoginInputDTO,
RegisterInputDTO,
@@ -58,6 +64,7 @@ export class AuthController {
@ApiOperation({ summary: 'Get current logged in user' })
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(AuthGuard)
@Get('/who-am-i')
async whoAmI(@Request() req) {
@@ -67,6 +74,7 @@ export class AuthController {
@ApiOperation({ summary: 'Update Profile' })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(AuthGuard)
@Patch('/update-profile')
async updateProfile(
@@ -79,6 +87,7 @@ export class AuthController {
@ApiOperation({ summary: 'Change Password' })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(AuthGuard)
@Post('/change-password')
async changePassword(
@@ -91,6 +100,7 @@ export class AuthController {
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Generate Api Key' })
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@Post('/api-keys')
async generateApiKey(@Request() req) {
const { apiKey, message } = await this.authService.generateApiKey(req.user)
@@ -107,6 +117,7 @@ export class AuthController {
'Filter keys: active (default), revoked only, or all (legacy full list)',
})
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@Get('/api-keys')
async getApiKey(
@Request() req,
@@ -119,6 +130,7 @@ export class AuthController {
@UseGuards(AuthGuard, CanModifyApiKey)
@ApiOperation({ summary: 'Delete Api Key' })
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@HttpCode(HttpStatus.OK)
@Delete('/api-keys/:id')
async deleteApiKey(@Param() params) {
@@ -129,6 +141,7 @@ export class AuthController {
@UseGuards(AuthGuard, CanModifyApiKey)
@ApiOperation({ summary: 'Revoke Api Key' })
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@HttpCode(HttpStatus.OK)
@Post('/api-keys/:id/revoke')
async revokeApiKey(@Param() params) {
@@ -139,6 +152,7 @@ export class AuthController {
@UseGuards(AuthGuard, CanModifyApiKey)
@ApiOperation({ summary: 'Rename Api Key' })
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@HttpCode(HttpStatus.OK)
@Patch('/api-keys/:id/rename')
async renameApiKey(@Param() params, @Body() input: { name: string }) {
@@ -148,6 +162,7 @@ export class AuthController {
@ApiOperation({ summary: 'Update dashboard onboarding progress' })
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(AuthGuard)
@Patch('/onboarding')
async updateOnboarding(
@@ -176,6 +191,7 @@ export class AuthController {
@ApiOperation({ summary: 'Send Email Verification Code' })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(AuthGuard)
@Post('/send-email-verification-email')
async sendEmailVerificationEmail(@Request() req) {
+9 -2
View File
@@ -1,7 +1,7 @@
import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common'
import { BillingService } from './billing.service'
import { AuthGuard } from 'src/auth/guards/auth.guard'
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger'
import {
ChangePlanInputDTO,
ChangePlanResponseDTO,
@@ -12,7 +12,6 @@ import {
import { BillingNotificationsService } from './billing-notifications.service'
@ApiTags('billing')
@ApiBearerAuth()
@Controller('billing')
export class BillingController {
constructor(
@@ -27,18 +26,24 @@ export class BillingController {
@Get('current-subscription')
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiSecurity('x-api-key')
async getCurrentSubscription(@Request() req: any) {
return this.billingService.getCurrentSubscription(req.user)
}
@Get('notifications')
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiSecurity('x-api-key')
async listNotifications(@Request() req: any) {
return this.billingNotifications.listForUser(req.user._id)
}
@Post('checkout')
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiSecurity('x-api-key')
async getCheckoutUrl(
@Body() payload: CheckoutInputDTO,
@Request() req: any,
@@ -52,6 +57,8 @@ export class BillingController {
@Post('change-plan')
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiSecurity('x-api-key')
async changePlan(
@Body() payload: ChangePlanInputDTO,
@Request() req: any,
+2
View File
@@ -16,6 +16,7 @@ import {
ApiOperation,
ApiQuery,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger'
import { AuthGuard } from '../auth/guards/auth.guard'
@@ -34,6 +35,7 @@ import { CanModifyDevice } from './guards/can-modify-device.guard'
@ApiTags('gateway')
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@Controller('gateway')
export class GatewayController {
constructor(private readonly gatewayService: GatewayService) {}
+8 -5
View File
@@ -41,11 +41,14 @@ async function bootstrap() {
.setDescription('TextBee - Android SMS Gateway API Docs')
.setVersion('1.0')
.addBearerAuth()
.addApiKey({
type: 'apiKey',
name: 'x-api-key',
in: 'header',
})
.addApiKey(
{
type: 'apiKey',
name: 'x-api-key',
in: 'header',
},
'x-api-key',
)
.build()
const document = SwaggerModule.createDocument(app, config)
SwaggerModule.setup('', app, document, {
+4
View File
@@ -1,4 +1,5 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'
import { ApiBearerAuth, ApiSecurity } from '@nestjs/swagger'
import {
CreateSupportMessageDto,
SupportCategory,
@@ -16,6 +17,8 @@ export class SupportController {
private readonly turnstileService: TurnstileService,
) {}
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(AuthGuard)
@Post('customer-support')
async createSupportMessage(
@@ -37,6 +40,7 @@ export class SupportController {
return this.supportService.createSupportMessage(createSupportMessageDto)
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('request-account-deletion')
async requestAccountDeletion(
+2 -1
View File
@@ -11,12 +11,13 @@ import {
Query,
} from '@nestjs/common'
import { WebhookService } from './webhook.service'
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'
import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto'
import { AuthGuard } from 'src/auth/guards/auth.guard'
@ApiTags('webhooks')
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@Controller('webhooks')
export class WebhookController {
constructor(private readonly webhookService: WebhookService) {}
+32 -11
View File
@@ -2,6 +2,20 @@ import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
import { mockDevices } from '../test/fixtures'
import { buildEndpoints } from '../app/(app)/dashboard/messaging/(components)/api-guide/snippets'
// The page streams behind a loading.tsx boundary, so its HTML can paint before
// React hydrates, and a tab or copy click landing in that gap is silently
// dropped. Prism tokens are not the signal to wait on: react-syntax-highlighter
// renders its spans server-side too, so they are in the SSR payload already.
// The snippets interpolate a device id from the client-side devices fetch, so
// the real id appearing is what proves hydration effects have run.
async function gotoGuide(page: import('@playwright/test').Page) {
await page.goto('/dashboard/messaging/api-guide')
await expect(
page.getByText(mockDevices[0]._id, { exact: false }).first()
).toBeVisible()
}
test.describe('api guide (mocked API, no real backend)', () => {
test('shows content immediately, not a collapsed accordion', async ({
@@ -42,7 +56,7 @@ test.describe('api guide (mocked API, no real backend)', () => {
test('switching language swaps every sample', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/api-guide')
await gotoGuide(page)
// cURL is the default.
await expect(page.getByText('curl -X POST').first()).toBeVisible()
@@ -86,22 +100,29 @@ test.describe('api guide (mocked API, no real backend)', () => {
await authenticate(context)
await mockApi(page)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('/dashboard/messaging/api-guide')
await gotoGuide(page)
// navigator.clipboard.writeText rejects when the document is not focused,
// which is the state a backgrounded page sits in while workers run in
// parallel. Granting permissions alone does not cover it.
await page.bringToFront()
await page.getByRole('button', { name: 'Copy code' }).first().click()
// The page's first Copy code button belongs to the Base URL chip, so the
// click has to be scoped to the send-sms sample it is asserting on.
await page
.locator('#send-sms')
.getByRole('button', { name: 'Copy code' })
.click()
// The button relabels itself only after the async clipboard write resolves,
// so this is the signal that there is something to read back.
await expect(page.getByRole('button', { name: 'Copied' }).first()).toBeVisible()
const clipboard = await page.evaluate(() =>
navigator.clipboard.readText()
)
expect(clipboard).toContain('api.textbee.dev')
// Assert on the clipboard, not on the button's "Copied" label. That label
// clears itself two seconds after the click (code-block.tsx setTimeout), so
// waiting for it races a window narrow enough to miss on a loaded runner.
// The clipboard content is the behaviour under test and it does not expire.
const expectedCurl = buildEndpoints(mockDevices[0]._id).find(
(endpoint) => endpoint.id === 'send-sms'
)!.samples.curl
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(expectedCurl)
})
test('does not scroll sideways at 375px', async ({ page, context }) => {
+13 -2
View File
@@ -2,6 +2,17 @@ import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
// The dashboard sections stream behind a loading.tsx boundary, so their HTML
// can paint before React hydrates. A click landing in that gap hits a button
// whose handler is not attached yet, the dialog never opens, and the test
// waits out its timeout on a heading that was never going to render. The
// total-sent figure comes from the client-side gateway stats fetch, so its
// presence proves hydration effects have run.
async function gotoDashboard(page: import('@playwright/test').Page) {
await page.goto('/dashboard')
await expect(page.getByText('12,840')).toBeVisible()
}
test.describe('dashboard (mocked API, no real backend)', () => {
test('redirects unauthenticated users to login', async ({ page }) => {
await mockApi(page)
@@ -60,7 +71,7 @@ test.describe('dashboard (mocked API, no real backend)', () => {
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
await gotoDashboard(page)
await page
.getByRole('button', { name: 'Add device' })
@@ -91,7 +102,7 @@ test.describe('dashboard (mocked API, no real backend)', () => {
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
await gotoDashboard(page)
// That button asks for a key, so device instructions would be noise.
await page.getByRole('button', { name: 'New API key' }).click()
+48 -2
View File
@@ -1,8 +1,16 @@
import { describe, expect, it } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { TestProviders } from '@/test/render'
import { mockDevices, mockSubscription, mockUser } from '@/test/fixtures'
import { useCurrentUser, useDevices, useSubscription } from './hooks'
import { API_BASE_URL, mockDevices, mockSubscription, mockUser } from '@/test/fixtures'
import { server } from '@/test/msw/server'
import { ApiEndpoints } from '@/config/api'
import {
useCurrentUser,
useDevices,
useSubscription,
useWebhookNotifications,
} from './hooks'
// Verifies the typed hooks talk to the mocked API and unwrap the various
// response envelopes correctly. No real backend is contacted (MSW).
@@ -27,4 +35,42 @@ describe('data hooks', () => {
expect(result.current.data).toHaveLength(mockDevices.length)
expect(result.current.data?.[0]._id).toBe(mockDevices[0]._id)
})
// Regression for #256: start/end were query params but not query-key
// members, so changing the date filter reused a cached response.
it('useWebhookNotifications refetches when the date range changes', async () => {
const startsSeen: string[] = []
server.use(
http.get(
`${API_BASE_URL}${ApiEndpoints.gateway.getWebhookNotifications().split('?')[0]}`,
({ request }) => {
const start = new URL(request.url).searchParams.get('start') ?? ''
startsSeen.push(start)
return HttpResponse.json({
data: { data: [], meta: { totalPages: 1, total: 0 } },
})
}
)
)
const { result, rerender } = renderHook(
({ start }) => useWebhookNotifications({ start, page: 1, limit: 10 }),
{
wrapper,
initialProps: { start: '2026-08-01T00:00:00.000Z' },
}
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(startsSeen).toEqual(['2026-08-01T00:00:00.000Z'])
rerender({ start: '2026-07-01T00:00:00.000Z' })
await waitFor(() =>
expect(startsSeen).toEqual([
'2026-08-01T00:00:00.000Z',
'2026-07-01T00:00:00.000Z',
])
)
})
})
+7 -6
View File
@@ -343,15 +343,16 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) {
limit = 10,
} = filters
return useQuery({
queryKey: [
'webhook-notification',
queryKey: queryKeys.webhookNotifications({
eventType,
page,
limit,
status,
deviceId,
webhookSubscriptionId,
status,
],
start,
end,
page,
limit,
}),
queryFn: () =>
httpBrowserClient
.get(
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { queryKeys } from './query-keys'
describe('queryKeys.webhookNotifications', () => {
it('includes start and end so date filter changes produce a new cache entry', () => {
const base = {
eventType: '',
status: '',
deviceId: '',
webhookSubscriptionId: '',
page: 1,
limit: 10,
}
const withoutDates = queryKeys.webhookNotifications(base)
const withStart = queryKeys.webhookNotifications({
...base,
start: '2026-08-01T00:00:00.000Z',
})
const withRange = queryKeys.webhookNotifications({
...base,
start: '2026-08-01T00:00:00.000Z',
end: '2026-08-02T00:00:00.000Z',
})
const differentStart = queryKeys.webhookNotifications({
...base,
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-02T00:00:00.000Z',
})
expect(withoutDates).not.toEqual(withStart)
expect(withStart).not.toEqual(withRange)
expect(withRange).not.toEqual(differentStart)
// Guard against dropping date params from the key shape again.
expect(withRange).toEqual([
'webhook-notification',
'',
1,
10,
'',
'',
'',
'2026-08-01T00:00:00.000Z',
'2026-08-02T00:00:00.000Z',
])
})
})
+23
View File
@@ -22,4 +22,27 @@ export const queryKeys = {
filters
? (['messages', deviceId, filters] as const)
: (['messages', deviceId] as const),
// start/end must be part of the key: they are sent on the request URL, and
// react-query only refetches when the key changes (see issue #256).
webhookNotifications: (filters: {
eventType?: string
status?: string
deviceId?: string
webhookSubscriptionId?: string
start?: string
end?: string
page?: number
limit?: number
}) =>
[
'webhook-notification',
filters.eventType ?? '',
filters.page ?? 1,
filters.limit ?? 10,
filters.deviceId ?? '',
filters.webhookSubscriptionId ?? '',
filters.status ?? '',
filters.start ?? '',
filters.end ?? '',
] as const,
}