Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
182 changes: 182 additions & 0 deletions src/__testing__/RelatedDesigns.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import RelatedDesigns from '../custom/CatalogDetail/RelatedDesigns';
import type { Pattern, SuggestedPattern } from '../custom/CustomCatalog/CustomCard';
import { SistentThemeProvider } from '../theme';

jest.mock('react-markdown', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}));

jest.mock('remark-gfm', () => ({
__esModule: true,
default: () => {}
}));

jest.mock('rehype-raw', () => ({
__esModule: true,
default: () => {}
}));

const originalResizeObserver = global.ResizeObserver;

beforeAll(() => {
global.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
});

afterAll(() => {
global.ResizeObserver = originalResizeObserver;
});

const sampleDetail: Pattern = {
id: 'current-id',
userId: 'user-1',
name: 'Current Design',
patternFile: '',
visibility: 'public',
avatarUrl: 'https://example.com/avatar.png',
user: {
firstName: 'Alice',
lastName: 'Smith'
},
viewCount: 0,
downloadCount: 0,
cloneCount: 0,
deploymentCount: 0,
shareCount: 0
};

const renderWithTheme = (ui: React.ReactElement) => {
return render(<SistentThemeProvider>{ui}</SistentThemeProvider>);
};

const renderRelated = (overrides?: Partial<React.ComponentProps<typeof RelatedDesigns>>) => {
return renderWithTheme(
<RelatedDesigns
details={sampleDetail}
type="pattern"
onSuggestedPatternClick={() => {}}
technologySVGPath=""
technologySVGSubpath=""
orgName="Layer5"
fetchingOrgError={false}
{...overrides}
/>
);
};

describe('RelatedDesigns runtime fallback and card handling', () => {
it('renders null when patternsPerUser is undefined', () => {
const { container } = renderRelated({ patternsPerUser: undefined });
expect(container.firstChild).toBeNull();
});

it('renders null when patternsPerUser has no patterns property', () => {
const { container } = renderRelated({ patternsPerUser: {} });
expect(container.firstChild).toBeNull();
});

it('renders null when patterns array is empty', () => {
const { container } = renderRelated({ patternsPerUser: { patterns: [] } });
expect(container.firstChild).toBeNull();
});

it('renders null when patterns array contains only current design', () => {
const { container } = renderRelated({
patternsPerUser: { patterns: [{ id: 'current-id', name: 'Current Design' }] }
});
expect(container.firstChild).toBeNull();
});

it('renders null when suggested patterns have no usable id or name', () => {
const { container } = renderRelated({
patternsPerUser: {
patterns: [
{ id: undefined, name: 'Missing ID' },
{ id: 'missing-name', name: undefined },
{ id: '', name: 'Empty ID' }
]
}
});
expect(container.firstChild).toBeNull();
});

it('falls back to snapshotURL when imageURL is an empty array', () => {
const suggested: SuggestedPattern = {
id: 'related-snapshot',
name: 'Related Design Snapshot',
catalogData: {
imageURL: [],
snapshotURL: ['https://example.com/snapshot.png']
}
};

renderRelated({ patternsPerUser: { patterns: [suggested] } });

const img = screen.getByAltText('Design SnapShot') as HTMLImageElement;
expect(img).toBeTruthy();
expect(img.src).toBe('https://example.com/snapshot.png');
});

it('uses non-empty imageURL in preference to snapshotURL', () => {
const suggested: SuggestedPattern = {
id: 'related-both',
name: 'Related Design Both',
catalogData: {
imageURL: ['https://example.com/primary.png'],
snapshotURL: ['https://example.com/snapshot.png']
}
};

renderRelated({ patternsPerUser: { patterns: [suggested] } });

const img = screen.getByAltText('Design SnapShot') as HTMLImageElement;
expect(img).toBeTruthy();
expect(img.src).toBe('https://example.com/primary.png');
});

it('renders the other design and excludes the current one when filterByType is true', () => {
const otherPattern: Pattern = {
...sampleDetail,
id: 'other-id',
name: 'Other Type Design'
};

renderRelated({
filterByType: true,
detailsByType: {
patterns: [sampleDetail, otherPattern]
}
});

expect(screen.queryByText('Current Design')).toBeNull();
expect(screen.getByText('Other Type Design')).toBeTruthy();
});

it('renders suggested patterns and invokes onSuggestedPatternClick on card click', () => {
const onSuggestedPatternClick = jest.fn();
const suggested: SuggestedPattern = {
id: 'related-1',
name: 'Related Design 1',
downloadCount: 10,
viewCount: 20
};

renderRelated({
patternsPerUser: { patterns: [suggested] },
onSuggestedPatternClick
});

expect(screen.getByText('Related Design 1')).toBeTruthy();
fireEvent.click(screen.getByText('Related Design 1'));
expect(onSuggestedPatternClick).toHaveBeenCalledTimes(1);
expect(onSuggestedPatternClick).toHaveBeenCalledWith(
expect.objectContaining({ id: 'related-1' })
);
});
});
108 changes: 108 additions & 0 deletions src/__testing__/fixtures/relatedDesignsPatterns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Type-level fixture for Issue #1873: RightPanel / RelatedDesigns patternsPerUser.
// It is compiled by `relatedDesignsPatternsTypes.test.ts` via `tsc --noEmit`.
import type { GetPatternsApiResponse } from '@meshery/schemas/cloudApi';
import React from 'react';
import type { DetailsByType, PatternsPerUser, SuggestedPattern } from '../../custom/CatalogDetail';
import RelatedDesigns from '../../custom/CatalogDetail/RelatedDesigns';
import RightPanel from '../../custom/CatalogDetail/RightPanel';
import type { Pattern } from '../../custom/CustomCatalog/CustomCard';

