mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
120 Commits
feature/bu
...
fix-ios-is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8adc6168 | ||
|
|
ec208960e8 | ||
|
|
b9505158b4 | ||
|
|
ad0c3421f0 | ||
|
|
916c00344b | ||
|
|
459cdee17e | ||
|
|
bb26a64dbb | ||
|
|
29a3fa532a | ||
|
|
738b8f9012 | ||
|
|
c95272288e | ||
|
|
919febd166 | ||
|
|
10ccc20b53 | ||
|
|
d9ca64da54 | ||
|
|
ce00ec97d1 | ||
|
|
2b9cd37c6c | ||
|
|
f8f14eb6f3 | ||
|
|
645fc863aa | ||
|
|
c53f030b24 | ||
|
|
45d74f9ba0 | ||
|
|
87870919ca | ||
|
|
ce2fdde474 | ||
|
|
6e2f30c6ed | ||
|
|
5c8040008a | ||
|
|
639e25d679 | ||
|
|
f7e5ef96d2 | ||
|
|
745f5487e9 | ||
|
|
0e7f3adf53 | ||
|
|
342d2b1fc4 | ||
|
|
15279685f7 | ||
|
|
12aa959f50 | ||
|
|
9478946c7a | ||
|
|
8560bbf28b | ||
|
|
df7afe1b64 | ||
|
|
df52b60d61 | ||
|
|
65b051f0eb | ||
|
|
7678084061 | ||
|
|
022d33d06f | ||
|
|
4d157bf8dc | ||
|
|
9fcbe4e8c5 | ||
|
|
5aeb92eb4f | ||
|
|
00dfa629b5 | ||
|
|
3ca471b6a2 | ||
|
|
a525589186 | ||
|
|
59ed10398d | ||
|
|
25a86e31df | ||
|
|
7d6743a81a | ||
|
|
6616f62da5 | ||
|
|
a3cbc05e12 | ||
|
|
97095a627a | ||
|
|
910d257c56 | ||
|
|
0c0a008b28 | ||
|
|
9879458353 | ||
|
|
d44f1f3b4b | ||
|
|
c5d387a7e5 | ||
|
|
a6aacd5c55 | ||
|
|
57e7485564 | ||
|
|
42a38a6f47 | ||
|
|
34bb9c2127 | ||
|
|
6442b5e4aa | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 | ||
|
|
35b2356a31 | ||
|
|
53ef756723 | ||
|
|
0f0b743a10 | ||
|
|
3f7dafb65c | ||
|
|
9df791b5ff | ||
|
|
dea40d9757 | ||
|
|
dd12a589d6 | ||
|
|
af6e5ba31e | ||
|
|
2b57b2080b | ||
|
|
154c85a0f7 | ||
|
|
3f465d4594 | ||
|
|
94e883f4c3 | ||
|
|
38622101f1 | ||
|
|
0eb64c0084 | ||
|
|
409f5b1791 | ||
|
|
14398a9c4f | ||
|
|
d1cdf6e216 | ||
|
|
65da25a626 | ||
|
|
ce8b019e93 | ||
|
|
67d7fe016d | ||
|
|
47583b5a32 | ||
|
|
03c9a6aaae | ||
|
|
4dcf9b093b | ||
|
|
5ba5ebf63d | ||
|
|
115bea2792 | ||
|
|
b0495a8a42 | ||
|
|
faabd371f5 | ||
|
|
f0be6de0b3 | ||
|
|
b338c6d28d | ||
|
|
07e9a7c007 | ||
|
|
928bb3f8bc | ||
|
|
b9d62f6af2 | ||
|
|
f7ac38953b | ||
|
|
6441c0aa31 | ||
|
|
16479eb6cf | ||
|
|
69472c21c2 | ||
|
|
c270688e8f | ||
|
|
00c86c7082 | ||
|
|
e95e9f9fda | ||
|
|
1588c2f47b | ||
|
|
53850c96db | ||
|
|
ae2cb15055 | ||
|
|
8bf1e096c0 | ||
|
|
0052dc88f0 | ||
|
|
d67d62df45 | ||
|
|
5d45de6bc4 | ||
|
|
cf5bc51e94 | ||
|
|
9a7d24ea4e | ||
|
|
649f28ff8d | ||
|
|
bc5a81d146 | ||
|
|
7dce35bde4 | ||
|
|
f30ebc32ec | ||
|
|
027bc20975 |
61
.cursor/rules/build-and-deployment.mdc
Normal file
61
.cursor/rules/build-and-deployment.mdc
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Build & Deployment Best Practices
|
||||
|
||||
## Build Process
|
||||
|
||||
### Running Builds
|
||||
- Use `pnpm build` from project root for full build
|
||||
- Monitor for React hooks warnings and fix them immediately
|
||||
- Ensure all TypeScript errors are resolved before deployment
|
||||
|
||||
### Common Build Issues & Fixes
|
||||
|
||||
#### React Hooks Warnings
|
||||
- Capture ref values in variables within useEffect cleanup
|
||||
- Avoid accessing `.current` directly in cleanup functions
|
||||
- Pattern for fixing ref cleanup warnings:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentRef = myRef.current;
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.cleanup();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
#### Test Failures During Build
|
||||
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
|
||||
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
|
||||
- Remove unused imports and constants from test files
|
||||
- Use literal values instead of imported constants when the constant isn't actually needed
|
||||
|
||||
### Test Execution
|
||||
- Run `pnpm test` to execute all tests
|
||||
- Use `pnpm test -- --run filename.test.tsx` for specific test files
|
||||
- Fix test failures before merging code
|
||||
- Ensure 100% test coverage for new components
|
||||
|
||||
### Performance Monitoring
|
||||
- Monitor build times and optimize if necessary
|
||||
- Watch for memory usage during builds
|
||||
- Use proper caching strategies for faster rebuilds
|
||||
|
||||
### Deployment Checklist
|
||||
1. All tests passing
|
||||
2. Build completes without warnings
|
||||
3. TypeScript compilation successful
|
||||
4. No linter errors
|
||||
5. Database migrations applied (if any)
|
||||
6. Environment variables configured
|
||||
|
||||
### EKS Deployment Considerations
|
||||
- Ensure latest code is deployed to all pods
|
||||
- Monitor AWS RDS Performance Insights for database issues
|
||||
- Verify environment-specific configurations
|
||||
- Check pod health and resource usage
|
||||
41
.cursor/rules/database-performance.mdc
Normal file
41
.cursor/rules/database-performance.mdc
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Database Performance & Prisma Best Practices
|
||||
|
||||
## Critical Performance Rules
|
||||
|
||||
### Response Count Queries
|
||||
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
|
||||
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
|
||||
- For pagination, separate count queries from data queries
|
||||
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
|
||||
|
||||
### Prisma Query Optimization
|
||||
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
||||
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
|
||||
- Use cursor-based pagination for large datasets instead of offset-based
|
||||
- Cache frequently accessed data using React Cache and custom cache tags
|
||||
|
||||
### Date Range Filtering
|
||||
- When filtering by `createdAt`, always use indexed queries
|
||||
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
|
||||
- Avoid complex WHERE clauses that can't utilize indexes
|
||||
|
||||
### Count vs Data Separation
|
||||
- Always separate count queries from data fetching queries
|
||||
- Use `Promise.all()` to run count and data queries in parallel
|
||||
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
|
||||
```typescript
|
||||
const [responses, totalCount] = await Promise.all([
|
||||
prisma.response.findMany(query),
|
||||
prisma.response.count({ where: whereClause }),
|
||||
]);
|
||||
```
|
||||
|
||||
### Monitoring & Debugging
|
||||
- Monitor AWS RDS Performance Insights for problematic queries
|
||||
- Look for queries with OFFSET in count operations - these indicate performance issues
|
||||
- Use proper error handling with `DatabaseError` for Prisma exceptions
|
||||
334
.cursor/rules/formbricks-architecture.mdc
Normal file
334
.cursor/rules/formbricks-architecture.mdc
Normal file
@@ -0,0 +1,334 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Formbricks Architecture & Patterns
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
### Apps Directory
|
||||
- `apps/web/` - Main Next.js web application
|
||||
- `packages/` - Shared packages and utilities
|
||||
|
||||
### Key Directories in Web App
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Next.js 13+ app directory
|
||||
│ ├── (app)/ # Main application routes
|
||||
│ ├── (auth)/ # Authentication routes
|
||||
│ ├── api/ # API routes
|
||||
│ └── share/ # Public sharing routes
|
||||
├── components/ # Shared components
|
||||
├── lib/ # Utility functions and services
|
||||
└── modules/ # Feature-specific modules
|
||||
```
|
||||
|
||||
## Routing Patterns
|
||||
|
||||
### App Router Structure
|
||||
The application uses Next.js 13+ app router with route groups:
|
||||
|
||||
```
|
||||
(app)/environments/[environmentId]/
|
||||
├── surveys/[surveyId]/
|
||||
│ ├── (analysis)/ # Analysis views
|
||||
│ │ ├── responses/ # Response management
|
||||
│ │ ├── summary/ # Survey summary
|
||||
│ │ └── hooks/ # Analysis-specific hooks
|
||||
│ ├── edit/ # Survey editing
|
||||
│ └── settings/ # Survey settings
|
||||
```
|
||||
|
||||
### Dynamic Routes
|
||||
- `[environmentId]` - Environment-specific routes
|
||||
- `[surveyId]` - Survey-specific routes
|
||||
- `[sharingKey]` - Public sharing routes
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
### Service Organization
|
||||
Services are organized by domain in `apps/web/lib/`:
|
||||
|
||||
```typescript
|
||||
// Example: Response service
|
||||
// apps/web/lib/response/service.ts
|
||||
export const getResponseCountAction = async ({
|
||||
surveyId,
|
||||
filterCriteria,
|
||||
}: {
|
||||
surveyId: string;
|
||||
filterCriteria: any;
|
||||
}) => {
|
||||
// Service implementation
|
||||
};
|
||||
```
|
||||
|
||||
### Action Pattern
|
||||
Server actions follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Action wrapper for service calls
|
||||
export const getResponseCountAction = async (params) => {
|
||||
try {
|
||||
const result = await responseService.getCount(params);
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Context Patterns
|
||||
|
||||
### Provider Structure
|
||||
Context providers follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Provider component
|
||||
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
|
||||
|
||||
const value = {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
// ... other state and methods
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponseFilterContext.Provider value={value}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook for consuming context
|
||||
export const useResponseFilter = () => {
|
||||
const context = useContext(ResponseFilterContext);
|
||||
if (!context) {
|
||||
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
```
|
||||
|
||||
### Context Composition
|
||||
Multiple contexts are often composed together:
|
||||
|
||||
```typescript
|
||||
// Layout component with multiple providers
|
||||
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ResponseFilterProvider>
|
||||
<ResponseCountProvider>
|
||||
{children}
|
||||
</ResponseCountProvider>
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Page Components
|
||||
Page components are located in the app directory and follow this pattern:
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
|
||||
export default function ResponsesPage() {
|
||||
return (
|
||||
<div>
|
||||
<ResponsesTable />
|
||||
<ResponsesPagination />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component Organization
|
||||
- **Pages** - Route components in app directory
|
||||
- **Components** - Reusable UI components
|
||||
- **Modules** - Feature-specific components and logic
|
||||
|
||||
### Shared Components
|
||||
Common components are in `apps/web/components/`:
|
||||
- UI components (buttons, inputs, modals)
|
||||
- Layout components (headers, sidebars)
|
||||
- Data display components (tables, charts)
|
||||
|
||||
## Hook Patterns
|
||||
|
||||
### Custom Hook Structure
|
||||
Custom hooks follow consistent patterns:
|
||||
|
||||
```typescript
|
||||
export const useResponseCount = ({
|
||||
survey,
|
||||
initialCount
|
||||
}: {
|
||||
survey: TSurvey;
|
||||
initialCount?: number;
|
||||
}) => {
|
||||
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Hook logic...
|
||||
|
||||
return {
|
||||
responseCount,
|
||||
isLoading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Hook Dependencies
|
||||
- Use context hooks for shared state
|
||||
- Implement proper cleanup with AbortController
|
||||
- Optimize dependency arrays to prevent unnecessary re-renders
|
||||
|
||||
## Data Fetching Patterns
|
||||
|
||||
### Server Actions
|
||||
The app uses Next.js server actions for data fetching:
|
||||
|
||||
```typescript
|
||||
// Server action
|
||||
export async function getResponsesAction(params: GetResponsesParams) {
|
||||
const responses = await getResponses(params);
|
||||
return { data: responses };
|
||||
}
|
||||
|
||||
// Client usage
|
||||
const { data } = await getResponsesAction(params);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
Consistent error handling across the application:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await apiCall();
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
console.error("Operation failed:", error);
|
||||
return { error: error.message };
|
||||
}
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
### Type Organization
|
||||
Types are organized in packages:
|
||||
- `@formbricks/types` - Shared type definitions
|
||||
- Local types in component/hook files
|
||||
|
||||
### Common Types
|
||||
```typescript
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Local State
|
||||
- Use `useState` for component-specific state
|
||||
- Use `useReducer` for complex state logic
|
||||
- Use refs for mutable values that don't trigger re-renders
|
||||
|
||||
### Global State
|
||||
- React Context for feature-specific shared state
|
||||
- URL state for filters and pagination
|
||||
- Server state through server actions
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Code Splitting
|
||||
- Dynamic imports for heavy components
|
||||
- Route-based code splitting with app router
|
||||
- Lazy loading for non-critical features
|
||||
|
||||
### Caching Strategy
|
||||
- Server-side caching for database queries
|
||||
- Client-side caching with React Query (where applicable)
|
||||
- Static generation for public pages
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test Organization
|
||||
```
|
||||
component/
|
||||
├── Component.tsx
|
||||
├── Component.test.tsx
|
||||
└── hooks/
|
||||
├── useHook.ts
|
||||
└── useHook.test.tsx
|
||||
```
|
||||
|
||||
### Test Patterns
|
||||
- Unit tests for utilities and services
|
||||
- Integration tests for components with context
|
||||
- Hook tests with proper mocking
|
||||
|
||||
## Build & Deployment
|
||||
|
||||
### Build Process
|
||||
- TypeScript compilation
|
||||
- Next.js build optimization
|
||||
- Asset optimization and bundling
|
||||
|
||||
### Environment Configuration
|
||||
- Environment-specific configurations
|
||||
- Feature flags for gradual rollouts
|
||||
- Database connection management
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### Authentication
|
||||
- Session-based authentication
|
||||
- Environment-based access control
|
||||
- API route protection
|
||||
|
||||
### Data Validation
|
||||
- Input validation on both client and server
|
||||
- Type-safe API contracts
|
||||
- Sanitization of user inputs
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Error Tracking
|
||||
- Client-side error boundaries
|
||||
- Server-side error logging
|
||||
- Performance monitoring
|
||||
|
||||
### Analytics
|
||||
- User interaction tracking
|
||||
- Performance metrics
|
||||
- Database query monitoring
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Code Organization
|
||||
- ✅ Follow the established directory structure
|
||||
- ✅ Use consistent naming conventions
|
||||
- ✅ Separate concerns (UI, logic, data)
|
||||
- ✅ Keep components focused and small
|
||||
|
||||
### Performance
|
||||
- ✅ Implement proper loading states
|
||||
- ✅ Use AbortController for async operations
|
||||
- ✅ Optimize database queries
|
||||
- ✅ Implement proper caching strategies
|
||||
|
||||
### Type Safety
|
||||
- ✅ Use TypeScript throughout
|
||||
- ✅ Define proper interfaces for props
|
||||
- ✅ Use type guards for runtime validation
|
||||
- ✅ Leverage shared type packages
|
||||
|
||||
### Testing
|
||||
- ✅ Write tests for critical functionality
|
||||
- ✅ Mock external dependencies properly
|
||||
- ✅ Test error scenarios and edge cases
|
||||
- ✅ Maintain good test coverage
|
||||
5
.cursor/rules/performance-optimization.mdc
Normal file
5
.cursor/rules/performance-optimization.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
52
.cursor/rules/react-context-patterns.mdc
Normal file
52
.cursor/rules/react-context-patterns.mdc
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React Context & Provider Patterns
|
||||
|
||||
## Context Provider Best Practices
|
||||
|
||||
### Provider Implementation
|
||||
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
|
||||
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
|
||||
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
|
||||
|
||||
### Cleanup Pattern for Refs
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentPendingRequests = pendingRequests.current;
|
||||
const currentAbortController = abortController.current;
|
||||
|
||||
return () => {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
currentPendingRequests.clear();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Testing Context Providers
|
||||
- Always wrap components using context in the provider during tests
|
||||
- Use `initialCount` prop for predictable test scenarios
|
||||
- Mock context dependencies like `useParams`, `useResponseFilter`
|
||||
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
|
||||
|
||||
```typescript
|
||||
render(
|
||||
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
|
||||
<ComponentUnderTest />
|
||||
</ResponseCountProvider>
|
||||
);
|
||||
```
|
||||
|
||||
### Required Mocks for Context Testing
|
||||
- Mock `next/navigation` with `useParams` returning environment and survey IDs
|
||||
- Mock response filter context and actions
|
||||
- Mock API actions that the provider depends on
|
||||
|
||||
### Context Hook Usage
|
||||
- Create custom hooks like `useResponseCountContext()` for consuming context
|
||||
- Provide meaningful error messages when context is used outside provider
|
||||
- Use context for shared state that multiple components need to access
|
||||
5
.cursor/rules/react-context-providers.mdc
Normal file
5
.cursor/rules/react-context-providers.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
282
.cursor/rules/testing-patterns.mdc
Normal file
282
.cursor/rules/testing-patterns.mdc
Normal file
@@ -0,0 +1,282 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Testing Patterns & Best Practices
|
||||
|
||||
## Test File Naming & Environment
|
||||
|
||||
### File Extensions
|
||||
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
|
||||
- Use `.test.ts` for utility/service tests (runs in Node environment)
|
||||
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
|
||||
|
||||
### Test Structure
|
||||
```typescript
|
||||
// Import the mocked functions first
|
||||
import { useHook } from "@/path/to/hook";
|
||||
import { serviceFunction } from "@/path/to/service";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/path/to/hook", () => ({
|
||||
useHook: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ComponentName", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default mocks
|
||||
});
|
||||
|
||||
test("descriptive test name", async () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## React Hook Testing
|
||||
|
||||
### Context Mocking
|
||||
When testing hooks that use React Context:
|
||||
```typescript
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
},
|
||||
setSelectedFilter: vi.fn(),
|
||||
selectedOptions: {
|
||||
questionOptions: [],
|
||||
questionFilterOptions: [],
|
||||
},
|
||||
setSelectedOptions: vi.fn(),
|
||||
dateRange: { from: new Date(), to: new Date() },
|
||||
setDateRange: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Hooks
|
||||
- Always use `waitFor` for async operations
|
||||
- Test both loading and completed states
|
||||
- Verify API calls with correct parameters
|
||||
|
||||
```typescript
|
||||
test("fetches data on mount", async () => {
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe(expectedData);
|
||||
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Hook Dependencies
|
||||
To test useEffect dependencies, ensure mocks return different values:
|
||||
```typescript
|
||||
// First render
|
||||
mockGetFormattedFilters.mockReturnValue(mockFilters);
|
||||
|
||||
// Change dependency and trigger re-render
|
||||
const newMockFilters = { ...mockFilters, finished: true };
|
||||
mockGetFormattedFilters.mockReturnValue(newMockFilters);
|
||||
rerender();
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Race Condition Testing
|
||||
Test AbortController implementation:
|
||||
```typescript
|
||||
test("cancels previous request when new request is made", async () => {
|
||||
let resolveFirst: (value: any) => void;
|
||||
let resolveSecond: (value: any) => void;
|
||||
|
||||
const firstPromise = new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const secondPromise = new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(apiCall)
|
||||
.mockReturnValueOnce(firstPromise as any)
|
||||
.mockReturnValueOnce(secondPromise as any);
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
// Trigger second request
|
||||
result.current.refetch();
|
||||
|
||||
// Resolve in order - first should be cancelled
|
||||
resolveFirst!({ data: 100 });
|
||||
resolveSecond!({ data: 200 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Should have result from second request
|
||||
expect(result.current.data).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup Testing
|
||||
```typescript
|
||||
test("cleans up on unmount", () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
const { unmount } = renderHook(() => useHook());
|
||||
unmount();
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
abortSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling Testing
|
||||
|
||||
### API Error Testing
|
||||
```typescript
|
||||
test("handles API errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
|
||||
expect(result.current.data).toBe(fallbackValue);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
### Cancelled Request Testing
|
||||
```typescript
|
||||
test("does not update state for cancelled requests", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
let rejectFirst: (error: any) => void;
|
||||
const firstPromise = new Promise((_, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
|
||||
vi.mocked(apiCall)
|
||||
.mockReturnValueOnce(firstPromise as any)
|
||||
.mockResolvedValueOnce({ data: 42 });
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
result.current.refetch();
|
||||
|
||||
const abortError = new Error("Request cancelled");
|
||||
rejectFirst!(abortError);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Should not log error for cancelled request
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
## Type Safety in Tests
|
||||
|
||||
### Mock Type Assertions
|
||||
Use type assertions for edge cases:
|
||||
```typescript
|
||||
vi.mocked(apiCall).mockResolvedValue({
|
||||
data: null as any, // For testing null handling
|
||||
});
|
||||
|
||||
vi.mocked(apiCall).mockResolvedValue({
|
||||
data: undefined as any, // For testing undefined handling
|
||||
});
|
||||
```
|
||||
|
||||
### Proper Mock Typing
|
||||
Ensure mocks match the actual interface:
|
||||
```typescript
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-123",
|
||||
name: "Test Survey",
|
||||
// ... other required properties
|
||||
} as unknown as TSurvey; // Use when partial mocking is needed
|
||||
```
|
||||
|
||||
## Common Test Patterns
|
||||
|
||||
### Testing State Changes
|
||||
```typescript
|
||||
test("updates state correctly", async () => {
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
// Initial state
|
||||
expect(result.current.value).toBe(initialValue);
|
||||
|
||||
// Trigger change
|
||||
result.current.updateValue(newValue);
|
||||
|
||||
// Verify change
|
||||
expect(result.current.value).toBe(newValue);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Multiple Scenarios
|
||||
```typescript
|
||||
test("handles different modes", async () => {
|
||||
// Test regular mode
|
||||
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
|
||||
const { rerender } = renderHook(() => useHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test sharing mode
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
surveyId: "123",
|
||||
sharingKey: "share-123"
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### Comprehensive Test Coverage
|
||||
For hooks, ensure you test:
|
||||
- ✅ Initialization (with/without initial values)
|
||||
- ✅ Data fetching (success/error cases)
|
||||
- ✅ State updates and refetching
|
||||
- ✅ Dependency changes triggering effects
|
||||
- ✅ Manual actions (refetch, reset)
|
||||
- ✅ Race condition prevention
|
||||
- ✅ Cleanup on unmount
|
||||
- ✅ Mode switching (if applicable)
|
||||
- ✅ Edge cases (null/undefined data)
|
||||
|
||||
### Test Naming
|
||||
Use descriptive test names that explain the scenario:
|
||||
- ✅ "initializes with initial count"
|
||||
- ✅ "fetches response count on mount for regular survey"
|
||||
- ✅ "cancels previous request when new request is made"
|
||||
- ❌ "test hook"
|
||||
- ❌ "it works"
|
||||
6
.cursor/rules/testing.mdc
Normal file
6
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
|
||||
14
.env.example
14
.env.example
@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Automatically assign new users to a specific organization and role within that organization
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
# AUTH_SSO_DEFAULT_TEAM_ID=
|
||||
# AUTH_SKIP_INVITE_FOR_SSO=
|
||||
|
||||
@@ -191,8 +190,7 @@ UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
@@ -200,9 +198,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
@@ -216,5 +211,8 @@ UNKEY_ROOT_KEY=
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT=1
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
2
.github/actions/cache-build-web/action.yml
vendored
2
.github/actions/cache-build-web/action.yml
vendored
@@ -49,7 +49,7 @@ runs:
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
@@ -11,10 +11,11 @@ When generating test files inside the "/app/web" path, follow these rules:
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
|
||||
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
||||
- When mocking data check if the properties added are part of the type of the object being mocked. Don't add properties that are not part of the type.
|
||||
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
|
||||
- When mocking data check if the properties added are part of the type of the object being mocked. Only specify known properties, don't use properties that are not part of the type.
|
||||
|
||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||
|
||||
@@ -27,4 +28,5 @@ afterEach(() => {
|
||||
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
- You don't need to mock @tolgee/react
|
||||
- You don't need to mock @tolgee/react
|
||||
- Use "import "@testing-library/jest-dom/vitest";"
|
||||
84
.github/dependabot.yml
vendored
84
.github/dependabot.yml
vendored
@@ -1,84 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
|
||||
directory: "/" # Root package.json
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: increase
|
||||
|
||||
# Apps directory packages
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo-react-native"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/storybook"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Packages directory
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/database"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/lib"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/types"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-eslint"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-prettier"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-typescript"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/js-core"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/surveys"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/logger"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Apply labels from linked issue to PR
|
||||
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
5
.github/workflows/chromatic.yml
vendored
5
.github/workflows/chromatic.yml
vendored
@@ -10,6 +10,11 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
|
||||
|
||||
10
.github/workflows/deploy-formbricks-cloud.yml
vendored
10
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release'
|
||||
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -75,6 +75,7 @@ jobs:
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -84,13 +85,14 @@ jobs:
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
|
||||
23
.github/workflows/e2e.yml
vendored
23
.github/workflows/e2e.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -23,7 +25,6 @@ permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -48,15 +49,17 @@ jobs:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: allow
|
||||
allowed-endpoints: |
|
||||
ee.formbricks.com:443
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
@@ -75,7 +78,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -89,8 +92,18 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
if [ -z "$LICENSE_KEY" ]; then
|
||||
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
|
||||
exit 1
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
|
||||
sleep 10 # Optional: gives some buffer for the app to start
|
||||
for attempt in {1..10}; do
|
||||
|
||||
2
.github/workflows/formbricks-release.yml
vendored
2
.github/workflows/formbricks-release.yml
vendored
@@ -30,5 +30,5 @@ jobs:
|
||||
- docker-build
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
27
.github/workflows/labeler.yml
vendored
27
.github/workflows/labeler.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
name: Pull Request Labeler
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
|
||||
sync-labels: ""
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
@@ -82,7 +82,6 @@ jobs:
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
no-cache: true
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
|
||||
6
.github/workflows/release-docker-github.yml
vendored
6
.github/workflows/release-docker-github.yml
vendored
@@ -20,18 +20,15 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
@@ -102,7 +99,6 @@ jobs:
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
no-cache: true
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Helm chart to release'
|
||||
description: "The version of the Helm chart to release"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
4
.github/workflows/semantic-pull-requests.yml
vendored
4
.github/workflows/semantic-pull-requests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
revert
|
||||
ossgg
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
# condition you can continue the execution with the populated error message.
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
|
||||
4
.github/workflows/sonarqube.yml
vendored
4
.github/workflows/sonarqube.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
18
.github/workflows/terraform-plan-and-apply.yml
vendored
18
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Terraform'
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,14 +14,13 @@ on:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -71,7 +70,7 @@ jobs:
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
@@ -83,4 +82,3 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
@@ -26,10 +24,10 @@
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.2",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
|
||||
@@ -18,8 +18,9 @@ FROM node:22-alpine3.21 AS base
|
||||
FROM base AS installer
|
||||
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN npm install -g corepack@latest
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.15.9 --activate
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
@@ -59,7 +60,7 @@ COPY . .
|
||||
RUN touch apps/web/.env
|
||||
|
||||
# Install the dependencies
|
||||
RUN pnpm install
|
||||
RUN pnpm install --ignore-scripts
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
@@ -75,7 +76,7 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
RUN npm install -g corepack@latest
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
@@ -141,12 +142,13 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g tsx typescript prisma pino-pretty
|
||||
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
|
||||
RUN npm install -g prisma
|
||||
|
||||
EXPOSE 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV="production"
|
||||
# USER nextjs
|
||||
USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
|
||||
|
||||
// Mocks before import
|
||||
const pushMock = vi.fn();
|
||||
const refreshMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
|
||||
vi.mock("./OnboardingSetupInstructions", () => ({
|
||||
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConnectWithFormbricks", () => {
|
||||
const environment = { id: "env1" } as any;
|
||||
const webAppUrl = "http://app";
|
||||
const channel = {} as any;
|
||||
|
||||
test("renders waiting state when widgetSetupCompleted is false", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("instructions")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success state when widgetSetupCompleted is true", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking finish button navigates to surveys", async () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
|
||||
await userEvent.click(button);
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
|
||||
});
|
||||
|
||||
test("refresh is called on visibilitychange to visible", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if session is missing", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
});
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user lacks access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
})
|
||||
).rejects.toThrow("User is not authorized to access this environment");
|
||||
});
|
||||
|
||||
test("renders children if user has access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div data-testid="child">Test Content</div>,
|
||||
});
|
||||
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!isAuthorized) {
|
||||
throw AuthorizationError;
|
||||
throw new AuthorizationError("User is not authorized to access this environment");
|
||||
}
|
||||
|
||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { XMTemplateList } from "./XMTemplateList";
|
||||
|
||||
// Prepare push mock and module mocks before importing component
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
|
||||
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
|
||||
getXMTemplates: (t: any) => [
|
||||
{ id: 1, name: "tmpl1" },
|
||||
{ id: 2, name: "tmpl2" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
|
||||
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
|
||||
}));
|
||||
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div>
|
||||
{options.map((opt, idx) => (
|
||||
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
|
||||
{opt.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Reset mocks between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("XMTemplateList component", () => {
|
||||
const project = { id: "proj1" } as any;
|
||||
const user = { id: "user1" } as any;
|
||||
const environmentId = "env1";
|
||||
|
||||
test("creates survey and navigates on success", async () => {
|
||||
// Mock successful survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option0 = screen.getByTestId("option-0");
|
||||
await userEvent.click(option0);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
|
||||
});
|
||||
|
||||
test("shows error toast on failure", async () => {
|
||||
// Mock failed survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option1 = screen.getByTestId("option-1");
|
||||
await userEvent.click(option1);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replacePresetPlaceholders } from "./utils";
|
||||
|
||||
// Mock data
|
||||
const mockProject: TProject = {
|
||||
id: "project1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Project",
|
||||
organizationId: "org1",
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#FFFFFF" },
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
config: {
|
||||
channel: "link" as const,
|
||||
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
inputType: "text",
|
||||
type: "email" as any,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
required: false,
|
||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "e1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you for completing the survey!" },
|
||||
},
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("replacePresetPlaceholders", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in template name", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in question headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
const originalTemplate = structuredClone(mockTemplate);
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result).not.toBe(mockTemplate);
|
||||
expect(mockTemplate).toEqual(originalTemplate);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("xm-templates", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("getXMSurveyDefault returns default survey template", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMSurveyDefault(tMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
});
|
||||
expect(result.endings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("getXMTemplates returns all templates", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0].name).toBe("templates.nps_survey_name");
|
||||
expect(result[1].name).toBe("templates.star_rating_survey_name");
|
||||
expect(result[2].name).toBe("templates.csat_survey_name");
|
||||
expect(result[3].name).toBe("templates.cess_survey_name");
|
||||
expect(result[4].name).toBe("templates.smileys_survey_name");
|
||||
expect(result[5].name).toBe("templates.enps_survey_name");
|
||||
});
|
||||
|
||||
test("getXMTemplates handles errors gracefully", async () => {
|
||||
const tMock = vi.fn(() => {
|
||||
throw new Error("Test error");
|
||||
}) as TFnType;
|
||||
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
// Dynamically import the mocked logger
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to load XM templates, returning empty array"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getTeamsByOrganizationId } from "./onboarding";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
team: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: (fn: any) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/team", () => ({
|
||||
teamCache: {
|
||||
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getTeamsByOrganizationId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns mapped teams", async () => {
|
||||
const mockTeams = [
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
];
|
||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||
const result = await getTeamsByOrganizationId("org1");
|
||||
expect(result).toEqual([
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error on unknown error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { LandingSidebar } from "./landing-sidebar";
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
||||
<div data-testid={open ? "modal-open" : "modal-closed"} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
|
||||
}));
|
||||
|
||||
// Ensure mocks are reset between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Profile avatar
|
||||
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
|
||||
// CreateOrganizationModal should be closed initially
|
||||
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
if (trigger) await userEvent.click(trigger);
|
||||
|
||||
// Click logout menu item
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import LandingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/lib/project/service");
|
||||
vi.mock("next-auth");
|
||||
vi.mock("next/navigation");
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("LandingLayout", () => {
|
||||
test("redirects to login if no session exists", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns notFound if no membership is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects to production environment if available", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([
|
||||
{
|
||||
id: "proj-123",
|
||||
organizationId: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
name: "Project 1",
|
||||
styling: { allowStyleOverwrite: true },
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
} as any,
|
||||
]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([
|
||||
{
|
||||
id: "env-123",
|
||||
type: "production",
|
||||
projectId: "proj-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
|
||||
});
|
||||
|
||||
test("renders children if no projects or production environment exist", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
const result = await LandingLayout(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
<>
|
||||
<div>Child Content</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: (fn: any) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("returns notFound if user does not exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: {},
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
expect(result).toBe("NOT_FOUND_STUB");
|
||||
});
|
||||
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
|
||||
|
||||
const result = await Page({ params });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("renders header, options, and close button when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header title and subtitle
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.channel.channel_select_title"
|
||||
);
|
||||
expect(
|
||||
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Options container with correct titles
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.channel.link_and_email_surveys," +
|
||||
"organizations.projects.new.channel.in_product_surveys"
|
||||
);
|
||||
|
||||
// Close button link rendered when projects >=1
|
||||
const closeLink = screen.getByRole("link");
|
||||
expect(closeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("does not render close button when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getOrganizationProjectsCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if no session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns not found if user is member or billing", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if organization is not found", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
test("redirects to home if project limit is reached", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
test("renders children when all conditions are met", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
const result = await OnboardingLayout(props);
|
||||
expect(result).toEqual(<>{props.children}</>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: any) => (
|
||||
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
describe("Mode Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("renders header and options without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.mode.what_are_you_here_for"
|
||||
);
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ProjectSettings } from "./ProjectSettings";
|
||||
|
||||
// Mocks before imports
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/modules/ui/components/color-picker", () => ({
|
||||
ColorPicker: ({ color, onChange }: any) => (
|
||||
<button data-testid="color-picker" onClick={() => onChange("#000")}>
|
||||
{color}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ value, onChange, placeholder }: any) => (
|
||||
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/multi-select", () => ({
|
||||
MultiSelect: ({ value, options, onChange }: any) => (
|
||||
<select
|
||||
data-testid="multi-select"
|
||||
multiple
|
||||
value={value}
|
||||
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
|
||||
{options.map((o: any) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/survey", () => ({
|
||||
SurveyInline: () => <div data-testid="survey-inline" />,
|
||||
}));
|
||||
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
|
||||
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
|
||||
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
|
||||
}));
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("ProjectSettings component", () => {
|
||||
const baseProps = {
|
||||
organizationId: "org1",
|
||||
projectMode: "cx",
|
||||
industry: "ind",
|
||||
defaultBrandColor: "#fff",
|
||||
organizationTeams: [],
|
||||
canDoRoleManagement: false,
|
||||
userProjectsCount: 0,
|
||||
} as any;
|
||||
|
||||
const fillAndSubmit = async () => {
|
||||
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "TestProject");
|
||||
const nextButton = screen.getByRole("button", { name: "common.next" });
|
||||
await userEvent.click(nextButton);
|
||||
};
|
||||
|
||||
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env123", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(createProjectAction).toHaveBeenCalledWith({
|
||||
organizationId: "org1",
|
||||
data: expect.objectContaining({ teamIds: [] }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
|
||||
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
|
||||
});
|
||||
|
||||
test("successful createProject for app channel navigates to connect", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env456", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
|
||||
});
|
||||
|
||||
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env789", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
|
||||
});
|
||||
|
||||
test("shows error toast on createProject error response", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({ error: "err" });
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
|
||||
test("shows error toast on exception", async () => {
|
||||
(createProjectAction as any).mockImplementation(() => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
|
||||
});
|
||||
});
|
||||
@@ -225,7 +225,7 @@ export const ProjectSettings = ({
|
||||
alt="Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
||||
// Mocks before component import
|
||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: any) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
|
||||
() => ({
|
||||
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
|
||||
})
|
||||
);
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectSettingsPage", () => {
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
|
||||
|
||||
test("redirects to login when no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params, searchParams });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws when teams not found", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||
|
||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||
});
|
||||
|
||||
test("renders header, settings and close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.settings.project_settings_title"
|
||||
);
|
||||
// ProjectSettings stub receives mode prop
|
||||
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
|
||||
// Close link for existing projects
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("renders without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Home, Settings } from "lucide-react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
|
||||
|
||||
describe("OnboardingOptionsContainer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders options with links", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Test Option",
|
||||
description: "Test Description",
|
||||
icon: Home,
|
||||
href: "/test",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Test Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with onClick handler", () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Click Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with iconText", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Icon Text Option",
|
||||
description: "Icon Text Description",
|
||||
icon: Home,
|
||||
iconText: "Custom Icon Text",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with loading state", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Loading Option",
|
||||
description: "Loading Description",
|
||||
icon: Home,
|
||||
isLoading: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Loading Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders multiple options", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "First Option",
|
||||
description: "First Description",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Second Option",
|
||||
description: "Second Description",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("First Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick handler when clicking an option", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
await userEvent.click(screen.getByText("Click Option"));
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SingleContactPage } from "@/modules/ee/contacts/[contactId]/page";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
// mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
ENCRYPTION_KEY: "test",
|
||||
ENTERPRISE_LICENSE_KEY: "test",
|
||||
GITHUB_ID: "test",
|
||||
GITHUB_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
test("should re-export SingleContactPage", () => {
|
||||
expect(Page).toBe(SingleContactPage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ContactsPage } from "@/modules/ee/contacts/page";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock the actual ContactsPage component
|
||||
vi.mock("@/modules/ee/contacts/page", () => ({
|
||||
ContactsPage: () => <div data-testid="contacts-page">Mock Contacts Page</div>,
|
||||
}));
|
||||
|
||||
describe("Contacts Page Re-export", () => {
|
||||
test("should re-export ContactsPage from the EE module", () => {
|
||||
// Assert that the default export 'Page' is the same as the mocked 'ContactsPage'
|
||||
expect(Page).toBe(ContactsPage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import SegmentsPageWrapper from "./page";
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/page", () => ({
|
||||
SegmentsPage: vi.fn(() => <div>SegmentsPageMock</div>),
|
||||
}));
|
||||
|
||||
describe("SegmentsPageWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the SegmentsPage component", () => {
|
||||
render(<SegmentsPageWrapper params={{ environmentId: "test-env" } as any} />);
|
||||
expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { createActionClassAction } from "@/modules/survey/editor/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { getActiveInactiveSurveysAction } from "../actions";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
ACTION_TYPE_ICON_LOOKUP: {
|
||||
noCode: <div>NoCodeIcon</div>,
|
||||
automatic: <div>AutomaticIcon</div>,
|
||||
code: <div>CodeIcon</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
convertDateTimeStringShort: (dateString: string) => `formatted-${dateString}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/strings", () => ({
|
||||
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/actions", () => ({
|
||||
createActionClassAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, ...props }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/error-component", () => ({
|
||||
ErrorComponent: () => <div>ErrorComponent</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children, ...props }: any) => <label {...props}>{children}</label>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/loading-spinner", () => ({
|
||||
LoadingSpinner: () => <div>LoadingSpinner</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
getActiveInactiveSurveysAction: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockActionClass = {
|
||||
id: "action1",
|
||||
createdAt: new Date("2023-01-01T10:00:00Z"),
|
||||
updatedAt: new Date("2023-01-10T11:00:00Z"),
|
||||
name: "Test Action",
|
||||
description: "Test Description",
|
||||
type: "noCode",
|
||||
environmentId: "env1_dev",
|
||||
noCodeConfig: {
|
||||
/* ... */
|
||||
} as any,
|
||||
} as unknown as TActionClass;
|
||||
|
||||
const mockEnvironmentDev = {
|
||||
id: "env1_dev",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockEnvironmentProd = {
|
||||
id: "env1_prod",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockOtherEnvActionClasses: TActionClass[] = [
|
||||
{
|
||||
id: "action2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Existing Action Prod",
|
||||
type: "noCode",
|
||||
environmentId: "env1_prod",
|
||||
} as unknown as TActionClass,
|
||||
{
|
||||
id: "action3",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Existing Code Action Prod",
|
||||
type: "code",
|
||||
key: "existing-key",
|
||||
environmentId: "env1_prod",
|
||||
} as unknown as TActionClass,
|
||||
];
|
||||
|
||||
describe("ActionActivityTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
|
||||
data: {
|
||||
activeSurveys: ["Active Survey 1"],
|
||||
inactiveSurveys: ["Inactive Survey 1", "Inactive Survey 2"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state initially", () => {
|
||||
// Don't resolve the promise immediately
|
||||
vi.mocked(getActiveInactiveSurveysAction).mockReturnValue(new Promise(() => {}));
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("LoadingSpinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders error state if fetching surveys fails", async () => {
|
||||
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
|
||||
data: undefined,
|
||||
});
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
// Wait for the component to update after the promise resolves
|
||||
await screen.findByText("ErrorComponent");
|
||||
expect(screen.getByText("ErrorComponent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders survey lists and action details correctly", async () => {
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for loading to finish
|
||||
await screen.findByText("common.active_surveys");
|
||||
|
||||
// Check survey lists
|
||||
expect(screen.getByText("Active Survey 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive Survey 2")).toBeInTheDocument();
|
||||
|
||||
// Check action details
|
||||
// Use the actual Date.toString() output that the mock receives
|
||||
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
|
||||
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
|
||||
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
|
||||
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
|
||||
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
|
||||
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
|
||||
});
|
||||
|
||||
test("calls copyAction with correct data on button click", async () => {
|
||||
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).toHaveBeenCalledTimes(1);
|
||||
// Include the extra properties that the component sends due to spreading mockActionClass
|
||||
const expectedActionInput = {
|
||||
...mockActionClass, // Spread the original object
|
||||
name: "Test Action", // Keep the original name as it doesn't conflict
|
||||
environmentId: "env1_prod", // Target environment ID
|
||||
};
|
||||
// Remove properties not expected by the action call itself, even if sent by component
|
||||
delete (expectedActionInput as any).id;
|
||||
delete (expectedActionInput as any).createdAt;
|
||||
delete (expectedActionInput as any).updatedAt;
|
||||
|
||||
// The assertion now checks against the structure sent by the component
|
||||
expect(createActionClassAction).toHaveBeenCalledWith({
|
||||
action: {
|
||||
...mockActionClass, // Include id, createdAt, updatedAt etc.
|
||||
name: "Test Action",
|
||||
environmentId: "env1_prod",
|
||||
},
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
|
||||
});
|
||||
|
||||
test("handles name conflict during copy", async () => {
|
||||
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
|
||||
const conflictingActionClass = { ...mockActionClass, name: "Existing Action Prod" };
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={conflictingActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The assertion now checks against the structure sent by the component
|
||||
expect(createActionClassAction).toHaveBeenCalledWith({
|
||||
action: {
|
||||
...conflictingActionClass, // Include id, createdAt, updatedAt etc.
|
||||
name: "Existing Action Prod (copy)",
|
||||
environmentId: "env1_prod",
|
||||
},
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
|
||||
});
|
||||
|
||||
test("handles key conflict during copy for 'code' type", async () => {
|
||||
const codeActionClass: TActionClass = {
|
||||
...mockActionClass,
|
||||
id: "codeAction1",
|
||||
type: "code",
|
||||
key: "existing-key", // Conflicting key
|
||||
noCodeConfig: {
|
||||
/* ... */
|
||||
} as any,
|
||||
};
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={codeActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_with_key_already_exists");
|
||||
});
|
||||
|
||||
test("shows error if copy action fails server-side", async () => {
|
||||
vi.mocked(createActionClassAction).mockResolvedValue({ data: undefined });
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_copy_failed");
|
||||
});
|
||||
|
||||
test("shows error and prevents copy if user is read-only", async () => {
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={true} // Set to read-only
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("common.you_are_not_authorised_to_perform_this_action");
|
||||
});
|
||||
|
||||
test("renders correct copy button text for production environment", async () => {
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={{ ...mockActionClass, environmentId: "env1_prod" }}
|
||||
environmentId="env1_prod"
|
||||
environment={mockEnvironmentProd} // Current env is Production
|
||||
otherEnvActionClasses={[]} // Assume dev env has no actions for simplicity
|
||||
otherEnvironment={mockEnvironmentDev} // Target env is Development
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
await screen.findByText("Copy to Development");
|
||||
expect(screen.getByText("Copy to Development")).toBeInTheDocument();
|
||||
expect(screen.getByText("Production")).toBeInTheDocument(); // Environment text
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionClassesTable } from "./ActionClassesTable";
|
||||
|
||||
// Mock the ActionDetailModal
|
||||
vi.mock("./ActionDetailModal", () => ({
|
||||
ActionDetailModal: ({ open, actionClass, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="action-detail-modal">
|
||||
Modal for {actionClass.name}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{ id: "1", name: "Action 1", type: "noCode", environmentId: "env1" } as TActionClass,
|
||||
{ id: "2", name: "Action 2", type: "code", environmentId: "env1" } as TActionClass,
|
||||
];
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
name: "Test Environment",
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockOtherEnvironment: TEnvironment = {
|
||||
id: "env2",
|
||||
name: "Other Environment",
|
||||
type: "production",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockTableHeading = <div data-testid="table-heading">Table Heading</div>;
|
||||
const mockActionRows = mockActionClasses.map((action) => (
|
||||
<div key={action.id} data-testid={`action-row-${action.id}`}>
|
||||
{action.name} Row
|
||||
</div>
|
||||
));
|
||||
|
||||
describe("ActionClassesTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders table heading and action rows when actions exist", () => {
|
||||
render(
|
||||
<ActionClassesTable
|
||||
environmentId="env1"
|
||||
actionClasses={mockActionClasses}
|
||||
environment={mockEnvironment}
|
||||
isReadOnly={false}
|
||||
otherEnvActionClasses={[]}
|
||||
otherEnvironment={mockOtherEnvironment}>
|
||||
{[mockTableHeading, mockActionRows]}
|
||||
</ActionClassesTable>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-row-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-row-2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No actions found")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders 'No actions found' message when no actions exist", () => {
|
||||
render(
|
||||
<ActionClassesTable
|
||||
environmentId="env1"
|
||||
actionClasses={[]}
|
||||
environment={mockEnvironment}
|
||||
isReadOnly={false}
|
||||
otherEnvActionClasses={[]}
|
||||
otherEnvironment={mockOtherEnvironment}>
|
||||
{[mockTableHeading, []]}
|
||||
</ActionClassesTable>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
|
||||
expect(screen.getByText("No actions found")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-row-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens ActionDetailModal with correct action when a row is clicked", async () => {
|
||||
render(
|
||||
<ActionClassesTable
|
||||
environmentId="env1"
|
||||
actionClasses={mockActionClasses}
|
||||
environment={mockEnvironment}
|
||||
isReadOnly={false}
|
||||
otherEnvActionClasses={[]}
|
||||
otherEnvironment={mockOtherEnvironment}>
|
||||
{[mockTableHeading, mockActionRows]}
|
||||
</ActionClassesTable>
|
||||
);
|
||||
|
||||
// Modal should not be open initially
|
||||
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Find the button wrapping the first action row
|
||||
const actionButton1 = screen.getByTitle("Action 1");
|
||||
await userEvent.click(actionButton1);
|
||||
|
||||
// Modal should now be open with the correct action name
|
||||
const modal = screen.getByTestId("action-detail-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(modal).toHaveTextContent("Modal for Action 1");
|
||||
|
||||
// Close the modal
|
||||
await userEvent.click(screen.getByText("Close Modal"));
|
||||
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Click the second action button
|
||||
const actionButton2 = screen.getByTitle("Action 2");
|
||||
await userEvent.click(actionButton2);
|
||||
|
||||
// Modal should open for the second action
|
||||
const modal2 = screen.getByTestId("action-detail-modal");
|
||||
expect(modal2).toBeInTheDocument();
|
||||
expect(modal2).toHaveTextContent("Modal for Action 2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
import { ActionDetailModal } from "./ActionDetailModal";
|
||||
// Import mocked components
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
||||
<div data-testid="modal-with-tabs">
|
||||
<span data-testid="modal-label">{label}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<span data-testid="modal-open">{open.toString()}</span>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
{icon}
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
{tab.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionActivityTab", () => ({
|
||||
ActionActivityTab: vi.fn(() => <div data-testid="action-activity-tab">ActionActivityTab</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionSettingsTab", () => ({
|
||||
ActionSettingsTab: vi.fn(() => <div data-testid="action-settings-tab">ActionSettingsTab</div>),
|
||||
}));
|
||||
|
||||
// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
ACTION_TYPE_ICON_LOOKUP: {
|
||||
code: <div data-testid="code-icon">Code Icon Mock</div>,
|
||||
noCode: <div data-testid="nocode-icon">No Code Icon Mock</div>,
|
||||
// Add other types if needed by other tests or default props
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production", // Use string literal as TEnvironmentType is not exported
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "action-class-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action",
|
||||
description: "This is a test action",
|
||||
type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP
|
||||
environmentId: mockEnvironmentId,
|
||||
noCodeConfig: null,
|
||||
key: "test-action-key",
|
||||
};
|
||||
|
||||
const mockActionClasses: TActionClass[] = [mockActionClass];
|
||||
const mockOtherEnvActionClasses: TActionClass[] = [];
|
||||
const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" };
|
||||
|
||||
const defaultProps = {
|
||||
environmentId: mockEnvironmentId,
|
||||
environment: mockEnvironment,
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
actionClass: mockActionClass,
|
||||
actionClasses: mockActionClasses,
|
||||
isReadOnly: false,
|
||||
otherEnvironment: mockOtherEnvironment,
|
||||
otherEnvActionClasses: mockOtherEnvActionClasses,
|
||||
};
|
||||
|
||||
describe("ActionDetailModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
|
||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
// Check basic props
|
||||
expect(props.open).toBe(true);
|
||||
expect(props.setOpen).toBe(mockSetOpen);
|
||||
expect(props.label).toBe(mockActionClass.name);
|
||||
expect(props.description).toBe(mockActionClass.description);
|
||||
|
||||
// Check icon data-testid based on the mock for the default 'code' type
|
||||
expect(props.icon).toBeDefined();
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
||||
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
|
||||
// Check if the correct mocked components are used as children
|
||||
// Access the mocked functions directly
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
|
||||
// Check props passed to child components
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
|
||||
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
|
||||
expect(activityTabProps.isReadOnly).toBe(false);
|
||||
expect(activityTabProps.environment).toBe(mockEnvironment);
|
||||
expect(activityTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
test("renders correct icon based on action type", () => {
|
||||
// Test with 'noCode' type
|
||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
|
||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly", () => {
|
||||
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
|
||||
// Access the mocked component directly
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.isReadOnly).toBe(true);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { ActionClassDataRow } from "./ActionRowData";
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "testId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action",
|
||||
description: "This is a test action",
|
||||
type: "code",
|
||||
noCodeConfig: null,
|
||||
environmentId: "envId",
|
||||
key: null,
|
||||
};
|
||||
|
||||
const locale = "en-US";
|
||||
const timeSinceOutput = "2 hours ago";
|
||||
|
||||
describe("ActionClassDataRow", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders code action correctly", () => {
|
||||
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
|
||||
const actionClass = { ...mockActionClass, type: "code" } as TActionClass;
|
||||
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
|
||||
|
||||
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
|
||||
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
|
||||
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
|
||||
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
|
||||
});
|
||||
|
||||
test("renders no-code action correctly", () => {
|
||||
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
|
||||
const actionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
|
||||
|
||||
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
|
||||
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
|
||||
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
|
||||
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
|
||||
});
|
||||
|
||||
test("renders without description", () => {
|
||||
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
|
||||
const actionClass = { ...mockActionClass, description: undefined } as unknown as TActionClass;
|
||||
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
|
||||
|
||||
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
|
||||
expect(screen.queryByText("This is a test action")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass, TActionClassNoCodeConfig, TActionClassType } from "@formbricks/types/action-classes";
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
deleteActionClassAction: vi.fn(),
|
||||
updateActionClassAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
||||
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, loading, ...props }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} disabled={loading} {...props}>
|
||||
{loading ? "Loading..." : children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
||||
Code Action Form
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<span>Delete Dialog</span>
|
||||
<button onClick={onDelete} disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Confirm Delete"}
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
||||
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
||||
No Code Action Form
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
||||
}));
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{
|
||||
id: "action1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Existing Action",
|
||||
description: "An existing action",
|
||||
type: "noCode",
|
||||
environmentId: "env1",
|
||||
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
|
||||
} as unknown as TActionClass,
|
||||
];
|
||||
|
||||
const createMockActionClass = (id: string, type: TActionClassType, name: string): TActionClass =>
|
||||
({
|
||||
id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name,
|
||||
description: `${name} description`,
|
||||
type,
|
||||
environmentId: "env1",
|
||||
...(type === "code" && { key: `${name}-key` }),
|
||||
...(type === "noCode" && {
|
||||
noCodeConfig: { type: "url", rule: "exactMatch", value: `http://${name}.com` },
|
||||
}),
|
||||
}) as unknown as TActionClass;
|
||||
|
||||
describe("ActionSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders correctly for 'code' action type", () => {
|
||||
const actionClass = createMockActionClass("code1", "code", "Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly for 'noCode' action type", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles successful deletion", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
vi.mocked(deleteActionClassAction).mockResolvedValue({ data: actionClass } as any);
|
||||
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
|
||||
await userEvent.click(deleteButtonTrigger);
|
||||
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
|
||||
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteActionClassAction).toHaveBeenCalledWith({ actionClassId: actionClass.id });
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_deleted_successfully");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles deletion failure", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
vi.mocked(deleteActionClassAction).mockRejectedValue(new Error("Deletion failed"));
|
||||
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
|
||||
await userEvent.click(deleteButtonTrigger);
|
||||
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteActionClassAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
|
||||
});
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders read-only state correctly", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={true} // Set to read-only
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
|
||||
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
||||
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
|
||||
});
|
||||
|
||||
test("prevents delete when read-only", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
|
||||
// Render with isReadOnly=true, but simulate a delete attempt
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
|
||||
// This test primarily checks the logic within handleDeleteAction if it were called.
|
||||
// A better approach might be to export handleDeleteAction for direct testing,
|
||||
// but for now, we assume the UI prevents calling it.
|
||||
|
||||
// We can assert that the delete button isn't there to prevent the flow
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders docs link correctly", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
const docsLink = screen.getByRole("link", { name: "common.read_docs" });
|
||||
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ActionTableHeading } from "./ActionTableHeading";
|
||||
|
||||
// Mock the server-side translation function
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("ActionTableHeading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the table heading with correct column names", async () => {
|
||||
// Render the async component
|
||||
const ResolvedComponent = await ActionTableHeading();
|
||||
render(ResolvedComponent);
|
||||
|
||||
// Check if the translated column headers are present
|
||||
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created")).toBeInTheDocument();
|
||||
// Check for the screen reader only text
|
||||
expect(screen.getByText("common.edit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
|
||||
import { AddActionModal } from "./AddActionModal";
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
|
||||
CreateNewActionTab: vi.fn(({ setOpen }) => (
|
||||
<div data-testid="create-new-action-tab">
|
||||
<span>CreateNewActionTab Content</span>
|
||||
<button onClick={() => setOpen(false)}>Close from Tab</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" {...props}>
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
MousePointerClickIcon: () => <div data-testid="mouse-pointer-icon" />,
|
||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||
}));
|
||||
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{
|
||||
id: "action1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Action 1",
|
||||
description: "Description 1",
|
||||
type: "noCode",
|
||||
environmentId: "env1",
|
||||
noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig,
|
||||
} as unknown as TActionClass,
|
||||
];
|
||||
|
||||
const environmentId = "env1";
|
||||
|
||||
describe("AddActionModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the 'Add Action' button initially", () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes correct props to CreateNewActionTab", async () => {
|
||||
const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab");
|
||||
const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab);
|
||||
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(mockedCreateNewActionTab).toHaveBeenCalled();
|
||||
const props = mockedCreateNewActionTab.mock.calls[0][0];
|
||||
expect(props.environmentId).toBe(environmentId);
|
||||
expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check
|
||||
expect(props.isReadOnly).toBe(false);
|
||||
expect(props.setOpen).toBeInstanceOf(Function);
|
||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked CreateNewActionTab's button
|
||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
||||
await userEvent.click(closeFromTabButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="page-content-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
|
||||
}));
|
||||
|
||||
describe("Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state correctly", () => {
|
||||
render(<Loading />);
|
||||
|
||||
// Check if mocked components are rendered
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
|
||||
|
||||
// Check for translated table headers
|
||||
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
|
||||
|
||||
// Check for skeleton elements (presence of animate-pulse class)
|
||||
const skeletonElements = document.querySelectorAll(".animate-pulse");
|
||||
expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
|
||||
|
||||
// Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
|
||||
const pulseDivs = screen.getAllByText((_, element) => {
|
||||
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
|
||||
});
|
||||
expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
// Import the component after mocks
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/actionClass/service", () => ({
|
||||
getActionClasses: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironments: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
|
||||
ActionClassesTable: ({ children }) => <div>ActionClassesTable Mock{children}</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
|
||||
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
|
||||
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
|
||||
AddActionModal: () => <div>AddActionModal Mock</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle, cta }) => (
|
||||
<div>
|
||||
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockProjectId = "test-project-id";
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
name: "Test Environment",
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockOtherEnvironment = {
|
||||
id: "other-env-id",
|
||||
name: "Other Environment",
|
||||
type: "production",
|
||||
} as unknown as TEnvironment;
|
||||
const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
|
||||
const mockActionClasses = [
|
||||
{ id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
|
||||
{ id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
|
||||
];
|
||||
const mockOtherEnvActionClasses = [
|
||||
{ id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
|
||||
];
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const mockParams = { environmentId: mockEnvironmentId };
|
||||
const mockProps = { params: mockParams };
|
||||
|
||||
describe("Actions Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getActionClasses)
|
||||
.mockResolvedValueOnce(mockActionClasses) // First call for current env
|
||||
.mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
|
||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("renders the page correctly with actions", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects if isBilling is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: true,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
await Page(mockProps);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
|
||||
});
|
||||
|
||||
test("does not render AddActionModal CTA if isReadOnly is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: true,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders AddActionModal CTA if isReadOnly is false", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ACTION_TYPE_ICON_LOOKUP } from "./utils";
|
||||
|
||||
describe("ACTION_TYPE_ICON_LOOKUP", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should contain the correct icon for 'code'", () => {
|
||||
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("code");
|
||||
const IconComponent = ACTION_TYPE_ICON_LOOKUP.code;
|
||||
expect(React.isValidElement(IconComponent)).toBe(true);
|
||||
|
||||
// Render the icon and check if it's the correct Lucide icon
|
||||
const { container } = render(IconComponent);
|
||||
const svgElement = container.querySelector("svg");
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
// Check for a class or attribute specific to Code2Icon if possible,
|
||||
// or compare the rendered output structure if necessary.
|
||||
// For simplicity, we check the component type directly (though this is less robust)
|
||||
expect(IconComponent.type).toBe(Code2Icon);
|
||||
});
|
||||
|
||||
test("should contain the correct icon for 'noCode'", () => {
|
||||
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("noCode");
|
||||
const IconComponent = ACTION_TYPE_ICON_LOOKUP.noCode;
|
||||
expect(React.isValidElement(IconComponent)).toBe(true);
|
||||
|
||||
// Render the icon and check if it's the correct Lucide icon
|
||||
const { container } = render(IconComponent);
|
||||
const svgElement = container.querySelector("svg");
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
// Similar check as above for MousePointerClickIcon
|
||||
expect(IconComponent.type).toBe(MousePointerClickIcon);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import {
|
||||
TOrganization,
|
||||
TOrganizationBilling,
|
||||
TOrganizationBillingPlanLimits,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
getEnvironments: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getUserProjects: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
getProjectPermissionByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockIsDevelopment = false;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
get IS_DEVELOPMENT() {
|
||||
return mockIsDevelopment;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
|
||||
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
||||
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({
|
||||
PendingDowngradeBanner: ({
|
||||
isPendingDowngrade,
|
||||
active,
|
||||
}: {
|
||||
isPendingDowngrade: boolean;
|
||||
active: boolean;
|
||||
}) =>
|
||||
isPendingDowngrade && active ? <div data-testid="downgrade-banner">PendingDowngradeBanner</div> : null,
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
|
||||
} as unknown as TOrganizationBilling,
|
||||
} as unknown as TOrganization;
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockProject: TProject = {
|
||||
id: "proj-1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org-1",
|
||||
environments: [mockEnvironment],
|
||||
} as unknown as TProject;
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "org-1",
|
||||
userId: "user-1",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
|
||||
const mockLicense = {
|
||||
plan: "free",
|
||||
active: false,
|
||||
lastChecked: new Date(),
|
||||
features: { isMultiOrgEnabled: false },
|
||||
} as any;
|
||||
|
||||
const mockProjectPermission = {
|
||||
userId: "user-1",
|
||||
projectId: "proj-1",
|
||||
role: "admin",
|
||||
} as any;
|
||||
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-1",
|
||||
},
|
||||
expires: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
describe("EnvironmentLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import EnvironmentStorageHandler from "./EnvironmentStorageHandler";
|
||||
|
||||
describe("EnvironmentStorageHandler", () => {
|
||||
test("sets environmentId in localStorage on mount", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const testEnvironmentId = "test-env-123";
|
||||
|
||||
render(<EnvironmentStorageHandler environmentId={testEnvironmentId} />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, testEnvironmentId);
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("updates environmentId in localStorage when prop changes", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const initialEnvironmentId = "test-env-initial";
|
||||
const updatedEnvironmentId = "test-env-updated";
|
||||
|
||||
const { rerender } = render(<EnvironmentStorageHandler environmentId={initialEnvironmentId} />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId);
|
||||
|
||||
rerender(<EnvironmentStorageHandler environmentId={updatedEnvironmentId} />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId);
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { EnvironmentSwitch } from "./EnvironmentSwitch";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: mockPush,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock @tolgee/react
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentDev: TEnvironment = {
|
||||
id: "dev-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironmentProd: TEnvironment = {
|
||||
id: "prod-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
||||
|
||||
describe("EnvironmentSwitch", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders checked when environment is development", () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
expect(switchElement).toBeChecked();
|
||||
expect(screen.getByText("common.dev_env")).toHaveClass("text-orange-800");
|
||||
});
|
||||
|
||||
test("renders unchecked when environment is production", () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
expect(switchElement).not.toBeChecked();
|
||||
expect(screen.getByText("common.dev_env")).not.toHaveClass("text-orange-800");
|
||||
});
|
||||
|
||||
test("calls router.push with development environment ID when toggled from production", async () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
expect(switchElement).not.toBeChecked();
|
||||
await userEvent.click(switchElement);
|
||||
|
||||
// Check loading state (switch disabled)
|
||||
expect(switchElement).toBeDisabled();
|
||||
|
||||
// Check router push call
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
|
||||
});
|
||||
|
||||
// Check visual state change (though state update happens before navigation)
|
||||
// In a real scenario, the component would re-render with the new environment prop after navigation.
|
||||
// Here, we simulate the state change directly for testing the toggle logic.
|
||||
await waitFor(() => {
|
||||
// Re-render or check internal state if possible, otherwise check mock calls
|
||||
// Since the component manages its own state, we can check the visual state after click
|
||||
expect(switchElement).toBeChecked(); // State updates immediately
|
||||
});
|
||||
});
|
||||
|
||||
test("calls router.push with production environment ID when toggled from development", async () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
expect(switchElement).toBeChecked();
|
||||
await userEvent.click(switchElement);
|
||||
|
||||
// Check loading state (switch disabled)
|
||||
expect(switchElement).toBeDisabled();
|
||||
|
||||
// Check router push call
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentProd.id}/`);
|
||||
});
|
||||
|
||||
// Check visual state change
|
||||
await waitFor(() => {
|
||||
expect(switchElement).not.toBeChecked(); // State updates immediately
|
||||
});
|
||||
});
|
||||
|
||||
test("does not call router.push if target environment is not found", async () => {
|
||||
const incompleteEnvironments = [mockEnvironmentProd]; // Only production exists
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={incompleteEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
await userEvent.click(switchElement); // Try to toggle to development
|
||||
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeDisabled(); // Loading state still set
|
||||
});
|
||||
|
||||
// router.push should not be called because dev env is missing
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
|
||||
// State still updates visually
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("toggles using the label click", async () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
||||
const labelElement = screen.getByText("common.dev_env");
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
expect(switchElement).not.toBeChecked();
|
||||
await userEvent.click(labelElement); // Click the label
|
||||
|
||||
// Check loading state (switch disabled)
|
||||
expect(switchElement).toBeDisabled();
|
||||
|
||||
// Check router push call
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
|
||||
});
|
||||
|
||||
// Check visual state change
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getLatestStableFbReleaseAction } from "../actions/actions";
|
||||
import { MainNavigation } from "./MainNavigation";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
usePathname: vi.fn(() => "/environments/env1/surveys"),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
getLatestStableFbReleaseAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/lib/formbricks", () => ({
|
||||
formbricksLogout: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: (role?: string) => ({
|
||||
isAdmin: role === "admin",
|
||||
isOwner: role === "owner",
|
||||
isManager: role === "manager",
|
||||
isMember: role === "member",
|
||||
isBilling: role === "billing",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
|
||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||
Project Switcher
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: (props: any) => <img alt="test" {...props} />,
|
||||
}));
|
||||
vi.mock("../../../../../package.json", () => ({
|
||||
version: "1.0.0",
|
||||
}));
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
|
||||
// Mock data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
emailVerified: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { stripeCustomerId: null, plan: "free", limits: { monthly: { responses: null } } } as any,
|
||||
} as unknown as TOrganization;
|
||||
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
mockOrganization,
|
||||
{ ...mockOrganization, id: "org2", name: "Another Org" },
|
||||
];
|
||||
const mockProject: TProject = {
|
||||
id: "proj1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org1",
|
||||
environments: [mockEnvironment],
|
||||
config: { channel: "website" },
|
||||
} as unknown as TProject;
|
||||
const mockProjects: TProject[] = [mockProject];
|
||||
|
||||
const defaultProps = {
|
||||
environment: mockEnvironment,
|
||||
organizations: mockOrganizations,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
projects: mockProjects,
|
||||
isMultiOrgEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
isDevelopment: false,
|
||||
membershipRole: "owner" as const,
|
||||
organizationProjectsLimit: 5,
|
||||
isLicenseActive: true,
|
||||
};
|
||||
|
||||
describe("MainNavigation", () => {
|
||||
let mockRouterPush: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouterPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any);
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env1/surveys");
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); // Default: no new version
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders expanded by default and collapses on toggle", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
const projectSwitcher = screen.getByTestId("project-switcher");
|
||||
// Assuming the toggle button is the only one initially without an accessible name
|
||||
// A more specific selector like data-testid would be better if available.
|
||||
const toggleButton = screen.getByRole("button", { name: "" });
|
||||
|
||||
// Check initial state (expanded)
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Check localStorage is not set initially after clear()
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
||||
|
||||
// Click to collapse
|
||||
await userEvent.click(toggleButton);
|
||||
|
||||
// Check state after first toggle (collapsed)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes true
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
||||
});
|
||||
// Check that the logo is eventually hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByAltText("environments.formbricks_logo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
await userEvent.click(toggleButton);
|
||||
|
||||
// Check state after second toggle (expanded)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes false
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
||||
});
|
||||
// Check that the logo is eventually visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders correct active navigation link", () => {
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
const actionsLink = screen.getByRole("link", { name: /common.actions/ });
|
||||
// Check if the parent li has the active class styling
|
||||
expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
|
||||
});
|
||||
|
||||
test("renders user dropdown and handles logout", async () => {
|
||||
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Find the avatar and get its parent div which acts as the trigger
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the dropdown content to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("common.organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
|
||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
||||
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
|
||||
await userEvent.click(org2Item);
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
|
||||
});
|
||||
|
||||
test("opens create organization modal", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
|
||||
await userEvent.click(createOrgButton);
|
||||
|
||||
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides new version banner for members or if no new version", async () => {
|
||||
// Test for member
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
||||
render(<MainNavigation {...defaultProps} membershipRole="member" />);
|
||||
let toggleButton = screen.getByRole("button", { name: "" });
|
||||
await userEvent.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
cleanup(); // Clean up before next render
|
||||
|
||||
// Test for no new version
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null });
|
||||
render(<MainNavigation {...defaultProps} membershipRole="owner" />);
|
||||
toggleButton = screen.getByRole("button", { name: "" });
|
||||
await userEvent.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("hides main nav and project switcher if user role is billing", () => {
|
||||
render(<MainNavigation {...defaultProps} membershipRole="billing" />);
|
||||
expect(screen.queryByRole("link", { name: /common.surveys/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows billing link and hides license link in cloud", async () => {
|
||||
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -109,7 +109,7 @@ export const MainNavigation = ({
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed ? true : false);
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
@@ -170,7 +170,7 @@ export const MainNavigation = ({
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { NavbarLoading } from "./NavbarLoading";
|
||||
|
||||
describe("NavbarLoading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the correct number of skeleton elements", () => {
|
||||
render(<NavbarLoading />);
|
||||
|
||||
// Find all divs with the animate-pulse class
|
||||
const skeletonElements = screen.getAllByText((content, element) => {
|
||||
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
|
||||
});
|
||||
|
||||
// There are 8 skeleton divs in the component
|
||||
expect(skeletonElements).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { NavigationLink } from "./NavigationLink";
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
// Mock tooltip components
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-provider">{children}</div>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-trigger">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
href: "/test-link",
|
||||
isActive: false,
|
||||
isCollapsed: false,
|
||||
children: <svg data-testid="icon" />,
|
||||
linkText: "Test Link Text",
|
||||
isTextVisible: true,
|
||||
};
|
||||
|
||||
describe("NavigationLink", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders expanded link correctly (inactive, text visible)", () => {
|
||||
render(<NavigationLink {...defaultProps} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
const textSpan = screen.getByText(defaultProps.linkText);
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveClass("opacity-0");
|
||||
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
|
||||
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
|
||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders expanded link correctly (active, text hidden)", () => {
|
||||
render(<NavigationLink {...defaultProps} isActive={true} isTextVisible={false} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
const textSpan = screen.getByText(defaultProps.linkText);
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveClass("opacity-100");
|
||||
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
|
||||
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
|
||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders collapsed link correctly (inactive)", () => {
|
||||
render(<NavigationLink {...defaultProps} isCollapsed={true} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
// Check text is NOT directly within the list item
|
||||
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
|
||||
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
|
||||
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
|
||||
|
||||
// Check tooltip elements
|
||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
|
||||
// Check text IS within the tooltip content mock
|
||||
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
|
||||
});
|
||||
|
||||
test("renders collapsed link correctly (active)", () => {
|
||||
render(<NavigationLink {...defaultProps} isCollapsed={true} isActive={true} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
// Check text is NOT directly within the list item
|
||||
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
|
||||
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
|
||||
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
|
||||
|
||||
// Check tooltip elements
|
||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||
// Check text IS within the tooltip content mock
|
||||
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ProjectNavItem } from "./ProjectNavItem";
|
||||
|
||||
describe("ProjectNavItem", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
href: "/test-path",
|
||||
children: <span>Test Child</span>,
|
||||
};
|
||||
|
||||
test("renders correctly when active", () => {
|
||||
render(<ProjectNavItem {...defaultProps} isActive={true} />);
|
||||
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", "/test-path");
|
||||
expect(screen.getByText("Test Child")).toBeInTheDocument();
|
||||
expect(listItem).toHaveClass("bg-slate-50");
|
||||
expect(listItem).toHaveClass("font-semibold");
|
||||
expect(listItem).not.toHaveClass("hover:bg-slate-50");
|
||||
});
|
||||
|
||||
test("renders correctly when inactive", () => {
|
||||
render(<ProjectNavItem {...defaultProps} isActive={false} />);
|
||||
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", "/test-path");
|
||||
expect(screen.getByText("Test Child")).toBeInTheDocument();
|
||||
expect(listItem).not.toHaveClass("bg-slate-50");
|
||||
expect(listItem).not.toHaveClass("font-semibold");
|
||||
expect(listItem).toHaveClass("hover:bg-slate-50");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { QuestionOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ResponseFilterProvider, useResponseFilter } from "./ResponseFilterContext";
|
||||
|
||||
// Mock the getTodayDate function
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
getTodayDate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockToday = new Date("2024-01-15T00:00:00.000Z");
|
||||
const mockFromDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
|
||||
// Test component to use the hook
|
||||
const TestComponent = () => {
|
||||
const {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
} = useResponseFilter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
|
||||
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
||||
<div data-testid="dateFrom">{dateRange.from?.toISOString()}</div>
|
||||
<div data-testid="dateTo">{dateRange.to?.toISOString()}</div>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectedFilter({
|
||||
filter: [
|
||||
{
|
||||
questionType: { id: "q1", label: "Question 1" },
|
||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
||||
},
|
||||
],
|
||||
onlyComplete: true,
|
||||
})
|
||||
}>
|
||||
Update Filter
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectedOptions({
|
||||
questionOptions: [{ header: "q1" } as unknown as QuestionOptions],
|
||||
questionFilterOptions: [{ id: "qFilterOpt1" } as unknown as QuestionFilterOptions],
|
||||
})
|
||||
}>
|
||||
Update Options
|
||||
</button>
|
||||
<button onClick={() => setDateRange({ from: mockFromDate, to: mockToday })}>Update Date Range</button>
|
||||
<button onClick={resetState}>Reset State</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("ResponseFilterContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTodayDate).mockReturnValue(mockToday);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should provide initial state values", () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("dateFrom").textContent).toBe("");
|
||||
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
|
||||
});
|
||||
|
||||
test("should update selectedFilter state", async () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
const updateButton = screen.getByText("Update Filter");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
||||
});
|
||||
|
||||
test("should update selectedOptions state", async () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
const updateButton = screen.getByText("Update Options");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("1");
|
||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("1");
|
||||
});
|
||||
|
||||
test("should update dateRange state", async () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
const updateButton = screen.getByText("Update Date Range");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("dateFrom").textContent).toBe(mockFromDate.toISOString());
|
||||
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
|
||||
});
|
||||
|
||||
test("should throw error when useResponseFilter is used outside of Provider", () => {
|
||||
// Hide console error temporarily
|
||||
const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => render(<TestComponent />)).toThrow("useFilterDate must be used within a FilterDateProvider");
|
||||
consoleErrorMock.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlBar } from "./TopControlBar";
|
||||
|
||||
// Mock the child component
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
|
||||
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
|
||||
}));
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments: TEnvironment[] = [
|
||||
mockEnvironment,
|
||||
{ ...mockEnvironment, id: "env2", type: "development" },
|
||||
];
|
||||
|
||||
const mockMembershipRole: TOrganizationRole = "owner";
|
||||
const mockProjectPermission = "manage";
|
||||
|
||||
describe("TopControlBar", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly and passes props to TopControlButtons", () => {
|
||||
render(
|
||||
<TopControlBar
|
||||
environment={mockEnvironment}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={mockMembershipRole}
|
||||
projectPermission={mockProjectPermission}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the main div is rendered
|
||||
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
|
||||
expect(mainDiv).toHaveClass(
|
||||
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
|
||||
);
|
||||
|
||||
// Check if the mocked child component is rendered
|
||||
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
|
||||
|
||||
// Check if the child component received the correct props
|
||||
expect(TopControlButtons).toHaveBeenCalledWith(
|
||||
{
|
||||
environment: mockEnvironment,
|
||||
environments: mockEnvironments,
|
||||
membershipRole: mockMembershipRole,
|
||||
projectPermission: mockProjectPermission,
|
||||
},
|
||||
undefined // Updated from {} to undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlButtons } from "./TopControlButtons";
|
||||
|
||||
// Mock dependencies
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: mockPush })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/utils/teams", () => ({
|
||||
getTeamPermissionFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
|
||||
EnvironmentSwitch: vi.fn(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
|
||||
const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
|
||||
return (
|
||||
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
|
||||
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
BugIcon: () => <div data-testid="bug-icon" />,
|
||||
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
|
||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
|
||||
<a href={href} target={target} data-testid="link-mock">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironmentDev: TEnvironment = {
|
||||
id: "dev-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironmentProd: TEnvironment = {
|
||||
id: "prod-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
||||
|
||||
describe("TopControlButtons", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mocks for access flags
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
isBilling: false,
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderComponent = (
|
||||
membershipRole?: TOrganizationRole,
|
||||
projectPermission: any = null,
|
||||
isBilling = false,
|
||||
hasReadAccess = false
|
||||
) => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isMember: membershipRole === "member",
|
||||
isBilling: isBilling,
|
||||
isOwner: membershipRole === "owner",
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: hasReadAccess,
|
||||
} as any);
|
||||
|
||||
return render(
|
||||
<TopControlButtons
|
||||
environment={mockEnvironmentDev}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test("renders correctly for Owner role", async () => {
|
||||
renderComponent("owner");
|
||||
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
|
||||
// Check link
|
||||
const link = screen.getByTestId("link-mock");
|
||||
expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
|
||||
// Click account button
|
||||
const accountButton = screen.getByTestId("circle-user-icon").closest("button");
|
||||
await userEvent.click(accountButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
|
||||
});
|
||||
|
||||
// Click new survey button
|
||||
const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
|
||||
await userEvent.click(newSurveyButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
|
||||
});
|
||||
});
|
||||
|
||||
test("hides EnvironmentSwitch for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
|
||||
});
|
||||
|
||||
test("hides New Survey button for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides New Survey button for read-only Member", () => {
|
||||
renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows New Survey button for Member with write access", () => {
|
||||
renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { WidgetStatusIndicator } from "./WidgetStatusIndicator";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
AlertTriangleIcon: () => <div data-testid="alert-icon">AlertTriangleIcon</div>,
|
||||
CheckIcon: () => <div data-testid="check-icon">CheckIcon</div>,
|
||||
RotateCcwIcon: () => <div data-testid="refresh-icon">RotateCcwIcon</div>,
|
||||
}));
|
||||
|
||||
// Mock Button component
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockEnvironmentNotImplemented: TEnvironment = {
|
||||
id: "env-not-implemented",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: false, // Not implemented state
|
||||
};
|
||||
|
||||
const mockEnvironmentRunning: TEnvironment = {
|
||||
id: "env-running",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true, // Running state
|
||||
};
|
||||
|
||||
describe("WidgetStatusIndicator", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly for 'notImplemented' state", () => {
|
||||
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
|
||||
|
||||
// Check icon
|
||||
expect(screen.getByTestId("alert-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
|
||||
|
||||
// Check texts
|
||||
expect(
|
||||
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected_description")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check button
|
||||
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
|
||||
expect(recheckButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId("refresh-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly for 'running' state", () => {
|
||||
render(<WidgetStatusIndicator environment={mockEnvironmentRunning} />);
|
||||
|
||||
// Check icon
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("alert-icon")).not.toBeInTheDocument();
|
||||
|
||||
// Check texts
|
||||
expect(screen.getByText("environments.project.app-connection.receiving_data")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.project.app-connection.formbricks_sdk_connected")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check button absence
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /environments.project.app-connection.recheck/ })
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("refresh-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls router.refresh when 'Recheck' button is clicked", async () => {
|
||||
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
|
||||
|
||||
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
|
||||
await userEvent.click(recheckButton);
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableCredential,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
|
||||
() => ({
|
||||
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
|
||||
<div>
|
||||
<label htmlFor="base">Base</label>
|
||||
<select
|
||||
id="base"
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => {
|
||||
control._mockOnChange({ target: { name: "base", value: e.target.value } });
|
||||
setValue("table", ""); // Reset table when base changes
|
||||
fetchTable(e.target.value);
|
||||
}}>
|
||||
<option value="">Select Base</option>
|
||||
{airtableArray.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
fetchTables: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value, _locale) => value?.default || value || "",
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey, _locale) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
AdditionalIntegrationSettings: ({
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
setIncludeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}) => (
|
||||
<div data-testid="additional-settings">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-variables"
|
||||
checked={includeVariables}
|
||||
onChange={(e) => setIncludeVariables(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-hidden"
|
||||
checked={includeHiddenFields}
|
||||
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-metadata"
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-createdat"
|
||||
checked={includeCreatedAt}
|
||||
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen }) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
|
||||
AlertTitle: ({ children }) => <div data-testid="alert-title">{children}</div>,
|
||||
AlertDescription: ({ children }) => <div data-testid="alert-description">{children}</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: (props) => <img alt="test" {...props} />,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ refresh: vi.fn() })),
|
||||
}));
|
||||
|
||||
// Mock the Select component used for Table and Survey selections
|
||||
vi.mock("@/modules/ui/components/select", () => ({
|
||||
Select: ({ children }) => (
|
||||
// Render children, assuming Controller passes props to the Trigger/Value
|
||||
// The actual select logic will be handled by the mocked Controller/field
|
||||
// We need to simulate the structure expected by the Controller render prop
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectTrigger: ({ children, ...props }) => <div {...props}>{children}</div>, // Mock Trigger
|
||||
SelectValue: ({ placeholder }) => <span>{placeholder || "Select..."}</span>, // Mock Value display
|
||||
SelectContent: ({ children }) => <div>{children}</div>, // Mock Content wrapper
|
||||
SelectItem: ({ children, value, ...props }) => (
|
||||
// Mock Item - crucial for userEvent.selectOptions if we were using a real select
|
||||
// For Controller, the value change is handled by field.onChange directly
|
||||
<div data-value={value} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-hook-form Controller to render a simple select
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
let fields = {};
|
||||
const mockReset = vi.fn((values) => {
|
||||
fields = values || {}; // Reset fields, optionally with new values
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useForm: vi.fn((options) => {
|
||||
fields = options?.defaultValues || {};
|
||||
const mockControlOnChange = (event) => {
|
||||
if (event && event.target) {
|
||||
fields[event.target.name] = event.target.value;
|
||||
}
|
||||
};
|
||||
return {
|
||||
handleSubmit: (fn) => (e) => {
|
||||
e?.preventDefault();
|
||||
fn(fields);
|
||||
},
|
||||
control: {
|
||||
_mockOnChange: mockControlOnChange,
|
||||
// Add other necessary control properties if needed
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
getFieldState: vi.fn(() => ({ invalid: false, isDirty: false, isTouched: false, error: null })),
|
||||
_names: { mount: new Set(), unMount: new Set(), array: new Set(), watch: new Set() },
|
||||
_options: {},
|
||||
_proxyFormState: {
|
||||
isDirty: false,
|
||||
isValidating: false,
|
||||
dirtyFields: {},
|
||||
touchedFields: {},
|
||||
errors: {},
|
||||
},
|
||||
_formState: { isDirty: false, isValidating: false, dirtyFields: {}, touchedFields: {}, errors: {} },
|
||||
_updateFormState: vi.fn(),
|
||||
_updateFieldArray: vi.fn(),
|
||||
_executeSchema: vi.fn().mockResolvedValue({ errors: {}, values: {} }),
|
||||
_getWatch: vi.fn(),
|
||||
_subjects: {
|
||||
watch: { subscribe: vi.fn() },
|
||||
array: { subscribe: vi.fn() },
|
||||
state: { subscribe: vi.fn() },
|
||||
},
|
||||
_getDirty: vi.fn(),
|
||||
_reset: vi.fn(),
|
||||
_removeUnmounted: vi.fn(),
|
||||
},
|
||||
watch: (name) => fields[name],
|
||||
setValue: (name, value) => {
|
||||
fields[name] = value;
|
||||
},
|
||||
reset: mockReset,
|
||||
formState: { errors: {}, isDirty: false, isValid: true, isSubmitting: false },
|
||||
getValues: (name) => (name ? fields[name] : fields),
|
||||
};
|
||||
}),
|
||||
Controller: ({ name, defaultValue }) => {
|
||||
// Initialize field value if not already set by reset/defaultValues
|
||||
if (fields[name] === undefined && defaultValue !== undefined) {
|
||||
fields[name] = defaultValue;
|
||||
}
|
||||
|
||||
const field = {
|
||||
onChange: (valueOrEvent) => {
|
||||
const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent;
|
||||
fields[name] = value;
|
||||
// Re-render might be needed here in a real scenario, but testing library handles it
|
||||
},
|
||||
onBlur: vi.fn(),
|
||||
value: fields[name],
|
||||
name: name,
|
||||
ref: vi.fn(),
|
||||
};
|
||||
|
||||
// Find the corresponding label to associate with the select
|
||||
const labelId = name; // Assuming label 'for' matches field name
|
||||
const labelText =
|
||||
name === "table" ? "environments.integrations.airtable.table_name" : "common.select_survey";
|
||||
|
||||
// Render a simple select element instead of the complex component
|
||||
// This makes interaction straightforward with userEvent.selectOptions
|
||||
return (
|
||||
<>
|
||||
{/* The actual label is rendered outside the Controller in the component */}
|
||||
<select
|
||||
id={labelId}
|
||||
aria-label={labelText} // Use aria-label for accessibility in tests
|
||||
{...field} // Spread field props
|
||||
defaultValue={defaultValue} // Pass defaultValue
|
||||
>
|
||||
{/* Need to dynamically get options based on context, simplified here */}
|
||||
{name === "table" &&
|
||||
mockTables.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
{name === "survey" &&
|
||||
mockSurveys.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
},
|
||||
reset: mockReset,
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
questions: [
|
||||
{ id: "q1", headline: { default: "Question 1" } },
|
||||
{ id: "q2", headline: { default: "Question 2" } },
|
||||
],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
|
||||
variables: { enabled: true, fieldIds: ["var1"] },
|
||||
} as any,
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Survey 2",
|
||||
questions: [{ id: "q3", headline: { default: "Question 3" } }],
|
||||
hiddenFields: { enabled: false },
|
||||
variables: { enabled: false },
|
||||
} as any,
|
||||
];
|
||||
const mockAirtableArray: TIntegrationItem[] = [
|
||||
{ id: "base1", name: "Base 1" },
|
||||
{ id: "base2", name: "Base 2" },
|
||||
];
|
||||
const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
id: "integration1",
|
||||
type: "airtable",
|
||||
environmentId,
|
||||
config: {
|
||||
key: { access_token: "abc" } as TIntegrationAirtableCredential,
|
||||
email: "test@test.com",
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
const mockTables: TIntegrationAirtableTables["tables"] = [
|
||||
{ id: "table1", name: "Table 1" },
|
||||
{ id: "table2", name: "Table 2" },
|
||||
];
|
||||
const mockSetOpenWithStates = vi.fn();
|
||||
const mockRouterRefresh = vi.fn();
|
||||
|
||||
describe("AddIntegrationModal", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useRouter).mockReturnValue({ refresh: mockRouterRefresh } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders in add mode correctly", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.link_airtable_table")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Base")).toBeInTheDocument();
|
||||
// Use getByLabelText for the mocked selects
|
||||
expect(screen.getByLabelText("environments.integrations.airtable.table_name")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("common.select_survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.save")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No Base Found' error when airtableArray is empty", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={[]}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("alert-title")).toHaveTextContent(
|
||||
"environments.integrations.airtable.no_bases_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("shows 'No Surveys Found' warning when surveys array is empty", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={[]}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.create_survey_warning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("fetches and displays tables when a base is selected", async () => {
|
||||
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const baseSelect = screen.getByLabelText("Base");
|
||||
await userEvent.selectOptions(baseSelect, "base1");
|
||||
|
||||
expect(fetchTables).toHaveBeenCalledWith(environmentId, "base1");
|
||||
await waitFor(() => {
|
||||
// Use getByLabelText (mocked select)
|
||||
const tableSelect = screen.getByLabelText("environments.integrations.airtable.table_name");
|
||||
expect(tableSelect).toBeEnabled();
|
||||
// Check options within the mocked select
|
||||
expect(tableSelect.querySelector("option[value='table1']")).toBeInTheDocument();
|
||||
expect(tableSelect.querySelector("option[value='table2']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles deletion in edit mode", async () => {
|
||||
const initialData: TIntegrationAirtableConfigData = {
|
||||
baseId: "base1",
|
||||
tableId: "table1",
|
||||
surveyId: "survey1",
|
||||
questionIds: ["q1"],
|
||||
questions: "common.selected_questions",
|
||||
tableName: "Table 1",
|
||||
surveyName: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
includeVariables: false,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
includeCreatedAt: true,
|
||||
};
|
||||
const integrationWithData = {
|
||||
...mockAirtableIntegration,
|
||||
config: { ...mockAirtableIntegration.config, data: [initialData] },
|
||||
};
|
||||
const defaultData = { ...initialData, index: 0 } as any;
|
||||
|
||||
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
|
||||
vi.mocked(createOrUpdateIntegrationAction).mockResolvedValue({ ok: true, data: {} } as any);
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={integrationWithData}
|
||||
isEditMode={true}
|
||||
defaultData={defaultData}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchTables).toHaveBeenCalled()); // Wait for initial load
|
||||
|
||||
// Click delete
|
||||
await userEvent.click(screen.getByText("common.delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledTimes(1);
|
||||
const submittedData = vi.mocked(createOrUpdateIntegrationAction).mock.calls[0][0].integrationData;
|
||||
// Expect data array to be empty after deletion
|
||||
expect(submittedData.config.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
|
||||
expect(mockRouterRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles cancel button click", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("common.cancel"));
|
||||
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "./AirtableWrapper";
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: ({ setIsConnected }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setIsConnected(false)}>Disconnect</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: ({ handleAuthorization, isEnabled }) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization} disabled={!isEnabled}>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock library function
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock image import
|
||||
vi.mock("@/images/airtableLogo.svg", () => ({
|
||||
default: "airtable-logo-path",
|
||||
}));
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const webAppUrl = "https://app.formbricks.com";
|
||||
const environment = { id: environmentId } as TEnvironment;
|
||||
const surveys = [];
|
||||
const airtableArray = [];
|
||||
const locale = "en-US" as const;
|
||||
|
||||
const baseProps = {
|
||||
environmentId,
|
||||
airtableArray,
|
||||
surveys,
|
||||
environment,
|
||||
webAppUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
describe("AirtableWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (no integration)", () => {
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (integration without key)", () => {
|
||||
const integrationWithoutKey = { config: {} } as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={integrationWithoutKey} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration disabled when isEnabled is false", () => {
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={false} airtableIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("calls authorize and redirects when Connect button is clicked", async () => {
|
||||
const mockAuthorize = vi.mocked(authorize);
|
||||
const redirectUrl = "https://airtable.com/auth";
|
||||
mockAuthorize.mockResolvedValue(redirectUrl);
|
||||
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
|
||||
await vi.waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders ManageIntegration when connected", () => {
|
||||
const connectedIntegration = {
|
||||
id: "int-1",
|
||||
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
|
||||
const connectedIntegration = {
|
||||
id: "int-1",
|
||||
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
|
||||
|
||||
// Initially, ManageIntegration is shown
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
|
||||
// Simulate disconnection via ManageIntegration's button
|
||||
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
|
||||
await userEvent.click(disconnectButton);
|
||||
|
||||
// Now, ConnectIntegration should be shown
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { IntegrationModalInputs } from "./AddIntegrationModal";
|
||||
import { BaseSelectDropdown } from "./BaseSelectDropdown";
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) => (
|
||||
<label htmlFor={htmlFor}>{children}</label>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/select", () => ({
|
||||
Select: ({ children, onValueChange, disabled, defaultValue }) => (
|
||||
<select
|
||||
data-testid="base-select"
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
defaultValue={defaultValue}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }) => <div>{children}</div>,
|
||||
SelectValue: () => <span>SelectValueMock</span>,
|
||||
SelectContent: ({ children }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }) => <option value={value}>{children}</option>,
|
||||
}));
|
||||
|
||||
// Mock react-hook-form's Controller specifically
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
// Keep the actual useForm
|
||||
const originalUseForm = actual.useForm;
|
||||
|
||||
// Mock Controller
|
||||
const MockController = ({ name, _, render, defaultValue }) => {
|
||||
// Minimal mock: call render with a basic field object
|
||||
const field = {
|
||||
onChange: vi.fn(), // Simple spy for field.onChange
|
||||
onBlur: vi.fn(),
|
||||
value: defaultValue, // Use defaultValue passed to Controller
|
||||
name: name,
|
||||
ref: vi.fn(),
|
||||
};
|
||||
// The component passes the render prop result to the actual Select component
|
||||
return render({ field });
|
||||
};
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useForm: originalUseForm, // Use the actual useForm
|
||||
Controller: MockController, // Use the mocked Controller
|
||||
};
|
||||
});
|
||||
|
||||
const mockAirtableArray: TIntegrationItem[] = [
|
||||
{ id: "base1", name: "Base One" },
|
||||
{ id: "base2", name: "Base Two" },
|
||||
];
|
||||
|
||||
const mockFetchTable = vi.fn();
|
||||
|
||||
// Use a wrapper component that utilizes the actual useForm
|
||||
const renderComponent = (
|
||||
isLoading = false,
|
||||
defaultValue: string | undefined = undefined,
|
||||
airtableArray = mockAirtableArray
|
||||
) => {
|
||||
const Component = () => {
|
||||
// Now uses the actual useForm because Controller is mocked separately
|
||||
const { control, setValue } = useForm<IntegrationModalInputs>({
|
||||
defaultValues: { base: defaultValue },
|
||||
});
|
||||
return (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
isLoading={isLoading}
|
||||
fetchTable={mockFetchTable} // The spy
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue} // Actual RHF setValue
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return render(<Component />);
|
||||
};
|
||||
|
||||
describe("BaseSelectDropdown", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the label and select trigger", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_base")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("base-select")).toBeInTheDocument();
|
||||
expect(screen.getByText("SelectValueMock")).toBeInTheDocument(); // From mocked SelectValue
|
||||
});
|
||||
|
||||
test("renders options from airtableArray", () => {
|
||||
renderComponent();
|
||||
const select = screen.getByTestId("base-select");
|
||||
expect(select.querySelectorAll("option")).toHaveLength(mockAirtableArray.length);
|
||||
expect(screen.getByText("Base One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Base Two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("disables the select when isLoading is true", () => {
|
||||
renderComponent(true);
|
||||
expect(screen.getByTestId("base-select")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables the select when isLoading is false", () => {
|
||||
renderComponent(false);
|
||||
expect(screen.getByTestId("base-select")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("renders correctly with empty airtableArray", () => {
|
||||
renderComponent(false, undefined, []);
|
||||
const select = screen.getByTestId("base-select");
|
||||
expect(select.querySelectorAll("option")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
|
||||
import { authorize, fetchTables } from "./airtable";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const baseId = "test-base-id";
|
||||
const apiHost = "http://localhost:3000";
|
||||
|
||||
describe("Airtable Library", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchTables", () => {
|
||||
test("should fetch tables successfully", async () => {
|
||||
const mockTables: TIntegrationAirtableTables = {
|
||||
tables: [
|
||||
{ id: "tbl1", name: "Table 1" },
|
||||
{ id: "tbl2", name: "Table 2" },
|
||||
],
|
||||
};
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({ data: mockTables }),
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const tables = await fetchTables(environmentId, baseId);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
cache: "no-store",
|
||||
});
|
||||
expect(tables).toEqual(mockTables);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorize", () => {
|
||||
test("should return authUrl successfully", async () => {
|
||||
const mockAuthUrl = "https://airtable.com/oauth2/v1/authorize?...";
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
});
|
||||
|
||||
test("should throw error and log when fetch fails", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable, TIntegrationAirtableCredential } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
|
||||
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
|
||||
vi.mock("@/lib/airtable/service");
|
||||
|
||||
let mockAirtableClientId: string | undefined = "test-client-id";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get AIRTABLE_CLIENT_ID() {
|
||||
return mockAirtableClientId;
|
||||
},
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
IS_PRODUCTION: true,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service");
|
||||
vi.mock("@/lib/utils/locale");
|
||||
vi.mock("@/modules/environments/lib/utils");
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(() => <div>GoBackButton Mock</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation");
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockSurveys: TSurvey[] = [{ id: "survey1", name: "Survey 1" } as TSurvey];
|
||||
const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
type: "airtable",
|
||||
config: {
|
||||
key: { access_token: "test-token" } as unknown as TIntegrationAirtableCredential,
|
||||
data: [],
|
||||
email: "test@example.com",
|
||||
},
|
||||
environmentId: mockEnvironmentId,
|
||||
id: "int_airtable_123",
|
||||
};
|
||||
const mockAirtableTables: TIntegrationItem[] = [{ id: "table1", name: "Table 1" } as TIntegrationItem];
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const props = {
|
||||
params: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
};
|
||||
|
||||
describe("Airtable Integration Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([mockAirtableIntegration]);
|
||||
vi.mocked(getAirtableTables).mockResolvedValue(mockAirtableTables);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects if user is readOnly", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
await render(await Page(props));
|
||||
expect(redirect).toHaveBeenCalledWith("./");
|
||||
});
|
||||
|
||||
test("renders correctly when integration is configured", async () => {
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("GoBackButton Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurveys)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getIntegrations)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getAirtableTables)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(findMatchingLocale)).toHaveBeenCalled();
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true,
|
||||
airtableIntegration: mockAirtableIntegration,
|
||||
airtableArray: mockAirtableTables,
|
||||
environmentId: mockEnvironmentId,
|
||||
surveys: mockSurveys,
|
||||
environment: mockEnvironment,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correctly when integration exists but is not configured (no key)", async () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockAirtableIntegration,
|
||||
config: { ...mockAirtableIntegration.config, key: undefined },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
vi.mocked(getIntegrations).mockResolvedValue([integrationWithoutKey]);
|
||||
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getAirtableTables)).not.toHaveBeenCalled(); // Should not fetch tables if no key
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
// Update assertion to match the actual call
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true, // isEnabled is true because AIRTABLE_CLIENT_ID is set in beforeEach
|
||||
airtableIntegration: integrationWithoutKey,
|
||||
airtableArray: [], // Should be empty as getAirtableTables is not called
|
||||
environmentId: mockEnvironmentId,
|
||||
surveys: mockSurveys,
|
||||
environment: mockEnvironment,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined // Change second argument to undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correctly when integration is disabled (no client ID)", async () => {
|
||||
mockAirtableClientId = undefined; // Simulate disabled integration
|
||||
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isEnabled: false, // Should be false
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
|
||||
getSpreadsheetNameByIdAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
|
||||
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
|
||||
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
|
||||
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
AdditionalIntegrationSettings: ({
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
setIncludeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}: any) => (
|
||||
<div>
|
||||
<span>Additional Settings</span>
|
||||
<input
|
||||
data-testid="include-variables"
|
||||
type="checkbox"
|
||||
checked={includeVariables}
|
||||
onChange={(e) => setIncludeVariables(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-hidden-fields"
|
||||
type="checkbox"
|
||||
checked={includeHiddenFields}
|
||||
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-metadata"
|
||||
type="checkbox"
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-created-at"
|
||||
type="checkbox"
|
||||
checked={includeCreatedAt}
|
||||
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
DropdownSelector: ({ label, items, selectedItem, setSelectedItem }: any) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid="survey-dropdown"
|
||||
value={selectedItem?.id || ""}
|
||||
onChange={(e) => {
|
||||
const selected = items.find((item: any) => item.id === e.target.value);
|
||||
setSelectedItem(selected);
|
||||
}}>
|
||||
<option value="">Select a survey</option>
|
||||
{items.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
}));
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
handleSubmit: (callback: any) => (event: any) => {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tolgee/react", async () => {
|
||||
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const useTranslate = () => ({
|
||||
t: (key: string, _?: any) => {
|
||||
// NOSONAR
|
||||
// Simple mock translation function
|
||||
if (key === "common.all_questions") return "All questions";
|
||||
if (key === "common.selected_questions") return "Selected questions";
|
||||
if (key === "environments.integrations.google_sheets.link_google_sheet") return "Link Google Sheet";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
if (key === "environments.integrations.google_sheets.spreadsheet_url") return "Spreadsheet URL";
|
||||
if (key === "common.select_survey") return "Select survey";
|
||||
if (key === "common.questions") return "Questions";
|
||||
if (key === "environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error")
|
||||
return "Please enter a valid Google Sheet URL.";
|
||||
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
|
||||
if (key === "environments.integrations.select_at_least_one_question_error")
|
||||
return "Please select at least one question.";
|
||||
if (key === "environments.integrations.integration_updated_successfully")
|
||||
return "Integration updated successfully.";
|
||||
if (key === "environments.integrations.integration_added_successfully")
|
||||
return "Integration added successfully.";
|
||||
if (key === "environments.integrations.integration_removed_successfully")
|
||||
return "Integration removed successfully.";
|
||||
if (key === "environments.integrations.google_sheets.google_sheet_logo") return "Google Sheet logo";
|
||||
if (key === "environments.integrations.google_sheets.google_sheets_integration_description")
|
||||
return "Sync responses with Google Sheets.";
|
||||
if (key === "environments.integrations.create_survey_warning")
|
||||
return "You need to create a survey first.";
|
||||
return key; // Return key if no translation is found
|
||||
},
|
||||
});
|
||||
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const getSpreadsheetNameByIdAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
|
||||
.getSpreadsheetNameByIdAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const surveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 2?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "c2", label: { default: "Choice 2" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 2",
|
||||
type: "link",
|
||||
environmentId: environmentId,
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate this?" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "integration1",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
key: {
|
||||
access_token: "mock_access_token",
|
||||
expiry_date: Date.now() + 3600000,
|
||||
refresh_token: "mock_refresh_token",
|
||||
scope: "mock_scope",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
email: "test@example.com",
|
||||
data: [], // Initially empty, will be populated in beforeEach
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
const mockSelectedIntegration: TIntegrationGoogleSheetsConfigData & { index: number } = {
|
||||
spreadsheetId: "existing-sheet-id",
|
||||
spreadsheetName: "Existing Sheet",
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: [surveys[0].questions[0].id],
|
||||
questions: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: false,
|
||||
index: 0,
|
||||
};
|
||||
|
||||
describe("AddIntegrationModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset integration data before each test if needed
|
||||
mockGoogleSheetIntegration.config.data = [
|
||||
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
|
||||
];
|
||||
});
|
||||
|
||||
test("renders correctly when open (create mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toBeInTheDocument();
|
||||
// Use getByTestId for the dropdown
|
||||
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Link Google Sheet" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when open (update mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toHaveValue("https://docs.google.com/spreadsheets/d/existing-sheet-id");
|
||||
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("include-variables")).toBeChecked();
|
||||
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
|
||||
expect(screen.getByTestId("include-metadata")).toBeChecked();
|
||||
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("selects survey and shows questions", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
|
||||
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
surveys[1].questions.forEach((q) => {
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
|
||||
// Initially all questions should be checked when a survey is selected in create mode
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles question selection", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
|
||||
});
|
||||
|
||||
test("creates integration successfully", async () => {
|
||||
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Test Sheet Name" });
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); // Mock successful action
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={{
|
||||
...mockGoogleSheetIntegration,
|
||||
config: { ...mockGoogleSheetIntegration.config, data: [] },
|
||||
}} // Start with empty data
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/new-sheet-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Wait for questions to appear and potentially uncheck one
|
||||
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
|
||||
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
|
||||
|
||||
// Check additional settings
|
||||
await userEvent.click(screen.getByTestId("include-variables"));
|
||||
await userEvent.click(screen.getByTestId("include-metadata"));
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpreadsheetNameByIdAction).toHaveBeenCalledWith({
|
||||
googleSheetIntegration: expect.any(Object),
|
||||
environmentId,
|
||||
spreadsheetId: "new-sheet-id",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
type: "googleSheets",
|
||||
config: expect.objectContaining({
|
||||
key: mockGoogleSheetIntegration.config.key,
|
||||
email: mockGoogleSheetIntegration.config.email,
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
spreadsheetId: "new-sheet-id",
|
||||
spreadsheetName: "Test Sheet Name",
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
|
||||
questions: "Selected questions",
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true, // Default
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes integration successfully", async () => {
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration} // Contains initial data at index 0
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByText("Delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
data: [], // Data array should be empty after deletion
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error for invalid URL", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "invalid-url");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please enter a valid Google Sheet URL.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no survey selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
|
||||
// No survey selected
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no questions selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Uncheck all questions
|
||||
for (const question of surveys[0].questions) {
|
||||
const checkbox = await screen.findByLabelText(question.headline.default);
|
||||
await userEvent.click(checkbox);
|
||||
}
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
|
||||
const errorMessage = "Failed to update integration";
|
||||
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Some Sheet Name" });
|
||||
createOrUpdateIntegrationAction.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/another-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpreadsheetNameByIdAction).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls setOpen(false) and resets form on cancel", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
|
||||
// Simulate some interaction
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/temp-id");
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Re-render with open=true to check if state was reset (URL should be empty)
|
||||
cleanup();
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
// Use getByPlaceholderText for the input check after re-render
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsCredential,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components and functions
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
|
||||
</div>
|
||||
)),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: vi.fn(({ handleAuthorization }) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization}>Connect</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: vi.fn(({ open }) =>
|
||||
open ? <div data-testid="add-integration-modal">Modal</div> : null
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
|
||||
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [];
|
||||
const mockWebAppUrl = "http://localhost:3000";
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "test-integration-id",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
key: { access_token: "test-token" } as unknown as TIntegrationGoogleSheetsCredential,
|
||||
data: [],
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
describe("GoogleSheetWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected", () => {
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
// No googleSheetIntegration provided initially
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when integration exists but has no key", () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockGoogleSheetIntegration,
|
||||
config: { data: [], email: "test" },
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={integrationWithoutKey}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls authorize when connect button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Mock window.location.replace
|
||||
const originalLocation = window.location;
|
||||
// @ts-expect-error
|
||||
delete window.location;
|
||||
window.location = { ...originalLocation, replace: vi.fn() } as any;
|
||||
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await user.click(connectButton);
|
||||
|
||||
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
|
||||
// Need to wait for the promise returned by authorize to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
|
||||
});
|
||||
|
||||
// Restore window.location
|
||||
window.location = originalLocation as any;
|
||||
});
|
||||
|
||||
test("renders ManageIntegration and AddIntegrationModal when connected", () => {
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
// Modal is rendered but initially hidden
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens AddIntegrationModal when triggered from ManageIntegration", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
const openModalButton = screen.getByRole("button", { name: "Open Modal" }); // Button inside mocked ManageIntegration
|
||||
await user.click(openModalButton);
|
||||
expect(screen.getByTestId("add-integration-modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { authorize } from "./google";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("authorize", () => {
|
||||
const environmentId = "test-env-id";
|
||||
const apiHost = "http://test.com";
|
||||
const expectedUrl = `${apiHost}/api/google-sheet`;
|
||||
const expectedHeaders = { environmentId: environmentId };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return authUrl on successful fetch", async () => {
|
||||
const mockAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?...";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
});
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw error and log on failed fetch", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
});
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ errorText },
|
||||
"authorize: Could not fetch google sheet config"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { constructGoogleSheetsUrl, extractSpreadsheetIdFromUrl, isValidGoogleSheetsUrl } from "./util";
|
||||
|
||||
describe("Google Sheets Util", () => {
|
||||
describe("extractSpreadsheetIdFromUrl", () => {
|
||||
test("should extract spreadsheet ID from a valid URL", () => {
|
||||
const url =
|
||||
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
|
||||
const expectedId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
|
||||
expect(extractSpreadsheetIdFromUrl(url)).toBe(expectedId);
|
||||
});
|
||||
|
||||
test("should throw an error for an invalid URL", () => {
|
||||
const invalidUrl = "https://not-a-google-sheet-url.com";
|
||||
expect(() => extractSpreadsheetIdFromUrl(invalidUrl)).toThrow("Invalid Google Sheets URL");
|
||||
});
|
||||
|
||||
test("should throw an error for a URL without an ID", () => {
|
||||
const urlWithoutId = "https://docs.google.com/spreadsheets/d/";
|
||||
expect(() => extractSpreadsheetIdFromUrl(urlWithoutId)).toThrow("Invalid Google Sheets URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructGoogleSheetsUrl", () => {
|
||||
test("should construct a valid Google Sheets URL from a spreadsheet ID", () => {
|
||||
const spreadsheetId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
|
||||
const expectedUrl =
|
||||
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
|
||||
expect(constructGoogleSheetsUrl(spreadsheetId)).toBe(expectedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidGoogleSheetsUrl", () => {
|
||||
test("should return true for a valid Google Sheets URL", () => {
|
||||
const validUrl =
|
||||
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
|
||||
expect(isValidGoogleSheetsUrl(validUrl)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for an invalid URL", () => {
|
||||
const invalidUrl = "https://not-a-google-sheet-url.com";
|
||||
expect(isValidGoogleSheetsUrl(invalidUrl)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for a base Google Sheets URL", () => {
|
||||
const baseUrl = "https://docs.google.com/spreadsheets/d/";
|
||||
expect(isValidGoogleSheetsUrl(baseUrl)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock the GoBackButton component
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: () => <div>GoBackButton</div>,
|
||||
}));
|
||||
|
||||
describe("Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the loading state correctly", () => {
|
||||
render(<Loading />);
|
||||
|
||||
// Check for GoBackButton mock
|
||||
expect(screen.getByText("GoBackButton")).toBeInTheDocument();
|
||||
|
||||
// Check for the disabled button text
|
||||
expect(screen.getByText("environments.integrations.google_sheets.link_new_sheet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.google_sheets.link_new_sheet").closest("button")
|
||||
).toHaveClass("pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none");
|
||||
|
||||
// Check for table headers
|
||||
expect(screen.getByText("common.survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.google_sheets.google_sheet_name")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.questions")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
|
||||
|
||||
// Check for placeholder elements (count based on the loop)
|
||||
const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles
|
||||
// Calculate expected placeholders: 3 rows * 5 placeholders per row = 15
|
||||
// Plus the button, header divs (4), and the main containers
|
||||
// It's simpler to check if there are *any* pulse animations
|
||||
expect(placeholders.some((el) => el.classList.contains("animate-pulse"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsCredential,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper",
|
||||
() => ({
|
||||
GoogleSheetWrapper: vi.fn(
|
||||
({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => (
|
||||
<div>
|
||||
<span>Mocked GoogleSheetWrapper</span>
|
||||
<span data-testid="isEnabled">{isEnabled.toString()}</span>
|
||||
<span data-testid="environmentId">{environment.id}</span>
|
||||
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
|
||||
<span data-testid="integrationId">{googleSheetIntegration?.id}</span>
|
||||
<span data-testid="webAppUrl">{webAppUrl}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
let mockGoogleSheetClientId: string | undefined = "test-client-id";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get GOOGLE_SHEETS_CLIENT_ID() {
|
||||
return mockGoogleSheetClientId;
|
||||
},
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
}));
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
type: "app",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "integration1",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
data: [],
|
||||
key: {
|
||||
refresh_token: "refresh",
|
||||
access_token: "access",
|
||||
expiry_date: Date.now() + 3600000,
|
||||
} as unknown as TIntegrationGoogleSheetsCredential,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
const mockProps = {
|
||||
params: { environmentId: "test-env-id" },
|
||||
};
|
||||
|
||||
describe("GoogleSheetsIntegrationPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
});
|
||||
|
||||
test("renders the page with GoogleSheetWrapper when enabled and not read-only", async () => {
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.integrations.google_sheets.google_sheets_integration")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("isEnabled")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
|
||||
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
|
||||
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockGoogleSheetIntegration.id);
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||
);
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls redirect when user is read-only", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
|
||||
});
|
||||
|
||||
test("passes isEnabled=false to GoogleSheetWrapper when constants are missing", async () => {
|
||||
mockGoogleSheetClientId = undefined;
|
||||
|
||||
const { default: PageWithMissingConstants } = (await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"
|
||||
)) as { default: typeof Page };
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
|
||||
const PageComponent = await PageWithMissingConstants(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("isEnabled")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
test("handles case where no Google Sheet integration exists", async () => {
|
||||
vi.mocked(getIntegrations).mockResolvedValue([]); // No integrations
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/survey/cache", () => ({
|
||||
surveyCache: {
|
||||
tag: {
|
||||
byEnvironmentId: vi.fn((environmentId) => `survey_environment_${environmentId}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage
|
||||
}));
|
||||
vi.mock("@/lib/survey/utils");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
return {
|
||||
...actual,
|
||||
cache: vi.fn((fn) => fn), // Mock reactCache to just return the function
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
|
||||
const mockPrismaSurveys = [
|
||||
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
|
||||
{ id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() },
|
||||
];
|
||||
const mockTransformedSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
type: "app", // Changed type to web to match original file
|
||||
environmentId: environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Survey 2",
|
||||
status: "draft",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
describe("getSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
test("should fetch and transform surveys successfully", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
|
||||
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => {
|
||||
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
|
||||
if (!found) throw new Error("Survey not found in mock transformed data");
|
||||
// Ensure the returned object matches the TSurvey structure precisely
|
||||
return { ...found } as TSurvey;
|
||||
});
|
||||
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
expect(surveys).toEqual(mockTransformedSurveys);
|
||||
// Use expect.any(ZId) for the Zod schema validation check
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
status: {
|
||||
not: "completed",
|
||||
},
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
|
||||
// Check if the inner cache function was called with the correct arguments
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function), // The async function passed to cache
|
||||
[`getSurveys-${environmentId}`], // The cache key
|
||||
{
|
||||
tags: [surveyCache.tag.byEnvironmentId(environmentId)], // Cache tags
|
||||
}
|
||||
);
|
||||
// Remove the assertion for reactCache being called within the test execution
|
||||
// expect(reactCache).toHaveBeenCalled(); // Removed this line
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
// No need to mock cache here again as beforeEach handles it
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
meta: {}, // Added meta property
|
||||
});
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
|
||||
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
|
||||
});
|
||||
|
||||
test("should throw original error on other errors", async () => {
|
||||
// No need to mock cache here again as beforeEach handles it
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getWebhookCountBySource } from "./webhook";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
tag: {
|
||||
byEnvironmentIdAndSource: vi.fn((envId, source) => `webhook_${envId}_${source ?? "all"}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const sourceZapier = "zapier";
|
||||
|
||||
describe("getWebhookCountBySource", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return webhook count for a specific source", async () => {
|
||||
const mockCount = 5;
|
||||
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
|
||||
|
||||
const count = await getWebhookCountBySource(environmentId, sourceZapier);
|
||||
|
||||
expect(count).toBe(mockCount);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[environmentId, expect.any(Object)],
|
||||
[sourceZapier, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
source: sourceZapier,
|
||||
},
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getWebhookCountBySource-${environmentId}-${sourceZapier}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, sourceZapier)],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("should return total webhook count when source is undefined", async () => {
|
||||
const mockCount = 10;
|
||||
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
|
||||
|
||||
const count = await getWebhookCountBySource(environmentId);
|
||||
|
||||
expect(count).toBe(mockCount);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[environmentId, expect.any(Object)],
|
||||
[undefined, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
source: undefined,
|
||||
},
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getWebhookCountBySource-${environmentId}-undefined`],
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, undefined)],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw original error on other errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionCredential,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
|
||||
}));
|
||||
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
getQuestionTypes: () => [
|
||||
{ id: TSurveyQuestionTypeEnum.OpenText, label: "Open Text" },
|
||||
{ id: TSurveyQuestionTypeEnum.MultipleChoiceSingle, label: "Multiple Choice Single" },
|
||||
{ id: TSurveyQuestionTypeEnum.Date, label: "Date" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
|
||||
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
|
||||
{loading ? "Loading..." : children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
|
||||
// Ensure the selected item is always available as an option
|
||||
const allOptions = [...items];
|
||||
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
|
||||
// Use a simple object structure consistent with how options are likely used
|
||||
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
|
||||
}
|
||||
// Remove duplicates just in case
|
||||
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<select
|
||||
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
|
||||
onChange={(e) => {
|
||||
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
|
||||
setSelectedItem(selected);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<option value="">{placeholder || "Select..."}</option>
|
||||
{/* Render options from the potentially augmented list */}
|
||||
{uniqueOptions.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
PlusIcon: () => <span data-testid="plus-icon">+</span>,
|
||||
XIcon: () => <span data-testid="x-icon">x</span>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
}));
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
handleSubmit: (callback: any) => (event: any) => {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tolgee/react", async () => {
|
||||
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const useTranslate = () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
// NOSONAR
|
||||
// Simple mock translation function
|
||||
if (key === "common.warning") return "Warning";
|
||||
if (key === "common.metadata") return "Metadata";
|
||||
if (key === "common.created_at") return "Created at";
|
||||
if (key === "common.hidden_field") return "Hidden Field";
|
||||
if (key === "environments.integrations.notion.link_notion_database") return "Link Notion Database";
|
||||
if (key === "environments.integrations.notion.sync_responses_with_a_notion_database")
|
||||
return "Sync responses with a Notion database.";
|
||||
if (key === "environments.integrations.notion.select_a_database") return "Select a database";
|
||||
if (key === "common.select_survey") return "Select survey";
|
||||
if (key === "environments.integrations.notion.map_formbricks_fields_to_notion_property")
|
||||
return "Map Formbricks fields to Notion property";
|
||||
if (key === "environments.integrations.notion.select_a_survey_question")
|
||||
return "Select a survey question";
|
||||
if (key === "environments.integrations.notion.select_a_field_to_map") return "Select a field to map";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "environments.integrations.notion.please_select_a_database")
|
||||
return "Please select a database.";
|
||||
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
|
||||
if (key === "environments.integrations.notion.please_select_at_least_one_mapping")
|
||||
return "Please select at least one mapping.";
|
||||
if (key === "environments.integrations.notion.please_resolve_mapping_errors")
|
||||
return "Please resolve mapping errors.";
|
||||
if (key === "environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
||||
return "Please complete mapping fields.";
|
||||
if (key === "environments.integrations.integration_updated_successfully")
|
||||
return "Integration updated successfully.";
|
||||
if (key === "environments.integrations.integration_added_successfully")
|
||||
return "Integration added successfully.";
|
||||
if (key === "environments.integrations.integration_removed_successfully")
|
||||
return "Integration removed successfully.";
|
||||
if (key === "environments.integrations.notion.notion_logo") return "Notion logo";
|
||||
if (key === "environments.integrations.create_survey_warning")
|
||||
return "You need to create a survey first.";
|
||||
if (key === "environments.integrations.notion.create_at_least_one_database_to_setup_this_integration")
|
||||
return "Create at least one database.";
|
||||
if (key === "environments.integrations.notion.duplicate_connection_warning")
|
||||
return "Duplicate connection warning.";
|
||||
if (key === "environments.integrations.notion.que_name_of_type_cant_be_mapped_to")
|
||||
return `Question ${params.que_name} (${params.question_label}) can't be mapped to ${params.col_name} (${params.col_type}). Allowed types: ${params.mapped_type}`;
|
||||
|
||||
return key; // Return key if no translation is found
|
||||
},
|
||||
});
|
||||
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const surveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 2?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "c2", label: { default: "Choice 2" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
variables: [{ id: "var1", name: "Variable 1" }],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 2",
|
||||
type: "link",
|
||||
environmentId: environmentId,
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
headline: { default: "Date Question?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
variables: [],
|
||||
hiddenFields: { enabled: false },
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const databases: TIntegrationNotionDatabase[] = [
|
||||
{
|
||||
id: "db1",
|
||||
name: "Database 1 Title",
|
||||
properties: {
|
||||
prop1: { id: "p1", name: "Title Prop", type: "title" },
|
||||
prop2: { id: "p2", name: "Text Prop", type: "rich_text" },
|
||||
prop3: { id: "p3", name: "Number Prop", type: "number" },
|
||||
prop4: { id: "p4", name: "Date Prop", type: "date" },
|
||||
prop5: { id: "p5", name: "Unsupported Prop", type: "formula" }, // Unsupported
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db2",
|
||||
name: "Database 2 Title",
|
||||
properties: {
|
||||
propA: { id: "pa", name: "Name", type: "title" },
|
||||
propB: { id: "pb", name: "Email", type: "email" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockNotionIntegration: TIntegrationNotion = {
|
||||
id: "integration1",
|
||||
type: "notion",
|
||||
environmentId: environmentId,
|
||||
config: {
|
||||
key: {
|
||||
access_token: "token",
|
||||
bot_id: "bot",
|
||||
workspace_name: "ws",
|
||||
workspace_icon: "",
|
||||
} as unknown as TIntegrationNotionCredential,
|
||||
data: [], // Initially empty
|
||||
},
|
||||
};
|
||||
|
||||
const mockSelectedIntegration: TIntegrationNotionConfigData & { index: number } = {
|
||||
databaseId: databases[0].id,
|
||||
databaseName: databases[0].name,
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
mapping: [
|
||||
{
|
||||
column: { id: "p1", name: "Title Prop", type: "title" },
|
||||
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
},
|
||||
{
|
||||
column: { id: "p2", name: "Text Prop", type: "rich_text" },
|
||||
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
describe("AddIntegrationModal (Notion)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset integration data before each test if needed
|
||||
mockNotionIntegration.config.data = [
|
||||
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
|
||||
];
|
||||
});
|
||||
|
||||
test("renders correctly when open (create mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Map Formbricks fields to Notion property")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when open (update mode)", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={mockNotionIntegration}
|
||||
databases={databases}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
|
||||
// Check if mapping rows are rendered
|
||||
await waitFor(() => {
|
||||
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
|
||||
const columnDropdowns = screen.getAllByTestId("dropdown-select-a-field-to-map");
|
||||
|
||||
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
|
||||
expect(columnDropdowns).toHaveLength(2);
|
||||
|
||||
// Assert values for the first row
|
||||
expect(questionDropdowns[0]).toHaveValue("q1");
|
||||
expect(columnDropdowns[0]).toHaveValue("p1");
|
||||
|
||||
// Assert values for the second row
|
||||
expect(questionDropdowns[1]).toHaveValue("var1");
|
||||
expect(columnDropdowns[1]).toHaveValue("p2");
|
||||
|
||||
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("selects database and survey, shows mapping", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
|
||||
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
|
||||
|
||||
await userEvent.selectOptions(dbDropdown, databases[0].id);
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-survey-question")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-field-to-map")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adds and removes mapping rows", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
|
||||
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
|
||||
|
||||
await userEvent.selectOptions(dbDropdown, databases[0].id);
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
|
||||
|
||||
const plusButton = screen.getByTestId("plus-icon");
|
||||
await userEvent.click(plusButton);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
|
||||
|
||||
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
|
||||
await userEvent.click(xButton);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("deletes integration successfully", async () => {
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={mockNotionIntegration} // Contains initial data at index 0
|
||||
databases={databases}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByText("Delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
data: [], // Data array should be empty after deletion
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error if no database selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a database.");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error if no survey selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error if no mapping defined", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
|
||||
// Default mapping row is empty
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select at least one mapping.");
|
||||
});
|
||||
});
|
||||
|
||||
test("calls setOpen(false) and resets form on cancel", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
|
||||
await userEvent.selectOptions(dbDropdown, databases[0].id); // Simulate interaction
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Re-render with open=true to check if state was reset
|
||||
cleanup();
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(""); // Should be reset
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionCredential } from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { NotionWrapper } from "./NotionWrapper";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration", () => ({
|
||||
ManageIntegration: vi.fn(({ setIsConnected }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setIsConnected(false)}>Disconnect</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: vi.fn(
|
||||
(
|
||||
{ handleAuthorization, isEnabled } // Reverted back to isEnabled
|
||||
) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization} disabled={!isEnabled}>
|
||||
{" "}
|
||||
{/* Reverted back to isEnabled */}
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock library function
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock image import
|
||||
vi.mock("@/images/notion-logo.svg", () => ({
|
||||
default: "notion-logo-path",
|
||||
}));
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const webAppUrl = "https://app.formbricks.com";
|
||||
const environment = { id: environmentId } as TEnvironment;
|
||||
const surveys: TSurvey[] = [];
|
||||
const databases = [];
|
||||
const locale = "en-US" as const;
|
||||
|
||||
const mockNotionIntegration: TIntegrationNotion = {
|
||||
id: "int-notion-123",
|
||||
type: "notion",
|
||||
environmentId: environmentId,
|
||||
config: {
|
||||
key: { access_token: "test-token" } as TIntegrationNotionCredential,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
environment,
|
||||
surveys,
|
||||
databasesArray: databases, // Renamed databases to databasesArray to match component prop
|
||||
webAppUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
describe("NotionWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration disabled when enabled is false", () => {
|
||||
// Changed description slightly
|
||||
render(<NotionWrapper {...baseProps} enabled={false} notionIntegration={undefined} />); // Changed isEnabled to enabled
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration enabled when enabled is true and not connected (no integration)", () => {
|
||||
// Changed description slightly
|
||||
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration enabled when enabled is true and not connected (integration without key)", () => {
|
||||
// Changed description slightly
|
||||
const integrationWithoutKey = {
|
||||
...mockNotionIntegration,
|
||||
config: { data: [] },
|
||||
} as unknown as TIntegrationNotion;
|
||||
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={integrationWithoutKey} />); // Changed isEnabled to enabled
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls authorize and redirects when Connect button is clicked", async () => {
|
||||
const mockAuthorize = vi.mocked(authorize);
|
||||
const redirectUrl = "https://notion.com/auth";
|
||||
mockAuthorize.mockResolvedValue(redirectUrl);
|
||||
|
||||
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { authorize } from "./notion";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("authorize", () => {
|
||||
const environmentId = "test-env-id";
|
||||
const apiHost = "http://test.com";
|
||||
const expectedUrl = `${apiHost}/api/v1/integrations/notion`;
|
||||
const expectedHeaders = { environmentId: environmentId };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return authUrl on successful fetch", async () => {
|
||||
const mockAuthUrl = "https://api.notion.com/v1/oauth/authorize?...";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
});
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw error and log on failed fetch", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
});
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch notion config");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, className }: { children: React.ReactNode; className: string }) => (
|
||||
<button className={className}>{children}</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: () => <div data-testid="go-back-button">Go Back</div>,
|
||||
}));
|
||||
|
||||
// Mock @tolgee/react
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key, // Simple mock translation
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Notion Integration Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state correctly", () => {
|
||||
render(<Loading />);
|
||||
|
||||
// Check for GoBackButton mock
|
||||
expect(screen.getByTestId("go-back-button")).toBeInTheDocument();
|
||||
|
||||
// Check for the disabled button
|
||||
const linkButton = screen.getByText("environments.integrations.notion.link_database");
|
||||
expect(linkButton).toBeInTheDocument();
|
||||
expect(linkButton.closest("button")).toHaveClass(
|
||||
"pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"
|
||||
);
|
||||
|
||||
// Check for table headers
|
||||
expect(screen.getByText("common.survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion.database_name")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
|
||||
|
||||
// Check for placeholder elements (skeleton loaders)
|
||||
// There should be 3 rows * 5 pulse divs per row = 15 pulse divs
|
||||
const pulseDivs = screen.getAllByText("", { selector: "div.animate-pulse" });
|
||||
expect(pulseDivs.length).toBeGreaterThanOrEqual(15); // Check if at least 15 pulse divs are rendered
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getNotionDatabases } from "@/lib/notion/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({
|
||||
NotionWrapper: vi.fn(
|
||||
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
|
||||
<div>
|
||||
<span>Mocked NotionWrapper</span>
|
||||
<span data-testid="enabled">{enabled.toString()}</span>
|
||||
<span data-testid="environmentId">{environment.id}</span>
|
||||
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
|
||||
<span data-testid="integrationId">{notionIntegration?.id}</span>
|
||||
<span data-testid="webAppUrl">{webAppUrl}</span>
|
||||
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
let mockNotionClientId: string | undefined = "test-client-id";
|
||||
let mockNotionClientSecret: string | undefined = "test-client-secret";
|
||||
let mockNotionAuthUrl: string | undefined = "https://notion.com/auth";
|
||||
let mockNotionRedirectUri: string | undefined = "https://app.formbricks.com/redirect";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get NOTION_OAUTH_CLIENT_ID() {
|
||||
return mockNotionClientId;
|
||||
},
|
||||
get NOTION_OAUTH_CLIENT_SECRET() {
|
||||
return mockNotionClientSecret;
|
||||
},
|
||||
get NOTION_AUTH_URL() {
|
||||
return mockNotionAuthUrl;
|
||||
},
|
||||
get NOTION_REDIRECT_URI() {
|
||||
return mockNotionRedirectUri;
|
||||
},
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrationByType: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notion/service", () => ({
|
||||
getNotionDatabases: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
type: "app",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockNotionIntegration = {
|
||||
id: "integration1",
|
||||
type: "notion",
|
||||
config: {
|
||||
data: [],
|
||||
key: { bot_id: "bot-id-123" },
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as unknown as TIntegrationNotion;
|
||||
|
||||
const mockDatabases: TIntegrationNotionDatabase[] = [
|
||||
{ id: "db1", name: "Database 1", properties: {} },
|
||||
{ id: "db2", name: "Database 2", properties: {} },
|
||||
];
|
||||
|
||||
const mockProps = {
|
||||
params: { environmentId: "test-env-id" },
|
||||
};
|
||||
|
||||
describe("NotionIntegrationPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(mockNotionIntegration);
|
||||
vi.mocked(getNotionDatabases).mockResolvedValue(mockDatabases);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
mockNotionClientId = "test-client-id";
|
||||
mockNotionClientSecret = "test-client-secret";
|
||||
mockNotionAuthUrl = "https://notion.com/auth";
|
||||
mockNotionRedirectUri = "https://app.formbricks.com/redirect";
|
||||
});
|
||||
|
||||
test("renders the page with NotionWrapper when enabled and not read-only", async () => {
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("environments.integrations.notion.notion_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
|
||||
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
|
||||
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockNotionIntegration.id);
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||
);
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
|
||||
});
|
||||
|
||||
test("calls redirect when user is read-only", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
|
||||
});
|
||||
|
||||
test("passes enabled=false to NotionWrapper when constants are missing", async () => {
|
||||
mockNotionClientId = undefined; // Simulate missing constant
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("enabled")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
test("handles case where no Notion integration exists", async () => {
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(null); // No integration
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
|
||||
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles case where integration exists but has no key (bot_id)", async () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, key: undefined },
|
||||
} as unknown as TIntegrationNotion;
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(integrationWithoutKey);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("integrationId")).toHaveTextContent(integrationWithoutKey.id);
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
|
||||
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/page";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegration } from "@formbricks/types/integration";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/webhook", () => ({
|
||||
getWebhookCountBySource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrations: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/integration-card", () => ({
|
||||
Card: ({ label, description, statusText, disabled }) => (
|
||||
<div data-testid={`card-${label}`}>
|
||||
<h1>{label}</h1>
|
||||
<p>{description}</p>
|
||||
<span>{statusText}</span>
|
||||
{disabled && <span>Disabled</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle }) => <h1>{pageTitle}</h1>,
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ alt }) => <img alt={alt} />,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: true,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockIntegrations: TIntegration[] = [
|
||||
{
|
||||
id: "google-sheets-id",
|
||||
type: "googleSheets",
|
||||
environmentId: "test-env-id",
|
||||
config: { data: [], email: "test@example.com" } as unknown as TIntegration["config"],
|
||||
},
|
||||
{
|
||||
id: "slack-id",
|
||||
type: "slack",
|
||||
environmentId: "test-env-id",
|
||||
config: { data: [] } as unknown as TIntegration["config"],
|
||||
},
|
||||
];
|
||||
|
||||
const mockParams = { environmentId: "test-env-id" };
|
||||
const mockProps = { params: mockParams };
|
||||
|
||||
describe("Integrations Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getWebhookCountBySource).mockResolvedValue(0);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([]);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
isBilling: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
});
|
||||
|
||||
test("renders the page header and integration cards", async () => {
|
||||
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
|
||||
if (source === "zapier") return 1;
|
||||
if (source === "user") return 2;
|
||||
return 0;
|
||||
});
|
||||
vi.mocked(getIntegrations).mockResolvedValue(mockIntegrations);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("common.integrations")).toBeInTheDocument(); // Page Header
|
||||
expect(screen.getByTestId("card-Javascript SDK")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.website_or_app_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.connected")[0]).toBeInTheDocument(); // JS SDK status
|
||||
|
||||
expect(screen.getByTestId("card-Zapier")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.zapier_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 zap")).toBeInTheDocument(); // Zapier status
|
||||
|
||||
expect(screen.getByTestId("card-Webhooks")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.webhook_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 webhooks")).toBeInTheDocument(); // Webhook status
|
||||
|
||||
expect(screen.getByTestId("card-Google Sheets")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.google_sheet_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.connected")[1]).toBeInTheDocument(); // Google Sheets status
|
||||
|
||||
expect(screen.getByTestId("card-Airtable")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.airtable_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[0]).toBeInTheDocument(); // Airtable status
|
||||
|
||||
expect(screen.getByTestId("card-Slack")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.slack_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.connected")[2]).toBeInTheDocument(); // Slack status
|
||||
|
||||
expect(screen.getByTestId("card-n8n")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.n8n_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[1]).toBeInTheDocument(); // n8n status
|
||||
|
||||
expect(screen.getByTestId("card-Make.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.make_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[2]).toBeInTheDocument(); // Make status
|
||||
|
||||
expect(screen.getByTestId("card-Notion")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[3]).toBeInTheDocument(); // Notion status
|
||||
|
||||
expect(screen.getByTestId("card-Activepieces")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.activepieces_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[4]).toBeInTheDocument(); // Activepieces status
|
||||
});
|
||||
|
||||
test("renders disabled cards when isReadOnly is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
isBilling: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
// JS SDK and Webhooks should not be disabled
|
||||
expect(screen.getByTestId("card-Javascript SDK")).not.toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Webhooks")).not.toHaveTextContent("Disabled");
|
||||
|
||||
// Other cards should be disabled
|
||||
expect(screen.getByTestId("card-Zapier")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Google Sheets")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Airtable")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Slack")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-n8n")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Notion")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("Disabled");
|
||||
});
|
||||
|
||||
test("redirects when isBilling is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
isBilling: true,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
await Page(mockProps);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith(
|
||||
`/environments/${mockParams.environmentId}/settings/billing`
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correct status text for single integration", async () => {
|
||||
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
|
||||
if (source === "n8n") return 1;
|
||||
if (source === "make") return 1;
|
||||
if (source === "activepieces") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("card-n8n")).toHaveTextContent("1 common.integration");
|
||||
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("1 common.integration");
|
||||
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("1 common.integration");
|
||||
});
|
||||
|
||||
test("renders correct status text for multiple integrations", async () => {
|
||||
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
|
||||
if (source === "n8n") return 3;
|
||||
if (source === "make") return 4;
|
||||
if (source === "activepieces") return 5;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("card-n8n")).toHaveTextContent("3 common.integrations");
|
||||
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("4 common.integrations");
|
||||
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("5 common.integrations");
|
||||
});
|
||||
|
||||
test("renders not connected status when widgetSetupCompleted is false", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: { ...mockEnvironment, appSetupCompleted: false },
|
||||
isReadOnly: false,
|
||||
isBilling: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("card-Javascript SDK")).toHaveTextContent("common.not_connected");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user