Skip to content
5 changes: 5 additions & 0 deletions backend/apps/owasp/graphql/nodes/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class SnapshotNode(GenericEntityNode):
new_projects = graphene.List(ProjectNode)
new_releases = graphene.List(ReleaseNode)
new_users = graphene.List(UserNode)
summary = graphene.String()

class Meta:
model = Snapshot
Expand Down Expand Up @@ -55,3 +56,7 @@ def resolve_new_releases(self, info):
def resolve_new_users(self, info):
"""Resolve recent new users."""
return self.new_users.order_by("-created_at")

def resolve_summary(self, info):
"""Resolve summary of the snapshot."""
return self.generate_summary()
31 changes: 31 additions & 0 deletions backend/apps/owasp/models/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,34 @@ def save(self, *args, **kwargs):
self.key = now().strftime("%Y-%m")

super().save(*args, **kwargs)

def generate_summary(self, max_examples=2):
"""Generate a snapshot summary with counts and examples."""
summary_parts = []

def summarize(queryset, label, example_attr):
count = queryset.count()
if count == 0:
return None
examples = list(queryset.values_list(example_attr, flat=True)[:max_examples])
example_str = ", ".join(str(e) for e in examples)
return f"{count} {label}{'s' if count != 1 else ''} (e.g., {example_str})"

entities = [
(self.new_users, "user", "login"),
(self.new_projects, "project", "name"),
(self.new_chapters, "chapter", "name"),
(self.new_issues, "issue", "title"),
(self.new_releases, "release", "tag_name"),
]

for queryset, label, attr in entities:
part = summarize(queryset, label, attr)
if part:
summary_parts.append(part)

return (
"Snapshot Summary: " + "; ".join(summary_parts)
if summary_parts
else "No new entities were added."
)
Comment on lines +59 to +84
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

Add unit tests for the new functionality.

The generate_summary method is a good candidate for unit testing to ensure it handles different data scenarios correctly.


🏁 Script executed:

#!/bin/bash
# Check if there are existing tests for the Snapshot model
echo "Checking for existing tests for Snapshot model..."
find backend -name "test_*.py" -o -name "*_test.py" | xargs grep -l "Snapshot" || echo "No existing tests found"

# Look for test patterns in the project
echo -e "\nExamining test patterns in the project..."
find backend -name "test_*.py" -o -name "*_test.py" | head -n 1 | xargs cat || echo "No test files found"

Length of output: 1868


Action Required: Add Dedicated Unit Tests for the generate_summary Method

While there are existing tests for the Snapshot model (as seen in backend/tests/apps/owasp/models/snapshot_test.py), it isn’t clear that the new generate_summary functionality is specifically covered. Please add unit tests that ensure the method behaves correctly under various data conditions. For example:

  • Test when the queryset returns zero records (should return "No new entities were added.").
  • Test when there is exactly one record (ensuring the singular label is used).
  • Test when multiple records are returned and the maximum examples limit (max_examples) is enforced.