export type ExercisedPatternsPerUser = PatternsPerUser;

declare const canonicalResponse: GetPatternsApiResponse;
declare const optionalResponse: GetPatternsApiResponse | undefined;
declare const completePattern: Pattern;
declare const detailsByType: DetailsByType;

const baseRightPanelProps = {
details: completePattern,
detailsByType: detailsByType,
type: 'pattern',
title: 'Test Pattern',
getUrl: () => '',
showContentDetails: true,
showVersion: true,
showCaveats: false,
classes: [],
handleCopyUrl: () => {},
onSuggestedPatternClick: () => {},
useGetUserProfileByIdQuery: () => ({ data: undefined }),
technologySVGPath: '',
technologySVGSubpath: '',
orgName: '',
fetchingOrgError: false,
showShareAction: false,
handleShare: () => {},
isVisibilityEnabled: false,
handleVisibilityChange: () => {}
};

const baseRelatedDesignsProps = {
details: completePattern,
type: 'pattern',
onSuggestedPatternClick: () => {},
technologySVGPath: '',
technologySVGSubpath: '',
orgName: '',
fetchingOrgError: false
};

// 1. RightPanel accepts undefined patternsPerUser.
export const rightPanelWithUndefined: React.ReactElement = (
<RightPanel {...baseRightPanelProps} patternsPerUser={undefined} />
);

// 2. RightPanel accepts the canonical GetPatternsApiResponse without a cast.
export const rightPanelWithCanonical: React.ReactElement = (
<RightPanel {...baseRightPanelProps} patternsPerUser={canonicalResponse} />
);

// 3. RightPanel accepts optional response (GetPatternsApiResponse | undefined).
export const rightPanelWithOptionalResponse: React.ReactElement = (
<RightPanel {...baseRightPanelProps} patternsPerUser={optionalResponse} />
);

// 4. RightPanel accepts omitted patternsPerUser (optional prop).
export const rightPanelWithOmittedProp: React.ReactElement = (
<RightPanel {...baseRightPanelProps} />
);

// 5. RightPanel accepts response with missing patterns property.
export const rightPanelWithMissingPatterns: React.ReactElement = (
<RightPanel {...baseRightPanelProps} patternsPerUser={{}} />
);

// 6. RightPanel accepts complete Pattern[] from consumers (consumers must type onSuggestedPatternClick as (pattern: SuggestedPattern) => void).
export const rightPanelWithCompletePattern: React.ReactElement = (
<RightPanel
{...baseRightPanelProps}
patternsPerUser={{ patterns: [completePattern] }}
onSuggestedPatternClick={(pattern: SuggestedPattern) => {
// SuggestedPattern does not require avatarUrl or user
void pattern.id;
void pattern.name;
}}
/>
);

// 7. RelatedDesigns directly accepts undefined, canonical, and complete Pattern.
export const relatedDesignsWithCanonical: React.ReactElement = (
<RelatedDesigns {...baseRelatedDesignsProps} patternsPerUser={canonicalResponse} />
);

export const relatedDesignsWithUndefined: React.ReactElement = (
<RelatedDesigns {...baseRelatedDesignsProps} patternsPerUser={undefined} />
);

export const relatedDesignsWithCompletePattern: React.ReactElement = (
<RelatedDesigns {...baseRelatedDesignsProps} patternsPerUser={{ patterns: [completePattern] }} />
);

// 8. Self-check: verifying that patternsPerUser rejects incompatible types.
export const rejectsIncompatiblePatterns: React.ReactElement = (
<RelatedDesigns
{...baseRelatedDesignsProps}
// @ts-expect-error - invalid object cannot be assigned to PatternsPerUser
patternsPerUser={{ patterns: [{ invalidField: 123 }] }}
/>
);
7 changes: 7 additions & 0 deletions src/__testing__/fixtures/tsconfig.relatedDesignsPatterns.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./relatedDesignsPatterns.tsx"]
}
36 changes: 36 additions & 0 deletions src/__testing__/relatedDesignsPatternsTypes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type Diagnostics, typeCheckFixture } from './helpers/tscFixture';

// Type-level regression guard for https://github.com/layer5io/sistent/issues/1873.
//
// `RightPanel` and `RelatedDesigns` receive patterns from canonical `getPatterns`
// (`GetPatternsApiResponse`), which returns optional `patterns` where items do
// not guarantee `avatarUrl`, `user`, or required fields from full `Pattern`.
//
// This test shells out to `tsc` over a fixture that exercises:
// - passing canonical GetPatternsApiResponse directly
// - passing undefined patternsPerUser
// - passing empty or missing patterns response
// - passing complete Pattern[] (consumers must type onSuggestedPatternClick as (pattern: SuggestedPattern) => void)

const FIXTURE = 'src/__testing__/fixtures/relatedDesignsPatterns.tsx';
const PROJECT = 'src/__testing__/fixtures/tsconfig.relatedDesignsPatterns.json';

describe('RightPanel / RelatedDesigns patternsPerUser type contract (Issue #1873)', () => {
let diagnostics: Diagnostics;

beforeAll(() => {
diagnostics = typeCheckFixture(FIXTURE, PROJECT);
}, 180_000);

it('compiles the fixture', () => {
expect(diagnostics.compiledFixture).toBe(true);
});

it('reports no config-level failure that would skip the fixture', () => {
expect(diagnostics.config).toEqual([]);
});

it('accepts canonical GetPatternsApiResponse, undefined, and complete Pattern without casts', () => {
expect(diagnostics.fixture).toEqual([]);
});
});
Loading
Loading