Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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
13 changes: 12 additions & 1 deletion backend/apps/owasp/graphql/nodes/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,25 @@ class ProjectNode(GenericEntityNode):
"""Project node."""

@strawberry.field
def health_metrics(self, limit: int = 30) -> list[ProjectHealthMetricsNode]:
def health_metrics_list(self, limit: int = 30) -> list[ProjectHealthMetricsNode]:
"""Resolve project health metrics."""
return ProjectHealthMetrics.objects.filter(
project=self,
).order_by(
"nest_created_at",
)[:limit]

@strawberry.field
def health_metrics_latest(self) -> ProjectHealthMetricsNode | None:
"""Resolve latest project health metrics."""
return (
ProjectHealthMetrics.get_latest_health_metrics()
.filter(
project=self,
)
.first()
)

@strawberry.field
def issues_count(self) -> int:
"""Resolve issues count."""
Expand Down
17 changes: 17 additions & 0 deletions backend/apps/owasp/graphql/nodes/project_health_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"recent_releases_count",
"score",
"stars_count",
"total_issues_count",
"total_releases_count",
"unanswered_issues_count",
"unassigned_issues_count",
],
Expand All @@ -35,6 +37,11 @@ def age_days(self) -> int:
"""Resolve project age in days."""
return self.age_days

@strawberry.field
def age_days_requirement(self) -> int:
"""Resolve project age requirement in days."""
return self.age_days_requirement

@strawberry.field
def created_at(self) -> datetime:
"""Resolve metrics creation date."""
Expand All @@ -55,6 +62,11 @@ def last_pull_request_days(self) -> int:
"""Resolve last pull request age in days."""
return self.last_pull_request_days

@strawberry.field
def last_pull_request_days_requirement(self) -> int:
"""Resolve last pull request age requirement in days."""
return self.last_pull_request_days_requirement

@strawberry.field
def last_release_days(self) -> int:
"""Resolve last release age in days."""
Expand All @@ -74,3 +86,8 @@ def project_name(self) -> str:
def owasp_page_last_update_days(self) -> int:
"""Resolve OWASP page last update age in days."""
return self.owasp_page_last_update_days

@strawberry.field
def owasp_page_last_update_days_requirement(self) -> int:
"""Resolve OWASP page last update age requirement in days."""
return self.owasp_page_last_update_days_requirement
15 changes: 15 additions & 0 deletions backend/apps/owasp/models/project_health_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ def age_days(self) -> int:
"""Calculate project age in days."""
return (timezone.now() - self.created_at).days if self.created_at else 0

@property
def age_days_requirement(self) -> int:
"""Get the age requirement for the project."""
return self.project_requirements.age_days

@property
def last_commit_days(self) -> int:
"""Calculate days since last commit."""
Expand All @@ -106,6 +111,11 @@ def last_pull_request_days(self) -> int:
else 0
)

@property
def last_pull_request_days_requirement(self) -> int:
"""Get the last pull request requirement for the project."""
return self.project_requirements.last_pull_request_days

@property
def last_release_days(self) -> int:
"""Calculate days since last release."""
Expand All @@ -125,6 +135,11 @@ def owasp_page_last_update_days(self) -> int:
else 0
)

@property
def owasp_page_last_update_days_requirement(self) -> int:
"""Get the OWASP page last update requirement for the project."""
return self.project_requirements.owasp_page_last_update_days