4 changes: 4 additions & 0 deletions cspell/custom-dict.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
a2eeef
abhayymishraaaa
vithobasatish
Juiz
Agsoc
Aichi
Aissue
Expand All @@ -20,6 +23,7 @@ csrfguard
csrfprotector
csrftoken
cva
Cyclonedx
dismissable
DRF
dsn
Expand Down
2 changes: 2 additions & 0 deletions frontend/__tests__/unit/data/mockSnapshotData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const mockSnapshotDetailsData = {
createdAt: '2025-03-01T22:00:34.361937+00:00',
startAt: '2024-12-01T00:00:00+00:00',
endAt: '2024-12-31T22:00:30+00:00',
summary:
'Snapshot Summary: 10 users (e.g., abhayymishraaaa, vithobasatish); 3 projects (e.g., OWASP Top 10 for Business Logic Abuse, OWASP ProdSecMan); 14 chapters (e.g., OWASP Oshawa, OWASP Juiz de Fora); 422 issues (e.g., Duplicate Components, Cyclonedx seems to ignore some configuration options); 71 releases (e.g., 2.0.1, v5.0.1)',
status: 'completed',
errorMessage: '',
newReleases: [
Expand Down
37 changes: 37 additions & 0 deletions frontend/__tests__/unit/pages/SnapshotDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ describe('SnapshotDetailsPage', () => {
expect(screen.getByText('New Releases')).toBeInTheDocument()
})

test('correctly parses and displays summary data', async () => {
;(useQuery as jest.Mock).mockReturnValue({
data: mockSnapshotDetailsData,
error: null,
})

render(<SnapshotDetailsPage />)

// Wait for the page to render
await waitFor(() => {
expect(screen.getByText('New Snapshot')).toBeInTheDocument()
})

// Check that summary section exists
expect(screen.getByText('Snapshot Summary')).toBeInTheDocument()

// Check for correctly parsed user count
expect(screen.getByText('10 Users')).toBeInTheDocument()
expect(screen.getByText(/abhayymishraaaa/)).toBeInTheDocument()

// Check for correctly parsed project count
expect(screen.getByText('3 Projects')).toBeInTheDocument()
expect(screen.getByText(/OWASP Top 10 for Business Logic Abuse/)).toBeInTheDocument()

// Check for correctly parsed chapter count
expect(screen.getByText('14 Chapters')).toBeInTheDocument()
expect(screen.getByText(/OWASP Oshawa/)).toBeInTheDocument()

// Check for correctly parsed issues count
expect(screen.getByText('422 Issues')).toBeInTheDocument()
expect(screen.getByText(/Duplicate Components/)).toBeInTheDocument()

// Check for correctly parsed releases count
expect(screen.getByText('71 Releases')).toBeInTheDocument()
expect(screen.getByText(/2\.0\.1/)).toBeInTheDocument()
})

test('renders error message when GraphQL request fails', async () => {
;(useQuery as jest.Mock).mockReturnValue({
data: null,
Expand Down
86 changes: 85 additions & 1 deletion frontend/src/app/snapshots/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,78 @@
'use client'

import { useQuery } from '@apollo/client'
import { faCalendar } from '@fortawesome/free-solid-svg-icons'
import {
faCalendar,
faUsers,
faFolder,
faBook,
faBug,
faTag,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useRouter, useParams } from 'next/navigation'
import React, { useState, useEffect } from 'react'

import { GET_SNAPSHOT_DETAILS } from 'server/queries/snapshotQueries'
import { ChapterTypeGraphQL } from 'types/chapter'
import { ProjectTypeGraphql } from 'types/project'
import { SnapshotDetailsProps } from 'types/snapshot'
import { level } from 'utils/data'
import { formatDate } from 'utils/dateFormatter'
import { getFilteredIconsGraphql, handleSocialUrls } from 'utils/utility'

import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper'
import Card from 'components/Card'
import ChapterMapWrapper from 'components/ChapterMapWrapper'
import LoadingSpinner from 'components/LoadingSpinner'
import { handleAppError, ErrorDisplay } from 'app/global-error'

type ParsedSummary = {
users: { count: number; examples: string[] }
projects: { count: number; examples: string[] }
chapters: { count: number; examples: string[] }
issues: { count: number; examples: string[] }
releases: { count: number; examples: string[] }
}

const parseSnapshotSummary = (summary: string): ParsedSummary => {
const result: ParsedSummary = {
users: { count: 0, examples: [] },
projects: { count: 0, examples: [] },
chapters: { count: 0, examples: [] },
issues: { count: 0, examples: [] },
releases: { count: 0, examples: [] },
}

if (!summary) return result

const sections = [
{ key: 'users', pattern: /(\d+) users \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'projects', pattern: /(\d+) projects \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'chapters', pattern: /(\d+) chapters \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'issues', pattern: /(\d+) issues \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'releases', pattern: /(\d+) releases \(e\.g\.,\s*([^)]+)\)/i },
]

sections.forEach((section) => {
const match = summary.match(section.pattern)
if (match && match.length >= 3) {
result[section.key as keyof ParsedSummary] = {
count: parseInt(match[1], 10),
examples: match[2].split(',').map((s) => s.trim()),
}
}
})

return result
}

const SnapshotDetailsPage: React.FC = () => {
const { id: snapshotKey } = useParams()
const [snapshot, setSnapshot] = useState<SnapshotDetailsProps | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const router = useRouter()
const [summaryData, setSummaryData] = useState<ParsedSummary | null>(null)

const { data: graphQLData, error: graphQLRequestError } = useQuery(GET_SNAPSHOT_DETAILS, {
variables: { key: snapshotKey },
Expand All @@ -30,6 +81,7 @@ const SnapshotDetailsPage: React.FC = () => {
useEffect(() => {
if (graphQLData) {
setSnapshot(graphQLData.snapshot)
setSummaryData(parseSnapshotSummary(graphQLData.snapshot.summary))
setIsLoading(false)
}
if (graphQLRequestError) {
Expand Down Expand Up @@ -129,6 +181,38 @@ const SnapshotDetailsPage: React.FC = () => {
</div>
</div>

{summaryData && (
<div className="mb-8 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-2xl font-semibold text-gray-700 dark:text-gray-200">
Snapshot Summary
</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[
{ label: 'Users', data: summaryData.users, icon: faUsers },
{ label: 'Projects', data: summaryData.projects, icon: faFolder },
{ label: 'Chapters', data: summaryData.chapters, icon: faBook },
{ label: 'Issues', data: summaryData.issues, icon: faBug },
{ label: 'Releases', data: summaryData.releases, icon: faTag },
].map(({ label, data, icon }) => (
<div
key={label}
className="flex items-start gap-4 rounded-lg border p-4 shadow-sm dark:border-gray-700"
>
<FontAwesomeIcon icon={icon} className="h-6 w-6 text-blue-500" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
{data.count} {label}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
e.g., {data.examples.join(', ')}
</p>
</div>
</div>
))}
</div>
</div>
)}

{snapshot?.newChapters && snapshot?.newChapters.length > 0 && (
<div className="mb-8">
<h2 className="mb-6 text-2xl font-semibold text-gray-700 dark:text-gray-200">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/server/queries/snapshotQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const GET_SNAPSHOT_DETAILS = gql`
key
startAt
title
summary
newReleases {
name
publishedAt
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface SnapshotDetailsProps {
newReleases: ReleaseType[]
newProjects: ProjectTypeGraphql[]
newChapters: ChapterTypeGraphQL[]
summary: string
}

export interface Snapshots {
Expand Down