Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6f9482e
created some initial file and add some changes
Piyushrathoree Sep 6, 2025
2289fdd
Add Badge functionality to UserNode and integrate Badges component
Piyushrathoree Sep 13, 2025
5b947da
updated the backend file for sorting badges
Piyushrathoree Sep 13, 2025
f22d2e0
Refactor tests and mock data for badges functionality
Piyushrathoree Sep 13, 2025
e78e42a
Update frontend/__tests__/unit/components/Badges.test.tsx
Piyushrathoree Sep 14, 2025
dcb90b7
Update frontend/src/components/Badges.tsx
Piyushrathoree Sep 14, 2025
7f270cf
Update frontend/__tests__/unit/pages/UserDetails.test.tsx
Piyushrathoree Sep 14, 2025
4fdca0f
Add Font Awesome icons to custom dictionary
Piyushrathoree Sep 14, 2025
e402712
Make Badge properties readonly
Piyushrathoree Sep 14, 2025
21d6803
Sort badges by weight before rendering
Piyushrathoree Sep 14, 2025
3e85e89
Use nullish coalescing for user badges
Piyushrathoree Sep 14, 2025
a38e89d
Add aria-label to FontAwesomeIcon for accessibility
Piyushrathoree Sep 14, 2025
984da0d
Update user.py
Piyushrathoree Sep 14, 2025
f3f2290
Merge branch 'main' into task/badge-implementation-in-frontend
Piyushrathoree Sep 14, 2025
39a7a8c
Refactor badge count resolver for improved readability and performanc…
Piyushrathoree Sep 16, 2025
8e76f8f
Update backend/apps/github/api/internal/nodes/user.py
Piyushrathoree Sep 16, 2025
81b21d9
Update frontend/src/components/Badges.tsx
Piyushrathoree Sep 16, 2025
8e846f3
Update frontend/src/components/Badges.tsx
Piyushrathoree Sep 16, 2025
3cb9785
Add badgeCount field to UserNode and update related queries; refactor…
Piyushrathoree Sep 16, 2025
1121d26
frontend: fix Badges non-prefixed and invalid cssClass handling; adju…
Piyushrathoree Sep 16, 2025
d32c6bf
Merge branch 'main' into task/badge-implementation-in-frontend
Piyushrathoree Sep 17, 2025
1b445b0
fix: correct badge relationship reference in UserNode and update tests
Sep 19, 2025
263d53f
refactor: streamline badge display in UserDetailsPage component
Piyushrathoree Sep 19, 2025
f429bbf
fix: update useParams to use memberKey in UserDetailsPage and adjust …
Piyushrathoree Sep 19, 2025
5c69863
fix: adjust class names for better layout consistency in UserDetailsPage
Piyushrathoree Sep 19, 2025
cc30ab4
Merge branch 'main' into task/badge-implementation-in-frontend
kasya Sep 20, 2025
f9e044d
Revert "fix: adjust class names for better layout consistency in User…
Sep 21, 2025
0e9dede
Merge branch 'task/badge-implementation-in-frontend' of https://githu…
Piyushrathoree Sep 22, 2025
f429071
Refactor UserNode badge logic and improve Badge component rendering
Piyushrathoree Sep 22, 2025
ac27f26
done with changes suggested by kate .
Piyushrathoree Sep 25, 2025
7535bc9
Update frontend/src/components/Badges.tsx
Piyushrathoree Sep 25, 2025
fb1e403
CodeRabbit: For Font Awesome icons, the CSS classes are backend-drive…
Piyushrathoree Sep 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions backend/apps/github/api/internal/nodes/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,16 @@ class UserNode:

