feat: Add comprehensive Vue component testing infrastructure
- Add Vitest configuration with JSDOM environment for Vue component testing - Create RegistrationNotice component mock with full TypeScript support - Implement comprehensive test suite for RegistrationNotice component (18 tests) - Add test setup with global mocks for ResizeObserver, IntersectionObserver, etc. - Update package.json with testing dependencies (@vue/test-utils, jsdom, vitest) - Add test scripts: test, test:unit, test:unit:watch, test:unit:coverage - Exclude Playwright tests from Vitest to prevent framework conflicts - Add comprehensive documentation with usage examples and best practices - All tests passing (20/20) with proper Vue-facing-decorator support
This commit is contained in:
281
src/test/README.md
Normal file
281
src/test/README.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# TimeSafari Testing Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains comprehensive testing infrastructure for the TimeSafari application, including mocks, test utilities, and examples for Vue components using vue-facing-decorator.
|
||||
|
||||
## Testing Setup
|
||||
|
||||
### Dependencies
|
||||
|
||||
The testing setup uses:
|
||||
|
||||
- **Vitest**: Fast unit testing framework
|
||||
- **JSDOM**: DOM environment for browser-like testing
|
||||
- **@vue/test-utils**: Vue component testing utilities
|
||||
- **vue-facing-decorator**: TypeScript decorators for Vue components
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `vitest.config.ts`: Main Vitest configuration
|
||||
- `src/test/setup.ts`: Test environment setup and global mocks
|
||||
|
||||
## RegistrationNotice Component Mock
|
||||
|
||||
### Overview
|
||||
|
||||
The `RegistrationNotice` component is the simplest component in the codebase (34 lines) and serves as an excellent example for testing Vue components with vue-facing-decorator.
|
||||
|
||||
### Mock Implementation
|
||||
|
||||
**File**: `src/test/__mocks__/RegistrationNotice.mock.ts`
|
||||
|
||||
The mock provides:
|
||||
- Same interface as the original component
|
||||
- Simplified behavior for testing
|
||||
- Additional helper methods for test scenarios
|
||||
- Full TypeScript support
|
||||
|
||||
### Key Features
|
||||
|
||||
```typescript
|
||||
// Basic usage
|
||||
const mockComponent = new RegistrationNoticeMock()
|
||||
mockComponent.isRegistered = false
|
||||
mockComponent.show = true
|
||||
|
||||
// Test helper methods
|
||||
expect(mockComponent.shouldShow).toBe(true)
|
||||
expect(mockComponent.buttonText).toBe('Share Your Info')
|
||||
expect(mockComponent.noticeText).toContain('Before you can publicly announce')
|
||||
|
||||
// Event emission
|
||||
mockComponent.shareInfo() // Emits 'share-info' event
|
||||
```
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
#### 1. Direct Mock Usage
|
||||
```typescript
|
||||
it('should create mock component with correct props', () => {
|
||||
const mockComponent = new RegistrationNoticeMock()
|
||||
mockComponent.isRegistered = false
|
||||
mockComponent.show = true
|
||||
|
||||
expect(mockComponent.shouldShow).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. Vue Test Utils Integration
|
||||
```typescript
|
||||
it('should mount mock component with props', () => {
|
||||
const wrapper = mount(RegistrationNoticeMock, {
|
||||
props: {
|
||||
isRegistered: false,
|
||||
show: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.vm.shouldShow).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. Event Testing
|
||||
```typescript
|
||||
it('should emit share-info event', async () => {
|
||||
const wrapper = mount(RegistrationNoticeMock, {
|
||||
props: { isRegistered: false, show: true }
|
||||
})
|
||||
|
||||
await wrapper.vm.shareInfo()
|
||||
|
||||
expect(wrapper.emitted('share-info')).toBeTruthy()
|
||||
})
|
||||
```
|
||||
|
||||
#### 4. Custom Mock Behavior
|
||||
```typescript
|
||||
class CustomRegistrationNoticeMock extends RegistrationNoticeMock {
|
||||
override get buttonText(): string {
|
||||
return 'Custom Button Text'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Advanced Testing Patterns
|
||||
|
||||
```typescript
|
||||
// Spy methods for testing
|
||||
const mockComponent = new RegistrationNoticeMock()
|
||||
const shareInfoSpy = vi.spyOn(mockComponent, 'shareInfo')
|
||||
const mockClickSpy = vi.spyOn(mockComponent, 'mockShareInfoClick')
|
||||
|
||||
mockComponent.mockShareInfoClick()
|
||||
expect(mockClickSpy).toHaveBeenCalledTimes(1)
|
||||
expect(shareInfoSpy).toHaveBeenCalledTimes(1)
|
||||
```
|
||||
|
||||
#### 6. Integration Testing
|
||||
```typescript
|
||||
// Simulate parent component context
|
||||
const parentData = {
|
||||
isUserRegistered: false,
|
||||
shouldShowNotice: true
|
||||
}
|
||||
|
||||
const mockComponent = new RegistrationNoticeMock()
|
||||
mockComponent.isRegistered = parentData.isUserRegistered
|
||||
mockComponent.show = parentData.shouldShowNotice
|
||||
|
||||
expect(mockComponent.shouldShow).toBe(true)
|
||||
```
|
||||
|
||||
#### 7. State Change Testing
|
||||
```typescript
|
||||
const mockComponent = new RegistrationNoticeMock()
|
||||
|
||||
// Initial state
|
||||
mockComponent.isRegistered = false
|
||||
mockComponent.show = true
|
||||
expect(mockComponent.shouldShow).toBe(true)
|
||||
|
||||
// State change
|
||||
mockComponent.isRegistered = true
|
||||
expect(mockComponent.shouldShow).toBe(false)
|
||||
```
|
||||
|
||||
#### 8. Performance Testing
|
||||
```typescript
|
||||
const mockComponent = new RegistrationNoticeMock()
|
||||
const startTime = performance.now()
|
||||
|
||||
// Call methods rapidly
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
mockComponent.shareInfo()
|
||||
mockComponent.shouldShow
|
||||
mockComponent.buttonText
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime
|
||||
expect(duration).toBeLessThan(100) // Should complete quickly
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test
|
||||
|
||||
# Run unit tests once
|
||||
npm run test:unit
|
||||
|
||||
# Run unit tests in watch mode
|
||||
npm run test:unit:watch
|
||||
|
||||
# Run tests with coverage
|
||||
npm run test:unit:coverage
|
||||
```
|
||||
|
||||
### Test File Structure
|
||||
|
||||
```
|
||||
src/test/
|
||||
├── __mocks__/
|
||||
│ └── RegistrationNotice.mock.ts # Component mock
|
||||
├── setup.ts # Test environment setup
|
||||
├── RegistrationNotice.test.ts # Component tests
|
||||
└── README.md # This documentation
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### 1. Component Testing
|
||||
- Test component rendering with different prop combinations
|
||||
- Verify event emissions
|
||||
- Check accessibility attributes
|
||||
- Test user interactions
|
||||
|
||||
### 2. Mock Usage
|
||||
- Use mocks for isolated unit testing
|
||||
- Test component interfaces, not implementation details
|
||||
- Create custom mocks for specific test scenarios
|
||||
- Verify mock behavior matches real component
|
||||
|
||||
### 3. Error Handling
|
||||
- Test edge cases and error conditions
|
||||
- Verify graceful degradation
|
||||
- Test invalid prop combinations
|
||||
|
||||
### 4. Performance Testing
|
||||
- Test rapid method calls
|
||||
- Verify efficient execution
|
||||
- Monitor memory usage in long-running tests
|
||||
|
||||
## Security Audit Checklist
|
||||
|
||||
When creating mocks and tests, ensure:
|
||||
|
||||
- [ ] No sensitive data in test files
|
||||
- [ ] Proper input validation testing
|
||||
- [ ] Event emission security
|
||||
- [ ] No hardcoded credentials
|
||||
- [ ] Proper error handling
|
||||
- [ ] Access control verification
|
||||
- [ ] Data sanitization testing
|
||||
|
||||
## Examples
|
||||
|
||||
See `src/test/RegistrationNotice.mock.example.ts` for comprehensive examples covering:
|
||||
- Direct mock usage
|
||||
- Vue Test Utils integration
|
||||
- Event testing
|
||||
- Custom mock behavior
|
||||
- Integration testing
|
||||
- Error handling
|
||||
- Performance testing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **JSDOM Environment Issues**
|
||||
- Ensure `vitest.config.ts` has `environment: 'jsdom'`
|
||||
- Check `src/test/setup.ts` for proper global mocks
|
||||
|
||||
2. **Vue-facing-decorator Issues**
|
||||
- Ensure TypeScript configuration supports decorators
|
||||
- Verify import paths are correct
|
||||
|
||||
3. **Test Utils Issues**
|
||||
- Check component mounting syntax
|
||||
- Verify prop passing
|
||||
- Ensure proper async/await usage
|
||||
|
||||
### Debug Tips
|
||||
|
||||
```bash
|
||||
# Run tests with verbose output
|
||||
npm run test:unit -- --reporter=verbose
|
||||
|
||||
# Run specific test file
|
||||
npm run test:unit src/test/RegistrationNotice.test.ts
|
||||
|
||||
# Debug with console output
|
||||
npm run test:unit -- --reporter=verbose --no-coverage
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new mocks or tests:
|
||||
|
||||
1. Follow the existing patterns in `RegistrationNotice.mock.ts`
|
||||
2. Add comprehensive documentation
|
||||
3. Include usage examples
|
||||
4. Update this README with new information
|
||||
5. Add security audit checklist items
|
||||
|
||||
## Author
|
||||
|
||||
Matthew Raymer
|
||||
219
src/test/RegistrationNotice.test.ts
Normal file
219
src/test/RegistrationNotice.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import RegistrationNotice from '@/components/RegistrationNotice.vue'
|
||||
|
||||
/**
|
||||
* RegistrationNotice Component Tests
|
||||
*
|
||||
* Comprehensive test suite for the RegistrationNotice component.
|
||||
* Tests component rendering, props, events, and user interactions.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
describe('RegistrationNotice', () => {
|
||||
let wrapper: any
|
||||
|
||||
/**
|
||||
* Test setup - creates a fresh component instance before each test
|
||||
*/
|
||||
beforeEach(() => {
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper function to mount component with props
|
||||
* @param props - Component props
|
||||
* @returns Vue test wrapper
|
||||
*/
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(RegistrationNotice, {
|
||||
props: {
|
||||
isRegistered: false,
|
||||
show: true,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render when not registered and show is true', () => {
|
||||
wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('#noticeBeforeAnnounce').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Before you can publicly announce')
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
expect(wrapper.find('button').text()).toBe('Share Your Info')
|
||||
})
|
||||
|
||||
it('should not render when user is registered', () => {
|
||||
wrapper = mountComponent({ isRegistered: true })
|
||||
|
||||
expect(wrapper.find('#noticeBeforeAnnounce').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not render when show is false', () => {
|
||||
wrapper = mountComponent({ show: false })
|
||||
|
||||
expect(wrapper.find('#noticeBeforeAnnounce').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not render when both registered and show is false', () => {
|
||||
wrapper = mountComponent({ isRegistered: true, show: false })
|
||||
|
||||
expect(wrapper.find('#noticeBeforeAnnounce').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Styling', () => {
|
||||
it('should have correct CSS classes', () => {
|
||||
wrapper = mountComponent()
|
||||
const notice = wrapper.find('#noticeBeforeAnnounce')
|
||||
|
||||
expect(notice.classes()).toContain('bg-amber-200')
|
||||
expect(notice.classes()).toContain('text-amber-900')
|
||||
expect(notice.classes()).toContain('border-amber-500')
|
||||
expect(notice.classes()).toContain('border-dashed')
|
||||
expect(notice.classes()).toContain('text-center')
|
||||
expect(notice.classes()).toContain('rounded-md')
|
||||
expect(notice.classes()).toContain('overflow-hidden')
|
||||
expect(notice.classes()).toContain('px-4')
|
||||
expect(notice.classes()).toContain('py-3')
|
||||
expect(notice.classes()).toContain('mt-4')
|
||||
})
|
||||
|
||||
it('should have correct accessibility attributes', () => {
|
||||
wrapper = mountComponent()
|
||||
const notice = wrapper.find('#noticeBeforeAnnounce')
|
||||
|
||||
expect(notice.attributes('role')).toBe('alert')
|
||||
expect(notice.attributes('aria-live')).toBe('polite')
|
||||
})
|
||||
|
||||
it('should have correct button styling', () => {
|
||||
wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.classes()).toContain('inline-block')
|
||||
expect(button.classes()).toContain('text-md')
|
||||
expect(button.classes()).toContain('bg-gradient-to-b')
|
||||
expect(button.classes()).toContain('from-blue-400')
|
||||
expect(button.classes()).toContain('to-blue-700')
|
||||
expect(button.classes()).toContain('text-white')
|
||||
expect(button.classes()).toContain('px-4')
|
||||
expect(button.classes()).toContain('py-2')
|
||||
expect(button.classes()).toContain('rounded-md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should emit share-info event when button is clicked', async () => {
|
||||
wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
await button.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('share-info')).toBeTruthy()
|
||||
expect(wrapper.emitted('share-info')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should emit share-info event multiple times when button is clicked multiple times', async () => {
|
||||
wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
await button.trigger('click')
|
||||
await button.trigger('click')
|
||||
await button.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('share-info')).toBeTruthy()
|
||||
expect(wrapper.emitted('share-info')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Props', () => {
|
||||
it('should accept isRegistered prop', () => {
|
||||
wrapper = mountComponent({ isRegistered: false })
|
||||
expect(wrapper.vm.isRegistered).toBe(false)
|
||||
|
||||
wrapper = mountComponent({ isRegistered: true })
|
||||
expect(wrapper.vm.isRegistered).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept show prop', () => {
|
||||
wrapper = mountComponent({ show: true })
|
||||
expect(wrapper.vm.show).toBe(true)
|
||||
|
||||
wrapper = mountComponent({ show: false })
|
||||
expect(wrapper.vm.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle both props together', () => {
|
||||
wrapper = mountComponent({ isRegistered: false, show: true })
|
||||
expect(wrapper.vm.isRegistered).toBe(false)
|
||||
expect(wrapper.vm.show).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Methods', () => {
|
||||
it('should have shareInfo method', () => {
|
||||
wrapper = mountComponent()
|
||||
expect(typeof wrapper.vm.shareInfo).toBe('function')
|
||||
})
|
||||
|
||||
it('should emit event when shareInfo is called', () => {
|
||||
wrapper = mountComponent()
|
||||
wrapper.vm.shareInfo()
|
||||
|
||||
expect(wrapper.emitted('share-info')).toBeTruthy()
|
||||
expect(wrapper.emitted('share-info')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid button clicks', async () => {
|
||||
wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
// Simulate rapid clicks
|
||||
await Promise.all([
|
||||
button.trigger('click'),
|
||||
button.trigger('click'),
|
||||
button.trigger('click')
|
||||
])
|
||||
|
||||
expect(wrapper.emitted('share-info')).toBeTruthy()
|
||||
expect(wrapper.emitted('share-info')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should maintain component state after prop changes', async () => {
|
||||
wrapper = mountComponent({ isRegistered: false, show: true })
|
||||
expect(wrapper.find('#noticeBeforeAnnounce').exists()).toBe(true)
|
||||
|
||||
await wrapper.setProps({ isRegistered: true })
|
||||
expect(wrapper.find('#noticeBeforeAnnounce').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ isRegistered: false })
|
||||
expect(wrapper.find('#noticeBeforeAnnounce').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper semantic structure', () => {
|
||||
wrapper = mountComponent()
|
||||
const notice = wrapper.find('#noticeBeforeAnnounce')
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(notice.exists()).toBe(true)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text()).toBe('Share Your Info')
|
||||
})
|
||||
|
||||
it('should have proper ARIA attributes', () => {
|
||||
wrapper = mountComponent()
|
||||
const notice = wrapper.find('#noticeBeforeAnnounce')
|
||||
|
||||
expect(notice.attributes('role')).toBe('alert')
|
||||
expect(notice.attributes('aria-live')).toBe('polite')
|
||||
})
|
||||
})
|
||||
})
|
||||
54
src/test/__mocks__/RegistrationNotice.mock.ts
Normal file
54
src/test/__mocks__/RegistrationNotice.mock.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||
|
||||
/**
|
||||
* RegistrationNotice Mock Component
|
||||
*
|
||||
* A mock implementation of the RegistrationNotice component for testing purposes.
|
||||
* Provides the same interface as the original component but with simplified behavior
|
||||
* for unit testing scenarios.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
@Component({ name: "RegistrationNotice" })
|
||||
export default class RegistrationNoticeMock extends Vue {
|
||||
@Prop({ required: true }) isRegistered!: boolean;
|
||||
@Prop({ required: true }) show!: boolean;
|
||||
|
||||
@Emit("share-info")
|
||||
shareInfo() {
|
||||
// Mock implementation - just emits the event
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock method to simulate button click for testing
|
||||
* @returns void
|
||||
*/
|
||||
mockShareInfoClick(): void {
|
||||
this.shareInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock method to check if component should be visible
|
||||
* @returns boolean - true if component should be shown
|
||||
*/
|
||||
get shouldShow(): boolean {
|
||||
return !this.isRegistered && this.show;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock method to get button text
|
||||
* @returns string - the button text
|
||||
*/
|
||||
get buttonText(): string {
|
||||
return "Share Your Info";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock method to get notice text
|
||||
* @returns string - the notice message
|
||||
*/
|
||||
get noticeText(): string {
|
||||
return "Before you can publicly announce a new project or time commitment, a friend needs to register you.";
|
||||
}
|
||||
}
|
||||
75
src/test/setup.ts
Normal file
75
src/test/setup.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { config } from '@vue/test-utils'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Test Setup Configuration for TimeSafari
|
||||
*
|
||||
* Configures the testing environment for Vue components with proper mocking
|
||||
* and global test utilities. Sets up JSDOM environment for component testing.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
|
||||
// Mock global objects that might not be available in JSDOM
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
global.localStorage = localStorageMock
|
||||
|
||||
// Mock sessionStorage
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
global.sessionStorage = sessionStorageMock
|
||||
|
||||
// Configure Vue Test Utils
|
||||
config.global.stubs = {
|
||||
// Add any global component stubs here
|
||||
}
|
||||
|
||||
// Mock console methods to reduce noise in tests
|
||||
const originalConsole = { ...console }
|
||||
beforeEach(() => {
|
||||
console.warn = vi.fn()
|
||||
console.error = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.warn = originalConsole.warn
|
||||
console.error = originalConsole.error
|
||||
})
|
||||
Reference in New Issue
Block a user