@property
def project_requirements(self) -> ProjectHealthRequirements:
"""Get the project health requirements for the project's level."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def test_meta_configuration(self):
"recent_releases_count",
"score",
"stars_count",
"total_issues_count",
"total_releases_count",
"unanswered_issues_count",
"unassigned_issues_count",
}
Expand Down Expand Up @@ -67,8 +69,10 @@ def _get_field_by_name(self, name):
("open_pull_requests_count", int),
("owasp_page_last_update_days", int),
("project_name", str),
("stars_count", int),
("recent_releases_count", int),
("stars_count", int),
("total_issues_count", int),
("total_releases_count", int),
("unanswered_issues_count", int),
("unassigned_issues_count", int),
],
Expand Down
9 changes: 7 additions & 2 deletions backend/tests/apps/owasp/graphql/nodes/project_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ def _get_field_by_name(self, name):
(f for f in ProjectNode.__strawberry_definition__.fields if f.name == name), None
)

def test_resolve_health_metrics(self):
field = self._get_field_by_name("health_metrics")
def test_resolve_health_metrics_latest(self):
field = self._get_field_by_name("health_metrics_latest")
assert field is not None
assert field.type.of_type is ProjectHealthMetricsNode
Comment on lines +47 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify the expected return type for health_metrics_latest field.

The test uses field.type.of_type which is typically used for list types, but the field name health_metrics_latest suggests it should return a single ProjectHealthMetricsNode object, not a list.

If health_metrics_latest returns a single object, the assertion should be:

-        assert field.type.of_type is ProjectHealthMetricsNode
+        assert field.type is ProjectHealthMetricsNode

If it returns a list, then the current assertion is correct.


🏁 Script executed:

#!/bin/bash
# Description: Check the actual field definition in the ProjectNode to determine the correct return type
# Expected: Find the field definition and see if it's a list or single object

ast-grep --pattern 'health_metrics_latest'

Length of output: 161


Incorrect assertion of health_metrics_latest return type

The health_metrics_latest field is defined to return a single ProjectHealthMetricsNode (or null), not a list. The test should assert the field’s type directly, not its element type.

File: backend/tests/apps/owasp/graphql/nodes/project_test.py (lines 47–50)

-        assert field.type.of_type is ProjectHealthMetricsNode
+        assert field.type is ProjectHealthMetricsNode
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_resolve_health_metrics_latest(self):
field = self._get_field_by_name("health_metrics_latest")
assert field is not None
assert field.type.of_type is ProjectHealthMetricsNode
def test_resolve_health_metrics_latest(self):
field = self._get_field_by_name("health_metrics_latest")
assert field is not None
assert field.type is ProjectHealthMetricsNode
🤖 Prompt for AI Agents
In backend/tests/apps/owasp/graphql/nodes/project_test.py around lines 47 to 50,
the test incorrectly asserts that the health_metrics_latest field returns a list
by checking field.type.of_type. Instead, update the assertion to check
field.type directly against ProjectHealthMetricsNode, reflecting that the field
returns a single node or null, not a list.


def test_resolve_health_metrics_list(self):
field = self._get_field_by_name("health_metrics_list")
assert field is not None
assert field.type.of_type is ProjectHealthMetricsNode

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def test_resolve_project_health_metrics(self):
is_funding_requirements_compliant=True,
is_leader_requirements_compliant=True,
recent_releases_count=3,
total_issues_count=15,
total_releases_count=5,
)
]
query = ProjectHealthMetricsQuery(project_health_metrics=metrics)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test'
import { mockProjectsDashboardMetricsDetailsData } from '@unit/data/mockProjectsDashboardMetricsDetailsData'

test.describe('Project Health Metrics Details Page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/graphql/', async (route) => {
await route.fulfill({
status: 200,
json: { data: mockProjectsDashboardMetricsDetailsData },
})
})
await page.context().addCookies([
{
name: 'csrftoken',
value: 'abc123',
domain: 'localhost',
path: '/',
},
])
await page.goto('/projects/dashboard/metrics/test-project')
})

test('renders project health metrics details', async ({ page }) => {
const metricsLatest = mockProjectsDashboardMetricsDetailsData.project.healthMetricsLatest
const headers = [
'Days Metrics',
'Issues',
'Stars',
'Forks',
'Contributors',
'Releases',
'Open Pull Requests',
'Health',
'Score',
]
await expect(page.getByText(metricsLatest.projectName)).toBeVisible()
await expect(page.getByText(metricsLatest.score.toString())).toBeVisible()
for (const header of headers) {
await expect(page.getByText(header, { exact: true })).toBeVisible()
}
})
})
2 changes: 1 addition & 1 deletion frontend/__tests__/unit/data/mockProjectDetailsData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const mockProjectDetailsData = {
project: {
contributorsCount: 1200,
forksCount: 10,
healthMetrics: [
healthMetricsList: [
{
openIssuesCount: 5,
unassignedIssuesCount: 2,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const mockProjectsDashboardMetricsDetailsData = {
project: {
healthMetricsLatest: {
ageDays: 200,
ageDaysRequirement: 100,
isFundingRequirementsCompliant: true,
isLeaderRequirementsCompliant: false,
lastCommitDays: 22,
lastCommitDaysRequirement: 30,
lastPullRequestDays: 55,
lastPullRequestDaysRequirement: 56,
lastReleaseDays: 57,
lastReleaseDaysRequirement: 58,
owaspPageLastUpdateDays: 52,
owaspPageLastUpdateDaysRequirement: 53,
projectName: 'Test',
score: 41,
},
healthMetricsList: [
{
contributorsCount: 50,
forksCount: 60,
openIssuesCount: 59,
openPullRequestsCount: 51,
recentReleasesCount: 75,
starsCount: 77,
totalIssuesCount: 78,
totalReleasesCount: 79,
unassignedIssuesCount: 85,
unansweredIssuesCount: 86,
},
],
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useQuery } from '@apollo/client'
import { render, screen, waitFor } from '@testing-library/react'
import { mockProjectsDashboardMetricsDetailsData } from '@unit/data/mockProjectsDashboardMetricsDetailsData'
import ProjectHealthMetricsDetails from 'app/projects/dashboard/metrics/[projectKey]/page'

jest.mock('react-apexcharts', () => {
return {
__esModule: true,
default: () => {
return <div data-testid="mock-apexcharts">Mock ApexChart</div>
},
}
})
jest.mock('@apollo/client', () => ({
...jest.requireActual('@apollo/client'),
useQuery: jest.fn(),
}))
jest.mock('next/navigation', () => ({
useParams: jest.fn(() => ({
projectKey: 'test-project',
})),
}))

jest.mock('@fortawesome/react-fontawesome', () => ({
FontAwesomeIcon: () => <span data-testid="mock-icon"></span>,
}))

const mockError = {
error: new Error('GraphQL error'),
}

describe('ProjectHealthMetricsDetails', () => {
beforeEach(() => {
;(useQuery as jest.Mock).mockReturnValue({
data: mockProjectsDashboardMetricsDetailsData,
loading: false,
error: null,
})
})

afterEach(() => {
jest.clearAllMocks()
})

test('renders loading state', async () => {
;(useQuery as jest.Mock).mockReturnValue({
data: null,
loading: true,
error: null,
})
render(<ProjectHealthMetricsDetails />)
const loadingSpinner = screen.getAllByAltText('Loading indicator')
await waitFor(() => {
expect(loadingSpinner.length).toBeGreaterThan(0)
})
})

test('renders error state', async () => {
;(useQuery as jest.Mock).mockReturnValue({
data: null,
loading: false,
error: mockError,
})
render(<ProjectHealthMetricsDetails />)
const errorMessage = screen.getByText('No metrics data available for this project.')
await waitFor(() => {
expect(errorMessage).toBeInTheDocument()
})
})

test('renders project health metrics details', async () => {
const headers = [
'Days Metrics',
'Issues',
'Stars',
'Forks',
'Contributors',
'Releases',
'Open Pull Requests',
'Health',
'Score',
]
const metrics = mockProjectsDashboardMetricsDetailsData.project.healthMetricsLatest
render(<ProjectHealthMetricsDetails />)
await waitFor(() => {
headers.forEach((header) => {
expect(screen.getByText(header)).toBeInTheDocument()
})
expect(screen.getByText(metrics.projectName)).toBeInTheDocument()
expect(screen.getByText(metrics.score.toString())).toBeInTheDocument()
})
})
})
2 changes: 1 addition & 1 deletion frontend/src/app/projects/[projectKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const ProjectDetailsPage = () => {
<DetailsCard
details={projectDetails}
entityKey={project.key}
healthMetricsData={project.healthMetrics}
healthMetricsData={project.healthMetricsList}
isActive={project.isActive}
languages={project.languages}
pullRequests={project.recentPullRequests}
Expand Down
Loading