@strawberry.field
def badges(self) -> list[BadgeNode]:
"""Return user badges."""
user_badges = (
self.user_badges.select_related("badge")
.filter(is_active=True)
.order_by(
"badge__weight",
"badge__name",
)
"""List badges assigned to the user sorted by weight and name."""
user_badges = self.user_badges.select_related("badge").order_by(
"-badge__weight", "badge__name"
)
return [ub.badge for ub in user_badges]
return [user_badge.badge for user_badge in user_badges]

@strawberry.field
def badge_count(self) -> int:
"""Resolve badge count."""
return self.user_badges.values("badge_id").distinct().count()

@strawberry.field
def created_at(self) -> float:
Expand Down
126 changes: 97 additions & 29 deletions backend/tests/apps/github/api/internal/nodes/user_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from apps.github.api.internal.nodes.user import UserNode
from apps.nest.api.internal.nodes.badge import BadgeNode


class TestUserNode:
Expand All @@ -19,6 +20,7 @@ def test_meta_configuration(self):
field_names = {field.name for field in UserNode.__strawberry_definition__.fields}
expected_field_names = {
"avatar_url",
"badge_count",
"badges",
"bio",
"company",
Expand Down Expand Up @@ -81,42 +83,108 @@ def test_url_field(self):
result = UserNode.url(mock_user)
assert result == "https://github.com/testuser"

def test_badges_resolver_behavior(self):
"""Unit test verifies the badges method returns badges sorted by weight."""
badge_high = Mock()
badge_high.weight = 1
badge_high.name = "High Priority Badge"

badge_medium = Mock()
badge_medium.weight = 5
badge_medium.name = "Medium Priority Badge"

badge_low = Mock()
badge_low.weight = 10
badge_low.name = "Low Priority Badge"
def test_badge_count_field(self):
"""Test badge_count field resolution."""
mock_user = Mock()
mock_badges_queryset = Mock()
mock_badges_queryset.values.return_value.distinct.return_value.count.return_value = 3
mock_user.user_badges = mock_badges_queryset

result = UserNode.badge_count(mock_user)
assert result == 3
mock_badges_queryset.values.assert_called_once_with("badge_id")
mock_badges_queryset.values.return_value.distinct.assert_called_once()
mock_badges_queryset.values.return_value.distinct.return_value.count.assert_called_once()

def test_badges_field_empty(self):
"""Test badges field resolution with no badges."""
mock_user = Mock()
mock_badges_queryset = Mock()
mock_badges_queryset.select_related.return_value.order_by.return_value = []
mock_user.user_badges = mock_badges_queryset

user_badge_high = Mock()
user_badge_high.badge = badge_high
result = UserNode.badges(mock_user)
assert result == []
mock_badges_queryset.select_related.assert_called_once_with("badge")
mock_badges_queryset.select_related.return_value.order_by.assert_called_once_with(
"-badge__weight", "badge__name"
)

user_badge_medium = Mock()
user_badge_medium.badge = badge_medium
def test_badges_field_single_badge(self):
"""Test badges field resolution with single badge."""
mock_user = Mock()
mock_badge = Mock(spec=BadgeNode)
mock_user_badge = Mock()
mock_user_badge.badge = mock_badge

user_badge_low = Mock()
user_badge_low.badge = badge_low
mock_badges_queryset = Mock()
mock_badges_queryset.select_related.return_value.order_by.return_value = [mock_user_badge]
mock_user.user_badges = mock_badges_queryset

sorted_user_badges = [user_badge_high, user_badge_medium, user_badge_low]
result = UserNode.badges(mock_user)
assert result == [mock_badge]
mock_badges_queryset.select_related.assert_called_once_with("badge")
mock_badges_queryset.select_related.return_value.order_by.assert_called_once_with(
"-badge__weight", "badge__name"
)

def test_badges_field_sorted_by_weight_and_name(self):
"""Test badges field resolution with multiple badges sorted by weight and name."""
# Create mock badges with different weights and names
mock_badge_high_weight = Mock(spec=BadgeNode)
mock_badge_high_weight.weight = 100
mock_badge_high_weight.name = "High Weight Badge"

mock_badge_medium_weight_a = Mock(spec=BadgeNode)
mock_badge_medium_weight_a.weight = 50
mock_badge_medium_weight_a.name = "Medium Weight A"

mock_badge_medium_weight_b = Mock(spec=BadgeNode)
mock_badge_medium_weight_b.weight = 50
mock_badge_medium_weight_b.name = "Medium Weight B"

mock_badge_low_weight = Mock(spec=BadgeNode)
mock_badge_low_weight.weight = 10
mock_badge_low_weight.name = "Low Weight Badge"

# Create mock user badges
mock_user_badge_high = Mock()
mock_user_badge_high.badge = mock_badge_high_weight

mock_user_badge_medium_a = Mock()
mock_user_badge_medium_a.badge = mock_badge_medium_weight_a

mock_user_badge_medium_b = Mock()
mock_user_badge_medium_b.badge = mock_badge_medium_weight_b

mock_user_badge_low = Mock()
mock_user_badge_low.badge = mock_badge_low_weight

# Set up the mock queryset to return badges in the expected sorted order
# (highest weight first, then by name for same weight)
mock_badges_queryset = Mock()
mock_badges_queryset.select_related.return_value.order_by.return_value = [
mock_user_badge_high, # weight 100
mock_user_badge_medium_a, # weight 50, name "Medium Weight A"
mock_user_badge_medium_b, # weight 50, name "Medium Weight B"
mock_user_badge_low, # weight 10
]
mock_user = Mock()
mock_queryset = Mock()
mock_queryset.filter.return_value.order_by.return_value = sorted_user_badges
mock_user.user_badges.select_related.return_value = mock_queryset
mock_user.user_badges = mock_badges_queryset

result = UserNode.badges(mock_user)

mock_user.user_badges.select_related.assert_called_once_with("badge")
mock_queryset.filter.assert_called_once_with(is_active=True)
mock_queryset.filter.return_value.order_by.assert_called_once_with(
"badge__weight", "badge__name"
# Verify the badges are returned in the correct order
expected_badges = [
mock_badge_high_weight,
mock_badge_medium_weight_a,
mock_badge_medium_weight_b,
mock_badge_low_weight,
]
assert result == expected_badges

# Verify the queryset was called with correct ordering
mock_badges_queryset.select_related.assert_called_once_with("badge")
mock_badges_queryset.select_related.return_value.order_by.assert_called_once_with(
"-badge__weight", "badge__name"
)

assert result == [ub.badge for ub in sorted_user_badges]
6 changes: 6 additions & 0 deletions cspell/custom-dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ demojize
dismissable
dsn
env
fa-brands
fa-regular
fa-solid
fab
facebookexternalhit
far
fas
gamesec
geocoders
geoloc
Expand Down
72 changes: 72 additions & 0 deletions frontend/__tests__/unit/components/Badges.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react'
import React from 'react'
import Badges from 'components/Badges'

// Mock FontAwesome
jest.mock('@fortawesome/fontawesome-svg-core', () => ({
findIconDefinition: jest.fn(({ iconName }: { iconName: string }) => {
// Return truthy value for valid icons, null for invalid
return iconName === 'medal' ? { iconName } : null
}),
}))

jest.mock('wrappers/FontAwesomeIconWrapper', () => {
return function MockFontAwesomeIconWrapper({ icon }: { icon: string }) {
const iconName = icon.split(' ').pop()?.replace('fa-', '') || icon.replace('fa-', '')
return <span data-testid={`icon-${iconName}`} />
}
})

jest.mock('@heroui/tooltip', () => ({
Tooltip: ({
children,
content,
isDisabled,
}: {
children: React.ReactNode
content: string
isDisabled?: boolean
}) => {
if (isDisabled) {
return <>{children}</>
}
return (
<div data-testid="tooltip" data-content={content}>
{children}
</div>
)
},
}))

describe('Badges Component', () => {
const defaultProps = {
name: 'Test Badge',
cssClass: 'fa-solid fa-medal',
}

it('renders valid icon with tooltip', () => {
render(<Badges {...defaultProps} />)

expect(screen.getByTestId('icon-medal')).toBeInTheDocument()
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Test Badge')
})

it('renders fallback fa-medal for invalid cssClass', () => {
render(<Badges {...defaultProps} cssClass="" />)

expect(screen.getByTestId('icon-medal')).toBeInTheDocument()
})

it('renders fa-question fallback for unrecognized icon', () => {
render(<Badges {...defaultProps} cssClass="fa-solid fa-unknown" />)

expect(screen.getByTestId('icon-question')).toBeInTheDocument()
})

it('hides tooltip when showTooltip is false', () => {
render(<Badges {...defaultProps} showTooltip={false} />)

expect(screen.getByTestId('icon-medal')).toBeInTheDocument()
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
})
39 changes: 39 additions & 0 deletions frontend/__tests__/unit/components/UserCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe('UserCard', () => {
followersCount: 0,
location: '',
repositoriesCount: 0,
badgeCount: 0,
}

beforeEach(() => {
Expand Down Expand Up @@ -120,6 +121,7 @@ describe('UserCard', () => {
followersCount: 1500,
location: 'San Francisco, CA',
repositoriesCount: 25,
badgeCount: 5,
button: {
label: 'View Profile',
onclick: mockButtonClick,
Expand Down Expand Up @@ -379,4 +381,41 @@ describe('UserCard', () => {
expect(screen.getByText('5.7k')).toBeInTheDocument()
})
})

describe('Badge Count Display', () => {
it('renders badge count when greater than 0', () => {
render(<UserCard {...defaultProps} badgeCount={5} />)

expect(screen.getByTestId('icon-medal')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})

it('does not render badge count when 0', () => {
render(<UserCard {...defaultProps} badgeCount={0} />)

expect(screen.queryByTestId('icon-medal')).not.toBeInTheDocument()
})

it('renders all three metrics when all are greater than 0', () => {
render(
<UserCard {...defaultProps} followersCount={100} repositoriesCount={50} badgeCount={3} />
)

expect(screen.getByTestId('icon-users')).toBeInTheDocument()
expect(screen.getByTestId('icon-folder-open')).toBeInTheDocument()
expect(screen.getByTestId('icon-medal')).toBeInTheDocument()
})

it('formats badge count with millify for large numbers', () => {
render(<UserCard {...defaultProps} badgeCount={1500} />)

expect(screen.getByText('1.5k')).toBeInTheDocument()
})

it('handles negative badge count', () => {
render(<UserCard {...defaultProps} badgeCount={-1} />)

expect(screen.queryByTestId('icon-medal')).not.toBeInTheDocument()
})
})
})
63 changes: 63 additions & 0 deletions frontend/__tests__/unit/data/mockBadgeData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Badge } from 'types/badge'

export const mockBadgeData: Badge[] = [
{
id: '1',
name: 'Contributor',
cssClass: 'fa-medal',
description: 'Active contributor to OWASP projects',
weight: 1,
},
{
id: '2',
name: 'Security Expert',
cssClass: 'fa-shield-alt',
description: 'Security expertise demonstrated',
weight: 2,
},
{
id: '3',
name: 'Code Reviewer',
cssClass: 'fa-code',
description: 'Regular code reviewer',
weight: 1,
},
{
id: '4',
name: 'Mentor',
cssClass: 'fa-user-graduate',
description: 'Mentors other contributors',
weight: 3,
},
{
id: '5',
name: 'Project Lead',
cssClass: 'fa-crown',
description: 'Leads OWASP projects',
weight: 4,
},
]

export const mockUserBadgeQueryResponse = {
user: {
id: '1',
login: 'testuser',
name: 'Test User',
badges: mockBadgeData,
badgeCount: 5,
},
}

export const mockUserWithoutBadgeQueryResponse = {
user: {
id: '2',
login: 'testuser2',
name: 'Test User 2',
badges: [],
badgeCount: 0,
},
}

export const mockBadgeQueryResponse = {
badges: mockBadgeData,
}
Loading