diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..e8772695 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug report +about: Report a bug +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go To Profile page +2. Click on the cover photo +3. ... + +**Text output/Error Messages** +If any + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/workflows/auto_assign_reviewer.yml b/.github/workflows/auto_assign_reviewer.yml deleted file mode 100644 index 40a16baa..00000000 --- a/.github/workflows/auto_assign_reviewer.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: PR Reviewer Assignment - -on: - pull_request: - types: [opened, reopened, ready_for_review] - -jobs: - auto-assign-reviewer: - name: Auto Assign Reviewer - runs-on: ubuntu-latest - steps: - - name: Assign reviewers - uses: actions/github-script@v7 - with: - script: | - const prAuthor = context.payload.pull_request.user.login; - const otherReviewers = ['Amrhanysayed', 'bedosaber77', 'hagar3bdelsalam', 'Safan05']; - let reviewers = []; - - if (prAuthor !== 'AliAlaa88') { - reviewers = ['AliAlaa88', ...otherReviewers]; - } else { - reviewers = otherReviewers; - } - - reviewers = reviewers.filter(r => r !== prAuthor); - - if (reviewers.length > 0) { - await github.rest.pulls.requestReviewers({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - reviewers: reviewers - }); - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f7a3e9..2305eba8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,9 @@ name: CI on: pull_request: - branches: [main, development] + branches: [main, development, production] + push: + branches: [fix/ci] permissions: contents: read @@ -12,7 +14,7 @@ jobs: checks: runs-on: ubuntu-latest strategy: - fail-fast: true + fail-fast: false steps: - name: ๐Ÿ›’ Checkout code @@ -27,20 +29,23 @@ jobs: - name: ๐Ÿ“ฆ Install dependencies run: npm ci + - name: ๐Ÿงช Run unit tests with coverage + run: npm run test:coverage + + - name: ๐Ÿ—๏ธ Build Nuxt app + run: npm run build + + - name: ๐ŸŽจ Check Prettier formatting + run: npm run format + - name: ๐ŸŽจ Check Prettier formatting - run: npx prettier --check "**/*.{js,ts,vue,json,css,md}" + run: npm run format:check - name: ๐Ÿงน Run ESLint run: npm run lint - - name: ๐Ÿงช Run unit tests with coverage - run: npm run test:coverage - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master + uses: SonarSource/sonarqube-scan-action@v6 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: ๐Ÿ—๏ธ Build Nuxt app - run: npm run build + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pr_title_check.yml b/.github/workflows/pr_title_check.yml index edb8ee9d..f966e843 100644 --- a/.github/workflows/pr_title_check.yml +++ b/.github/workflows/pr_title_check.yml @@ -1,67 +1,67 @@ name: PR Title Check on: - pull_request: - types: [opened, edited, synchronize, reopened] + pull_request: + types: [opened, edited, synchronize, reopened] jobs: - check-pr-title: - name: Check PR Title Format - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + check-pr-title: + name: Check PR Title Format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' - - name: Install dependencies - run: npm install --save-dev @commitlint/config-conventional + - name: Install dependencies + run: npm install --save-dev @commitlint/config-conventional - - name: Validate PR title - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: | - echo "$PR_TITLE" | npx commitlint --config commitlint.config.js + - name: Validate PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + echo "$PR_TITLE" | npx commitlint --config commitlint.config.js - - name: Comment on PR if title is invalid - if: failure() - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: `โŒ **PR Title Check Failed** + - name: Comment on PR if title is invalid + if: failure() + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `โŒ **PR Title Check Failed** - Your PR title does not follow the conventional commit format. + Your PR title does not follow the conventional commit format. - **Expected format:** \`type(scope): subject\` + **Expected format:** \`type(scope): subject\` - **Allowed types:** - - \`feat\`: A new feature - - \`fix\`: A bug fix - - \`docs\`: Documentation changes - - \`style\`: Code style changes (formatting, etc.) - - \`refactor\`: Code refactoring - - \`perf\`: Performance improvements - - \`test\`: Adding or updating tests - - \`chore\`: Maintenance tasks - - \`ci\`: CI/CD changes + **Allowed types:** + - \`feat\`: A new feature + - \`fix\`: A bug fix + - \`docs\`: Documentation changes + - \`style\`: Code style changes (formatting, etc.) + - \`refactor\`: Code refactoring + - \`perf\`: Performance improvements + - \`test\`: Adding or updating tests + - \`chore\`: Maintenance tasks + - \`ci\`: CI/CD changes - **Examples:** - - \`feat: add user authentication\` - - \`fix(api): resolve data fetching issue\` - - \`docs: update README with setup instructions\` + **Examples:** + - \`feat: add user authentication\` + - \`fix(api): resolve data fetching issue\` + - \`docs: update README with setup instructions\` - **Rules:** - - Type must be lowercase - - Subject must not start with uppercase - - Subject must not end with a period - - Maximum length: 100 characters + **Rules:** + - Type must be lowercase + - Subject must not start with uppercase + - Subject must not end with a period + - Maximum length: 100 characters - Please update your PR title and try again.` - }) + Please update your PR title and try again.` + }) diff --git a/.sonarlint/connectedMode.json b/.sonarlint/connectedMode.json new file mode 100644 index 00000000..0670e00d --- /dev/null +++ b/.sonarlint/connectedMode.json @@ -0,0 +1,5 @@ +{ + "sonarCloudOrganization": "alialaa88", + "projectKey": "AliAlaa88_Yapper-frontend", + "region": "EU" +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..8ecb4a05 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "yapper", + "projectKey": "AliAlaa88_Yapper-frontend" + } +} diff --git a/app/core/serviceRegistry.ts b/app/core/serviceRegistry.ts index 33007855..38d7a3be 100644 --- a/app/core/serviceRegistry.ts +++ b/app/core/serviceRegistry.ts @@ -4,7 +4,7 @@ import { createAuthService } from '../modules/auth/services' import { createMediaService } from '../modules/Common/services' import { createTimelineService } from '../modules/TimeLine/services' import { createSearchService } from '../modules/search/services' -import { exploreService } from "~/modules/explore/services"; +import { exploreService } from '~/modules/explore/services' import { settingsService } from '~/modules/settings/services/settingsService' import { listService } from '~/modules/Common/services/listService' @@ -22,7 +22,7 @@ export const serviceFactories = { exploreService: exploreService, chatService: createChatService, listService: () => listService, - notificationsService : createNotificationsService, + notificationsService: createNotificationsService, } export type Services = { diff --git a/app/layouts/main-layout.vue b/app/layouts/main-layout.vue index 6f959e61..d8353c42 100644 --- a/app/layouts/main-layout.vue +++ b/app/layouts/main-layout.vue @@ -52,7 +52,10 @@ const { width } = useWindowSize() const { locale, locales } = useI18n() const { sidebarWidth } = useSidebarState() const isSearch = computed( - () => route.path.startsWith('/explore') || route.path.startsWith('/search') || route.path.startsWith('/notifications'), + () => + route.path.startsWith('/explore') || + route.path.startsWith('/search') || + route.path.startsWith('/notifications'), ) const isRTL = computed(() => { diff --git a/app/layouts/profile.vue b/app/layouts/profile.vue index c5442aee..5f9ffaa2 100644 --- a/app/layouts/profile.vue +++ b/app/layouts/profile.vue @@ -27,8 +27,8 @@ type="button" class="flex h-8 w-8 items-center justify-center rounded-full hover:bg-hover transition-colors" :aria-label="$t('timeline.banner.search')" - @click="router.push({ name: 'explore', state: {user: username}})" - > + @click="router.push({ name: 'explore', state: { user: username } })" + > diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts index 3eca4fa0..1e59a89b 100644 --- a/app/middleware/auth.ts +++ b/app/middleware/auth.ts @@ -1,7 +1,7 @@ import { useUserStore } from '../modules/auth/stores/userStore' export default defineNuxtRouteMiddleware(async (to) => { const authPages = ['/auth/login', '/auth/register', '/auth'] - const isAuthPage = authPages.some(page => to.path.startsWith(page)) + const isAuthPage = authPages.some((page) => to.path.startsWith(page)) const userStore = useUserStore() const token = userStore.getAccessToken() diff --git a/app/modules/Common/components/Button/Button.vue b/app/modules/Common/components/Button/Button.vue index 0c30d77f..55e5294a 100644 --- a/app/modules/Common/components/Button/Button.vue +++ b/app/modules/Common/components/Button/Button.vue @@ -1,10 +1,13 @@ @@ -34,5 +34,4 @@ const sizeClasses = computed(() => { } return sizes[props.size] }) - diff --git a/app/modules/Common/components/Logo/Logo.vue b/app/modules/Common/components/Logo/Logo.vue index 76127387..b7a44cbb 100644 --- a/app/modules/Common/components/Logo/Logo.vue +++ b/app/modules/Common/components/Logo/Logo.vue @@ -1,6 +1,6 @@ diff --git a/app/modules/Common/components/Logo/LogoBlack.vue b/app/modules/Common/components/Logo/LogoBlack.vue index 9a90cb0e..e8d73617 100644 --- a/app/modules/Common/components/Logo/LogoBlack.vue +++ b/app/modules/Common/components/Logo/LogoBlack.vue @@ -1,5 +1,5 @@ diff --git a/app/modules/Common/components/Popup/Popup.vue b/app/modules/Common/components/Popup/Popup.vue index 4f763fba..a03a81e0 100644 --- a/app/modules/Common/components/Popup/Popup.vue +++ b/app/modules/Common/components/Popup/Popup.vue @@ -20,24 +20,24 @@

{{ title }}

-
+
diff --git a/app/modules/Common/components/Tabs/Tabs.vue b/app/modules/Common/components/Tabs/Tabs.vue index 0f0fab67..e4173479 100644 --- a/app/modules/Common/components/Tabs/Tabs.vue +++ b/app/modules/Common/components/Tabs/Tabs.vue @@ -6,11 +6,11 @@ > @@ -24,10 +24,10 @@
diff --git a/app/modules/Common/components/UserImage/UserImage.vue b/app/modules/Common/components/UserImage/UserImage.vue index acfd7bf5..f5961ef2 100644 --- a/app/modules/Common/components/UserImage/UserImage.vue +++ b/app/modules/Common/components/UserImage/UserImage.vue @@ -6,14 +6,14 @@ :class="compact ? `w-${size} h-${size}` : `w-${size} h-${size}`" class="object-cover rounded-full" :onerror="(event: any) => handleImageError(name ?? '', event)" - /> + > + > diff --git a/app/modules/Common/composables/useGenericInfiniteQuery.ts b/app/modules/Common/composables/useGenericInfiniteQuery.ts index a3f5c02a..7495b419 100644 --- a/app/modules/Common/composables/useGenericInfiniteQuery.ts +++ b/app/modules/Common/composables/useGenericInfiniteQuery.ts @@ -18,9 +18,11 @@ export function useGenericInfiniteQuery( } = options const query = useInfiniteQuery({ - queryKey: typeof queryKey === 'function' || 'value' in queryKey ? queryKey : toRef(() => queryKey), - queryFn: ({ pageParam = initialPageParam }) => - queryFn({ pageParam: pageParam as string }), + queryKey: + typeof queryKey === 'function' || 'value' in queryKey + ? queryKey + : toRef(() => queryKey), + queryFn: ({ pageParam = initialPageParam }) => queryFn({ pageParam: pageParam as string }), getNextPageParam: (lastPage) => getNextPageParam(lastPage) ?? undefined, initialPageParam, staleTime, diff --git a/app/modules/Common/composables/useSeo.ts b/app/modules/Common/composables/useSeo.ts index 668bde85..7bbc40f0 100644 --- a/app/modules/Common/composables/useSeo.ts +++ b/app/modules/Common/composables/useSeo.ts @@ -82,7 +82,12 @@ export function useSeo(options: SeoOptions = {}) { }) } -export function useProfileSeo(profile: { name?: string; username?: string; bio?: string; profile_image?: string }) { +export function useProfileSeo(profile: { + name?: string + username?: string + bio?: string + profile_image?: string +}) { const { t } = useI18n() if (!profile.name || !profile.username) { @@ -94,7 +99,7 @@ export function useProfileSeo(profile: { name?: string; username?: string; bio?: const description = t('seo.profile.description', { name: profile.name, username: profile.username, - bio: profile.bio || '' + bio: profile.bio || '', }) const keywords = t('seo.profile.keywords', { username: profile.username }) @@ -107,7 +112,11 @@ export function useProfileSeo(profile: { name?: string; username?: string; bio?: }) } -export function useTweetSeo(tweet: { user?: { name?: string; username?: string }; content?: string; media?: any[] }) { +export function useTweetSeo(tweet: { + user?: { name?: string; username?: string } + content?: string + media?: any[] +}) { const { t } = useI18n() if (!tweet.user?.name || !tweet.content) { @@ -116,13 +125,12 @@ export function useTweetSeo(tweet: { user?: { name?: string; username?: string } } // Truncate tweet text for title (max 50 chars) - const truncatedText = tweet.content.length > 50 - ? tweet.content.substring(0, 50) + '...' - : tweet.content + const truncatedText = + tweet.content.length > 50 ? tweet.content.substring(0, 50) + '...' : tweet.content const title = t('seo.tweet.title', { name: tweet.user.name, - text: truncatedText + text: truncatedText, }) const description = tweet.content const keywords = t('seo.tweet.keywords', { username: tweet.user.username }) diff --git a/app/modules/Common/queries/queryKeys.ts b/app/modules/Common/queries/queryKeys.ts index 39a08bdf..eeace6a7 100644 --- a/app/modules/Common/queries/queryKeys.ts +++ b/app/modules/Common/queries/queryKeys.ts @@ -38,11 +38,11 @@ export const queryKeys = { all: ['notifications'] as const, mentions: ['mentions'] as const, }, - + search: { all: ['tweets', '/search'] as const, }, - + bookmarks: { all: ['tweets', 'tweets/bookmarks'] as const, }, diff --git a/app/modules/Common/services/listService.ts b/app/modules/Common/services/listService.ts index ef24e17a..314d1012 100644 --- a/app/modules/Common/services/listService.ts +++ b/app/modules/Common/services/listService.ts @@ -1,3 +1,4 @@ +import { useNuxtApp } from '#app' export const listService = { async fetchList(path: string, nextCursor: string): Promise { @@ -5,7 +6,7 @@ export const listService = { const separator = path.includes('?') ? '&' : '?' const response = await $axios.get( - `${path}` + (nextCursor ? `${separator}cursor=${nextCursor}` : '') + `${path}` + (nextCursor ? `${separator}cursor=${nextCursor}` : ''), ) const page = response.data.data diff --git a/app/modules/Common/test/unit/MediaGrid.spec.ts b/app/modules/Common/test/unit/MediaGrid.spec.ts new file mode 100644 index 00000000..a5a8e046 --- /dev/null +++ b/app/modules/Common/test/unit/MediaGrid.spec.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' +import MediaGrid from '../../components/MediaGrid/MediaGrid.vue' + +const i18n = createI18n({ + locale: 'en', + messages: { + en: { + tweets: { + loading: { tweets: 'Loading tweets...' }, + errors: { loadFailed: 'Failed to load', tryAgain: 'Try again' }, + empty: { noTweets: 'No tweets', noTweetsDescription: 'No tweets to display' }, + }, + }, + ar: {}, + }, +}) + +// Mock useGenericInfiniteQuery composable +vi.mock('~/modules/Common/composables/useGenericInfiniteQuery', () => ({ + useGenericInfiniteQuery: vi.fn(() => ({ + items: ref([]), + isFetching: ref(false), + isFetchingNextPage: ref(false), + isPending: ref(false), + error: ref(null), + loadMoreTrigger: ref(null), + refetch: vi.fn(), + })), +})) + +// Mock InfiniteList component +vi.mock('~/modules/Common/components/InfiniteList', () => ({ + InfiniteList: { + name: 'InfiniteList', + props: ['modelValue', 'items', 'isPending', 'isFetching', 'isFetchingNextPage', 'error'], + emits: ['update:modelValue', 'retry'], + template: '
', + }, +})) + +vi.mock('#app', () => ({ + useNuxtApp: () => ({ + $listService: { + fetchList: vi.fn(), + }, + }), +})) + +describe('MediaGrid Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render media grid container', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.find('.infinite-list').exists()).toBe(true) + }) + + it('should handle fetchingSource prop', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'profile', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.props('fetchingSource')).toBe('profile') + }) + + it('should initialize useGenericInfiniteQuery hook', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should have videoDurations reactive object', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should render InfiniteList component', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + const infiniteList = wrapper.findComponent({ name: 'InfiniteList' }) + expect(infiniteList.exists()).toBe(true) + }) + + it('should pass fetchingSource to query', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'timeline', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.props('fetchingSource')).toBe('timeline') + }) + + it('should handle null fetchingSource', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.props('fetchingSource')).toBeNull() + }) + + it('should have formatDuration method', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should handle handleVideoMetadata callback', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should support prop updates', async () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'profile', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + await wrapper.setProps({ fetchingSource: 'search' }) + expect(wrapper.props('fetchingSource')).toBe('search') + }) + + it('should maintain reactive state', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.find('.infinite-list').exists()).toBe(true) + }) + + it('should render without errors', () => { + expect(() => { + mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + }).not.toThrow() + }) + + it('should support slot rendering', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.find('.infinite-list').exists()).toBe(true) + }) + + it('should handle component lifecycle', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + wrapper.unmount() + }) + + it('should use correct grid styling', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + const infiniteList = wrapper.findComponent({ name: 'InfiniteList' }) + expect(infiniteList.exists()).toBe(true) + }) + + it('should handle reactive fetchingSource changes', async () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'profile', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + const before = wrapper.props('fetchingSource') + await wrapper.setProps({ fetchingSource: 'explore' }) + const after = wrapper.props('fetchingSource') + + expect(before).not.toBe(after) + expect(after).toBe('explore') + }) +}) diff --git a/app/modules/Common/test/unit/Popup.spec.ts b/app/modules/Common/test/unit/Popup.spec.ts new file mode 100644 index 00000000..5f8324d2 --- /dev/null +++ b/app/modules/Common/test/unit/Popup.spec.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import Popup from '../../components/Popup/Popup.vue' + +const i18n = createI18n({ + locale: 'en', + messages: { + en: {}, + ar: {}, + }, +}) + +// Mock Teleport component +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + Teleport: { + name: 'Teleport', + props: ['to'], + template: '', + }, + } +}) + +describe('Popup Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render when isOpen is true', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + title: 'Test Title', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + slots: { + default: 'Popup Content', + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should not render when isOpen is false', () => { + const wrapper = mount(Popup, { + props: { + isOpen: false, + title: 'Test Title', + }, + global: { + plugins: [i18n], + }, + }) + + expect(wrapper.html()).not.toContain('popup-content') + }) + + it('should display title when provided', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + title: 'My Title', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const text = wrapper.text() + expect(text).toContain('My Title') + }) + + it('should show close button when hasCloseButton is true', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasCloseButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#close-popup-btn').exists()).toBe(true) + }) + + it('should show back button when hasBackButton is true', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasBackButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#back-popup-btn').exists()).toBe(true) + }) + + it('should emit close event when close button is clicked', async () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasCloseButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const closeBtn = wrapper.find('#close-popup-btn') + if (closeBtn.exists()) { + await closeBtn.trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + } + }) + + it('should emit back event when back button is clicked', async () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasBackButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const backBtn = wrapper.find('#back-popup-btn') + if (backBtn.exists()) { + await backBtn.trigger('click') + expect(wrapper.emitted('back')).toBeTruthy() + } + }) + + it('should support different positioning classes', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + xPosition: 'start', + yPosition: 'end', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should accept custom contentClass', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + contentClass: 'custom-content-class', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const content = wrapper.find('#popup-content') + if (content.exists()) { + expect(content.classes()).toContain('custom-content-class') + } + }) + + it('should apply header class when provided', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + headerClass: 'custom-header-class', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const html = wrapper.html() + expect(html).toContain('custom-header-class') + }) + + it('should render slot content', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + slots: { + default: 'Test Slot Content', + }, + }) + + expect(wrapper.text()).toContain('Test Slot Content') + }) + + it('should support custom background color', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + bgColor: 'bg-custom-color', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should render with default props', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should handle hasCloseButton default value', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.props('hasCloseButton')).toBe(true) + }) + + it('should handle hasBackButton default value', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.props('hasBackButton')).toBe(false) + }) + + it('should hide close button when hasCloseButton is false', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasCloseButton: false, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#close-popup-btn').exists()).toBe(false) + }) + + it('should hide back button when hasBackButton is false', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasBackButton: false, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#back-popup-btn').exists()).toBe(false) + }) +}) diff --git a/app/modules/Common/test/unit/Tabs.spec.ts b/app/modules/Common/test/unit/Tabs.spec.ts new file mode 100644 index 00000000..3afc6033 --- /dev/null +++ b/app/modules/Common/test/unit/Tabs.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' +import Tabs from '../../components/Tabs/Tabs.vue' + +describe('Tabs Component', () => { + const defaultTabs = [ + { label: 'Tab 1', value: 'tab1', test_id: 'tab-1' }, + { label: 'Tab 2', value: 'tab2', test_id: 'tab-2' }, + { label: 'Tab 3', value: 'tab3', test_id: 'tab-3' }, + ] + + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render all tabs', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(wrapper.text()).toContain('Tab 1') + expect(wrapper.text()).toContain('Tab 2') + expect(wrapper.text()).toContain('Tab 3') + }) + + it('should highlight active tab', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const activeButton = wrapper.find('[class*="text-primary"]') + expect(activeButton.exists()).toBe(true) + }) + + it('should call onChange when tab is clicked', async () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const tab2 = wrapper.find('#tab-2') + await tab2.trigger('click') + + expect(mockOnChange).toHaveBeenCalledWith('tab2') + }) + + it('should render correct number of tabs', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const tabs = wrapper.findAll('li') + expect(tabs.length).toBe(3) + }) + + it('should handle single tab', () => { + const singleTab = [{ label: 'Only Tab', value: 'only', test_id: 'only-tab' }] + + const wrapper = mount(Tabs, { + props: { + tabs: singleTab, + activeTab: 'only', + onChange: mockOnChange, + }, + }) + + expect(wrapper.text()).toContain('Only Tab') + const tabs = wrapper.findAll('li') + expect(tabs.length).toBe(1) + }) + + it('should display underline on active tab', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab2', + onChange: mockOnChange, + }, + }) + + const activeTab = wrapper.find('#tab-2 button span') + expect(activeTab.exists()).toBe(true) + }) + + it('should update active tab when prop changes', async () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + await wrapper.setProps({ activeTab: 'tab3' }) + + expect(wrapper.emitted()).toBeDefined() + }) + + it('should handle tabs with special characters in labels', () => { + const specialTabs = [ + { label: 'Tab & Test', value: 'tab1', test_id: 'tab-1' }, + { label: 'Tab ', value: 'tab2', test_id: 'tab-2' }, + ] + + const wrapper = mount(Tabs, { + props: { + tabs: specialTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(wrapper.text()).toContain('Tab & Test') + expect(wrapper.text()).toContain('Tab ') + }) + + it('should have correct test_id attributes', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(wrapper.find('#tab-1').exists()).toBe(true) + expect(wrapper.find('#tab-2').exists()).toBe(true) + expect(wrapper.find('#tab-3').exists()).toBe(true) + }) + + it('should not call onChange on initial render', () => { + mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should handle rapid tab switching', async () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + await wrapper.find('#tab-2').trigger('click') + await wrapper.find('#tab-3').trigger('click') + await wrapper.find('#tab-1').trigger('click') + + expect(mockOnChange).toHaveBeenCalledTimes(3) + }) + + it('should maintain tab order', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const labels = wrapper.findAll('button').map(btn => btn.text()) + expect(labels[0]).toContain('Tab 1') + expect(labels[1]).toContain('Tab 2') + expect(labels[2]).toContain('Tab 3') + }) +}) diff --git a/app/modules/Common/test/unit/UserCard.spec.ts b/app/modules/Common/test/unit/UserCard.spec.ts new file mode 100644 index 00000000..f1dbd588 --- /dev/null +++ b/app/modules/Common/test/unit/UserCard.spec.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import UserCard from '../../components/UserCard/UserCard.vue' +import type { FollowUser } from '~/modules/profile/types/user' + +vi.mock('~/modules/profile/components/ProfileHeader/SubComponents/ProfileFollowAction.vue', () => ({ + default: { + name: 'ProfileFollowAction', + template: '', + props: ['userId', 'username'], + }, +})) + +vi.mock('../../components/UserImage/UserImage.vue', () => ({ + default: { + name: 'UserImage', + template: '
Avatar
', + props: ['imageUrl', 'name', 'compact'], + }, +})) + +describe('UserCard Component', () => { + const mockUser: FollowUser = { + user_id: '1', + id: '1', + name: 'John Doe', + username: 'johndoe', + bio: 'Software developer', + avatar_url: 'https://example.com/avatar.jpg', + is_following: false, + is_follower: false, + is_muted: false, + is_blocked: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render user card', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John Doe') + }) + + it('should display user name', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John Doe') + }) + + it('should display user username', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('@johndoe') + }) + + it('should display user bio when hideBio is false', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Software developer') + }) + + it('should hide bio when hideBio is true', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + hideBio: true, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).not.toContain('Software developer') + }) + + it('should show "Follows you" badge when is_follower is true', () => { + const followerUser = { ...mockUser, is_follower: true } + + const wrapper = mount(UserCard, { + props: { + user: followerUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Follows you') + }) + + it('should not show "Follows you" badge when is_follower is false', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).not.toContain('Follows you') + }) + + it('should render follow action button', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + }) + + it('should link to user profile', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + const link = wrapper.find('a') + expect(link.attributes('to')).toBe('/johndoe') + }) + + it('should render user image component', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.user-image').exists()).toBe(true) + }) + + it('should handle user without bio', () => { + const userNoBio = { ...mockUser, bio: '' } + + const wrapper = mount(UserCard, { + props: { + user: userNoBio, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John Doe') + expect(wrapper.text()).toContain('@johndoe') + }) + + it('should handle special characters in user data', () => { + const specialUser: FollowUser = { + ...mockUser, + name: 'John & Jane', + username: 'john_jane_123', + bio: 'Developer @ Company', + } + + const wrapper = mount(UserCard, { + props: { + user: specialUser, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John & Jane') + expect(wrapper.text()).toContain('@john_jane_123') + }) + + it('should handle user with multiple follower states', async () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + await wrapper.setProps({ + user: { ...mockUser, is_follower: true }, + }) + + expect(wrapper.text()).toContain('Follows you') + }) + + it('should render truncated text for long usernames', () => { + const longUsernameUser: FollowUser = { + ...mockUser, + username: 'very_long_username_that_should_be_truncated', + } + + const wrapper = mount(UserCard, { + props: { + user: longUsernameUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('very_long_username_that_should_be_truncated') + }) + + it('should apply hover effects', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + const container = wrapper.find('[class*="hover:"]') + expect(container.exists()).toBe(true) + }) + + it('should render with correct component structure', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + expect(wrapper.find('.user-image').exists()).toBe(true) + }) + + it('should pass correct props to UserImage', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.user-image').exists()).toBe(true) + }) + + it('should pass correct props to ProfileFollowAction', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + }) + + it('should handle bio line clamping', () => { + const userLongBio = { + ...mockUser, + bio: 'This is a very long bio that should be clamped to show only a limited number of lines and then truncated with ellipsis to indicate there is more content', + } + + const wrapper = mount(UserCard, { + props: { + user: userLongBio, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('[class*="line-clamp"]').exists()).toBe(true) + }) + + it('should be fully interactive', async () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + expect(wrapper.find('a').attributes('to')).toBe('/johndoe') + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + }) +}) diff --git a/app/modules/Common/test/unit/cacheInvalidation.spec.ts b/app/modules/Common/test/unit/cacheInvalidation.spec.ts new file mode 100644 index 00000000..ed4bcbbc --- /dev/null +++ b/app/modules/Common/test/unit/cacheInvalidation.spec.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { cacheInvalidation } from '../../queries/cacheInvalidation' +import { queryKeys } from '../../queries/queryKeys' + +describe('cacheInvalidation', () => { + let mockQueryClient: any + + beforeEach(() => { + mockQueryClient = { + setQueryData: vi.fn(), + invalidateQueries: vi.fn(), + removeQueries: vi.fn(), + setQueriesData: vi.fn(), + clear: vi.fn(), + } + vi.clearAllMocks() + }) + + describe('toggleBlockedInCache', () => { + it('should update blocked status in cache', () => { + const userId = 'user-123' + const isBlocked = true + + cacheInvalidation.toggleBlockedInCache(mockQueryClient, userId, isBlocked) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledWith( + queryKeys.settings.blockedUsers(), + expect.any(Function), + ) + }) + + it('should not modify cache if data is undefined', () => { + const userId = 'user-123' + const updater = mockQueryClient.setQueryData.mock.calls[0]?.[1] + + cacheInvalidation.toggleBlockedInCache(mockQueryClient, userId, true) + + expect(mockQueryClient.setQueryData).toHaveBeenCalled() + }) + + it('should handle toggle from blocked to unblocked', () => { + cacheInvalidation.toggleBlockedInCache(mockQueryClient, 'user-123', false) + cacheInvalidation.toggleBlockedInCache(mockQueryClient, 'user-123', true) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledTimes(2) + }) + }) + + describe('toggleMutedInCache', () => { + it('should update muted status in cache', () => { + const userId = 'user-456' + const isMuted = true + + cacheInvalidation.toggleMutedInCache(mockQueryClient, userId, isMuted) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledWith( + queryKeys.settings.mutedUsers(), + expect.any(Function), + ) + }) + + it('should handle toggle from muted to unmuted', () => { + cacheInvalidation.toggleMutedInCache(mockQueryClient, 'user-456', true) + cacheInvalidation.toggleMutedInCache(mockQueryClient, 'user-456', false) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledTimes(2) + }) + }) + + describe('Tweet Mutations', () => { + it('onTweetCreate should invalidate user posts cache', () => { + const userId = 'user-123' + cacheInvalidation.onTweetCreate(mockQueryClient, userId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list(`/users/${userId}/posts`), + }) + }) + + it('onTweetDelete should remove tweet details cache', () => { + const tweetId = 'tweet-123' + cacheInvalidation.onTweetDelete(mockQueryClient, tweetId) + + expect(mockQueryClient.removeQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + }) + + it('onReplyCreate should invalidate parent tweet and related caches', () => { + const parentTweetId = 'tweet-456' + const userId = 'user-123' + + cacheInvalidation.onReplyCreate(mockQueryClient, parentTweetId, userId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(parentTweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list(`/users/${userId}/replies`), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/timeline/following'), + }) + }) + + it('onReplyDelete should invalidate parent tweet and timeline caches', () => { + const parentTweetId = 'tweet-456' + + cacheInvalidation.onReplyDelete(mockQueryClient, parentTweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(parentTweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/timeline/following'), + }) + }) + + it('onTweetLikeChange should invalidate tweet details and likes cache', () => { + const tweetId = 'tweet-123' + + cacheInvalidation.onTweetLikeChange(mockQueryClient, tweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/users/me/liked-posts'), + }) + }) + + it('onTweetRepostChange should invalidate tweet details and user timeline caches', () => { + const tweetId = 'tweet-123' + const path = '/timeline/following' + + cacheInvalidation.onTweetRepostChange(mockQueryClient, tweetId, path) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list(path), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/timeline/following'), + }) + }) + + it('onTweetBookmarkChange should invalidate tweet details and bookmarks cache', () => { + const tweetId = 'tweet-123' + + cacheInvalidation.onTweetBookmarkChange(mockQueryClient, tweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.bookmarks.all, + }) + }) + + it('onTweetUpdate should invalidate tweet summary, details and search cache', () => { + const tweetId = 'tweet-123' + + cacheInvalidation.onTweetUpdate(mockQueryClient, tweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.summary(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.search.all, + }) + }) + }) + + describe('Profile Mutations', () => { + it('onProfileUpdate should invalidate profile and tweets caches', () => { + const username = 'johndoe' + + cacheInvalidation.onProfileUpdate(mockQueryClient, username) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(username), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.all, + }) + }) + + it('onUsernameChange should remove old profile and invalidate caches', () => { + const oldUsername = 'oldname' + + cacheInvalidation.onUsernameChange(mockQueryClient, oldUsername) + + expect(mockQueryClient.removeQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(oldUsername), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + }) + + it('onAvatarChange should invalidate profile and tweets caches', () => { + const username = 'johndoe' + + cacheInvalidation.onAvatarChange(mockQueryClient, username) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(username), + }) + }) + + it('onCoverPhotoChange should invalidate me and profile caches', () => { + const username = 'johndoe' + + cacheInvalidation.onCoverPhotoChange(mockQueryClient, username) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(username), + }) + }) + }) + + describe('User Action Mutations', () => { + it('onFollowChange should invalidate relevant user and tweet caches', () => { + const targetUserId = 'user-123' + const targetUsername = 'johndoe' + const currentUserId = 'user-456' + const isFollowing = true + + cacheInvalidation.onFollowChange( + mockQueryClient, + targetUserId, + targetUsername, + currentUserId, + isFollowing, + ) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.byId(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(targetUsername), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.followers(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.following(currentUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + }) + + it('onFollowChange should also update tweet caches with follower delta', () => { + const targetUserId = 'user-123' + const targetUsername = 'johndoe' + const currentUserId = 'user-456' + + cacheInvalidation.onFollowChange( + mockQueryClient, + targetUserId, + targetUsername, + currentUserId, + true, + ) + + expect(mockQueryClient.setQueriesData).toHaveBeenCalledWith( + { queryKey: ['tweets'] }, + expect.any(Function), + ) + }) + + it('onBlockChange should invalidate user and notifications caches', () => { + const targetUserId = 'user-123' + + cacheInvalidation.onBlockChange(mockQueryClient, targetUserId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.byId(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.search.all, + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.notifications.all, + }) + }) + + it('onMuteChange should invalidate user and search caches', () => { + const targetUserId = 'user-123' + + cacheInvalidation.onMuteChange(mockQueryClient, targetUserId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.byId(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.search.all, + }) + }) + + it('onRemoveFollower should invalidate follower and me caches', () => { + const currentUserId = 'user-123' + + cacheInvalidation.onRemoveFollower(mockQueryClient, currentUserId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.followers(currentUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + }) + }) + + describe('Auth Mutations', () => { + it('onLogout should clear all query data', () => { + cacheInvalidation.onLogout(mockQueryClient) + + expect(mockQueryClient.clear).toHaveBeenCalled() + }) + + it('onLogin should invalidate user and auth caches', () => { + cacheInvalidation.onLogin(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.auth.user(), + }) + }) + }) + + describe('Chat/Conversation Mutations', () => { + it('onConversationCreate should invalidate conversations cache', () => { + cacheInvalidation.onConversationCreate(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.conversations.all, + }) + }) + + it('onFirstMessageSent should invalidate conversations cache', () => { + cacheInvalidation.onFirstMessageSent(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.conversations.all, + }) + }) + + it('onRemoveNotification should invalidate notifications cache', () => { + cacheInvalidation.onRemoveNotification(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.notifications.all, + }) + }) + + it('onRemoveMention should invalidate mentions cache', () => { + cacheInvalidation.onRemoveMention(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.notifications.mentions, + }) + }) + }) + + describe('cache invalidation methods', () => { + it('should have all expected methods', () => { + const expectedMethods = [ + 'toggleBlockedInCache', + 'toggleMutedInCache', + 'onTweetCreate', + 'onTweetDelete', + 'onReplyCreate', + 'onReplyDelete', + 'onTweetLikeChange', + 'onTweetRepostChange', + 'onTweetBookmarkChange', + 'onTweetUpdate', + 'onProfileUpdate', + 'onUsernameChange', + 'onAvatarChange', + 'onCoverPhotoChange', + 'onFollowChange', + 'onBlockChange', + 'onMuteChange', + 'onRemoveFollower', + 'onLogout', + 'onLogin', + 'onConversationCreate', + 'onFirstMessageSent', + 'onRemoveNotification', + 'onRemoveMention', + ] + + expectedMethods.forEach((method) => { + expect(cacheInvalidation).toHaveProperty(method) + expect(typeof cacheInvalidation[method as keyof typeof cacheInvalidation]).toBe('function') + }) + }) + }) +}) diff --git a/app/modules/Common/test/unit/constants.spec.ts b/app/modules/Common/test/unit/constants.spec.ts new file mode 100644 index 00000000..5915e8c8 --- /dev/null +++ b/app/modules/Common/test/unit/constants.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { LOCALE_COOKIE_KEY } from '../../constants/localStorageConstants' +import { tooltipContentClass } from '../../constants/stylesConstants' + +describe('Constants', () => { + describe('localStorageConstants', () => { + it('should export LOCALE_COOKIE_KEY', () => { + expect(LOCALE_COOKIE_KEY).toBeDefined() + }) + + it('should have correct LOCALE_COOKIE_KEY value', () => { + expect(LOCALE_COOKIE_KEY).toBe('i18n_redirected') + }) + + it('should be a string', () => { + expect(typeof LOCALE_COOKIE_KEY).toBe('string') + }) + + it('should not be empty', () => { + expect(LOCALE_COOKIE_KEY.length).toBeGreaterThan(0) + }) + }) + + describe('stylesConstants', () => { + it('should export tooltipContentClass', () => { + expect(tooltipContentClass).toBeDefined() + }) + + it('should be a string', () => { + expect(typeof tooltipContentClass).toBe('string') + }) + + it('should contain tailwind classes', () => { + expect(tooltipContentClass).toContain('text-white') + expect(tooltipContentClass).toContain('bg-') + expect(tooltipContentClass).toContain('rounded-md') + }) + + it('should have specific styling', () => { + expect(tooltipContentClass).toBe( + 'text-white bg-[#536471] text-[12px] font-medium p-1 rounded-md', + ) + }) + + it('should not be empty', () => { + expect(tooltipContentClass.length).toBeGreaterThan(0) + }) + + it('should include color hex value', () => { + expect(tooltipContentClass).toContain('#536471') + }) + + it('should include padding', () => { + expect(tooltipContentClass).toContain('p-1') + }) + + it('should include font styling', () => { + expect(tooltipContentClass).toContain('font-medium') + }) + + it('should include text size', () => { + expect(tooltipContentClass).toContain('text-[12px]') + }) + }) + + describe('Constants exports', () => { + it('should not have name conflicts', () => { + const locale = LOCALE_COOKIE_KEY + const tooltip = tooltipContentClass + expect(locale).not.toBe(tooltip) + }) + + it('should have distinct purposes', () => { + expect(LOCALE_COOKIE_KEY).toMatch(/i18n|locale/i) + expect(tooltipContentClass).toMatch(/text|bg|rounded/i) + }) + }) +}) diff --git a/app/modules/Common/test/unit/listService.spec.ts b/app/modules/Common/test/unit/listService.spec.ts new file mode 100644 index 00000000..19c09204 --- /dev/null +++ b/app/modules/Common/test/unit/listService.spec.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useNuxtApp } from '#app' +import { listService } from '../../services/listService' + +vi.mock('#app', () => ({ + useNuxtApp: vi.fn(), +})) + +const mockUseNuxtApp = vi.mocked(useNuxtApp) + +describe('listService', () => { + let mockAxios: any + + beforeEach(() => { + vi.clearAllMocks() + + mockAxios = { + get: vi.fn(), + } + + mockUseNuxtApp.mockReturnValue({ + $axios: mockAxios, + } as any) + }) + + describe('fetchList', () => { + it('should fetch data without cursor', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1, name: 'Item 1' }], + pagination: { + next_cursor: 'cursor-2', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets') + expect(result).toEqual({ + data: [{ id: 1, name: 'Item 1' }], + nextCursor: 'cursor-2', + hasMore: true, + }) + }) + + it('should fetch data with cursor parameter', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 2, name: 'Item 2' }], + pagination: { + next_cursor: 'cursor-3', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', 'cursor-2') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?cursor=cursor-2') + expect(result).toEqual({ + data: [{ id: 2, name: 'Item 2' }], + nextCursor: 'cursor-3', + hasMore: true, + }) + }) + + it('should handle paths with existing query parameters', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1 }], + pagination: { + next_cursor: null, + has_more: false, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + await listService.fetchList('/tweets?filter=latest', 'cursor-1') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?filter=latest&cursor=cursor-1') + }) + + it('should handle last page (no next cursor)', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 3 }], + pagination: { + next_cursor: null, + has_more: false, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', 'last-cursor') + + expect(result).toEqual({ + data: [{ id: 3 }], + nextCursor: undefined, + hasMore: false, + }) + }) + + it('should handle alternative pagination format (next_cursor at root level)', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1 }], + next_cursor: 'cursor-2', + has_more: true, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(result.nextCursor).toBe('cursor-2') + expect(result.hasMore).toBe(true) + }) + + it('should handle multiple items in response', async () => { + const mockResponse = { + data: { + data: { + data: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + ], + pagination: { + next_cursor: 'cursor-next', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/users', '') + + expect(result.data.length).toBe(3) + expect(result.data[0].id).toBe(1) + expect(result.data[2].id).toBe(3) + }) + + it('should handle empty data array', async () => { + const mockResponse = { + data: { + data: { + data: [], + pagination: { + next_cursor: null, + has_more: false, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(result.data).toEqual([]) + expect(result.hasMore).toBe(false) + }) + + it('should throw error on API failure', async () => { + const error = new Error('Network error') + mockAxios.get.mockRejectedValue(error) + + await expect(listService.fetchList('/tweets', '')).rejects.toThrow('Network error') + }) + + it('should use correct separator for URL construction', async () => { + const mockResponse = { + data: { + data: { + data: [], + pagination: { next_cursor: null, has_more: false }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + // Path without query params should use ? + await listService.fetchList('/tweets', 'cursor-1') + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?cursor=cursor-1') + + mockAxios.get.mockClear() + + // Path with query params should use & + await listService.fetchList('/tweets?sort=desc', 'cursor-2') + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?sort=desc&cursor=cursor-2') + }) + + it('should extract data correctly from nested response structure', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1, title: 'Test' }], + pagination: { + next_cursor: 'next', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(result.data).toEqual([{ id: 1, title: 'Test' }]) + expect(result.nextCursor).toBe('next') + expect(result.hasMore).toBe(true) + }) + + it('should handle null cursor correctly', async () => { + const mockResponse = { + data: { + data: { + data: [], + pagination: { next_cursor: null, has_more: false }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + await listService.fetchList('/tweets', '') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets') + }) + }) +}) + diff --git a/app/modules/Common/test/unit/mediaService.spec.ts b/app/modules/Common/test/unit/mediaService.spec.ts new file mode 100644 index 00000000..28d289e9 --- /dev/null +++ b/app/modules/Common/test/unit/mediaService.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createMediaService } from '../../services/mediaService' +import { useNuxtApp } from '#app' + +vi.mock('#app', () => ({ + useNuxtApp: vi.fn(), +})) + +const mockUseNuxtApp = vi.mocked(useNuxtApp) + +describe('createMediaService', () => { + let mockAxios: any + let mockFormData: any + + beforeEach(() => { + vi.clearAllMocks() + + mockAxios = { + post: vi.fn(), + } + + mockUseNuxtApp.mockReturnValue({ + $axios: mockAxios, + } as any) + + // Mock FormData + mockFormData = { + append: vi.fn(), + } + global.FormData = vi.fn(() => mockFormData) as any + }) + describe('uploadMedia', () => { + it('should upload image successfully', async () => { + const mockFile = new File(['image content'], 'test.jpg', { type: 'image/jpeg' }) + const mockResponse = { + data: { + url: 'https://cdn.example.com/images/abc123.jpg', + id: 'img-123', + }, + } + mockAxios.post.mockResolvedValue(mockResponse) + + const result = await createMediaService.uploadMedia(mockFile, 'image') + + expect(mockFormData.append).toHaveBeenCalledWith('file', mockFile) + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/image', + mockFormData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 60000, + }, + ) + expect(result).toEqual(mockResponse.data) + }) + + it('should upload video successfully', async () => { + const mockFile = new File(['video content'], 'test.mp4', { type: 'video/mp4' }) + const mockResponse = { + data: { + url: 'https://cdn.example.com/videos/abc123.mp4', + id: 'vid-123', + }, + } + mockAxios.post.mockResolvedValue(mockResponse) + + const result = await createMediaService.uploadMedia(mockFile, 'video') + + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/video', + mockFormData, + expect.objectContaining({ + timeout: 60000, + }), + ) + expect(result).toEqual(mockResponse.data) + }) + + it('should use correct endpoint for image upload', async () => { + const mockFile = new File([''], 'test.jpg') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'image') + + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/image', + expect.any(Object), + expect.any(Object), + ) + }) + + it('should use correct endpoint for video upload', async () => { + const mockFile = new File([''], 'test.mp4') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'video') + + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/video', + expect.any(Object), + expect.any(Object), + ) + }) + + it('should set multipart form-data headers', async () => { + const mockFile = new File([''], 'test.jpg') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'image') + + const callArgs = mockAxios.post.mock.calls[0] + expect(callArgs[2]).toEqual({ + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 60000, + }) + }) + + it('should append file to FormData', async () => { + const mockFile = new File(['content'], 'test.jpg') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'image') + + expect(mockFormData.append).toHaveBeenCalledWith('file', mockFile) + }) + + it('should set 60 second timeout for uploads', async () => { + const mockFile = new File([''], 'test.mp4') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'video') + + const callArgs = mockAxios.post.mock.calls[0] + expect(callArgs[2].timeout).toBe(60000) + }) + + it('should return response data correctly', async () => { + const mockFile = new File([''], 'test.jpg') + const mockResponse = { + data: { + url: 'https://example.com/image.jpg', + id: 'media-id', + width: 1920, + height: 1080, + }, + } + mockAxios.post.mockResolvedValue(mockResponse) + + const result = await createMediaService.uploadMedia(mockFile, 'image') + + expect(result).toEqual(mockResponse.data) + expect(result.url).toBe('https://example.com/image.jpg') + expect(result.width).toBe(1920) + }) + + it('should throw error on upload failure', async () => { + const mockFile = new File([''], 'test.jpg') + const error = new Error('Upload failed') + mockAxios.post.mockRejectedValue(error) + + await expect(createMediaService.uploadMedia(mockFile, 'image')).rejects.toThrow( + 'Upload failed', + ) + }) + + it('should handle network errors', async () => { + const mockFile = new File([''], 'test.jpg') + const error = new Error('Network error') + mockAxios.post.mockRejectedValue(error) + + await expect(createMediaService.uploadMedia(mockFile, 'image')).rejects.toThrow( + 'Network error', + ) + }) + }) +}) + diff --git a/app/modules/Common/test/unit/queryKeys.spec.ts b/app/modules/Common/test/unit/queryKeys.spec.ts new file mode 100644 index 00000000..58c93f5b --- /dev/null +++ b/app/modules/Common/test/unit/queryKeys.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { queryKeys } from '../../queries/queryKeys' + +describe('queryKeys', () => { + describe('tweets', () => { + it('should have tweets.all key', () => { + expect(queryKeys.tweets.all).toEqual(['tweets']) + }) + + it('should generate tweets.list key with path', () => { + const key = queryKeys.tweets.list('/timeline') + expect(key).toEqual(['tweets', '/timeline']) + }) + + it('should generate tweets.details key with tweetId', () => { + const key = queryKeys.tweets.details('123') + expect(key).toEqual(['tweetDetails', '123']) + }) + + it('should generate tweets.summary key with tweetId', () => { + const key = queryKeys.tweets.summary('456') + expect(key).toEqual(['tweetSummary', '456']) + }) + + it('should handle different paths in list keys', () => { + const paths = ['/timeline/following', '/users/me/liked-posts', '/search'] + paths.forEach((path) => { + const key = queryKeys.tweets.list(path) + expect(key).toEqual(['tweets', path]) + expect(key[1]).toBe(path) + }) + }) + }) + + describe('users', () => { + it('should have users.all key', () => { + expect(queryKeys.users.all).toEqual(['user']) + }) + + it('should generate users.profile key with username', () => { + const key = queryKeys.users.profile('johndoe') + expect(key).toEqual(['user', 'johndoe']) + }) + + it('should generate users.byId key with userId', () => { + const key = queryKeys.users.byId('user-123') + expect(key).toEqual(['user', 'user-123']) + }) + + it('should generate users.me key', () => { + const key = queryKeys.users.me() + expect(key).toEqual(['me']) + }) + + it('should generate users.followers key', () => { + const key = queryKeys.users.followers('user-123') + expect(key).toEqual(['followers', 'user-123']) + }) + + it('should generate users.following key', () => { + const key = queryKeys.users.following('user-456') + expect(key).toEqual(['following', 'user-456']) + }) + }) + + describe('settings', () => { + it('should generate settings.mutedUsers key', () => { + const key = queryKeys.settings.mutedUsers() + expect(key).toEqual(['muted-users']) + }) + + it('should generate settings.blockedUsers key', () => { + const key = queryKeys.settings.blockedUsers() + expect(key).toEqual(['blocked-users']) + }) + + it('should generate settings.usernameRecommendation key', () => { + const key = queryKeys.settings.usernameRecommendation() + expect(key).toEqual(['username-recommendation']) + }) + }) + + describe('auth', () => { + it('should generate auth.user key', () => { + const key = queryKeys.auth.user() + expect(key).toEqual(['getUser']) + }) + }) + + describe('conversations', () => { + it('should have conversations.all key', () => { + expect(queryKeys.conversations.all).toEqual(['conversations']) + }) + }) + + describe('notifications', () => { + it('should have notifications.all key', () => { + expect(queryKeys.notifications.all).toEqual(['notifications']) + }) + + it('should have notifications.mentions key', () => { + expect(queryKeys.notifications.mentions).toEqual(['mentions']) + }) + }) + + describe('search', () => { + it('should have search.all key', () => { + expect(queryKeys.search.all).toEqual(['tweets', '/search']) + }) + }) + + describe('bookmarks', () => { + it('should have bookmarks.all key', () => { + expect(queryKeys.bookmarks.all).toEqual(['tweets', 'tweets/bookmarks']) + }) + }) + + describe('query key consistency', () => { + it('should have proper key structure for nested queries', () => { + expect(Array.isArray(queryKeys.tweets.all)).toBe(true) + expect(Array.isArray(queryKeys.users.all)).toBe(true) + expect(Array.isArray(queryKeys.conversations.all)).toBe(true) + }) + + it('should have proper key structure for functions', () => { + const tweetKey = queryKeys.tweets.details('123') + const userKey = queryKeys.users.profile('user') + expect(Array.isArray(tweetKey)).toBe(true) + expect(Array.isArray(userKey)).toBe(true) + expect(tweetKey.length).toBe(2) + expect(userKey.length).toBe(2) + }) + + it('should not have duplicate top-level keys', () => { + const topKeys = Object.keys(queryKeys) + const uniqueKeys = new Set(topKeys) + expect(topKeys.length).toBe(uniqueKeys.size) + }) + }) + + describe('key isolation', () => { + it('tweets and search keys should be distinct', () => { + expect(queryKeys.tweets.all).not.toEqual(queryKeys.search.all) + }) + + it('different user keys should be distinct', () => { + const profile1 = queryKeys.users.profile('user1') + const profile2 = queryKeys.users.profile('user2') + expect(profile1).not.toEqual(profile2) + }) + + it('different tweet detail keys should be distinct', () => { + const tweet1 = queryKeys.tweets.details('123') + const tweet2 = queryKeys.tweets.details('456') + expect(tweet1).not.toEqual(tweet2) + }) + }) +}) diff --git a/app/modules/Common/test/unit/useGenericInfiniteQuery.spec.ts b/app/modules/Common/test/unit/useGenericInfiniteQuery.spec.ts index aaa0bf16..b300df7a 100644 --- a/app/modules/Common/test/unit/useGenericInfiniteQuery.spec.ts +++ b/app/modules/Common/test/unit/useGenericInfiniteQuery.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ref, computed } from 'vue' +import { ref } from 'vue' import { useGenericInfiniteQuery } from '../../composables/useGenericInfiniteQuery' // Mock useInfiniteScroll @@ -72,9 +72,7 @@ describe('useGenericInfiniteQuery', () => { it('flattens pages into items correctly', () => { // Set up mock data with pages mockQueryResult.data.value = { - pages: [ - { data: [{ id: 1 }, { id: 2 }], nextCursor: null }, - ], + pages: [{ data: [{ id: 1 }, { id: 2 }], nextCursor: null }], } const result = useGenericInfiniteQuery({ diff --git a/app/modules/TimeLine/components/banner/Banner.vue b/app/modules/TimeLine/components/banner/Banner.vue index aa9bd889..d3cb0465 100644 --- a/app/modules/TimeLine/components/banner/Banner.vue +++ b/app/modules/TimeLine/components/banner/Banner.vue @@ -16,7 +16,11 @@
- +

{{ t('timeline.banner.error') }}

diff --git a/app/modules/TimeLine/components/banner/index.ts b/app/modules/TimeLine/components/banner/index.ts index 2b686214..166013cf 100644 --- a/app/modules/TimeLine/components/banner/index.ts +++ b/app/modules/TimeLine/components/banner/index.ts @@ -1 +1 @@ -export { default } from './Banner.vue' \ No newline at end of file +export { default } from './Banner.vue' diff --git a/app/modules/TimeLine/components/bookmarkList/bookmarkList.vue b/app/modules/TimeLine/components/bookmarkList/bookmarkList.vue index ec431430..522e9226 100644 --- a/app/modules/TimeLine/components/bookmarkList/bookmarkList.vue +++ b/app/modules/TimeLine/components/bookmarkList/bookmarkList.vue @@ -15,7 +15,7 @@
- + \ No newline at end of file + showCompleteAccount.value = false + showLoadingPage.value = true + navigateTo('/') +} + diff --git a/app/modules/auth/components/createAccount.vue b/app/modules/auth/components/createAccount.vue index 61022be9..7bf9d6d1 100644 --- a/app/modules/auth/components/createAccount.vue +++ b/app/modules/auth/components/createAccount.vue @@ -1,38 +1,38 @@ @@ -63,7 +63,7 @@ const signupData = reactive({ year: '', otp: '', password: '', - language: 'en' + language: 'en', }) const onNext = async (email: string) => { diff --git a/app/modules/auth/components/forgetPassword.vue b/app/modules/auth/components/forgetPassword.vue index f978c53d..7f451a53 100644 --- a/app/modules/auth/components/forgetPassword.vue +++ b/app/modules/auth/components/forgetPassword.vue @@ -1,73 +1,72 @@ diff --git a/app/modules/auth/components/login.vue b/app/modules/auth/components/login.vue index 92509990..0f9984f4 100644 --- a/app/modules/auth/components/login.vue +++ b/app/modules/auth/components/login.vue @@ -1,13 +1,13 @@ diff --git a/app/modules/auth/components/subComponents/OAuth.vue b/app/modules/auth/components/subComponents/OAuth.vue index 4cb3cfa5..00219c45 100644 --- a/app/modules/auth/components/subComponents/OAuth.vue +++ b/app/modules/auth/components/subComponents/OAuth.vue @@ -9,7 +9,7 @@ src="https://www.svgrepo.com/show/355037/google.svg" alt="Google" class="w-5 h-5" - /> + > {{ $t('auth.OAuth.continueWithGoogle') }} @@ -22,7 +22,7 @@ src="https://www.svgrepo.com/show/512317/github-142.svg" alt="GitHub" class="w-5 h-5 github-icon" - /> + > {{ $t('auth.OAuth.continueWithGithub') }} diff --git a/app/modules/auth/components/subComponents/OAuthComponents/OAuthStep1.vue b/app/modules/auth/components/subComponents/OAuthComponents/OAuthStep1.vue index 29912ca3..ce4ab28e 100644 --- a/app/modules/auth/components/subComponents/OAuthComponents/OAuthStep1.vue +++ b/app/modules/auth/components/subComponents/OAuthComponents/OAuthStep1.vue @@ -1,23 +1,29 @@ @@ -100,11 +106,10 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import Logo from '~/modules/Common/components/Logo' -import { useOAuthCompleteStep1Query } from '~/modules/auth/queries/useOAuthQuery' -import { useOAuthCompleteStep2Query } from '~/modules/auth/queries/useOAuthQuery' +import { useOAuthCompleteStep1Query, useOAuthCompleteStep2Query } from '~/modules/auth/queries/useOAuthQuery' import Popup from '~/modules/Common/components/Popup/Popup.vue' import Button from '~/modules/Common/components/Button/Button.vue' -import { useUserStore } from '~/modules/auth/stores/userStore'; +import { useUserStore } from '~/modules/auth/stores/userStore' const userStore = useUserStore() const { locale } = useI18n() const isArabic = computed(() => locale.value === 'ar') diff --git a/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep1.vue b/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep1.vue index 7e401d31..58ffde99 100644 --- a/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep1.vue +++ b/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep1.vue @@ -1,18 +1,18 @@ @@ -113,7 +115,7 @@ const handleOtpInput = (event: Event) => { const validateOtpField = () => { const result = validateOtp(otp.value) - otpError.value = result.valid ? '' : (result.messageKey ? t(result.messageKey) : '') + otpError.value = result.valid ? '' : result.messageKey ? t(result.messageKey) : '' return result.valid } diff --git a/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep3.vue b/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep3.vue index 3b6b71cf..8b0f5961 100644 --- a/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep3.vue +++ b/app/modules/auth/components/subComponents/forgetPasswordComponents/forgetPasswordStep3.vue @@ -1,54 +1,61 @@ @@ -123,7 +130,7 @@ const resetPasswordMutation = useResetPasswordQuery( const validatePasswordField = () => { const result = validatePassword(password.value) - passwordError.value = result.valid ? '' : (result.messageKey ? t(result.messageKey) : '') + passwordError.value = result.valid ? '' : result.messageKey ? t(result.messageKey) : '' return result.valid } @@ -137,12 +144,12 @@ const clearMatchError = () => { } const validatePasswordMatch = (pwd: string, confirmPwd: string) => { - if (!confirmPwd) return '' - return pwd === confirmPwd ? '' : 'Passwords do not match.' + if (!confirmPwd) return '' + return pwd === confirmPwd ? '' : 'Passwords do not match.' } watch([password, verifyPassword], ([pwd, confirmPwd]) => { - matchError.value = validatePasswordMatch(pwd, confirmPwd) + matchError.value = validatePasswordMatch(pwd, confirmPwd) }) const onFinish = () => { diff --git a/app/modules/auth/components/subComponents/loginComponents/loginStep1.vue b/app/modules/auth/components/subComponents/loginComponents/loginStep1.vue index 4fbfd4ac..7d0ccd85 100644 --- a/app/modules/auth/components/subComponents/loginComponents/loginStep1.vue +++ b/app/modules/auth/components/subComponents/loginComponents/loginStep1.vue @@ -1,44 +1,50 @@ @@ -111,7 +117,6 @@ const checkMutation = useCheckIdentifierAvailabilityQuery( emit('next', identifier.value, Type) }, (error: any) => { - // Extract error message from response const errorMsg = error?.response?.data?.message || @@ -126,7 +131,7 @@ const { t } = useI18n() const validateIdentifierField = () => { const result = validateIdentifier(identifier.value) - validationError.value = result.valid ? '' : (result.messageKey ? t(result.messageKey) : '') + validationError.value = result.valid ? '' : result.messageKey ? t(result.messageKey) : '' return result.valid } diff --git a/app/modules/auth/components/subComponents/loginComponents/loginStep2.vue b/app/modules/auth/components/subComponents/loginComponents/loginStep2.vue index b9c806fb..9c51f65c 100644 --- a/app/modules/auth/components/subComponents/loginComponents/loginStep2.vue +++ b/app/modules/auth/components/subComponents/loginComponents/loginStep2.vue @@ -1,25 +1,31 @@ @@ -140,7 +157,7 @@ const { t } = useI18n() const validatePasswordField = () => { const result = validatePassword(password.value) - passwordError.value = result.valid ? '' : (result.messageKey ? t(result.messageKey) : '') + passwordError.value = result.valid ? '' : result.messageKey ? t(result.messageKey) : '' return result.valid } diff --git a/app/modules/auth/components/subComponents/recaptcha.vue b/app/modules/auth/components/subComponents/recaptcha.vue index df07b8ce..2c6ddc86 100644 --- a/app/modules/auth/components/subComponents/recaptcha.vue +++ b/app/modules/auth/components/subComponents/recaptcha.vue @@ -3,9 +3,9 @@ diff --git a/app/modules/auth/components/subComponents/signupComponents/FinalRegister.vue b/app/modules/auth/components/subComponents/signupComponents/FinalRegister.vue index 067089a4..24578758 100644 --- a/app/modules/auth/components/subComponents/signupComponents/FinalRegister.vue +++ b/app/modules/auth/components/subComponents/signupComponents/FinalRegister.vue @@ -1,59 +1,76 @@ @@ -110,7 +127,7 @@ const { t } = useI18n() const validatePasswordField = () => { const result = validatePassword(password.value) - passwordError.value = result.valid ? '' : (result.messageKey ? t(result.messageKey) : '') + passwordError.value = result.valid ? '' : result.messageKey ? t(result.messageKey) : '' return result.valid } diff --git a/app/modules/auth/components/subComponents/signupComponents/createAccount.vue b/app/modules/auth/components/subComponents/signupComponents/createAccount.vue index d37123f3..0204d517 100644 --- a/app/modules/auth/components/subComponents/signupComponents/createAccount.vue +++ b/app/modules/auth/components/subComponents/signupComponents/createAccount.vue @@ -1,34 +1,43 @@ diff --git a/app/modules/explore/components/trending/index.vue b/app/modules/explore/components/trending/index.vue index 5ec17ca6..6959838e 100644 --- a/app/modules/explore/components/trending/index.vue +++ b/app/modules/explore/components/trending/index.vue @@ -11,7 +11,11 @@ class="flex items-center justify-center min-h-[calc(100vh-60px)] border-t border-primary" >

{{ t('explore.errorLoading') }}

- diff --git a/app/modules/explore/components/whoToFollow/index.vue b/app/modules/explore/components/whoToFollow/index.vue index 63ea1c84..7381fee4 100644 --- a/app/modules/explore/components/whoToFollow/index.vue +++ b/app/modules/explore/components/whoToFollow/index.vue @@ -4,7 +4,11 @@
-

{{ t('explore.connect') }}

@@ -29,13 +33,16 @@
-
+

{{ t('explore.errorLoading') }}

@@ -43,7 +50,10 @@
-
+

{{ t('explore.noUsersFound') }}

diff --git a/app/modules/explore/constants/index.ts b/app/modules/explore/constants/index.ts index 6063b841..a9567f66 100644 --- a/app/modules/explore/constants/index.ts +++ b/app/modules/explore/constants/index.ts @@ -9,5 +9,9 @@ export const tabs: Tab[] = [ { value: 'trending', label: 'Trending', translationKey: 'explore.tabs.trending' }, { value: 'news', label: 'News', translationKey: 'explore.tabs.news' }, { value: 'sports', label: 'Sports', translationKey: 'explore.tabs.sports' }, - { value: 'entertainment', label: 'Entertainment', translationKey: 'explore.tabs.entertainment' }, -] \ No newline at end of file + { + value: 'entertainment', + label: 'Entertainment', + translationKey: 'explore.tabs.entertainment', + }, +] diff --git a/app/modules/explore/queries/useGetExploreQuery.ts b/app/modules/explore/queries/useGetExploreQuery.ts index 0430c83b..11766db9 100644 --- a/app/modules/explore/queries/useGetExploreQuery.ts +++ b/app/modules/explore/queries/useGetExploreQuery.ts @@ -1,10 +1,8 @@ import { useNuxtApp } from '#app' import { useQuery } from '@tanstack/vue-query' -import { watch, type Ref } from 'vue' +import type { Ref } from 'vue' -export function useGetExploreQuery( - enabled: Ref | boolean = false, -) { +export function useGetExploreQuery(enabled: Ref | boolean = false) { const { $exploreService } = useNuxtApp() const query = useQuery({ queryKey: ['getExplore'], @@ -18,7 +16,7 @@ export function useGetExploreQuery( } export function useGetTrendsQuery( - category?: String, + category?: string, enabled: Ref | boolean = false, limit?: number, ) { @@ -35,9 +33,7 @@ export function useGetTrendsQuery( return query } -export function useGetWhoToFollowQuery( - enabled: Ref | boolean = false, -) { +export function useGetWhoToFollowQuery(enabled: Ref | boolean = false) { const { $exploreService } = useNuxtApp() const query = useQuery({ queryKey: ['who-to-follow'], diff --git a/app/modules/explore/services/exploreService.mock.ts b/app/modules/explore/services/exploreService.mock.ts index 4ea05ca6..cc72dd8b 100644 --- a/app/modules/explore/services/exploreService.mock.ts +++ b/app/modules/explore/services/exploreService.mock.ts @@ -2,360 +2,362 @@ export const exploreServiceMock = () => { return { getExplore: async () => { return { - "data": { - "trending": [ - { - "text": "#WorldCup2026", - "posts_count": 45678, - "reference_id": "worldcup2026", - "category": "sports", - "trend_rank": 1 - }, - { - "text": "#TechConference", - "posts_count": 23456, - "reference_id": "techconference", - "category": "none", - "trend_rank": 2 - }, - { - "text": "New Movie Release", - "posts_count": 18234, - "reference_id": "new-movie-release", - "category": "entertainment", - "trend_rank": 3 - } - ], - "who_to_follow": [ - { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "username": "techenthusiast", - "name": "Tech Enthusiast", - "bio": "Passionate about technology, AI, and innovation. Sharing the latest tech news and insights.", - "avatar_url": "https://cdn.example.com/avatars/techenthusiast.jpg", - "verified": true, - "followers": 45678, - "following": 892, - "is_following": false, - "is_followed_by": false - }, - { - "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "username": "sportsanalyst", - "name": "Sports Analyst", - "bio": "Breaking down the game | Sports statistics and analysis | Former athlete", - "avatar_url": "https://cdn.example.com/avatars/sportsanalyst.jpg", - "verified": true, - "followers": 32145, - "following": 543, - "is_following": false, - "is_followed_by": true - }, - { - "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", - "username": "musiclover", - "name": "Music Lover", - "bio": "๐ŸŽต Music is life | Playlist curator | Concert photographer", - "avatar_url": "https://cdn.example.com/avatars/musiclover.jpg", - "verified": false, - "followers": 8234, - "following": 1205, - "is_following": false, - "is_followed_by": false - } - ], - "for_you_posts": [ - { - "category": { - "id": 1, - "name": "Sports" - }, - "posts": [ - { - "tweet_id": "550e8400-e29b-41d4-a716-446655440000", - "type": "tweet", - "content": "Just scored the winning goal! โšฝ What an incredible match! #Football #Victory", - "images": [ - "https://cdn.example.com/images/goal-celebration.jpg" - ], - "videos": [], - "likes_count": 1247, - "reposts_count": 89, - "views_count": 5432, - "quotes_count": 34, - "replies_count": 156, - "bookmarks_count": 78, - "is_liked": false, - "is_reposted": false, - "is_bookmarked": false, - "created_at": "2025-11-23T10:30:00.000Z", - "updated_at": "2025-11-23T10:30:00.000Z", - "user": { - "id": "c8b1f8e2-3f4a-4d2a-9f0e-123456789abc", - "username": "johndoe", - "name": "John Doe", - "avatar_url": "https://cdn.example.com/profiles/johndoe.jpg", - "verified": true, - "bio": "Sports enthusiast | Football lover โšฝ", - "cover_url": "https://cdn.example.com/covers/johndoe.jpg", - "followers": 15234, - "following": 892, - "is_following": false, - "is_followed_by": false - } - }, - { - "tweet_id": "770f0600-g41d-63f6-c938-668877662222", - "type": "tweet", - "content": "Breaking: Championship finals set for next week! ๐Ÿ† #Sports #Championship", - "images": [], - "videos": [ - "https://cdn.example.com/videos/championship-preview.mp4" - ], - "likes_count": 892, - "reposts_count": 156, - "views_count": 3421, - "quotes_count": 23, - "replies_count": 89, - "bookmarks_count": 45, - "is_liked": false, - "is_reposted": false, - "is_bookmarked": false, - "created_at": "2025-11-23T08:45:00.000Z", - "updated_at": "2025-11-23T08:45:00.000Z", - "user": { - "id": "d9e3f1a4-5h6c-6f4d-1h2g-345678901ghi", - "username": "sportsnews", - "name": "Sports News", - "avatar_url": "https://cdn.example.com/profiles/sportsnews.jpg", - "verified": true, - "bio": "Breaking sports news and updates ๐Ÿ† | Official sports media", - "cover_url": "https://cdn.example.com/covers/sportsnews.jpg", - "followers": 2456789, - "following": 234, - "is_following": true, - "is_followed_by": false - } - } - ] - }, - { - "category": { - "id": 2, - "name": "Music" - }, - "posts": [ - { - "tweet_id": "660e9500-f30c-52e5-b827-557766551111", - "type": "tweet", - "content": "New album dropping tonight at midnight! ๐ŸŽต Get ready! #NewMusic #AlbumRelease", - "images": [ - "https://cdn.example.com/images/album-cover.jpg" - ], - "videos": [], - "likes_count": 3421, - "reposts_count": 567, - "views_count": 12890, - "quotes_count": 89, - "replies_count": 445, - "bookmarks_count": 234, - "is_liked": true, - "is_reposted": false, - "is_bookmarked": true, - "created_at": "2025-11-23T09:15:00.000Z", - "updated_at": "2025-11-23T09:15:00.000Z", - "user": { - "id": "a7c2e9f3-4g5b-5e3c-0g1f-234567890def", - "username": "musicartist", - "name": "Music Artist", - "avatar_url": "https://cdn.example.com/profiles/artist.jpg", - "verified": true, - "bio": "๐ŸŽต Singer | Songwriter | Producer | New album out now!", - "cover_url": "https://cdn.example.com/covers/artist.jpg", - "followers": 567890, - "following": 1234, - "is_following": true, - "is_followed_by": true - } - },{ - "tweet_id": "770f0611-g41d-63f6-c938-668877663333", - "type": "tweet", - "content": "Umm kulthum drops her new song Altalal today! Can't wait to listen to it. ๐ŸŽถ #UmmKulthum #NewSingle", - "images": [ "https://i.ibb.co/yctfVWJh/07-01-1971-8.jpg"], - "videos": [], - "likes_count": 8123890, - "reposts_count": 1432345, - "views_count": 25678901, - "quotes_count": 12345, - "replies_count": 67890, - "bookmarks_count": 45678, - "is_liked": true, - "is_reposted": true, - "is_bookmarked": true, - "created_at": "2025-11-23T07:00:00.000Z", - "updated_at": "2025-11-23T07:00:00.000Z", - "user": { - "id": "e1f2g3h4-i5j6-7k8l-9m0n-567890123ghi", - "username": "ummkulthum", - "name": "Umm Kulthum", - "avatar_url": "https://i.ibb.co/BVDLL6cc/07-01-1971.jpg", - "verified": true, - "bio": "Iconic Egyptian singer, songwriter, and actress. The Voice of Egypt and the Arab World. ๐ŸŽค", - "cover_url": "https://i.ibb.co/3p1LZ5Y/umm-kulthum-cover.jpg", - "followers": 150000000, - "following": 150, - "is_following": false, - "is_followed_by": true - } - } - ] - }, - { - "category": { - "id": 3, - "name": "Entertainment" - }, - "posts": [ - { - "tweet_id": "880g1711-h52e-74g7-d049-779988773333", - "type": "tweet", - "content": "I dont think the casual Hunger Games films fans realise how deeply traumatising and genuinely heartbreaking Sunrise on the Reaping is about to be. ๐ŸŽฌ", - "images": [], - "videos": [], - "likes_count": 5621, - "reposts_count": 892, - "views_count": 18234, - "quotes_count": 156, - "replies_count": 678, - "bookmarks_count": 412, - "is_liked": false, - "is_reposted": true, - "is_bookmarked": false, - "created_at": "2025-11-22T18:30:00.000Z", - "updated_at": "2025-11-22T18:30:00.000Z", - "user": { - "id": "e0f4g2b5-6i7d-7g5e-2i3h-456789012jkl", - "username": "moviebuff", - "name": "Cinema Enthusiast", - "avatar_url": "https://cdn.example.com/profiles/moviebuff.jpg", - "verified": false, - "bio": "๐ŸŽฌ Film critic | Movie reviews | Hunger Games superfan", - "cover_url": "https://cdn.example.com/covers/moviebuff.jpg", - "followers": 8934, - "following": 456, - "is_following": false, - "is_followed_by": true - } - } - ] - } - ] - }, - "count": 1, - "message": "Explore page data retrieved successfully" - }; + data: { + trending: [ + { + text: '#WorldCup2026', + posts_count: 45678, + reference_id: 'worldcup2026', + category: 'sports', + trend_rank: 1, + }, + { + text: '#TechConference', + posts_count: 23456, + reference_id: 'techconference', + category: 'none', + trend_rank: 2, + }, + { + text: 'New Movie Release', + posts_count: 18234, + reference_id: 'new-movie-release', + category: 'entertainment', + trend_rank: 3, + }, + ], + who_to_follow: [ + { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + username: 'techenthusiast', + name: 'Tech Enthusiast', + bio: 'Passionate about technology, AI, and innovation. Sharing the latest tech news and insights.', + avatar_url: 'https://cdn.example.com/avatars/techenthusiast.jpg', + verified: true, + followers: 45678, + following: 892, + is_following: false, + is_followed_by: false, + }, + { + id: 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + username: 'sportsanalyst', + name: 'Sports Analyst', + bio: 'Breaking down the game | Sports statistics and analysis | Former athlete', + avatar_url: 'https://cdn.example.com/avatars/sportsanalyst.jpg', + verified: true, + followers: 32145, + following: 543, + is_following: false, + is_followed_by: true, + }, + { + id: 'c3d4e5f6-a7b8-9012-cdef-123456789012', + username: 'musiclover', + name: 'Music Lover', + bio: '๐ŸŽต Music is life | Playlist curator | Concert photographer', + avatar_url: 'https://cdn.example.com/avatars/musiclover.jpg', + verified: false, + followers: 8234, + following: 1205, + is_following: false, + is_followed_by: false, + }, + ], + for_you_posts: [ + { + category: { + id: 1, + name: 'Sports', + }, + posts: [ + { + tweet_id: '550e8400-e29b-41d4-a716-446655440000', + type: 'tweet', + content: + 'Just scored the winning goal! โšฝ What an incredible match! #Football #Victory', + images: ['https://cdn.example.com/images/goal-celebration.jpg'], + videos: [], + likes_count: 1247, + reposts_count: 89, + views_count: 5432, + quotes_count: 34, + replies_count: 156, + bookmarks_count: 78, + is_liked: false, + is_reposted: false, + is_bookmarked: false, + created_at: '2025-11-23T10:30:00.000Z', + updated_at: '2025-11-23T10:30:00.000Z', + user: { + id: 'c8b1f8e2-3f4a-4d2a-9f0e-123456789abc', + username: 'johndoe', + name: 'John Doe', + avatar_url: 'https://cdn.example.com/profiles/johndoe.jpg', + verified: true, + bio: 'Sports enthusiast | Football lover โšฝ', + cover_url: 'https://cdn.example.com/covers/johndoe.jpg', + followers: 15234, + following: 892, + is_following: false, + is_followed_by: false, + }, + }, + { + tweet_id: '770f0600-g41d-63f6-c938-668877662222', + type: 'tweet', + content: + 'Breaking: Championship finals set for next week! ๐Ÿ† #Sports #Championship', + images: [], + videos: [ + 'https://cdn.example.com/videos/championship-preview.mp4', + ], + likes_count: 892, + reposts_count: 156, + views_count: 3421, + quotes_count: 23, + replies_count: 89, + bookmarks_count: 45, + is_liked: false, + is_reposted: false, + is_bookmarked: false, + created_at: '2025-11-23T08:45:00.000Z', + updated_at: '2025-11-23T08:45:00.000Z', + user: { + id: 'd9e3f1a4-5h6c-6f4d-1h2g-345678901ghi', + username: 'sportsnews', + name: 'Sports News', + avatar_url: + 'https://cdn.example.com/profiles/sportsnews.jpg', + verified: true, + bio: 'Breaking sports news and updates ๐Ÿ† | Official sports media', + cover_url: 'https://cdn.example.com/covers/sportsnews.jpg', + followers: 2456789, + following: 234, + is_following: true, + is_followed_by: false, + }, + }, + ], + }, + { + category: { + id: 2, + name: 'Music', + }, + posts: [ + { + tweet_id: '660e9500-f30c-52e5-b827-557766551111', + type: 'tweet', + content: + 'New album dropping tonight at midnight! ๐ŸŽต Get ready! #NewMusic #AlbumRelease', + images: ['https://cdn.example.com/images/album-cover.jpg'], + videos: [], + likes_count: 3421, + reposts_count: 567, + views_count: 12890, + quotes_count: 89, + replies_count: 445, + bookmarks_count: 234, + is_liked: true, + is_reposted: false, + is_bookmarked: true, + created_at: '2025-11-23T09:15:00.000Z', + updated_at: '2025-11-23T09:15:00.000Z', + user: { + id: 'a7c2e9f3-4g5b-5e3c-0g1f-234567890def', + username: 'musicartist', + name: 'Music Artist', + avatar_url: 'https://cdn.example.com/profiles/artist.jpg', + verified: true, + bio: '๐ŸŽต Singer | Songwriter | Producer | New album out now!', + cover_url: 'https://cdn.example.com/covers/artist.jpg', + followers: 567890, + following: 1234, + is_following: true, + is_followed_by: true, + }, + }, + { + tweet_id: '770f0611-g41d-63f6-c938-668877663333', + type: 'tweet', + content: + "Umm kulthum drops her new song Altalal today! Can't wait to listen to it. ๐ŸŽถ #UmmKulthum #NewSingle", + images: ['https://i.ibb.co/yctfVWJh/07-01-1971-8.jpg'], + videos: [], + likes_count: 8123890, + reposts_count: 1432345, + views_count: 25678901, + quotes_count: 12345, + replies_count: 67890, + bookmarks_count: 45678, + is_liked: true, + is_reposted: true, + is_bookmarked: true, + created_at: '2025-11-23T07:00:00.000Z', + updated_at: '2025-11-23T07:00:00.000Z', + user: { + id: 'e1f2g3h4-i5j6-7k8l-9m0n-567890123ghi', + username: 'ummkulthum', + name: 'Umm Kulthum', + avatar_url: 'https://i.ibb.co/BVDLL6cc/07-01-1971.jpg', + verified: true, + bio: 'Iconic Egyptian singer, songwriter, and actress. The Voice of Egypt and the Arab World. ๐ŸŽค', + cover_url: 'https://i.ibb.co/3p1LZ5Y/umm-kulthum-cover.jpg', + followers: 150000000, + following: 150, + is_following: false, + is_followed_by: true, + }, + }, + ], + }, + { + category: { + id: 3, + name: 'Entertainment', + }, + posts: [ + { + tweet_id: '880g1711-h52e-74g7-d049-779988773333', + type: 'tweet', + content: + 'I dont think the casual Hunger Games films fans realise how deeply traumatising and genuinely heartbreaking Sunrise on the Reaping is about to be. ๐ŸŽฌ', + images: [], + videos: [], + likes_count: 5621, + reposts_count: 892, + views_count: 18234, + quotes_count: 156, + replies_count: 678, + bookmarks_count: 412, + is_liked: false, + is_reposted: true, + is_bookmarked: false, + created_at: '2025-11-22T18:30:00.000Z', + updated_at: '2025-11-22T18:30:00.000Z', + user: { + id: 'e0f4g2b5-6i7d-7g5e-2i3h-456789012jkl', + username: 'moviebuff', + name: 'Cinema Enthusiast', + avatar_url: + 'https://cdn.example.com/profiles/moviebuff.jpg', + verified: false, + bio: '๐ŸŽฌ Film critic | Movie reviews | Hunger Games superfan', + cover_url: 'https://cdn.example.com/covers/moviebuff.jpg', + followers: 8934, + following: 456, + is_following: false, + is_followed_by: true, + }, + }, + ], + }, + ], + }, + count: 1, + message: 'Explore page data retrieved successfully', + } }, - getTrending: async (category: String, country: String) => { + getTrending: async (category: string, country: string) => { return { data: [ { - text: "#WorldCup2026", - posts_count: 45678, - reference_id: "worldcup2026", - category: "sports", - trend_rank: 1 + text: '#WorldCup2026', + posts_count: 45678, + reference_id: 'worldcup2026', + category: 'sports', + trend_rank: 1, }, { - text: "#TechConference", - posts_count: 23456, - reference_id: "techconference", - category: "none", - trend_rank: 2 + text: '#TechConference', + posts_count: 23456, + reference_id: 'techconference', + category: 'none', + trend_rank: 2, }, { - text: "New Movie Release", - posts_count: 18234, - reference_id: "new-movie-release", - category: "entertainment", - trend_rank: 3 + text: 'New Movie Release', + posts_count: 18234, + reference_id: 'new-movie-release', + category: 'entertainment', + trend_rank: 3, }, { - text: "#ClimateAction", - posts_count: 15890, - reference_id: "climateaction", - category: "news", - trend_rank: 4 + text: '#ClimateAction', + posts_count: 15890, + reference_id: 'climateaction', + category: 'news', + trend_rank: 4, }, { - text: "Champions League", - posts_count: 12567, - reference_id: "champions-league", - category: "sports", - trend_rank: 5 - } + text: 'Champions League', + posts_count: 12567, + reference_id: 'champions-league', + category: 'sports', + trend_rank: 5, + }, ], count: 5, - message: "Explore trending items retrieved successfully" - }; + message: 'Explore trending items retrieved successfully', + } }, - getExploreCategories: async (category: String) => { + getExploreCategories: async (category: string) => { if (category === 'sports') { return { data: [ - { - text: "#WorldCup2026", - posts_count: 45678, - reference_id: "worldcup2026", - category: "sports", - trend_rank: 1 - }, - { - text: "Champions League", - posts_count: 12567, - reference_id: "champions-league", - category: "sports", - trend_rank: 2 - } + { + text: '#WorldCup2026', + posts_count: 45678, + reference_id: 'worldcup2026', + category: 'sports', + trend_rank: 1, + }, + { + text: 'Champions League', + posts_count: 12567, + reference_id: 'champions-league', + category: 'sports', + trend_rank: 2, + }, ], - message: "Category details retrieved successfully", - count: 2 - }; - } - else if (category === 'news') { + message: 'Category details retrieved successfully', + count: 2, + } + } else if (category === 'news') { return { data: [ - { - text: "#ClimateAction", - posts_count: 15890, - reference_id: "climateaction", - category: "news", - trend_rank: 1 - } + { + text: '#ClimateAction', + posts_count: 15890, + reference_id: 'climateaction', + category: 'news', + trend_rank: 1, + }, ], - message: "Category details retrieved successfully", - count: 1 - }; - } - else if (category === 'entertainment') { + message: 'Category details retrieved successfully', + count: 1, + } + } else if (category === 'entertainment') { return { data: [ - { - text: "New Movie Release", - posts_count: 18234, - reference_id: "new-movie-release", - category: "entertainment", - trend_rank: 1 - } + { + text: 'New Movie Release', + posts_count: 18234, + reference_id: 'new-movie-release', + category: 'entertainment', + trend_rank: 1, + }, ], - message: "Category details retrieved successfully", - count: 1 - }; + message: 'Category details retrieved successfully', + count: 1, + } } return { data: [], count: 0, - message: "Invalid category" - }; - } - }; -} \ No newline at end of file + message: 'Invalid category', + } + }, + } +} diff --git a/app/modules/explore/services/exploreService.real.ts b/app/modules/explore/services/exploreService.real.ts index f9c19be8..0544e62e 100644 --- a/app/modules/explore/services/exploreService.real.ts +++ b/app/modules/explore/services/exploreService.real.ts @@ -8,7 +8,7 @@ export const exploreServiceReal = () => { const response = await $yapperApi.get(`${API_URL}/explore`) return response.data }, - getTrending: async (category?: String, limit?: number) => { + getTrending: async (category?: string, limit?: number) => { const params = category ? { category } : {} const response = await $yapperApi.get(`${API_URL}/trend`, { params: { ...params, limit }, @@ -17,7 +17,7 @@ export const exploreServiceReal = () => { }, getExploreCategories: async (category_id: string, page: number = 1, limit: number = 20) => { const response = await $yapperApi.get(`${API_URL}/explore/category/${category_id}`, { - params: { page, limit } + params: { page, limit }, }) return response.data }, diff --git a/app/modules/explore/services/index.ts b/app/modules/explore/services/index.ts index 0bd11968..e2b3c9c8 100644 --- a/app/modules/explore/services/index.ts +++ b/app/modules/explore/services/index.ts @@ -1,7 +1,7 @@ -import { exploreServiceReal } from "./exploreService.real"; -import { exploreServiceMock } from "./exploreService.mock"; +import { exploreServiceReal } from './exploreService.real' +import { exploreServiceMock } from './exploreService.mock' export function exploreService() { - const isMock = false; - return isMock ? exploreServiceMock() : exploreServiceReal(); -} \ No newline at end of file + const isMock = false + return isMock ? exploreServiceMock() : exploreServiceReal() +} diff --git a/app/modules/explore/test/unit/categories.spec.ts b/app/modules/explore/test/unit/categories.spec.ts index 200d741c..c5399cfa 100644 --- a/app/modules/explore/test/unit/categories.spec.ts +++ b/app/modules/explore/test/unit/categories.spec.ts @@ -207,7 +207,7 @@ describe('Categories Component', () => { it('should maintain props throughout lifecycle', async () => { const wrapper = mountCategories('news') expect(wrapper.props('category')).toBe('news') - + await wrapper.vm.$nextTick() expect(wrapper.props('category')).toBe('news') }) diff --git a/app/modules/explore/test/unit/exploreTabs.spec.ts b/app/modules/explore/test/unit/exploreTabs.spec.ts index 8d27a80b..ed54b0ba 100644 --- a/app/modules/explore/test/unit/exploreTabs.spec.ts +++ b/app/modules/explore/test/unit/exploreTabs.spec.ts @@ -158,9 +158,11 @@ describe('ExploreTabs Component', () => { expect(wrapper.vm.translatedTabs.length).toBeGreaterThanOrEqual(2) // Check that for_you and trending are present const hasForYou = wrapper.vm.translatedTabs.some((tab: any) => tab.value === 'for_you') - const hasTrending = wrapper.vm.translatedTabs.some((tab: any) => tab.value === 'trending') + const hasTrending = wrapper.vm.translatedTabs.some( + (tab: any) => tab.value === 'trending', + ) expect(hasForYou).toBe(true) expect(hasTrending).toBe(true) }) }) -}) \ No newline at end of file +}) diff --git a/app/modules/explore/test/unit/forYou.spec.ts b/app/modules/explore/test/unit/forYou.spec.ts index 6a4a6a33..2cf3f572 100644 --- a/app/modules/explore/test/unit/forYou.spec.ts +++ b/app/modules/explore/test/unit/forYou.spec.ts @@ -140,7 +140,7 @@ describe('ForYou Component', () => { trending: { data: [] }, who_to_follow: [], for_you: [], - } + }, } const wrapper = mountForYou() const errorText = wrapper.text() @@ -171,9 +171,7 @@ describe('ForYou Component', () => { for_you: [ { category: { id: 'cat1', name: 'Category 1' }, - tweets: [ - { tweet_id: '1', content: 'Tweet 1' }, - ], + tweets: [{ tweet_id: '1', content: 'Tweet 1' }], }, ], }, @@ -197,4 +195,4 @@ describe('ForYou Component', () => { expect(wrapper.html()).toContain('tweet-stub') }) }) -}) \ No newline at end of file +}) diff --git a/app/modules/explore/test/unit/trending.spec.ts b/app/modules/explore/test/unit/trending.spec.ts index 2e6a5179..d35e0e78 100644 --- a/app/modules/explore/test/unit/trending.spec.ts +++ b/app/modules/explore/test/unit/trending.spec.ts @@ -154,4 +154,4 @@ describe('Trending Component', () => { expect(trendsList.props('showRank')).toBe(true) }) }) -}) \ No newline at end of file +}) diff --git a/app/modules/explore/types/nuxt.d.ts b/app/modules/explore/types/nuxt.d.ts index 79506ff3..0006cfb5 100644 --- a/app/modules/explore/types/nuxt.d.ts +++ b/app/modules/explore/types/nuxt.d.ts @@ -9,4 +9,4 @@ declare module 'vue' { interface ComponentCustomProperties { $exploreService: exploreService } -} \ No newline at end of file +} diff --git a/app/modules/notifications/components/NotificationsList.vue b/app/modules/notifications/components/NotificationsList.vue index ebabeed2..6e2e43cc 100644 --- a/app/modules/notifications/components/NotificationsList.vue +++ b/app/modules/notifications/components/NotificationsList.vue @@ -4,16 +4,9 @@ tag="div" class="flex flex-col" appear> - + -
+
@@ -29,8 +22,6 @@ const props = defineProps<{ fetchNextPage: () => unknown | Promise }>() - - const loadMore = ref(null) onMounted(() => { @@ -43,11 +34,7 @@ onMounted(() => { isFetching: props.isFetchingNextPage, }) - if ( - entry?.isIntersecting && - props.hasNextPage && - !props.isFetchingNextPage - ) { + if (entry?.isIntersecting && props.hasNextPage && !props.isFetchingNextPage) { props.fetchNextPage() } }, @@ -57,34 +44,37 @@ onMounted(() => { }, ) - watch(loadMore, (newVal) => { - if (newVal) { - observer.observe(newVal) - } - }, { immediate: true }) + watch( + loadMore, + (newVal) => { + if (newVal) { + observer.observe(newVal) + } + }, + { immediate: true }, + ) onUnmounted(() => { observer.disconnect() }) }) - diff --git a/app/modules/notifications/components/SubComponents/NotificationCard.vue b/app/modules/notifications/components/SubComponents/NotificationCard.vue index ee5fdacd..eb4cd22a 100644 --- a/app/modules/notifications/components/SubComponents/NotificationCard.vue +++ b/app/modules/notifications/components/SubComponents/NotificationCard.vue @@ -4,7 +4,11 @@ :to="link" class="flex gap-3 border-b border-primary p-5 hover:bg-hover transition" > - +
@@ -76,7 +80,7 @@ diff --git a/app/modules/notifications/components/SubComponents/NotificationItem.vue b/app/modules/notifications/components/SubComponents/NotificationItem.vue index 58804613..2f8ab4fa 100644 --- a/app/modules/notifications/components/SubComponents/NotificationItem.vue +++ b/app/modules/notifications/components/SubComponents/NotificationItem.vue @@ -1,4 +1,3 @@ - - diff --git a/app/modules/notifications/queries/useGetNotificationsQuery.ts b/app/modules/notifications/queries/useGetNotificationsQuery.ts index 90a41258..d6f4462b 100644 --- a/app/modules/notifications/queries/useGetNotificationsQuery.ts +++ b/app/modules/notifications/queries/useGetNotificationsQuery.ts @@ -4,7 +4,7 @@ import type { ApiNotification, NotificationsApiData } from '../types/notificatio export const useGetNotificationsQuery = () => { const { $notificationsService } = useNuxtApp() - const query = useInfiniteQuery({ + const query = useInfiniteQuery({ queryKey: ['notifications'], queryFn: ({ pageParam = 1 }: { pageParam?: number }) => { const res = $notificationsService.getNotifications(pageParam) @@ -19,7 +19,10 @@ export const useGetNotificationsQuery = () => { const notifications = computed(() => { console.log('notifications', query.data.value?.pages) - return query.data.value?.pages.flatMap((page: NotificationsApiData) => page.notifications) ?? [] + return ( + query.data.value?.pages.flatMap((page: NotificationsApiData) => page.notifications) ?? + [] + ) }) return { @@ -28,7 +31,7 @@ export const useGetNotificationsQuery = () => { isFetchingNotifications: query.isFetching, isErrorNotifications: query.isError, errorNotifications: query.error, - isSuccessfullyNotifications : query.isSuccess, + isSuccessfullyNotifications: query.isSuccess, hasNextNotifications: query.hasNextPage, hasPreviousNotifications: query.hasPreviousPage, isFetchingNextNotifications: query.isFetchingNextPage, diff --git a/app/modules/notifications/types/notifications.ts b/app/modules/notifications/types/notifications.ts index 87432711..8d8523ff 100644 --- a/app/modules/notifications/types/notifications.ts +++ b/app/modules/notifications/types/notifications.ts @@ -1,13 +1,15 @@ import type { User, BaseTweet, QuoteTweet } from './notificationsSocketEvents' -export interface FollowNotification { // done +export interface FollowNotification { + // done id: string type: 'follow' created_at: string followers: User[] } -export interface LikeNotification { // done +export interface LikeNotification { + // done id: string type: 'like' created_at: string @@ -15,7 +17,8 @@ export interface LikeNotification { // done tweets: BaseTweet[] } -export interface ReplyNotification { //done +export interface ReplyNotification { + //done id: string type: 'reply' created_at: string @@ -25,7 +28,8 @@ export interface ReplyNotification { //done conversation_id?: string } -export interface RepostNotification { //done +export interface RepostNotification { + //done id: string type: 'repost' created_at: string @@ -33,7 +37,8 @@ export interface RepostNotification { //done tweets: BaseTweet[] } -export interface QuoteNotification { // done +export interface QuoteNotification { + // done id: string type: 'quote' created_at: string @@ -41,7 +46,8 @@ export interface QuoteNotification { // done quote_tweet: QuoteTweet } -export interface MentionNotification { // done +export interface MentionNotification { + // done id: string type: 'mention' created_at: string @@ -50,7 +56,8 @@ export interface MentionNotification { // done tweet_type: 'tweet' | 'quote' | 'reply' } -export interface MessageNotification { // done +export interface MessageNotification { + // done id: string type: 'message' created_at: string diff --git a/app/modules/profile/components/EditProfile/EditProfile.vue b/app/modules/profile/components/EditProfile/EditProfile.vue index d5b89e45..c274e04a 100644 --- a/app/modules/profile/components/EditProfile/EditProfile.vue +++ b/app/modules/profile/components/EditProfile/EditProfile.vue @@ -40,7 +40,7 @@ accept="image/*" class="hidden" @change="handleCoverFileChange" - /> + > + >
diff --git a/app/modules/profile/components/EditProfile/SubComponents/EditProfileAvatar.vue b/app/modules/profile/components/EditProfile/SubComponents/EditProfileAvatar.vue index e2c64e57..db7219db 100644 --- a/app/modules/profile/components/EditProfile/SubComponents/EditProfileAvatar.vue +++ b/app/modules/profile/components/EditProfile/SubComponents/EditProfileAvatar.vue @@ -1,17 +1,30 @@ @@ -62,15 +72,19 @@ const searchQueryInput = ref('') const searchQuery = useDebounce(searchQueryInput, 300) const inputRef = ref(null) const STORAGE_KEY = 'yapper-search-history' -const initialQuery = history.state.user as string || '' +const initialQuery = (history.state.user as string) || '' onMounted(() => { - searchQueryInput.value = (route.query.q as string) || (initialQuery ? `from:${initialQuery} ` : '') || '' + searchQueryInput.value = + (route.query.q as string) || (initialQuery ? `from:${initialQuery} ` : '') || '' }) -watch(() => route.query.q, (newQuery) => { - searchQueryInput.value = (newQuery as string) || '' -}) +watch( + () => route.query.q, + (newQuery) => { + searchQueryInput.value = (newQuery as string) || '' + }, +) const handleClearQuery = () => { searchQueryInput.value = '' @@ -93,7 +107,10 @@ const handleBack = () => { isFocused.value = false } -const handleSearchSubmit = (query: string, src: 'typed_query' | 'typeahead_click' | 'recent_search_click' | 'trend_click' = 'typed_query') => { +const handleSearchSubmit = ( + query: string, + src: 'typed_query' | 'typeahead_click' | 'recent_search_click' | 'trend_click' = 'typed_query', +) => { if (!query.trim()) return searchQueryInput.value = query @@ -103,9 +120,11 @@ const handleSearchSubmit = (query: string, src: 'typed_query' | 'typeahead_click try { const stored = localStorage.getItem(STORAGE_KEY) - let searchHistory = stored ? JSON.parse(stored) : [] + const searchHistory = stored ? JSON.parse(stored) : [] - const existingIndex = searchHistory.findIndex((item: any) => item.type === 'query' && item.query === query) + const existingIndex = searchHistory.findIndex( + (item: any) => item.type === 'query' && item.query === query, + ) if (existingIndex !== -1) { searchHistory.splice(existingIndex, 1) } @@ -113,7 +132,7 @@ const handleSearchSubmit = (query: string, src: 'typed_query' | 'typeahead_click searchHistory.unshift({ type: 'query', query: query, - timestamp: Date.now() + timestamp: Date.now(), }) localStorage.setItem(STORAGE_KEY, JSON.stringify(searchHistory)) diff --git a/app/modules/search/components/SearchHistory.vue b/app/modules/search/components/SearchHistory.vue index 7d819102..e431c56e 100644 --- a/app/modules/search/components/SearchHistory.vue +++ b/app/modules/search/components/SearchHistory.vue @@ -54,14 +54,14 @@ :alt="item.name" class="w-10 h-10 rounded-full object-cover shrink-0" :onerror="`this.src = 'https://ui-avatars.com/api/?name=${encodeURIComponent(item.name)}&background=random'`" - /> + > + >
{{ item.name }} diff --git a/app/modules/search/components/SearchResults.vue b/app/modules/search/components/SearchResults.vue index 8292ac06..82819cb5 100644 --- a/app/modules/search/components/SearchResults.vue +++ b/app/modules/search/components/SearchResults.vue @@ -19,14 +19,14 @@
diff --git a/app/modules/search/components/SearchSuggestions.vue b/app/modules/search/components/SearchSuggestions.vue index d701d344..20ab32b6 100644 --- a/app/modules/search/components/SearchSuggestions.vue +++ b/app/modules/search/components/SearchSuggestions.vue @@ -46,14 +46,14 @@ :alt="user.name" class="w-10 h-10 rounded-full object-cover shrink-0" :onerror="`this.src = 'https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random'`" - /> + > + >
{{ user.name }} @@ -85,7 +85,7 @@
@@ -134,7 +134,7 @@ const handleUserClick = (user: any) => { try { const stored = localStorage.getItem(STORAGE_KEY) - let searchHistory = stored ? JSON.parse(stored) : [] + const searchHistory = stored ? JSON.parse(stored) : [] // Remove duplicate if exists const existingIndex = searchHistory.findIndex( diff --git a/app/modules/search/test/unit/SearchBar.spec.ts b/app/modules/search/test/unit/SearchBar.spec.ts index 2a41b102..0977729b 100644 --- a/app/modules/search/test/unit/SearchBar.spec.ts +++ b/app/modules/search/test/unit/SearchBar.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mount, flushPromises } from '@vue/test-utils' -import { ref, nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' // Mock dependencies const mockRouterPush = vi.fn() @@ -15,15 +15,21 @@ vi.mock('vue-router', () => ({ })) vi.mock('~/modules/search/components/SearchHistory.vue', () => ({ - default: { template: '
SearchHistory
' } + default: { + template: + "
SearchHistory
", + }, })) vi.mock('~/modules/search/components/SearchSuggestions.vue', () => ({ - default: { template: '
SearchSuggestions
', props: ['searchQuery'] } + default: { + template: '
SearchSuggestions
', + props: ['searchQuery'], + }, })) vi.mock('~/modules/Common/composables/useDebounce', () => ({ - useDebounce: (value: any) => value + useDebounce: (value: any) => value, })) vi.mock('lucide-vue-next', () => ({ @@ -38,14 +44,20 @@ describe('SearchBar', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() - Object.keys(mockLocalStorage).forEach(key => delete mockLocalStorage[key]) + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]) // Mock localStorage vi.stubGlobal('localStorage', { getItem: vi.fn((key: string) => mockLocalStorage[key] || null), - setItem: vi.fn((key: string, value: string) => { mockLocalStorage[key] = value }), - removeItem: vi.fn((key: string) => { delete mockLocalStorage[key] }), - clear: vi.fn(() => { Object.keys(mockLocalStorage).forEach(key => delete mockLocalStorage[key]) }), + setItem: vi.fn((key: string, value: string) => { + mockLocalStorage[key] = value + }), + removeItem: vi.fn((key: string) => { + delete mockLocalStorage[key] + }), + clear: vi.fn(() => { + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]) + }), }) // Mock history.state @@ -66,8 +78,14 @@ describe('SearchBar', () => { $t: (key: string) => key, }, stubs: { - SearchHistory: { template: '
SearchHistory
' }, - SearchSuggestions: { template: '
SearchSuggestions
', props: ['searchQuery'] }, + SearchHistory: { + template: + "
SearchHistory
", + }, + SearchSuggestions: { + template: '
SearchSuggestions
', + props: ['searchQuery'], + }, ArrowLeft: true, Search: true, CircleX: true, @@ -201,7 +219,7 @@ describe('SearchBar', () => { it('removes duplicate from history before adding', async () => { mockLocalStorage['yapper-search-history'] = JSON.stringify([ - { type: 'query', query: 'test search', timestamp: 1000 } + { type: 'query', query: 'test search', timestamp: 1000 }, ]) const wrapper = await mountComponent() @@ -243,7 +261,9 @@ describe('SearchBar', () => { describe('error handling', () => { it('handles localStorage error gracefully', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('Storage error') }) + vi.mocked(localStorage.getItem).mockImplementation(() => { + throw new Error('Storage error') + }) const wrapper = await mountComponent() const input = wrapper.find('#input-search-bar') diff --git a/app/modules/search/test/unit/SearchHistory.spec.ts b/app/modules/search/test/unit/SearchHistory.spec.ts index ba22df17..30083cd5 100644 --- a/app/modules/search/test/unit/SearchHistory.spec.ts +++ b/app/modules/search/test/unit/SearchHistory.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mount, flushPromises } from '@vue/test-utils' +import { mount } from '@vue/test-utils' import { nextTick } from 'vue' // Mock dependencies @@ -36,12 +36,16 @@ describe('SearchHistory', () => { beforeEach(() => { vi.clearAllMocks() - Object.keys(mockLocalStorage).forEach(key => delete mockLocalStorage[key]) + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]) vi.stubGlobal('localStorage', { getItem: vi.fn((key: string) => mockLocalStorage[key] || null), - setItem: vi.fn((key: string, value: string) => { mockLocalStorage[key] = value }), - removeItem: vi.fn((key: string) => { delete mockLocalStorage[key] }), + setItem: vi.fn((key: string, value: string) => { + mockLocalStorage[key] = value + }), + removeItem: vi.fn((key: string) => { + delete mockLocalStorage[key] + }), clear: vi.fn(), }) }) @@ -157,7 +161,10 @@ describe('SearchHistory', () => { await wrapper.find('.flex-1.min-w-0.cursor-pointer').trigger('click') expect(wrapper.emitted('handleSearchSubmit')).toBeTruthy() - expect(wrapper.emitted('handleSearchSubmit')![0]).toEqual(['test query', 'recent_search_click']) + expect(wrapper.emitted('handleSearchSubmit')![0]).toEqual([ + 'test query', + 'recent_search_click', + ]) }) it('navigates to user profile when user item clicked', async () => { @@ -181,12 +188,15 @@ describe('SearchHistory', () => { expect(localStorage.setItem).toHaveBeenCalledWith( 'yapper-search-history', - JSON.stringify([]) + JSON.stringify([]), ) }) it('clears all history when clear all button clicked', async () => { - mockLocalStorage['yapper-search-history'] = JSON.stringify([mockQueryItem, mockUserItem]) + mockLocalStorage['yapper-search-history'] = JSON.stringify([ + mockQueryItem, + mockUserItem, + ]) const wrapper = await mountComponent() await nextTick() @@ -195,7 +205,7 @@ describe('SearchHistory', () => { expect(localStorage.setItem).toHaveBeenCalledWith( 'yapper-search-history', - JSON.stringify([]) + JSON.stringify([]), ) }) }) @@ -203,7 +213,7 @@ describe('SearchHistory', () => { describe('data migration', () => { it('migrates old format without type field', async () => { mockLocalStorage['yapper-search-history'] = JSON.stringify([ - { query: 'old format query' } + { query: 'old format query' }, ]) const wrapper = await mountComponent() @@ -229,7 +239,9 @@ describe('SearchHistory', () => { it('handles localStorage save error gracefully', async () => { mockLocalStorage['yapper-search-history'] = JSON.stringify([mockQueryItem]) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error('Storage full') }) + vi.mocked(localStorage.setItem).mockImplementation(() => { + throw new Error('Storage full') + }) const wrapper = await mountComponent() await nextTick() diff --git a/app/modules/search/test/unit/SearchResults.spec.ts b/app/modules/search/test/unit/SearchResults.spec.ts index dcb54358..58aa773a 100644 --- a/app/modules/search/test/unit/SearchResults.spec.ts +++ b/app/modules/search/test/unit/SearchResults.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' -import { nextTick } from 'vue' // Mock dependencies const mockRouterPush = vi.fn() @@ -30,34 +29,57 @@ vi.mock('lucide-vue-next', () => ({ // Stub child components vi.mock('~/modules/search/components/SearchBar.vue', () => ({ - default: { template: '', props: ['hasArrow'] } + default: { template: '', props: ['hasArrow'] }, })) vi.mock('~/modules/Common/components/Tabs/Tabs.vue', () => ({ - default: { - template: '
', + default: { + template: '
', props: ['tabs', 'activeTab', 'onChange'], - } + }, })) vi.mock('~/modules/tweets/components/TweetsList/TweetsList.vue', () => ({ - default: { template: '
TweetsList
', props: ['fetchingSource', 'compact'] } + default: { + template: '
TweetsList
', + props: ['fetchingSource', 'compact'], + }, })) vi.mock('~/modules/Common/components/UserList', () => ({ - UserList: { template: '
', props: ['fetchingSource', 'queryKeyPrefix', 'loadingText', 'errorText', 'retryText', 'emptyTitle', 'emptyDescription'] } + UserList: { + template: '
', + props: [ + 'fetchingSource', + 'queryKeyPrefix', + 'loadingText', + 'errorText', + 'retryText', + 'emptyTitle', + 'emptyDescription', + ], + }, })) vi.mock('~/modules/profile/components/ProfileContent/SubComponents/EmptyState.vue', () => ({ - default: { template: '
EmptyState
', props: ['icon', 'title', 'description'] } + default: { + template: '
EmptyState
', + props: ['icon', 'title', 'description'], + }, })) vi.mock('~/modules/Common/components/UserCard/UserCard.vue', () => ({ - default: { template: '
UserCard
', props: ['user', 'showTooltip'] } + default: { + template: '
UserCard
', + props: ['user', 'showTooltip'], + }, })) vi.mock('~/modules/Common/components/MediaGrid/MediaGrid.vue', () => ({ - default: { template: '
MediaGrid
', props: ['fetchingSource'] } + default: { + template: '
MediaGrid
', + props: ['fetchingSource'], + }, })) describe('SearchResults', () => { @@ -74,16 +96,34 @@ describe('SearchResults', () => { $t: (key: string) => key, }, stubs: { - SearchBar: { template: '', props: ['hasArrow'] }, - Tabs: { - template: '
', + SearchBar: { + template: '', + props: ['hasArrow'], + }, + Tabs: { + template: '
', props: ['tabs', 'activeTab', 'onChange'], }, - TweetsList: { template: '
TweetsList
', props: ['fetchingSource', 'compact'] }, - UserList: { template: '
', props: ['fetchingSource'] }, - EmptyState: { template: '
EmptyState
', props: ['icon', 'title', 'description'] }, - FollowListUserCard: { template: '
UserCard
', props: ['user', 'showTooltip'] }, - MediaGrid: { template: '
MediaGrid
', props: ['fetchingSource'] }, + TweetsList: { + template: '
TweetsList
', + props: ['fetchingSource', 'compact'], + }, + UserList: { + template: '
', + props: ['fetchingSource'], + }, + EmptyState: { + template: '
EmptyState
', + props: ['icon', 'title', 'description'], + }, + FollowListUserCard: { + template: '
UserCard
', + props: ['user', 'showTooltip'], + }, + MediaGrid: { + template: '
MediaGrid
', + props: ['fetchingSource'], + }, ArrowLeft: true, }, }, @@ -138,7 +178,7 @@ describe('SearchResults', () => { it('extracts username from from:username format', async () => { mockRouteQuery.q = 'from:johndoe test query' const wrapper = await mountComponent() - + // The component should have extracted 'johndoe' as fromUsername // and 'test query' as the search query for API expect(wrapper.find('.mock-tweets-list').exists()).toBe(true) diff --git a/app/modules/search/test/unit/SearchSuggestions.spec.ts b/app/modules/search/test/unit/SearchSuggestions.spec.ts index dc8f0070..a0e66f81 100644 --- a/app/modules/search/test/unit/SearchSuggestions.spec.ts +++ b/app/modules/search/test/unit/SearchSuggestions.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount } from '@vue/test-utils' -import { ref, nextTick, defineComponent, h } from 'vue' +import { ref, nextTick } from 'vue' // Mock dependencies const mockRouterPush = vi.fn() @@ -69,11 +69,13 @@ describe('SearchSuggestions', () => { mockSuggestionsData.value = null mockIsLoading.value = false mockIsError.value = false - Object.keys(mockLocalStorage).forEach(key => delete mockLocalStorage[key]) + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]) vi.stubGlobal('localStorage', { getItem: vi.fn((key: string) => mockLocalStorage[key] || null), - setItem: vi.fn((key: string, value: string) => { mockLocalStorage[key] = value }), + setItem: vi.fn((key: string, value: string) => { + mockLocalStorage[key] = value + }), }) }) @@ -159,7 +161,10 @@ describe('SearchSuggestions', () => { await wrapper.findAll('li')[0].trigger('click') expect(wrapper.emitted('handleSearchSubmit')).toBeTruthy() - expect(wrapper.emitted('handleSearchSubmit')![0]).toEqual(['suggested query 1', 'typeahead_click']) + expect(wrapper.emitted('handleSearchSubmit')![0]).toEqual([ + 'suggested query 1', + 'typeahead_click', + ]) }) }) @@ -251,7 +256,13 @@ describe('SearchSuggestions', () => { it('removes duplicate user from history before adding', async () => { mockLocalStorage['yapper-search-history'] = JSON.stringify([ - { type: 'user', user_id: 'user1', name: 'Old Name', username: 'testuser1', timestamp: 1000 } + { + type: 'user', + user_id: 'user1', + name: 'Old Name', + username: 'testuser1', + timestamp: 1000, + }, ]) mockSuggestionsData.value = { @@ -268,7 +279,9 @@ describe('SearchSuggestions', () => { it('handles localStorage error gracefully', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('Storage error') }) + vi.mocked(localStorage.getItem).mockImplementation(() => { + throw new Error('Storage error') + }) mockSuggestionsData.value = { suggested_queries: [], diff --git a/app/modules/search/test/unit/useSearchSuggestionsQuery.spec.ts b/app/modules/search/test/unit/useSearchSuggestionsQuery.spec.ts index 5f8f9771..29edc910 100644 --- a/app/modules/search/test/unit/useSearchSuggestionsQuery.spec.ts +++ b/app/modules/search/test/unit/useSearchSuggestionsQuery.spec.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ref } from 'vue' +// Import after mocking +import { useSearchSuggestionsQuery } from '../../queries/useSearchSuggestionsQuery' + // Mock useQuery const mockUseQuery = vi.fn() @@ -19,9 +22,6 @@ vi.mock('nuxt/app', () => ({ }), })) -// Import after mocking -import { useSearchSuggestionsQuery } from '../../queries/useSearchSuggestionsQuery' - describe('useSearchSuggestionsQuery', () => { beforeEach(() => { vi.clearAllMocks() @@ -39,7 +39,7 @@ describe('useSearchSuggestionsQuery', () => { expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ queryKey: ['search-suggestions', query], - }) + }), ) }) @@ -51,7 +51,7 @@ describe('useSearchSuggestionsQuery', () => { expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ enabled, - }) + }), ) }) diff --git a/app/modules/settings/components/AccountInformations/ChangeAge.vue b/app/modules/settings/components/AccountInformations/ChangeAge.vue index 19aa105a..5a77d829 100644 --- a/app/modules/settings/components/AccountInformations/ChangeAge.vue +++ b/app/modules/settings/components/AccountInformations/ChangeAge.vue @@ -16,7 +16,8 @@ + class="text-accent hover:underline" + > {{ $t('settings.accountInfo.profile') }} {{ $t('settings.accountInfo.age_notRight2') }} @@ -35,5 +36,4 @@ const userStore = useUserStore() const { user } = storeToRefs(userStore) const age = computed(() => calculateAge(user.value?.birth_date ?? '')) const profileRoute = computed(() => '/' + user.value?.username + '/settings/profile') - diff --git a/app/modules/settings/components/AccountInformations/ChangeCountry.vue b/app/modules/settings/components/AccountInformations/ChangeCountry.vue index cfab8046..27a0696a 100644 --- a/app/modules/settings/components/AccountInformations/ChangeCountry.vue +++ b/app/modules/settings/components/AccountInformations/ChangeCountry.vue @@ -4,9 +4,9 @@ + class="peer-focus:text-accent absolute start-3 top-1 text-xs text-muted pointer-events-none px-1 py-1" + >{{ t('settings.accountInfo.country') }} + :class="locale === 'ar' ? 'left-4' : 'right-4'" + />
+ @cancel="cancelChange" + /> diff --git a/app/modules/settings/components/AccountInformations/SubComponents/LanguageSelector.vue b/app/modules/settings/components/AccountInformations/SubComponents/LanguageSelector.vue index 13389aca..cc876f52 100644 --- a/app/modules/settings/components/AccountInformations/SubComponents/LanguageSelector.vue +++ b/app/modules/settings/components/AccountInformations/SubComponents/LanguageSelector.vue @@ -49,7 +49,6 @@ import { useI18n } from 'vue-i18n' import { LOCALE_COOKIE_KEY } from '~/modules/Common/constants/localStorageConstants' const { t, locale } = useI18n() - const { useChangeLanguage } = userSettingsQueries() const selected = ref<'en' | 'ar'>((locale.value as 'en' | 'ar') || 'en') diff --git a/app/modules/settings/components/AccountInformations/SubComponents/VerifyEmailOTP.vue b/app/modules/settings/components/AccountInformations/SubComponents/VerifyEmailOTP.vue index a7b71c40..b4814747 100644 --- a/app/modules/settings/components/AccountInformations/SubComponents/VerifyEmailOTP.vue +++ b/app/modules/settings/components/AccountInformations/SubComponents/VerifyEmailOTP.vue @@ -8,10 +8,7 @@ @close="handleClose" > - +

{{ $t('settings.accountInfo.we_sent_code') }}

@@ -28,9 +25,8 @@ :disabled="verifyEmailOTPMutation.isPending.value" required autofocus - class="w-full bg-primary text-primary border border-primary rounded-md px-4 py-2 - focus:outline-none focus:border-accent mb-4 shadow-sm transition-colors - disabled:opacity-50 disabled:cursor-not-allowed" > + class="w-full bg-primary text-primary border border-primary rounded-md px-4 py-2 focus:outline-none focus:border-accent mb-4 shadow-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + >

diff --git a/app/modules/settings/components/DetailedRow.vue b/app/modules/settings/components/DetailedRow.vue index 29510a9c..ded4d938 100644 --- a/app/modules/settings/components/DetailedRow.vue +++ b/app/modules/settings/components/DetailedRow.vue @@ -3,8 +3,7 @@ id="detailed-link" :key="category.href" :to="category.href" - class="block relative px-5 py-3 rounded hover:bg-hover - transition-colors text-primary" + class="block relative px-5 py-3 rounded hover:bg-hover transition-colors text-primary" >
@@ -17,7 +16,8 @@ + :class="locale === 'ar' ? 'left-3' : 'right-3'" + />
@@ -27,12 +27,11 @@ import { ChevronRight, ChevronLeft } from 'lucide-vue-next' import { useI18n } from 'vue-i18n' const { locale } = useI18n() interface Category { - label: string, - content?: string | null, + label: string + content?: string | null href: string } defineProps<{ category: Category }>() - diff --git a/app/modules/settings/components/Display/DisplaySettings.vue b/app/modules/settings/components/Display/DisplaySettings.vue index f1d22d4b..aa340f8b 100644 --- a/app/modules/settings/components/Display/DisplaySettings.vue +++ b/app/modules/settings/components/Display/DisplaySettings.vue @@ -1,7 +1,7 @@