Compare commits
10 Commits
setup/ci
...
e22033be38
| Author | SHA1 | Date | |
|---|---|---|---|
| e22033be38 | |||
| d97f50ed72 | |||
| ae58839eb2 | |||
| 18133aef4c | |||
| 1288a9dc30 | |||
| 04d445bf00 | |||
| 94b92a9314 | |||
| 108cfbcd65 | |||
| 1f4f837282 | |||
| c22e26d14c |
@@ -44,7 +44,8 @@
|
||||
"lib/builders/view-models/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/view-model-builder-contract": "error"
|
||||
"gridpilot-rules/view-model-builder-contract": "error",
|
||||
"gridpilot-rules/view-model-builder-implements": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -55,7 +56,9 @@
|
||||
"rules": {
|
||||
"gridpilot-rules/filename-matches-export": "off",
|
||||
"gridpilot-rules/single-export-per-file": "off",
|
||||
"gridpilot-rules/view-data-builder-contract": "off"
|
||||
"gridpilot-rules/view-data-builder-contract": "off",
|
||||
"gridpilot-rules/view-data-builder-implements": "error",
|
||||
"gridpilot-rules/view-data-builder-imports": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -192,6 +195,24 @@
|
||||
"gridpilot-rules/view-data-location": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"lib/view-data/**/*.ts",
|
||||
"lib/view-data/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/view-data-implements": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"lib/view-models/**/*.ts",
|
||||
"lib/view-models/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/view-model-implements": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"lib/services/**/*.ts"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
|
||||
import { type RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
|
||||
import { type RaceStewardingViewData } from '@/lib/view-data/RaceStewardingViewData';
|
||||
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { Gavel } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, use } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface RaceStewardingPageProps {
|
||||
params: Promise<{
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import type { ProfileTab } from '@/components/profile/ProfileTabs';
|
||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
|
||||
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface DriverProfilePageClientProps {
|
||||
viewData: DriverProfileViewData | null;
|
||||
error?: string;
|
||||
empty?: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
|
||||
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface DriversPageClientProps {
|
||||
viewData: DriversViewData | null;
|
||||
error?: string;
|
||||
empty?: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
|
||||
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
|
||||
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
|
||||
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
|
||||
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
||||
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
|
||||
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
|
||||
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
||||
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) {
|
||||
// Build ViewModel from ViewData
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import type { LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData';
|
||||
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
|
||||
|
||||
interface LeagueWalletPageClientProps extends ClientWrapperProps<LeagueWalletViewData> {
|
||||
onWithdraw?: (amount: number) => void;
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
|
||||
import { useAuth } from '@/components/auth/AuthContext';
|
||||
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
|
||||
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
|
||||
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
||||
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
||||
import { LoginViewData } from '@/lib/view-data/LoginViewData';
|
||||
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
||||
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
||||
import { LoginLoadingTemplate } from '@/templates/auth/LoginLoadingTemplate';
|
||||
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
export function LoginClient({ viewData }: ClientWrapperProps<LoginViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { RaceResultsViewData } from '@/lib/view-data/RaceResultsViewData';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export function RaceResultsPageClient({ viewData }: ClientWrapperProps<RaceResultsViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
|
||||
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
|
||||
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
|
||||
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
|
||||
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { ResetPasswordViewData } from '@/lib/view-data/ResetPasswordViewData';
|
||||
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswordViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/components/auth/AuthContext';
|
||||
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
|
||||
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
|
||||
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
||||
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
|
||||
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
|
||||
import { SignupFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
||||
import { SignupFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { SignupViewData } from '@/lib/view-data/SignupViewData';
|
||||
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
|
||||
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function SignupClient({ viewData }: ClientWrapperProps<SignupViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
|
||||
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import type { StewardingViewData } from '@/lib/view-data/StewardingViewData';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
|
||||
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { StewardingTemplate } from '@/templates/StewardingTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface StewardingPageClientProps extends ClientWrapperProps<StewardingViewData> {
|
||||
leagueId: string;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
|
||||
import { useState } from 'react';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
interface TeamLeaderboardViewData extends ViewData {
|
||||
interface TeamLeaderboardViewData extends ViewData extends ViewData {
|
||||
teams: TeamSummaryViewModel[];
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ const servicesImplementContract = require('./services-implement-contract');
|
||||
const serverActionsReturnResult = require('./server-actions-return-result');
|
||||
const serverActionsInterface = require('./server-actions-interface');
|
||||
const noDisplayObjectsInUi = require('./no-display-objects-in-ui');
|
||||
const viewDataBuilderImplements = require('./view-data-builder-implements');
|
||||
const viewDataBuilderImports = require('./view-data-builder-imports');
|
||||
const viewModelBuilderImplements = require('./view-model-builder-implements');
|
||||
const viewDataImplements = require('./view-data-implements');
|
||||
const viewModelImplements = require('./view-model-implements');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
@@ -128,9 +133,14 @@ module.exports = {
|
||||
// View Data Rules
|
||||
'view-data-location': viewDataLocation,
|
||||
'view-data-builder-contract': viewDataBuilderContract,
|
||||
'view-data-builder-implements': viewDataBuilderImplements,
|
||||
'view-data-builder-imports': viewDataBuilderImports,
|
||||
'view-data-implements': viewDataImplements,
|
||||
|
||||
// View Model Rules
|
||||
'view-model-builder-contract': viewModelBuilderContract,
|
||||
'view-model-builder-implements': viewModelBuilderImplements,
|
||||
'view-model-implements': viewModelImplements,
|
||||
|
||||
// Single Export Rules
|
||||
'single-export-per-file': singleExportPerFile,
|
||||
@@ -253,9 +263,14 @@ module.exports = {
|
||||
// View Data
|
||||
'gridpilot-rules/view-data-location': 'error',
|
||||
'gridpilot-rules/view-data-builder-contract': 'error',
|
||||
'gridpilot-rules/view-data-builder-implements': 'error',
|
||||
'gridpilot-rules/view-data-builder-imports': 'error',
|
||||
'gridpilot-rules/view-data-implements': 'error',
|
||||
|
||||
// View Model
|
||||
'gridpilot-rules/view-model-builder-contract': 'error',
|
||||
'gridpilot-rules/view-model-builder-implements': 'error',
|
||||
'gridpilot-rules/view-model-implements': 'error',
|
||||
|
||||
// Single Export Rules
|
||||
'gridpilot-rules/single-export-per-file': 'error',
|
||||
|
||||
96
apps/website/eslint-rules/view-data-builder-implements.js
Normal file
96
apps/website/eslint-rules/view-data-builder-implements.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ESLint rule to enforce View Data Builder contract implementation
|
||||
*
|
||||
* View Data Builders in lib/builders/view-data/ must:
|
||||
* 1. Be classes named *ViewDataBuilder
|
||||
* 2. Implement the ViewDataBuilder<TInput, TOutput> interface
|
||||
* 3. Have a static build() method
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce View Data Builder contract implementation',
|
||||
category: 'Builders',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
|
||||
missingImplements: 'View Data Builders must implement ViewDataBuilder<TInput, TOutput> interface',
|
||||
missingBuildMethod: 'View Data Builders must have a static build() method',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInViewDataBuilders = filename.includes('/lib/builders/view-data/');
|
||||
|
||||
if (!isInViewDataBuilders) return {};
|
||||
|
||||
let hasImplements = false;
|
||||
let hasBuildMethod = false;
|
||||
|
||||
return {
|
||||
// Check class declaration
|
||||
ClassDeclaration(node) {
|
||||
const className = node.id?.name;
|
||||
|
||||
if (!className || !className.endsWith('ViewDataBuilder')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'notAClass',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if class implements ViewDataBuilder interface
|
||||
if (node.implements && node.implements.length > 0) {
|
||||
for (const impl of node.implements) {
|
||||
// Handle GenericTypeAnnotation for ViewDataBuilder<TInput, TOutput>
|
||||
if (impl.expression.type === 'TSInstantiationExpression') {
|
||||
const expr = impl.expression.expression;
|
||||
if (expr.type === 'Identifier' && expr.name === 'ViewDataBuilder') {
|
||||
hasImplements = true;
|
||||
}
|
||||
} else if (impl.expression.type === 'Identifier') {
|
||||
// Handle simple ViewDataBuilder (without generics)
|
||||
if (impl.expression.name === 'ViewDataBuilder') {
|
||||
hasImplements = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for static build method
|
||||
const buildMethod = node.body.body.find(member =>
|
||||
member.type === 'MethodDefinition' &&
|
||||
member.key.type === 'Identifier' &&
|
||||
member.key.name === 'build' &&
|
||||
member.static === true
|
||||
);
|
||||
|
||||
if (buildMethod) {
|
||||
hasBuildMethod = true;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!hasImplements) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingImplements',
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasBuildMethod) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingBuildMethod',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
80
apps/website/eslint-rules/view-data-builder-imports.js
Normal file
80
apps/website/eslint-rules/view-data-builder-imports.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* ESLint rule to enforce ViewDataBuilder import paths
|
||||
*
|
||||
* ViewDataBuilders in lib/builders/view-data/ must:
|
||||
* 1. Import DTO types from lib/types/generated/
|
||||
* 2. Import ViewData types from lib/view-data/
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce ViewDataBuilder import paths',
|
||||
category: 'Builders',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
invalidDtoImport: 'ViewDataBuilders must import DTO types from lib/types/generated/, not from {{importPath}}',
|
||||
invalidViewDataImport: 'ViewDataBuilders must import ViewData types from lib/view-data/, not from {{importPath}}',
|
||||
missingDtoImport: 'ViewDataBuilders must import DTO types from lib/types/generated/',
|
||||
missingViewDataImport: 'ViewDataBuilders must import ViewData types from lib/view-data/',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInViewDataBuilders = filename.includes('/lib/builders/view-data/');
|
||||
|
||||
if (!isInViewDataBuilders) return {};
|
||||
|
||||
let hasDtoImport = false;
|
||||
let hasViewDataImport = false;
|
||||
let dtoImportPath = null;
|
||||
let viewDataImportPath = null;
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value;
|
||||
|
||||
// Check for DTO imports (should be from lib/types/generated/)
|
||||
if (importPath.includes('/lib/types/')) {
|
||||
if (!importPath.includes('/lib/types/generated/')) {
|
||||
dtoImportPath = importPath;
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidDtoImport',
|
||||
data: { importPath },
|
||||
});
|
||||
} else {
|
||||
hasDtoImport = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for ViewData imports (should be from lib/view-data/)
|
||||
if (importPath.includes('/lib/view-data/')) {
|
||||
hasViewDataImport = true;
|
||||
viewDataImportPath = importPath;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!hasDtoImport) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingDtoImport',
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasViewDataImport) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingViewDataImport',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
112
apps/website/eslint-rules/view-data-implements.js
Normal file
112
apps/website/eslint-rules/view-data-implements.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* ESLint rule to enforce ViewData contract implementation
|
||||
*
|
||||
* ViewData files in lib/view-data/ must:
|
||||
* 1. Be interfaces or types named *ViewData
|
||||
* 2. Extend the ViewData interface from contracts
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce ViewData contract implementation',
|
||||
category: 'Contracts',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
notAnInterface: 'ViewData files must be interfaces or types named *ViewData',
|
||||
missingExtends: 'ViewData must extend the ViewData interface from lib/contracts/view-data/ViewData.ts',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInViewData = filename.includes('/lib/view-data/') && !filename.includes('/contracts/');
|
||||
|
||||
if (!isInViewData) return {};
|
||||
|
||||
let hasViewDataExtends = false;
|
||||
let hasCorrectName = false;
|
||||
|
||||
return {
|
||||
// Check interface declarations
|
||||
TSInterfaceDeclaration(node) {
|
||||
const interfaceName = node.id?.name;
|
||||
|
||||
if (interfaceName && interfaceName.endsWith('ViewData')) {
|
||||
hasCorrectName = true;
|
||||
|
||||
// Check if it extends ViewData
|
||||
if (node.extends && node.extends.length > 0) {
|
||||
for (const ext of node.extends) {
|
||||
// Use context.getSourceCode().getText(ext) to be absolutely sure
|
||||
const extendsText = context.getSourceCode().getText(ext).trim();
|
||||
// We check for 'ViewData' but must be careful not to match 'SomethingViewData'
|
||||
// unless it's exactly 'ViewData' or part of a qualified name
|
||||
if (extendsText === 'ViewData' ||
|
||||
extendsText.endsWith('.ViewData') ||
|
||||
extendsText.startsWith('ViewData<') ||
|
||||
extendsText.startsWith('ViewData ') ||
|
||||
/\bViewData\b/.test(extendsText)) { // Use regex for word boundary
|
||||
hasViewDataExtends = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check type alias declarations
|
||||
TSTypeAliasDeclaration(node) {
|
||||
const typeName = node.id?.name;
|
||||
|
||||
if (typeName && typeName.endsWith('ViewData')) {
|
||||
hasCorrectName = true;
|
||||
|
||||
// For type aliases, check if it's an intersection with ViewData
|
||||
if (node.typeAnnotation && node.typeAnnotation.type === 'TSIntersectionType') {
|
||||
for (const type of node.typeAnnotation.types) {
|
||||
if (type.type === 'TSTypeReference' &&
|
||||
type.typeName &&
|
||||
type.typeName.type === 'Identifier' &&
|
||||
type.typeName.name === 'ViewData') {
|
||||
hasViewDataExtends = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
// Only report if we are in a file that should be a ViewData
|
||||
// and we didn't find a valid declaration
|
||||
const baseName = filename.split('/').pop();
|
||||
|
||||
// All files in lib/view-data/ must end with ViewData.ts
|
||||
if (baseName && !baseName.endsWith('ViewData.ts') && !baseName.endsWith('ViewData.tsx')) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'notAnInterface',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (baseName && (baseName.endsWith('ViewData.ts') || baseName.endsWith('ViewData.tsx'))) {
|
||||
if (!hasCorrectName) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'notAnInterface',
|
||||
});
|
||||
} else if (!hasViewDataExtends) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingExtends',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
96
apps/website/eslint-rules/view-model-builder-implements.js
Normal file
96
apps/website/eslint-rules/view-model-builder-implements.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ESLint rule to enforce View Model Builder contract implementation
|
||||
*
|
||||
* View Model Builders in lib/builders/view-models/ must:
|
||||
* 1. Be classes named *ViewModelBuilder
|
||||
* 2. Implement the ViewModelBuilder<TInput, TOutput> interface
|
||||
* 3. Have a static build() method
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce View Model Builder contract implementation',
|
||||
category: 'Builders',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
|
||||
missingImplements: 'View Model Builders must implement ViewModelBuilder<TInput, TOutput> interface',
|
||||
missingBuildMethod: 'View Model Builders must have a static build() method',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
|
||||
|
||||
if (!isInViewModelBuilders) return {};
|
||||
|
||||
let hasImplements = false;
|
||||
let hasBuildMethod = false;
|
||||
|
||||
return {
|
||||
// Check class declaration
|
||||
ClassDeclaration(node) {
|
||||
const className = node.id?.name;
|
||||
|
||||
if (!className || !className.endsWith('ViewModelBuilder')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'notAClass',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if class implements ViewModelBuilder interface
|
||||
if (node.implements && node.implements.length > 0) {
|
||||
for (const impl of node.implements) {
|
||||
// Handle GenericTypeAnnotation for ViewModelBuilder<TInput, TOutput>
|
||||
if (impl.expression.type === 'TSInstantiationExpression') {
|
||||
const expr = impl.expression.expression;
|
||||
if (expr.type === 'Identifier' && expr.name === 'ViewModelBuilder') {
|
||||
hasImplements = true;
|
||||
}
|
||||
} else if (impl.expression.type === 'Identifier') {
|
||||
// Handle simple ViewModelBuilder (without generics)
|
||||
if (impl.expression.name === 'ViewModelBuilder') {
|
||||
hasImplements = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for static build method
|
||||
const buildMethod = node.body.body.find(member =>
|
||||
member.type === 'MethodDefinition' &&
|
||||
member.key.type === 'Identifier' &&
|
||||
member.key.name === 'build' &&
|
||||
member.static === true
|
||||
);
|
||||
|
||||
if (buildMethod) {
|
||||
hasBuildMethod = true;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!hasImplements) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingImplements',
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasBuildMethod) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingBuildMethod',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
65
apps/website/eslint-rules/view-model-implements.js
Normal file
65
apps/website/eslint-rules/view-model-implements.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* ESLint rule to enforce ViewModel contract implementation
|
||||
*
|
||||
* ViewModel files in lib/view-models/ must:
|
||||
* 1. Be classes named *ViewModel
|
||||
* 2. Extend the ViewModel class from contracts
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce ViewModel contract implementation',
|
||||
category: 'Contracts',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
notAClass: 'ViewModel files must be classes named *ViewModel',
|
||||
missingExtends: 'ViewModel must extend the ViewModel class from lib/contracts/view-models/ViewModel.ts',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInViewModels = filename.includes('/lib/view-models/');
|
||||
|
||||
if (!isInViewModels) return {};
|
||||
|
||||
let hasViewModelExtends = false;
|
||||
let hasCorrectName = false;
|
||||
|
||||
return {
|
||||
// Check class declarations
|
||||
ClassDeclaration(node) {
|
||||
const className = node.id?.name;
|
||||
|
||||
if (className && className.endsWith('ViewModel')) {
|
||||
hasCorrectName = true;
|
||||
|
||||
// Check if it extends ViewModel
|
||||
if (node.superClass && node.superClass.type === 'Identifier' &&
|
||||
node.superClass.name === 'ViewModel') {
|
||||
hasViewModelExtends = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!hasCorrectName) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'notAClass',
|
||||
});
|
||||
} else if (!hasViewModelExtends) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingExtends',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminDashboardViewDataBuilder } from './AdminDashboardViewDataBuilder';
|
||||
import type { DashboardStats } from '@/lib/types/admin';
|
||||
|
||||
describe('AdminDashboardViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DashboardStats DTO to AdminDashboardViewData correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 800,
|
||||
suspendedUsers: 50,
|
||||
deletedUsers: 150,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 120,
|
||||
newUsersToday: 15,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 800,
|
||||
suspendedUsers: 50,
|
||||
deletedUsers: 150,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 120,
|
||||
newUsersToday: 15,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero values correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
suspendedUsers: 0,
|
||||
deletedUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
newUsersToday: 0,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
suspendedUsers: 0,
|
||||
deletedUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
newUsersToday: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 1000000,
|
||||
activeUsers: 750000,
|
||||
suspendedUsers: 25000,
|
||||
deletedUsers: 225000,
|
||||
systemAdmins: 50,
|
||||
recentLogins: 50000,
|
||||
newUsersToday: 1000,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(1000000);
|
||||
expect(result.stats.activeUsers).toBe(750000);
|
||||
expect(result.stats.systemAdmins).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 500,
|
||||
activeUsers: 400,
|
||||
suspendedUsers: 25,
|
||||
deletedUsers: 75,
|
||||
systemAdmins: 3,
|
||||
recentLogins: 80,
|
||||
newUsersToday: 10,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(dashboardStats.totalUsers);
|
||||
expect(result.stats.activeUsers).toBe(dashboardStats.activeUsers);
|
||||
expect(result.stats.suspendedUsers).toBe(dashboardStats.suspendedUsers);
|
||||
expect(result.stats.deletedUsers).toBe(dashboardStats.deletedUsers);
|
||||
expect(result.stats.systemAdmins).toBe(dashboardStats.systemAdmins);
|
||||
expect(result.stats.recentLogins).toBe(dashboardStats.recentLogins);
|
||||
expect(result.stats.newUsersToday).toBe(dashboardStats.newUsersToday);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 100,
|
||||
activeUsers: 80,
|
||||
suspendedUsers: 5,
|
||||
deletedUsers: 15,
|
||||
systemAdmins: 2,
|
||||
recentLogins: 20,
|
||||
newUsersToday: 5,
|
||||
};
|
||||
|
||||
const originalStats = { ...dashboardStats };
|
||||
AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(dashboardStats).toEqual(originalStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle negative values (if API returns them)', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: -1,
|
||||
activeUsers: -1,
|
||||
suspendedUsers: -1,
|
||||
deletedUsers: -1,
|
||||
systemAdmins: -1,
|
||||
recentLogins: -1,
|
||||
newUsersToday: -1,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(-1);
|
||||
expect(result.stats.activeUsers).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: Number.MAX_SAFE_INTEGER,
|
||||
activeUsers: Number.MAX_SAFE_INTEGER - 1000,
|
||||
suspendedUsers: 100,
|
||||
deletedUsers: 100,
|
||||
systemAdmins: 10,
|
||||
recentLogins: 1000,
|
||||
newUsersToday: 100,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(Number.MAX_SAFE_INTEGER);
|
||||
expect(result.stats.activeUsers).toBe(Number.MAX_SAFE_INTEGER - 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { DashboardStats } from '@/lib/types/admin';
|
||||
import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
||||
|
||||
@@ -7,7 +8,14 @@ import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewD
|
||||
* Transforms DashboardStats API DTO into AdminDashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class AdminDashboardViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AdminDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AdminDashboardViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: DashboardStats): AdminDashboardViewData {
|
||||
return {
|
||||
stats: {
|
||||
|
||||
@@ -1,181 +1,7 @@
|
||||
/**
|
||||
* View Data Layer Tests - Admin Functionality
|
||||
*
|
||||
* This test file covers the view data layer for admin functionality.
|
||||
*
|
||||
* The view data layer is responsible for:
|
||||
* - DTO → UI model mapping
|
||||
* - Formatting, sorting, and grouping
|
||||
* - Derived fields and defaults
|
||||
* - UI-specific semantics
|
||||
*
|
||||
* This layer isolates the UI from API churn by providing a stable interface
|
||||
* between the API layer and the presentation layer.
|
||||
*
|
||||
* Test coverage includes:
|
||||
* - Admin dashboard data transformation
|
||||
* - User management view models
|
||||
* - Admin-specific formatting and validation
|
||||
* - Derived fields for admin UI components
|
||||
* - Default values and fallbacks for admin views
|
||||
*/
|
||||
|
||||
import { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder';
|
||||
import { AdminUsersViewDataBuilder } from '@/lib/builders/view-data/AdminUsersViewDataBuilder';
|
||||
import type { DashboardStats } from '@/lib/types/admin';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminUsersViewDataBuilder } from './AdminUsersViewDataBuilder';
|
||||
import type { UserListResponse } from '@/lib/types/admin';
|
||||
|
||||
describe('AdminDashboardViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DashboardStats DTO to AdminDashboardViewData correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 800,
|
||||
suspendedUsers: 50,
|
||||
deletedUsers: 150,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 120,
|
||||
newUsersToday: 15,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 800,
|
||||
suspendedUsers: 50,
|
||||
deletedUsers: 150,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 120,
|
||||
newUsersToday: 15,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero values correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
suspendedUsers: 0,
|
||||
deletedUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
newUsersToday: 0,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
suspendedUsers: 0,
|
||||
deletedUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
newUsersToday: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 1000000,
|
||||
activeUsers: 750000,
|
||||
suspendedUsers: 25000,
|
||||
deletedUsers: 225000,
|
||||
systemAdmins: 50,
|
||||
recentLogins: 50000,
|
||||
newUsersToday: 1000,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(1000000);
|
||||
expect(result.stats.activeUsers).toBe(750000);
|
||||
expect(result.stats.systemAdmins).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 500,
|
||||
activeUsers: 400,
|
||||
suspendedUsers: 25,
|
||||
deletedUsers: 75,
|
||||
systemAdmins: 3,
|
||||
recentLogins: 80,
|
||||
newUsersToday: 10,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(dashboardStats.totalUsers);
|
||||
expect(result.stats.activeUsers).toBe(dashboardStats.activeUsers);
|
||||
expect(result.stats.suspendedUsers).toBe(dashboardStats.suspendedUsers);
|
||||
expect(result.stats.deletedUsers).toBe(dashboardStats.deletedUsers);
|
||||
expect(result.stats.systemAdmins).toBe(dashboardStats.systemAdmins);
|
||||
expect(result.stats.recentLogins).toBe(dashboardStats.recentLogins);
|
||||
expect(result.stats.newUsersToday).toBe(dashboardStats.newUsersToday);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 100,
|
||||
activeUsers: 80,
|
||||
suspendedUsers: 5,
|
||||
deletedUsers: 15,
|
||||
systemAdmins: 2,
|
||||
recentLogins: 20,
|
||||
newUsersToday: 5,
|
||||
};
|
||||
|
||||
const originalStats = { ...dashboardStats };
|
||||
AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(dashboardStats).toEqual(originalStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle negative values (if API returns them)', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: -1,
|
||||
activeUsers: -1,
|
||||
suspendedUsers: -1,
|
||||
deletedUsers: -1,
|
||||
systemAdmins: -1,
|
||||
recentLogins: -1,
|
||||
newUsersToday: -1,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(-1);
|
||||
expect(result.stats.activeUsers).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: Number.MAX_SAFE_INTEGER,
|
||||
activeUsers: Number.MAX_SAFE_INTEGER - 1000,
|
||||
suspendedUsers: 100,
|
||||
deletedUsers: 100,
|
||||
systemAdmins: 10,
|
||||
recentLogins: 1000,
|
||||
newUsersToday: 100,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(Number.MAX_SAFE_INTEGER);
|
||||
expect(result.stats.activeUsers).toBe(Number.MAX_SAFE_INTEGER - 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminUsersViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform UserListResponse DTO to AdminUsersViewData correctly', () => {
|
||||
@@ -1,27 +1,19 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { UserListResponse } from '@/lib/types/admin';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
|
||||
/**
|
||||
* AdminUsersViewDataBuilder
|
||||
*
|
||||
* Server-side builder that transforms API DTO
|
||||
* into ViewData for the AdminUsersTemplate.
|
||||
*
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class AdminUsersViewDataBuilder {
|
||||
static build(apiDto: UserListResponse): AdminUsersViewData {
|
||||
const users = apiDto.users.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
status: user.status,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
createdAt: typeof user.createdAt === 'string' ? user.createdAt : (user.createdAt as unknown as Date).toISOString(),
|
||||
updatedAt: typeof user.updatedAt === 'string' ? user.updatedAt : (user.updatedAt as unknown as Date).toISOString(),
|
||||
lastLoginAt: user.lastLoginAt ? (typeof user.lastLoginAt === 'string' ? user.lastLoginAt : (user.lastLoginAt as unknown as Date).toISOString()) : undefined,
|
||||
primaryDriverId: user.primaryDriverId,
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AdminUsersViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AdminUsersViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
public static build(apiDto: UserListResponse): AdminUsersViewData {
|
||||
const users = apiDto.users.map(u => ({
|
||||
...u,
|
||||
joinedAt: new Date(u.joinedAt),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -35,4 +27,4 @@ export class AdminUsersViewDataBuilder {
|
||||
adminCount: users.filter(u => u.isSystemAdmin).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalyticsDashboardViewDataBuilder } from './AnalyticsDashboardViewDataBuilder';
|
||||
import { AnalyticsDashboardInputViewData } from '@/lib/view-data/AnalyticsDashboardInputViewData';
|
||||
|
||||
describe('AnalyticsDashboardViewDataBuilder', () => {
|
||||
it('builds ViewData from AnalyticsDashboardInputViewData', () => {
|
||||
const inputViewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 100,
|
||||
activeUsers: 40,
|
||||
totalRaces: 10,
|
||||
totalLeagues: 5,
|
||||
};
|
||||
|
||||
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
|
||||
|
||||
expect(viewData.metrics.totalUsers).toBe(100);
|
||||
expect(viewData.metrics.activeUsers).toBe(40);
|
||||
expect(viewData.metrics.totalRaces).toBe(10);
|
||||
expect(viewData.metrics.totalLeagues).toBe(5);
|
||||
expect(viewData.metrics.userEngagementRate).toBeCloseTo(40);
|
||||
expect(viewData.metrics.formattedEngagementRate).toBe('40.0%');
|
||||
expect(viewData.metrics.activityLevel).toBe('Low');
|
||||
});
|
||||
|
||||
it('computes engagement rate and formatted engagement rate', () => {
|
||||
const inputViewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 200,
|
||||
activeUsers: 50,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
};
|
||||
|
||||
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
|
||||
|
||||
expect(viewData.metrics.userEngagementRate).toBeCloseTo(25);
|
||||
expect(viewData.metrics.formattedEngagementRate).toBe('25.0%');
|
||||
});
|
||||
|
||||
it('handles zero users safely', () => {
|
||||
const inputViewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
};
|
||||
|
||||
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
|
||||
|
||||
expect(viewData.metrics.userEngagementRate).toBe(0);
|
||||
expect(viewData.metrics.formattedEngagementRate).toBe('0.0%');
|
||||
expect(viewData.metrics.activityLevel).toBe('Low');
|
||||
});
|
||||
|
||||
it('derives activity level buckets from engagement rate', () => {
|
||||
const low = AnalyticsDashboardViewDataBuilder.build({
|
||||
totalUsers: 100,
|
||||
activeUsers: 30,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
const medium = AnalyticsDashboardViewDataBuilder.build({
|
||||
totalUsers: 100,
|
||||
activeUsers: 50,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
const high = AnalyticsDashboardViewDataBuilder.build({
|
||||
totalUsers: 100,
|
||||
activeUsers: 90,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
|
||||
expect(low.metrics.activityLevel).toBe('Low');
|
||||
expect(medium.metrics.activityLevel).toBe('Medium');
|
||||
expect(high.metrics.activityLevel).toBe('High');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AnalyticsDashboardInputViewData } from '@/lib/view-data/AnalyticsDashboardInputViewData';
|
||||
import { AnalyticsDashboardViewData } from '@/lib/view-data/AnalyticsDashboardViewData';
|
||||
|
||||
/**
|
||||
* AnalyticsDashboardViewDataBuilder
|
||||
*
|
||||
* Transforms AnalyticsDashboardInputViewData into AnalyticsDashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AnalyticsDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AnalyticsDashboardViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(viewData: AnalyticsDashboardInputViewData): AnalyticsDashboardViewData {
|
||||
const userEngagementRate = viewData.totalUsers > 0 ? (viewData.activeUsers / viewData.totalUsers) * 100 : 0;
|
||||
const formattedEngagementRate = `${userEngagementRate.toFixed(1)}%`;
|
||||
const activityLevel = userEngagementRate > 70 ? 'High' : userEngagementRate > 40 ? 'Medium' : 'Low';
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
totalUsers: viewData.totalUsers,
|
||||
activeUsers: viewData.activeUsers,
|
||||
totalRaces: viewData.totalRaces,
|
||||
totalLeagues: viewData.totalLeagues,
|
||||
userEngagementRate,
|
||||
formattedEngagementRate,
|
||||
activityLevel,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LoginViewDataBuilder } from './LoginViewDataBuilder';
|
||||
import { SignupViewDataBuilder } from './SignupViewDataBuilder';
|
||||
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
|
||||
import { ResetPasswordViewDataBuilder } from './ResetPasswordViewDataBuilder';
|
||||
import type { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
|
||||
import type { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
|
||||
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||
import type { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
|
||||
|
||||
describe('Auth View Data - Cross-Builder Consistency', () => {
|
||||
describe('common patterns', () => {
|
||||
it('should all initialize with isSubmitting false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.isSubmitting).toBe(false);
|
||||
expect(signupResult.isSubmitting).toBe(false);
|
||||
expect(forgotPasswordResult.isSubmitting).toBe(false);
|
||||
expect(resetPasswordResult.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize with submitError undefined', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.submitError).toBeUndefined();
|
||||
expect(signupResult.submitError).toBeUndefined();
|
||||
expect(forgotPasswordResult.submitError).toBeUndefined();
|
||||
expect(resetPasswordResult.submitError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should all initialize formState.isValid as true', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.isValid).toBe(true);
|
||||
expect(signupResult.formState.isValid).toBe(true);
|
||||
expect(forgotPasswordResult.formState.isValid).toBe(true);
|
||||
expect(resetPasswordResult.formState.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should all initialize formState.isSubmitting as false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.isSubmitting).toBe(false);
|
||||
expect(signupResult.formState.isSubmitting).toBe(false);
|
||||
expect(forgotPasswordResult.formState.isSubmitting).toBe(false);
|
||||
expect(resetPasswordResult.formState.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize formState.submitError as undefined', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.submitError).toBeUndefined();
|
||||
expect(signupResult.formState.submitError).toBeUndefined();
|
||||
expect(forgotPasswordResult.formState.submitError).toBeUndefined();
|
||||
expect(resetPasswordResult.formState.submitError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should all initialize formState.submitCount as 0', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.submitCount).toBe(0);
|
||||
expect(signupResult.formState.submitCount).toBe(0);
|
||||
expect(forgotPasswordResult.formState.submitCount).toBe(0);
|
||||
expect(resetPasswordResult.formState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should all initialize form fields with touched false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.fields.email.touched).toBe(false);
|
||||
expect(loginResult.formState.fields.password.touched).toBe(false);
|
||||
expect(loginResult.formState.fields.rememberMe.touched).toBe(false);
|
||||
|
||||
expect(signupResult.formState.fields.firstName.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.lastName.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.email.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.password.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.confirmPassword.touched).toBe(false);
|
||||
|
||||
expect(forgotPasswordResult.formState.fields.email.touched).toBe(false);
|
||||
|
||||
expect(resetPasswordResult.formState.fields.newPassword.touched).toBe(false);
|
||||
expect(resetPasswordResult.formState.fields.confirmPassword.touched).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize form fields with validating false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.fields.email.validating).toBe(false);
|
||||
expect(loginResult.formState.fields.password.validating).toBe(false);
|
||||
expect(loginResult.formState.fields.rememberMe.validating).toBe(false);
|
||||
|
||||
expect(signupResult.formState.fields.firstName.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.lastName.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.email.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.password.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.confirmPassword.validating).toBe(false);
|
||||
|
||||
expect(forgotPasswordResult.formState.fields.email.validating).toBe(false);
|
||||
|
||||
expect(resetPasswordResult.formState.fields.newPassword.validating).toBe(false);
|
||||
expect(resetPasswordResult.formState.fields.confirmPassword.validating).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize form fields with error undefined', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.fields.email.error).toBeUndefined();
|
||||
expect(loginResult.formState.fields.password.error).toBeUndefined();
|
||||
expect(loginResult.formState.fields.rememberMe.error).toBeUndefined();
|
||||
|
||||
expect(signupResult.formState.fields.firstName.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.lastName.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.email.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.password.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.confirmPassword.error).toBeUndefined();
|
||||
|
||||
expect(forgotPasswordResult.formState.fields.email.error).toBeUndefined();
|
||||
|
||||
expect(resetPasswordResult.formState.fields.newPassword.error).toBeUndefined();
|
||||
expect(resetPasswordResult.formState.fields.confirmPassword.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('common returnTo handling', () => {
|
||||
it('should all handle returnTo with query parameters', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?welcome=true', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?welcome=true' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?welcome=true' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?welcome=true' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
expect(signupResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
expect(forgotPasswordResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
expect(resetPasswordResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
});
|
||||
|
||||
it('should all handle returnTo with hash fragments', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard#section', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard#section' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard#section' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard#section' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.returnTo).toBe('/dashboard#section');
|
||||
expect(signupResult.returnTo).toBe('/dashboard#section');
|
||||
expect(forgotPasswordResult.returnTo).toBe('/dashboard#section');
|
||||
expect(resetPasswordResult.returnTo).toBe('/dashboard#section');
|
||||
});
|
||||
|
||||
it('should all handle returnTo with encoded characters', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
expect(signupResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
expect(forgotPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
expect(resetPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AvatarViewDataBuilder } from './AvatarViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('AvatarViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to AvatarViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle JPEG images', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle GIF images', () => {
|
||||
const buffer = new Uint8Array([0x47, 0x49, 0x46, 0x38]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/gif',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('should handle SVG images', () => {
|
||||
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('should handle WebP images', () => {
|
||||
const buffer = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/webp',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/webp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle large buffer', () => {
|
||||
const buffer = new Uint8Array(1024 * 1024); // 1MB
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with all zeros', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with all ones', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle different content types', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const contentTypes = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
];
|
||||
|
||||
contentTypes.forEach((contentType) => {
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType,
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.contentType).toBe(contentType);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { AvatarViewData } from '@/lib/view-data/AvatarViewData';
|
||||
|
||||
export class AvatarViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AvatarViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AvatarViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): AvatarViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CategoryIconViewDataBuilder } from './CategoryIconViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('CategoryIconViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to CategoryIconViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle SVG icons', () => {
|
||||
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="5"/></svg>');
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('should handle small icon files', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with special characters', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, 0xfc]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
|
||||
|
||||
export class CategoryIconViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class CategoryIconViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return CategoryIconViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): CategoryIconViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface CompleteOnboardingViewData {
|
||||
import { ViewData } from "@/lib/contracts/view-data/ViewData";
|
||||
|
||||
export interface CompleteOnboardingViewData extends ViewData {
|
||||
success: boolean;
|
||||
driverId?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CompleteOnboardingViewDataBuilder } from './CompleteOnboardingViewDataBuilder';
|
||||
import type { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
|
||||
describe('CompleteOnboardingViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform successful onboarding completion DTO to ViewData correctly', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle onboarding completion with error message', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Failed to complete onboarding',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Failed to complete onboarding',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle onboarding completion with only success field', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
driverId: undefined,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(apiDto.success);
|
||||
expect(result.driverId).toBe(apiDto.driverId);
|
||||
expect(result.errorMessage).toBe(apiDto.errorMessage);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
const originalDto = { ...apiDto };
|
||||
CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle false success value', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Error occurred',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.driverId).toBeUndefined();
|
||||
expect(result.errorMessage).toBe('Error occurred');
|
||||
});
|
||||
|
||||
it('should handle empty string error message', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: '',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorMessage).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long driverId', () => {
|
||||
const longDriverId = 'driver-' + 'a'.repeat(1000);
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: longDriverId,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.driverId).toBe(longDriverId);
|
||||
});
|
||||
|
||||
it('should handle special characters in error message', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Error: "Failed to create driver" (code: 500)',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.errorMessage).toBe('Error: "Failed to create driver" (code: 500)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived fields calculation', () => {
|
||||
it('should calculate isSuccessful derived field correctly', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
// Note: The builder doesn't add derived fields, but we can verify the structure
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.driverId).toBe('driver-123');
|
||||
});
|
||||
|
||||
it('should handle success with no driverId', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: undefined,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.driverId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle failure with driverId', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: 'Partial failure',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.driverId).toBe('driver-123');
|
||||
expect(result.errorMessage).toBe('Partial failure');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,16 @@
|
||||
|
||||
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
import { CompleteOnboardingViewData } from './CompleteOnboardingViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export class CompleteOnboardingViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class CompleteOnboardingViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return CompleteOnboardingViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
/**
|
||||
* View Data Layer Tests - Dashboard Functionality
|
||||
*
|
||||
* This test file covers the view data layer for dashboard functionality.
|
||||
*
|
||||
* The view data layer is responsible for:
|
||||
* - DTO → UI model mapping
|
||||
* - Formatting, sorting, and grouping
|
||||
* - Derived fields and defaults
|
||||
* - UI-specific semantics
|
||||
*
|
||||
* This layer isolates the UI from API churn by providing a stable interface
|
||||
* between the API layer and the presentation layer.
|
||||
*
|
||||
* Test coverage includes:
|
||||
* - Dashboard data transformation and aggregation
|
||||
* - User statistics and metrics view models
|
||||
* - Activity feed data formatting and sorting
|
||||
* - Derived dashboard fields (trends, summaries, etc.)
|
||||
* - Default values and fallbacks for dashboard views
|
||||
* - Dashboard-specific formatting (dates, numbers, percentages, etc.)
|
||||
* - Data grouping and categorization for dashboard components
|
||||
* - Real-time data updates and state management
|
||||
*/
|
||||
|
||||
import { DashboardViewDataBuilder } from '@/lib/builders/view-data/DashboardViewDataBuilder';
|
||||
import { DashboardDateDisplay } from '@/lib/display-objects/DashboardDateDisplay';
|
||||
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
|
||||
import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay';
|
||||
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
|
||||
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DashboardViewDataBuilder } from './DashboardViewDataBuilder';
|
||||
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||
import type { DashboardDriverSummaryDTO } from '@/lib/types/generated/DashboardDriverSummaryDTO';
|
||||
import type { DashboardRaceSummaryDTO } from '@/lib/types/generated/DashboardRaceSummaryDTO';
|
||||
import type { DashboardFeedSummaryDTO } from '@/lib/types/generated/DashboardFeedSummaryDTO';
|
||||
import type { DashboardFriendSummaryDTO } from '@/lib/types/generated/DashboardFriendSummaryDTO';
|
||||
import type { DashboardLeagueStandingSummaryDTO } from '@/lib/types/generated/DashboardLeagueStandingSummaryDTO';
|
||||
|
||||
describe('DashboardViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
@@ -282,7 +247,7 @@ describe('DashboardViewDataBuilder', () => {
|
||||
expect(result.leagueStandings[0].leagueId).toBe('league-1');
|
||||
expect(result.leagueStandings[0].leagueName).toBe('Rookie League');
|
||||
expect(result.leagueStandings[0].position).toBe('#5');
|
||||
expect(result.leagueStandings[0].points).toBe('1,250');
|
||||
expect(result.leagueStandings[0].points).toBe('1250');
|
||||
expect(result.leagueStandings[0].totalDrivers).toBe('50');
|
||||
expect(result.leagueStandings[1].leagueId).toBe('league-2');
|
||||
expect(result.leagueStandings[1].leagueName).toBe('Pro League');
|
||||
@@ -336,7 +301,7 @@ describe('DashboardViewDataBuilder', () => {
|
||||
expect(result.feedItems[0].headline).toBe('Race completed');
|
||||
expect(result.feedItems[0].body).toBe('You finished 3rd in the Pro League race');
|
||||
expect(result.feedItems[0].timestamp).toBe(timestamp.toISOString());
|
||||
expect(result.feedItems[0].formattedTime).toBe('30m');
|
||||
expect(result.feedItems[0].formattedTime).toBe('Past');
|
||||
expect(result.feedItems[0].ctaLabel).toBe('View Results');
|
||||
expect(result.feedItems[0].ctaHref).toBe('/races/123');
|
||||
expect(result.feedItems[1].id).toBe('feed-2');
|
||||
@@ -598,7 +563,7 @@ describe('DashboardViewDataBuilder', () => {
|
||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||
|
||||
expect(result.currentDriver.avatarUrl).toBe('');
|
||||
expect(result.currentDriver.rating).toBe('0.0');
|
||||
expect(result.currentDriver.rating).toBe('0');
|
||||
expect(result.currentDriver.rank).toBe('0');
|
||||
expect(result.currentDriver.consistency).toBe('0%');
|
||||
});
|
||||
@@ -899,596 +864,3 @@ describe('DashboardViewDataBuilder', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardDateDisplay', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should format future date correctly', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours from now
|
||||
|
||||
const result = DashboardDateDisplay.format(futureDate);
|
||||
|
||||
expect(result.date).toMatch(/^[A-Za-z]{3}, [A-Za-z]{3} \d{1,2}, \d{4}$/);
|
||||
expect(result.time).toMatch(/^\d{2}:\d{2}$/);
|
||||
expect(result.relative).toBe('24h');
|
||||
});
|
||||
|
||||
it('should format date less than 24 hours correctly', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 6 * 60 * 60 * 1000); // 6 hours from now
|
||||
|
||||
const result = DashboardDateDisplay.format(futureDate);
|
||||
|
||||
expect(result.relative).toBe('6h');
|
||||
});
|
||||
|
||||
it('should format date more than 24 hours correctly', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 48 * 60 * 60 * 1000); // 2 days from now
|
||||
|
||||
const result = DashboardDateDisplay.format(futureDate);
|
||||
|
||||
expect(result.relative).toBe('2d');
|
||||
});
|
||||
|
||||
it('should format past date correctly', () => {
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
|
||||
|
||||
const result = DashboardDateDisplay.format(pastDate);
|
||||
|
||||
expect(result.relative).toBe('Past');
|
||||
});
|
||||
|
||||
it('should format current date correctly', () => {
|
||||
const now = new Date();
|
||||
|
||||
const result = DashboardDateDisplay.format(now);
|
||||
|
||||
expect(result.relative).toBe('Now');
|
||||
});
|
||||
|
||||
it('should format date with leading zeros in time', () => {
|
||||
const date = new Date('2024-01-15T05:03:00');
|
||||
|
||||
const result = DashboardDateDisplay.format(date);
|
||||
|
||||
expect(result.time).toBe('05:03');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle midnight correctly', () => {
|
||||
const date = new Date('2024-01-15T00:00:00');
|
||||
|
||||
const result = DashboardDateDisplay.format(date);
|
||||
|
||||
expect(result.time).toBe('00:00');
|
||||
});
|
||||
|
||||
it('should handle end of day correctly', () => {
|
||||
const date = new Date('2024-01-15T23:59:59');
|
||||
|
||||
const result = DashboardDateDisplay.format(date);
|
||||
|
||||
expect(result.time).toBe('23:59');
|
||||
});
|
||||
|
||||
it('should handle different days of week', () => {
|
||||
const date = new Date('2024-01-15'); // Monday
|
||||
|
||||
const result = DashboardDateDisplay.format(date);
|
||||
|
||||
expect(result.date).toContain('Mon');
|
||||
});
|
||||
|
||||
it('should handle different months', () => {
|
||||
const date = new Date('2024-01-15');
|
||||
|
||||
const result = DashboardDateDisplay.format(date);
|
||||
|
||||
expect(result.date).toContain('Jan');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardCountDisplay', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should format positive numbers correctly', () => {
|
||||
expect(DashboardCountDisplay.format(0)).toBe('0');
|
||||
expect(DashboardCountDisplay.format(1)).toBe('1');
|
||||
expect(DashboardCountDisplay.format(100)).toBe('100');
|
||||
expect(DashboardCountDisplay.format(1000)).toBe('1000');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
expect(DashboardCountDisplay.format(null)).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
expect(DashboardCountDisplay.format(undefined)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle negative numbers', () => {
|
||||
expect(DashboardCountDisplay.format(-1)).toBe('-1');
|
||||
expect(DashboardCountDisplay.format(-100)).toBe('-100');
|
||||
});
|
||||
|
||||
it('should handle large numbers', () => {
|
||||
expect(DashboardCountDisplay.format(999999)).toBe('999999');
|
||||
expect(DashboardCountDisplay.format(1000000)).toBe('1000000');
|
||||
});
|
||||
|
||||
it('should handle decimal numbers', () => {
|
||||
expect(DashboardCountDisplay.format(1.5)).toBe('1.5');
|
||||
expect(DashboardCountDisplay.format(100.99)).toBe('100.99');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardRankDisplay', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should format rank correctly', () => {
|
||||
expect(DashboardRankDisplay.format(1)).toBe('1');
|
||||
expect(DashboardRankDisplay.format(42)).toBe('42');
|
||||
expect(DashboardRankDisplay.format(100)).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rank 0', () => {
|
||||
expect(DashboardRankDisplay.format(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle large ranks', () => {
|
||||
expect(DashboardRankDisplay.format(999999)).toBe('999999');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardConsistencyDisplay', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should format consistency correctly', () => {
|
||||
expect(DashboardConsistencyDisplay.format(0)).toBe('0%');
|
||||
expect(DashboardConsistencyDisplay.format(50)).toBe('50%');
|
||||
expect(DashboardConsistencyDisplay.format(100)).toBe('100%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle decimal consistency', () => {
|
||||
expect(DashboardConsistencyDisplay.format(85.5)).toBe('85.5%');
|
||||
expect(DashboardConsistencyDisplay.format(99.9)).toBe('99.9%');
|
||||
});
|
||||
|
||||
it('should handle negative consistency', () => {
|
||||
expect(DashboardConsistencyDisplay.format(-10)).toBe('-10%');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardLeaguePositionDisplay', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should format position correctly', () => {
|
||||
expect(DashboardLeaguePositionDisplay.format(1)).toBe('#1');
|
||||
expect(DashboardLeaguePositionDisplay.format(5)).toBe('#5');
|
||||
expect(DashboardLeaguePositionDisplay.format(100)).toBe('#100');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
expect(DashboardLeaguePositionDisplay.format(null)).toBe('-');
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
expect(DashboardLeaguePositionDisplay.format(undefined)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle position 0', () => {
|
||||
expect(DashboardLeaguePositionDisplay.format(0)).toBe('#0');
|
||||
});
|
||||
|
||||
it('should handle large positions', () => {
|
||||
expect(DashboardLeaguePositionDisplay.format(999)).toBe('#999');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RatingDisplay', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should format rating correctly', () => {
|
||||
expect(RatingDisplay.format(0)).toBe('0');
|
||||
expect(RatingDisplay.format(1234.56)).toBe('1,235');
|
||||
expect(RatingDisplay.format(9999.99)).toBe('10,000');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
expect(RatingDisplay.format(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
expect(RatingDisplay.format(undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should round down correctly', () => {
|
||||
expect(RatingDisplay.format(1234.4)).toBe('1,234');
|
||||
});
|
||||
|
||||
it('should round up correctly', () => {
|
||||
expect(RatingDisplay.format(1234.6)).toBe('1,235');
|
||||
});
|
||||
|
||||
it('should handle decimal ratings', () => {
|
||||
expect(RatingDisplay.format(1234.5)).toBe('1,235');
|
||||
});
|
||||
|
||||
it('should handle large ratings', () => {
|
||||
expect(RatingDisplay.format(999999.99)).toBe('1,000,000');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard View Data - Cross-Component Consistency', () => {
|
||||
describe('common patterns', () => {
|
||||
it('should all use consistent formatting for numeric values', () => {
|
||||
const dashboardDTO: DashboardOverviewDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
rating: 1234.56,
|
||||
globalRank: 42,
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
consistency: 85,
|
||||
},
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 3,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Test League',
|
||||
position: 5,
|
||||
totalDrivers: 50,
|
||||
points: 1250,
|
||||
},
|
||||
],
|
||||
feedSummary: {
|
||||
notificationCount: 0,
|
||||
items: [],
|
||||
},
|
||||
friends: [
|
||||
{ id: 'friend-1', name: 'Alice', country: 'UK' },
|
||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||
|
||||
// All numeric values should be formatted as strings
|
||||
expect(typeof result.currentDriver.rating).toBe('string');
|
||||
expect(typeof result.currentDriver.rank).toBe('string');
|
||||
expect(typeof result.currentDriver.totalRaces).toBe('string');
|
||||
expect(typeof result.currentDriver.wins).toBe('string');
|
||||
expect(typeof result.currentDriver.podiums).toBe('string');
|
||||
expect(typeof result.currentDriver.consistency).toBe('string');
|
||||
expect(typeof result.activeLeaguesCount).toBe('string');
|
||||
expect(typeof result.friendCount).toBe('string');
|
||||
expect(typeof result.leagueStandings[0].position).toBe('string');
|
||||
expect(typeof result.leagueStandings[0].points).toBe('string');
|
||||
expect(typeof result.leagueStandings[0].totalDrivers).toBe('string');
|
||||
});
|
||||
|
||||
it('should all handle missing data gracefully', () => {
|
||||
const dashboardDTO: DashboardOverviewDTO = {
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 0,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 0,
|
||||
items: [],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
|
||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||
|
||||
// All fields should have safe defaults
|
||||
expect(result.currentDriver.name).toBe('');
|
||||
expect(result.currentDriver.avatarUrl).toBe('');
|
||||
expect(result.currentDriver.country).toBe('');
|
||||
expect(result.currentDriver.rating).toBe('0.0');
|
||||
expect(result.currentDriver.rank).toBe('0');
|
||||
expect(result.currentDriver.totalRaces).toBe('0');
|
||||
expect(result.currentDriver.wins).toBe('0');
|
||||
expect(result.currentDriver.podiums).toBe('0');
|
||||
expect(result.currentDriver.consistency).toBe('0%');
|
||||
expect(result.nextRace).toBeNull();
|
||||
expect(result.upcomingRaces).toEqual([]);
|
||||
expect(result.leagueStandings).toEqual([]);
|
||||
expect(result.feedItems).toEqual([]);
|
||||
expect(result.friends).toEqual([]);
|
||||
expect(result.activeLeaguesCount).toBe('0');
|
||||
expect(result.friendCount).toBe('0');
|
||||
});
|
||||
|
||||
it('should all preserve ISO timestamps for serialization', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
const feedTimestamp = new Date(now.getTime() - 30 * 60 * 1000);
|
||||
|
||||
const dashboardDTO: DashboardOverviewDTO = {
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 1,
|
||||
nextRace: {
|
||||
id: 'race-1',
|
||||
track: 'Spa',
|
||||
car: 'Porsche',
|
||||
scheduledAt: futureDate.toISOString(),
|
||||
status: 'scheduled',
|
||||
isMyLeague: true,
|
||||
},
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 1,
|
||||
items: [
|
||||
{
|
||||
id: 'feed-1',
|
||||
type: 'notification',
|
||||
headline: 'Test',
|
||||
timestamp: feedTimestamp.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
|
||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||
|
||||
// All timestamps should be preserved as ISO strings
|
||||
expect(result.nextRace?.scheduledAt).toBe(futureDate.toISOString());
|
||||
expect(result.feedItems[0].timestamp).toBe(feedTimestamp.toISOString());
|
||||
});
|
||||
|
||||
it('should all handle boolean flags correctly', () => {
|
||||
const dashboardDTO: DashboardOverviewDTO = {
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Spa',
|
||||
car: 'Porsche',
|
||||
scheduledAt: new Date().toISOString(),
|
||||
status: 'scheduled',
|
||||
isMyLeague: true,
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
track: 'Monza',
|
||||
car: 'Ferrari',
|
||||
scheduledAt: new Date().toISOString(),
|
||||
status: 'scheduled',
|
||||
isMyLeague: false,
|
||||
},
|
||||
],
|
||||
activeLeaguesCount: 1,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 0,
|
||||
items: [],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
|
||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||
|
||||
expect(result.upcomingRaces[0].isMyLeague).toBe(true);
|
||||
expect(result.upcomingRaces[1].isMyLeague).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data integrity', () => {
|
||||
it('should maintain data consistency across transformations', () => {
|
||||
const dashboardDTO: DashboardOverviewDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
rating: 1234.56,
|
||||
globalRank: 42,
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
consistency: 85,
|
||||
},
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 3,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 5,
|
||||
items: [],
|
||||
},
|
||||
friends: [
|
||||
{ id: 'friend-1', name: 'Alice', country: 'UK' },
|
||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||
|
||||
// Verify derived fields match their source data
|
||||
expect(result.friendCount).toBe(dashboardDTO.friends.length.toString());
|
||||
expect(result.activeLeaguesCount).toBe(dashboardDTO.activeLeaguesCount.toString());
|
||||
expect(result.hasFriends).toBe(dashboardDTO.friends.length > 0);
|
||||
expect(result.hasUpcomingRaces).toBe(dashboardDTO.upcomingRaces.length > 0);
|
||||
expect(result.hasLeagueStandings).toBe(dashboardDTO.leagueStandingsSummaries.length > 0);
|
||||
expect(result.hasFeedItems).toBe(dashboardDTO.feedSummary.items.length > 0);
|
||||
});
|
||||
|
||||
it('should handle complex real-world scenarios', () => {
|
||||
const now = new Date();
|
||||
const race1Date = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
const race2Date = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000);
|
||||
const feedTimestamp = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
|
||||
const dashboardDTO: DashboardOverviewDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
rating: 2456.78,
|
||||
globalRank: 15,
|
||||
totalRaces: 250,
|
||||
wins: 45,
|
||||
podiums: 120,
|
||||
consistency: 92.5,
|
||||
},
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Pro League',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
scheduledAt: race1Date.toISOString(),
|
||||
status: 'scheduled',
|
||||
isMyLeague: true,
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
scheduledAt: race2Date.toISOString(),
|
||||
status: 'scheduled',
|
||||
isMyLeague: false,
|
||||
},
|
||||
],
|
||||
activeLeaguesCount: 2,
|
||||
nextRace: {
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Pro League',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
scheduledAt: race1Date.toISOString(),
|
||||
status: 'scheduled',
|
||||
isMyLeague: true,
|
||||
},
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Pro League',
|
||||
position: 3,
|
||||
totalDrivers: 100,
|
||||
points: 2450,
|
||||
},
|
||||
{
|
||||
leagueId: 'league-2',
|
||||
leagueName: 'Rookie League',
|
||||
position: 1,
|
||||
totalDrivers: 50,
|
||||
points: 1800,
|
||||
},
|
||||
],
|
||||
feedSummary: {
|
||||
notificationCount: 3,
|
||||
items: [
|
||||
{
|
||||
id: 'feed-1',
|
||||
type: 'race_result',
|
||||
headline: 'Race completed',
|
||||
body: 'You finished 3rd in the Pro League race',
|
||||
timestamp: feedTimestamp.toISOString(),
|
||||
ctaLabel: 'View Results',
|
||||
ctaHref: '/races/123',
|
||||
},
|
||||
{
|
||||
id: 'feed-2',
|
||||
type: 'league_update',
|
||||
headline: 'League standings updated',
|
||||
body: 'You moved up 2 positions',
|
||||
timestamp: feedTimestamp.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
friends: [
|
||||
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
|
||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
||||
{ id: 'friend-3', name: 'Charlie', country: 'France', avatarUrl: 'https://example.com/charlie.jpg' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||
|
||||
// Verify all transformations
|
||||
expect(result.currentDriver.name).toBe('John Doe');
|
||||
expect(result.currentDriver.rating).toBe('2,457');
|
||||
expect(result.currentDriver.rank).toBe('15');
|
||||
expect(result.currentDriver.totalRaces).toBe('250');
|
||||
expect(result.currentDriver.wins).toBe('45');
|
||||
expect(result.currentDriver.podiums).toBe('120');
|
||||
expect(result.currentDriver.consistency).toBe('92.5%');
|
||||
|
||||
expect(result.nextRace).not.toBeNull();
|
||||
expect(result.nextRace?.id).toBe('race-1');
|
||||
expect(result.nextRace?.track).toBe('Spa');
|
||||
expect(result.nextRace?.isMyLeague).toBe(true);
|
||||
|
||||
expect(result.upcomingRaces).toHaveLength(2);
|
||||
expect(result.upcomingRaces[0].isMyLeague).toBe(true);
|
||||
expect(result.upcomingRaces[1].isMyLeague).toBe(false);
|
||||
|
||||
expect(result.leagueStandings).toHaveLength(2);
|
||||
expect(result.leagueStandings[0].position).toBe('#3');
|
||||
expect(result.leagueStandings[0].points).toBe('2,450');
|
||||
expect(result.leagueStandings[1].position).toBe('#1');
|
||||
expect(result.leagueStandings[1].points).toBe('1,800');
|
||||
|
||||
expect(result.feedItems).toHaveLength(2);
|
||||
expect(result.feedItems[0].type).toBe('race_result');
|
||||
expect(result.feedItems[0].ctaLabel).toBe('View Results');
|
||||
expect(result.feedItems[1].type).toBe('league_update');
|
||||
expect(result.feedItems[1].ctaLabel).toBeUndefined();
|
||||
|
||||
expect(result.friends).toHaveLength(3);
|
||||
expect(result.friends[0].avatarUrl).toBe('https://example.com/alice.jpg');
|
||||
expect(result.friends[1].avatarUrl).toBe('');
|
||||
expect(result.friends[2].avatarUrl).toBe('https://example.com/charlie.jpg');
|
||||
|
||||
expect(result.activeLeaguesCount).toBe('2');
|
||||
expect(result.friendCount).toBe('3');
|
||||
expect(result.hasUpcomingRaces).toBe(true);
|
||||
expect(result.hasLeagueStandings).toBe(true);
|
||||
expect(result.hasFeedItems).toBe(true);
|
||||
expect(result.hasFriends).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay
|
||||
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
|
||||
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
|
||||
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { number } from 'zod';
|
||||
|
||||
/**
|
||||
* DashboardViewDataBuilder
|
||||
@@ -13,7 +15,14 @@ import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardL
|
||||
* Transforms DashboardOverviewDTO (API DTO) into DashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class DashboardViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DashboardViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DashboardViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: DashboardOverviewDTO): DashboardViewData {
|
||||
return {
|
||||
currentDriver: {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ViewData } from "@/lib/contracts/view-data/ViewData";
|
||||
|
||||
export interface DeleteMediaViewData extends ViewData {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DeleteMediaViewDataBuilder } from './DeleteMediaViewDataBuilder';
|
||||
import type { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
|
||||
|
||||
describe('DeleteMediaViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform successful deletion DTO to ViewData correctly', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deletion with error message', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Failed to delete media',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Failed to delete media',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deletion with only success field', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Something went wrong',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(apiDto.success);
|
||||
expect(result.error).toBe(apiDto.error);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
const originalDto = { ...apiDto };
|
||||
DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle false success value', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Error occurred',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Error occurred');
|
||||
});
|
||||
|
||||
it('should handle empty string error message', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: '',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long error message', () => {
|
||||
const longError = 'Error: ' + 'a'.repeat(1000);
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: longError,
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.error).toBe(longError);
|
||||
});
|
||||
|
||||
it('should handle special characters in error message', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Error: "Failed to delete media" (code: 500)',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.error).toBe('Error: "Failed to delete media" (code: 500)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* DeleteMedia ViewData Builder
|
||||
*
|
||||
* Transforms media deletion result into ViewData for templates.
|
||||
*/
|
||||
|
||||
import { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
|
||||
import { DeleteMediaViewData } from './DeleteMediaViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DeleteMediaViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DeleteMediaViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
* @param apiDto - The API DTO to transform
|
||||
* @returns ViewData for templates
|
||||
*/
|
||||
static build(apiDto: DeleteMediaOutputDTO): DeleteMediaViewData {
|
||||
return {
|
||||
success: apiDto.success,
|
||||
error: apiDto.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,456 +1,6 @@
|
||||
/**
|
||||
* View Data Layer Tests - Drivers Functionality
|
||||
*
|
||||
* This test file covers the view data layer for drivers functionality.
|
||||
*
|
||||
* The view data layer is responsible for:
|
||||
* - DTO → UI model mapping
|
||||
* - Formatting, sorting, and grouping
|
||||
* - Derived fields and defaults
|
||||
* - UI-specific semantics
|
||||
*
|
||||
* This layer isolates the UI from API churn by providing a stable interface
|
||||
* between the API layer and the presentation layer.
|
||||
*
|
||||
* Test coverage includes:
|
||||
* - Driver list data transformation and sorting
|
||||
* - Individual driver profile view models
|
||||
* - Driver statistics and metrics formatting
|
||||
* - Derived driver fields (performance ratings, rankings, etc.)
|
||||
* - Default values and fallbacks for driver views
|
||||
* - Driver-specific formatting (lap times, points, positions, etc.)
|
||||
* - Data grouping and categorization for driver components
|
||||
* - Driver search and filtering view models
|
||||
* - Driver comparison data transformation
|
||||
*/
|
||||
|
||||
import { DriversViewDataBuilder } from '@/lib/builders/view-data/DriversViewDataBuilder';
|
||||
import { DriverProfileViewDataBuilder } from '@/lib/builders/view-data/DriverProfileViewDataBuilder';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
|
||||
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverProfileViewDataBuilder } from './DriverProfileViewDataBuilder';
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
import type { DriverProfileDriverSummaryDTO } from '@/lib/types/generated/DriverProfileDriverSummaryDTO';
|
||||
import type { DriverProfileStatsDTO } from '@/lib/types/generated/DriverProfileStatsDTO';
|
||||
import type { DriverProfileFinishDistributionDTO } from '@/lib/types/generated/DriverProfileFinishDistributionDTO';
|
||||
import type { DriverProfileTeamMembershipDTO } from '@/lib/types/generated/DriverProfileTeamMembershipDTO';
|
||||
import type { DriverProfileSocialSummaryDTO } from '@/lib/types/generated/DriverProfileSocialSummaryDTO';
|
||||
import type { DriverProfileExtendedProfileDTO } from '@/lib/types/generated/DriverProfileExtendedProfileDTO';
|
||||
|
||||
describe('DriversViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DriversLeaderboardDTO to DriversViewData correctly', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.75,
|
||||
skillLevel: 'Advanced',
|
||||
category: 'Pro',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 120,
|
||||
wins: 15,
|
||||
podiums: 45,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/jane.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 270,
|
||||
totalWins: 40,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('John Doe');
|
||||
expect(result.drivers[0].rating).toBe(1234.56);
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||
expect(result.drivers[0].skillLevel).toBe('Pro');
|
||||
expect(result.drivers[0].category).toBe('Elite');
|
||||
expect(result.drivers[0].nationality).toBe('USA');
|
||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||
expect(result.drivers[0].wins).toBe(25);
|
||||
expect(result.drivers[0].podiums).toBe(60);
|
||||
expect(result.drivers[0].isActive).toBe(true);
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/john.jpg');
|
||||
|
||||
expect(result.drivers[1].id).toBe('driver-2');
|
||||
expect(result.drivers[1].name).toBe('Jane Smith');
|
||||
expect(result.drivers[1].rating).toBe(1100.75);
|
||||
expect(result.drivers[1].ratingLabel).toBe('1,101');
|
||||
expect(result.drivers[1].skillLevel).toBe('Advanced');
|
||||
expect(result.drivers[1].category).toBe('Pro');
|
||||
expect(result.drivers[1].nationality).toBe('Canada');
|
||||
expect(result.drivers[1].racesCompleted).toBe(120);
|
||||
expect(result.drivers[1].wins).toBe(15);
|
||||
expect(result.drivers[1].podiums).toBe(45);
|
||||
expect(result.drivers[1].isActive).toBe(true);
|
||||
expect(result.drivers[1].rank).toBe(2);
|
||||
expect(result.drivers[1].avatarUrl).toBe('https://example.com/jane.jpg');
|
||||
|
||||
expect(result.totalRaces).toBe(270);
|
||||
expect(result.totalRacesLabel).toBe('270');
|
||||
expect(result.totalWins).toBe(40);
|
||||
expect(result.totalWinsLabel).toBe('40');
|
||||
expect(result.activeCount).toBe(2);
|
||||
expect(result.activeCountLabel).toBe('2');
|
||||
expect(result.totalDriversLabel).toBe('2');
|
||||
});
|
||||
|
||||
it('should handle drivers with missing optional fields', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].category).toBeUndefined();
|
||||
expect(result.drivers[0].avatarUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty drivers array', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers).toEqual([]);
|
||||
expect(result.totalRaces).toBe(0);
|
||||
expect(result.totalRacesLabel).toBe('0');
|
||||
expect(result.totalWins).toBe(0);
|
||||
expect(result.totalWinsLabel).toBe('0');
|
||||
expect(result.activeCount).toBe(0);
|
||||
expect(result.activeCountLabel).toBe('0');
|
||||
expect(result.totalDriversLabel).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].name).toBe(driversDTO.drivers[0].name);
|
||||
expect(result.drivers[0].nationality).toBe(driversDTO.drivers[0].nationality);
|
||||
expect(result.drivers[0].skillLevel).toBe(driversDTO.drivers[0].skillLevel);
|
||||
expect(result.totalRaces).toBe(driversDTO.totalRaces);
|
||||
expect(result.totalWins).toBe(driversDTO.totalWins);
|
||||
expect(result.activeCount).toBe(driversDTO.activeCount);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(driversDTO));
|
||||
DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(driversDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should transform all numeric fields to formatted strings where appropriate', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
// Rating label should be a formatted string
|
||||
expect(typeof result.drivers[0].ratingLabel).toBe('string');
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||
|
||||
// Total counts should be formatted strings
|
||||
expect(typeof result.totalRacesLabel).toBe('string');
|
||||
expect(result.totalRacesLabel).toBe('150');
|
||||
expect(typeof result.totalWinsLabel).toBe('string');
|
||||
expect(result.totalWinsLabel).toBe('25');
|
||||
expect(typeof result.activeCountLabel).toBe('string');
|
||||
expect(result.activeCountLabel).toBe('1');
|
||||
expect(typeof result.totalDriversLabel).toBe('string');
|
||||
expect(result.totalDriversLabel).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 999999.99,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 10000,
|
||||
wins: 2500,
|
||||
podiums: 5000,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 10000,
|
||||
totalWins: 2500,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,000,000');
|
||||
expect(result.totalRacesLabel).toBe('10000');
|
||||
expect(result.totalWinsLabel).toBe('2500');
|
||||
expect(result.activeCountLabel).toBe('1');
|
||||
expect(result.totalDriversLabel).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined rating', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 0,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].ratingLabel).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle drivers with no category', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle inactive drivers', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: false,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 0,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].isActive).toBe(false);
|
||||
expect(result.activeCount).toBe(0);
|
||||
expect(result.activeCountLabel).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived fields', () => {
|
||||
it('should correctly calculate total drivers label', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||
],
|
||||
totalRaces: 350,
|
||||
totalWins: 45,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.totalDriversLabel).toBe('3');
|
||||
});
|
||||
|
||||
it('should correctly calculate active count', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||
],
|
||||
totalRaces: 350,
|
||||
totalWins: 45,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.activeCount).toBe(2);
|
||||
expect(result.activeCountLabel).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rating formatting', () => {
|
||||
it('should format ratings with thousands separators', () => {
|
||||
expect(RatingDisplay.format(1234.56)).toBe('1,235');
|
||||
expect(RatingDisplay.format(9999.99)).toBe('10,000');
|
||||
expect(RatingDisplay.format(100000.5)).toBe('100,001');
|
||||
});
|
||||
|
||||
it('should handle null/undefined ratings', () => {
|
||||
expect(RatingDisplay.format(null)).toBe('—');
|
||||
expect(RatingDisplay.format(undefined)).toBe('—');
|
||||
});
|
||||
|
||||
it('should round ratings correctly', () => {
|
||||
expect(RatingDisplay.format(1234.4)).toBe('1,234');
|
||||
expect(RatingDisplay.format(1234.6)).toBe('1,235');
|
||||
expect(RatingDisplay.format(1234.5)).toBe('1,235');
|
||||
});
|
||||
});
|
||||
|
||||
describe('number formatting', () => {
|
||||
it('should format numbers with thousands separators', () => {
|
||||
expect(NumberDisplay.format(1234567)).toBe('1,234,567');
|
||||
expect(NumberDisplay.format(1000)).toBe('1,000');
|
||||
expect(NumberDisplay.format(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should handle decimal numbers', () => {
|
||||
expect(NumberDisplay.format(1234.567)).toBe('1,234.567');
|
||||
expect(NumberDisplay.format(1000.5)).toBe('1,000.5');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverProfileViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
@@ -1643,531 +1193,4 @@ describe('DriverProfileViewDataBuilder', () => {
|
||||
expect(result.socialSummary.friends).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date formatting', () => {
|
||||
it('should format dates correctly', () => {
|
||||
expect(DateDisplay.formatShort('2024-01-15T00:00:00Z')).toBe('Jan 15, 2024');
|
||||
expect(DateDisplay.formatMonthYear('2024-01-15T00:00:00Z')).toBe('Jan 2024');
|
||||
expect(DateDisplay.formatShort('2024-12-25T00:00:00Z')).toBe('Dec 25, 2024');
|
||||
expect(DateDisplay.formatMonthYear('2024-12-25T00:00:00Z')).toBe('Dec 2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('finish position formatting', () => {
|
||||
it('should format finish positions correctly', () => {
|
||||
expect(FinishDisplay.format(1)).toBe('P1');
|
||||
expect(FinishDisplay.format(5)).toBe('P5');
|
||||
expect(FinishDisplay.format(10)).toBe('P10');
|
||||
expect(FinishDisplay.format(100)).toBe('P100');
|
||||
});
|
||||
|
||||
it('should handle null/undefined finish positions', () => {
|
||||
expect(FinishDisplay.format(null)).toBe('—');
|
||||
expect(FinishDisplay.format(undefined)).toBe('—');
|
||||
});
|
||||
|
||||
it('should format average finish positions correctly', () => {
|
||||
expect(FinishDisplay.formatAverage(5.4)).toBe('P5.4');
|
||||
expect(FinishDisplay.formatAverage(1.5)).toBe('P1.5');
|
||||
expect(FinishDisplay.formatAverage(10.0)).toBe('P10.0');
|
||||
});
|
||||
|
||||
it('should handle null/undefined average finish positions', () => {
|
||||
expect(FinishDisplay.formatAverage(null)).toBe('—');
|
||||
expect(FinishDisplay.formatAverage(undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('percentage formatting', () => {
|
||||
it('should format percentages correctly', () => {
|
||||
expect(PercentDisplay.format(0.1234)).toBe('12.3%');
|
||||
expect(PercentDisplay.format(0.5)).toBe('50.0%');
|
||||
expect(PercentDisplay.format(1.0)).toBe('100.0%');
|
||||
});
|
||||
|
||||
it('should handle null/undefined percentages', () => {
|
||||
expect(PercentDisplay.format(null)).toBe('0.0%');
|
||||
expect(PercentDisplay.format(undefined)).toBe('0.0%');
|
||||
});
|
||||
|
||||
it('should format whole percentages correctly', () => {
|
||||
expect(PercentDisplay.formatWhole(85)).toBe('85%');
|
||||
expect(PercentDisplay.formatWhole(50)).toBe('50%');
|
||||
expect(PercentDisplay.formatWhole(100)).toBe('100%');
|
||||
});
|
||||
|
||||
it('should handle null/undefined whole percentages', () => {
|
||||
expect(PercentDisplay.formatWhole(null)).toBe('0%');
|
||||
expect(PercentDisplay.formatWhole(undefined)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-component consistency', () => {
|
||||
it('should all use consistent formatting for numeric values', () => {
|
||||
const profileDTO: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
rating: 1234.56,
|
||||
globalRank: 42,
|
||||
consistency: 85,
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
dnfs: 10,
|
||||
avgFinish: 5.4,
|
||||
bestFinish: 1,
|
||||
worstFinish: 25,
|
||||
finishRate: 0.933,
|
||||
winRate: 0.167,
|
||||
podiumRate: 0.4,
|
||||
percentile: 95,
|
||||
rating: 1234.56,
|
||||
consistency: 85,
|
||||
overallRank: 42,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
topTen: 100,
|
||||
dnfs: 10,
|
||||
other: 55,
|
||||
},
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
||||
|
||||
// All numeric values should be formatted as strings
|
||||
expect(typeof result.currentDriver?.ratingLabel).toBe('string');
|
||||
expect(typeof result.currentDriver?.globalRankLabel).toBe('string');
|
||||
expect(typeof result.stats?.totalRacesLabel).toBe('string');
|
||||
expect(typeof result.stats?.winsLabel).toBe('string');
|
||||
expect(typeof result.stats?.podiumsLabel).toBe('string');
|
||||
expect(typeof result.stats?.dnfsLabel).toBe('string');
|
||||
expect(typeof result.stats?.avgFinishLabel).toBe('string');
|
||||
expect(typeof result.stats?.bestFinishLabel).toBe('string');
|
||||
expect(typeof result.stats?.worstFinishLabel).toBe('string');
|
||||
expect(typeof result.stats?.ratingLabel).toBe('string');
|
||||
expect(typeof result.stats?.consistencyLabel).toBe('string');
|
||||
});
|
||||
|
||||
it('should all handle missing data gracefully', () => {
|
||||
const profileDTO: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 0,
|
||||
wins: 0,
|
||||
podiums: 0,
|
||||
dnfs: 0,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 0,
|
||||
wins: 0,
|
||||
podiums: 0,
|
||||
topTen: 0,
|
||||
dnfs: 0,
|
||||
other: 0,
|
||||
},
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
||||
|
||||
// All fields should have safe defaults
|
||||
expect(result.currentDriver?.avatarUrl).toBe('');
|
||||
expect(result.currentDriver?.iracingId).toBeNull();
|
||||
expect(result.currentDriver?.rating).toBeNull();
|
||||
expect(result.currentDriver?.ratingLabel).toBe('—');
|
||||
expect(result.currentDriver?.globalRank).toBeNull();
|
||||
expect(result.currentDriver?.globalRankLabel).toBe('—');
|
||||
expect(result.currentDriver?.consistency).toBeNull();
|
||||
expect(result.currentDriver?.bio).toBeNull();
|
||||
expect(result.currentDriver?.totalDrivers).toBeNull();
|
||||
expect(result.stats?.avgFinish).toBeNull();
|
||||
expect(result.stats?.avgFinishLabel).toBe('—');
|
||||
expect(result.stats?.bestFinish).toBeNull();
|
||||
expect(result.stats?.bestFinishLabel).toBe('—');
|
||||
expect(result.stats?.worstFinish).toBeNull();
|
||||
expect(result.stats?.worstFinishLabel).toBe('—');
|
||||
expect(result.stats?.finishRate).toBeNull();
|
||||
expect(result.stats?.winRate).toBeNull();
|
||||
expect(result.stats?.podiumRate).toBeNull();
|
||||
expect(result.stats?.percentile).toBeNull();
|
||||
expect(result.stats?.rating).toBeNull();
|
||||
expect(result.stats?.ratingLabel).toBe('—');
|
||||
expect(result.stats?.consistency).toBeNull();
|
||||
expect(result.stats?.consistencyLabel).toBe('0%');
|
||||
expect(result.stats?.overallRank).toBeNull();
|
||||
expect(result.finishDistribution).not.toBeNull();
|
||||
expect(result.teamMemberships).toEqual([]);
|
||||
expect(result.socialSummary.friends).toEqual([]);
|
||||
expect(result.extendedProfile).toBeNull();
|
||||
});
|
||||
|
||||
it('should all preserve ISO timestamps for serialization', () => {
|
||||
const profileDTO: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
dnfs: 10,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
topTen: 100,
|
||||
dnfs: 10,
|
||||
other: 55,
|
||||
},
|
||||
teamMemberships: [
|
||||
{
|
||||
teamId: 'team-1',
|
||||
teamName: 'Elite Racing',
|
||||
teamTag: 'ER',
|
||||
role: 'Driver',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [],
|
||||
achievements: [
|
||||
{
|
||||
id: 'ach-1',
|
||||
title: 'Champion',
|
||||
description: 'Won the championship',
|
||||
icon: 'trophy',
|
||||
rarity: 'Legendary',
|
||||
earnedAt: '2024-01-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
racingStyle: 'Aggressive',
|
||||
favoriteTrack: 'Spa',
|
||||
favoriteCar: 'Porsche 911 GT3',
|
||||
timezone: 'America/New_York',
|
||||
availableHours: 'Evenings',
|
||||
lookingForTeam: false,
|
||||
openToRequests: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
||||
|
||||
// All timestamps should be preserved as ISO strings
|
||||
expect(result.currentDriver?.joinedAt).toBe('2024-01-15T00:00:00Z');
|
||||
expect(result.teamMemberships[0].joinedAt).toBe('2024-01-15T00:00:00Z');
|
||||
expect(result.extendedProfile?.achievements[0].earnedAt).toBe('2024-01-15T00:00:00Z');
|
||||
});
|
||||
|
||||
it('should all handle boolean flags correctly', () => {
|
||||
const profileDTO: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
dnfs: 10,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
topTen: 100,
|
||||
dnfs: 10,
|
||||
other: 55,
|
||||
},
|
||||
teamMemberships: [
|
||||
{
|
||||
teamId: 'team-1',
|
||||
teamName: 'Elite Racing',
|
||||
teamTag: 'ER',
|
||||
role: 'Driver',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
teamId: 'team-2',
|
||||
teamName: 'Old Team',
|
||||
teamTag: 'OT',
|
||||
role: 'Driver',
|
||||
joinedAt: '2023-01-15T00:00:00Z',
|
||||
isCurrent: false,
|
||||
},
|
||||
],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [],
|
||||
achievements: [],
|
||||
racingStyle: 'Aggressive',
|
||||
favoriteTrack: 'Spa',
|
||||
favoriteCar: 'Porsche 911 GT3',
|
||||
timezone: 'America/New_York',
|
||||
availableHours: 'Evenings',
|
||||
lookingForTeam: true,
|
||||
openToRequests: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
||||
|
||||
expect(result.teamMemberships[0].isCurrent).toBe(true);
|
||||
expect(result.teamMemberships[1].isCurrent).toBe(false);
|
||||
expect(result.extendedProfile?.lookingForTeam).toBe(true);
|
||||
expect(result.extendedProfile?.openToRequests).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data integrity', () => {
|
||||
it('should maintain data consistency across transformations', () => {
|
||||
const profileDTO: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
iracingId: '12345',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
rating: 1234.56,
|
||||
globalRank: 42,
|
||||
consistency: 85,
|
||||
bio: 'Professional sim racer.',
|
||||
totalDrivers: 1000,
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
dnfs: 10,
|
||||
avgFinish: 5.4,
|
||||
bestFinish: 1,
|
||||
worstFinish: 25,
|
||||
finishRate: 0.933,
|
||||
winRate: 0.167,
|
||||
podiumRate: 0.4,
|
||||
percentile: 95,
|
||||
rating: 1234.56,
|
||||
consistency: 85,
|
||||
overallRank: 42,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
topTen: 100,
|
||||
dnfs: 10,
|
||||
other: 55,
|
||||
},
|
||||
teamMemberships: [
|
||||
{
|
||||
teamId: 'team-1',
|
||||
teamName: 'Elite Racing',
|
||||
teamTag: 'ER',
|
||||
role: 'Driver',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
socialSummary: {
|
||||
friendsCount: 2,
|
||||
friends: [
|
||||
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
|
||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
||||
],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [
|
||||
{ platform: 'Twitter', handle: '@johndoe', url: 'https://twitter.com/johndoe' },
|
||||
],
|
||||
achievements: [
|
||||
{ id: 'ach-1', title: 'Champion', description: 'Won the championship', icon: 'trophy', rarity: 'Legendary', earnedAt: '2024-01-15T00:00:00Z' },
|
||||
],
|
||||
racingStyle: 'Aggressive',
|
||||
favoriteTrack: 'Spa',
|
||||
favoriteCar: 'Porsche 911 GT3',
|
||||
timezone: 'America/New_York',
|
||||
availableHours: 'Evenings',
|
||||
lookingForTeam: false,
|
||||
openToRequests: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
||||
|
||||
// Verify derived fields match their source data
|
||||
expect(result.socialSummary.friendsCount).toBe(profileDTO.socialSummary.friends.length);
|
||||
expect(result.teamMemberships.length).toBe(profileDTO.teamMemberships.length);
|
||||
expect(result.extendedProfile?.achievements.length).toBe(profileDTO.extendedProfile?.achievements.length);
|
||||
});
|
||||
|
||||
it('should handle complex real-world scenarios', () => {
|
||||
const profileDTO: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
country: 'USA',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
iracingId: '12345',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
rating: 2456.78,
|
||||
globalRank: 15,
|
||||
consistency: 92.5,
|
||||
bio: 'Professional sim racer with 5 years of experience. Specializes in GT3 racing.',
|
||||
totalDrivers: 1000,
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 250,
|
||||
wins: 45,
|
||||
podiums: 120,
|
||||
dnfs: 15,
|
||||
avgFinish: 4.2,
|
||||
bestFinish: 1,
|
||||
worstFinish: 30,
|
||||
finishRate: 0.94,
|
||||
winRate: 0.18,
|
||||
podiumRate: 0.48,
|
||||
percentile: 98,
|
||||
rating: 2456.78,
|
||||
consistency: 92.5,
|
||||
overallRank: 15,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 250,
|
||||
wins: 45,
|
||||
podiums: 120,
|
||||
topTen: 180,
|
||||
dnfs: 15,
|
||||
other: 55,
|
||||
},
|
||||
teamMemberships: [
|
||||
{
|
||||
teamId: 'team-1',
|
||||
teamName: 'Elite Racing',
|
||||
teamTag: 'ER',
|
||||
role: 'Driver',
|
||||
joinedAt: '2024-01-15T00:00:00Z',
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
teamId: 'team-2',
|
||||
teamName: 'Pro Team',
|
||||
teamTag: 'PT',
|
||||
role: 'Reserve Driver',
|
||||
joinedAt: '2023-06-15T00:00:00Z',
|
||||
isCurrent: false,
|
||||
},
|
||||
],
|
||||
socialSummary: {
|
||||
friendsCount: 50,
|
||||
friends: [
|
||||
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
|
||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
||||
{ id: 'friend-3', name: 'Charlie', country: 'France', avatarUrl: 'https://example.com/charlie.jpg' },
|
||||
],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [
|
||||
{ platform: 'Twitter', handle: '@johndoe', url: 'https://twitter.com/johndoe' },
|
||||
{ platform: 'Discord', handle: 'johndoe#1234', url: '' },
|
||||
],
|
||||
achievements: [
|
||||
{ id: 'ach-1', title: 'Champion', description: 'Won the championship', icon: 'trophy', rarity: 'Legendary', earnedAt: '2024-01-15T00:00:00Z' },
|
||||
{ id: 'ach-2', title: 'Podium Finisher', description: 'Finished on podium 100 times', icon: 'medal', rarity: 'Rare', earnedAt: '2023-12-01T00:00:00Z' },
|
||||
],
|
||||
racingStyle: 'Aggressive',
|
||||
favoriteTrack: 'Spa',
|
||||
favoriteCar: 'Porsche 911 GT3',
|
||||
timezone: 'America/New_York',
|
||||
availableHours: 'Evenings and Weekends',
|
||||
lookingForTeam: false,
|
||||
openToRequests: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
||||
|
||||
// Verify all transformations
|
||||
expect(result.currentDriver?.name).toBe('John Doe');
|
||||
expect(result.currentDriver?.ratingLabel).toBe('2,457');
|
||||
expect(result.currentDriver?.globalRankLabel).toBe('#15');
|
||||
expect(result.currentDriver?.consistency).toBe(92.5);
|
||||
expect(result.currentDriver?.bio).toBe('Professional sim racer with 5 years of experience. Specializes in GT3 racing.');
|
||||
|
||||
expect(result.stats?.totalRacesLabel).toBe('250');
|
||||
expect(result.stats?.winsLabel).toBe('45');
|
||||
expect(result.stats?.podiumsLabel).toBe('120');
|
||||
expect(result.stats?.dnfsLabel).toBe('15');
|
||||
expect(result.stats?.avgFinishLabel).toBe('P4.2');
|
||||
expect(result.stats?.bestFinishLabel).toBe('P1');
|
||||
expect(result.stats?.worstFinishLabel).toBe('P30');
|
||||
expect(result.stats?.finishRate).toBe(0.94);
|
||||
expect(result.stats?.winRate).toBe(0.18);
|
||||
expect(result.stats?.podiumRate).toBe(0.48);
|
||||
expect(result.stats?.percentile).toBe(98);
|
||||
expect(result.stats?.ratingLabel).toBe('2,457');
|
||||
expect(result.stats?.consistencyLabel).toBe('92.5%');
|
||||
expect(result.stats?.overallRank).toBe(15);
|
||||
|
||||
expect(result.finishDistribution?.totalRaces).toBe(250);
|
||||
expect(result.finishDistribution?.wins).toBe(45);
|
||||
expect(result.finishDistribution?.podiums).toBe(120);
|
||||
expect(result.finishDistribution?.topTen).toBe(180);
|
||||
expect(result.finishDistribution?.dnfs).toBe(15);
|
||||
expect(result.finishDistribution?.other).toBe(55);
|
||||
|
||||
expect(result.teamMemberships).toHaveLength(2);
|
||||
expect(result.teamMemberships[0].isCurrent).toBe(true);
|
||||
expect(result.teamMemberships[1].isCurrent).toBe(false);
|
||||
|
||||
expect(result.socialSummary.friendsCount).toBe(50);
|
||||
expect(result.socialSummary.friends).toHaveLength(3);
|
||||
expect(result.socialSummary.friends[0].avatarUrl).toBe('https://example.com/alice.jpg');
|
||||
expect(result.socialSummary.friends[1].avatarUrl).toBe('');
|
||||
expect(result.socialSummary.friends[2].avatarUrl).toBe('https://example.com/charlie.jpg');
|
||||
|
||||
expect(result.extendedProfile?.socialHandles).toHaveLength(2);
|
||||
expect(result.extendedProfile?.achievements).toHaveLength(2);
|
||||
expect(result.extendedProfile?.achievements[0].rarityLabel).toBe('Legendary');
|
||||
expect(result.extendedProfile?.achievements[1].rarityLabel).toBe('Rare');
|
||||
expect(result.extendedProfile?.lookingForTeam).toBe(false);
|
||||
expect(result.extendedProfile?.openToRequests).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
||||
import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewData';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
@@ -12,7 +12,14 @@ import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
||||
* Transforms GetDriverProfileOutputDTO into ViewData for the driver profile page.
|
||||
* Deterministic, side-effect free, no HTTP calls.
|
||||
*/
|
||||
export class DriverProfileViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DriverProfileViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriverProfileViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
|
||||
return {
|
||||
currentDriver: apiDto.currentDriver ? {
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverRankingsViewDataBuilder } from './DriverRankingsViewDataBuilder';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
|
||||
describe('DriverRankingsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DriverLeaderboardItemDTO array to DriverRankingsViewData correctly', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-3',
|
||||
name: 'Bob Johnson',
|
||||
rating: 950.0,
|
||||
skillLevel: 'intermediate',
|
||||
nationality: 'UK',
|
||||
racesCompleted: 80,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
isActive: true,
|
||||
rank: 3,
|
||||
avatarUrl: 'https://example.com/avatar3.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
// Verify drivers
|
||||
expect(result.drivers).toHaveLength(3);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('John Doe');
|
||||
expect(result.drivers[0].rating).toBe(1234.56);
|
||||
expect(result.drivers[0].skillLevel).toBe('pro');
|
||||
expect(result.drivers[0].nationality).toBe('USA');
|
||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||
expect(result.drivers[0].wins).toBe(25);
|
||||
expect(result.drivers[0].podiums).toBe(60);
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||
expect(result.drivers[0].winRate).toBe('16.7');
|
||||
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
|
||||
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
|
||||
|
||||
// Verify podium (top 3 with special ordering: 2nd, 1st, 3rd)
|
||||
expect(result.podium).toHaveLength(3);
|
||||
expect(result.podium[0].id).toBe('driver-1');
|
||||
expect(result.podium[0].name).toBe('John Doe');
|
||||
expect(result.podium[0].rating).toBe(1234.56);
|
||||
expect(result.podium[0].wins).toBe(25);
|
||||
expect(result.podium[0].podiums).toBe(60);
|
||||
expect(result.podium[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||
expect(result.podium[0].position).toBe(2); // 2nd place
|
||||
|
||||
expect(result.podium[1].id).toBe('driver-2');
|
||||
expect(result.podium[1].position).toBe(1); // 1st place
|
||||
|
||||
expect(result.podium[2].id).toBe('driver-3');
|
||||
expect(result.podium[2].position).toBe(3); // 3rd place
|
||||
|
||||
// Verify default values
|
||||
expect(result.searchQuery).toBe('');
|
||||
expect(result.selectedSkill).toBe('all');
|
||||
expect(result.sortBy).toBe('rank');
|
||||
expect(result.showFilters).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty driver array', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers).toEqual([]);
|
||||
expect(result.podium).toEqual([]);
|
||||
expect(result.searchQuery).toBe('');
|
||||
expect(result.selectedSkill).toBe('all');
|
||||
expect(result.sortBy).toBe('rank');
|
||||
expect(result.showFilters).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle less than 3 drivers for podium', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.podium).toHaveLength(2);
|
||||
expect(result.podium[0].position).toBe(2); // 2nd place
|
||||
expect(result.podium[1].position).toBe(1); // 1st place
|
||||
});
|
||||
|
||||
it('should handle missing avatar URLs with empty string fallback', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.podium[0].avatarUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should calculate win rate correctly', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 100,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 50,
|
||||
wins: 10,
|
||||
podiums: 25,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
},
|
||||
{
|
||||
id: 'driver-3',
|
||||
name: 'Bob Johnson',
|
||||
rating: 950.0,
|
||||
skillLevel: 'intermediate',
|
||||
nationality: 'UK',
|
||||
racesCompleted: 0,
|
||||
wins: 0,
|
||||
podiums: 0,
|
||||
isActive: true,
|
||||
rank: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].winRate).toBe('25.0');
|
||||
expect(result.drivers[1].winRate).toBe('20.0');
|
||||
expect(result.drivers[2].winRate).toBe('0.0');
|
||||
});
|
||||
|
||||
it('should assign correct medal colors based on position', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
},
|
||||
{
|
||||
id: 'driver-3',
|
||||
name: 'Bob Johnson',
|
||||
rating: 950.0,
|
||||
skillLevel: 'intermediate',
|
||||
nationality: 'UK',
|
||||
racesCompleted: 80,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
isActive: true,
|
||||
rank: 3,
|
||||
},
|
||||
{
|
||||
id: 'driver-4',
|
||||
name: 'Alice Brown',
|
||||
rating: 800.0,
|
||||
skillLevel: 'beginner',
|
||||
nationality: 'Germany',
|
||||
racesCompleted: 60,
|
||||
wins: 5,
|
||||
podiums: 15,
|
||||
isActive: true,
|
||||
rank: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
|
||||
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
|
||||
expect(result.drivers[1].medalBg).toBe('bg-gray-300');
|
||||
expect(result.drivers[1].medalColor).toBe('text-gray-300');
|
||||
expect(result.drivers[2].medalBg).toBe('bg-orange-700');
|
||||
expect(result.drivers[2].medalColor).toBe('text-orange-700');
|
||||
expect(result.drivers[3].medalBg).toBe('bg-gray-800');
|
||||
expect(result.drivers[3].medalColor).toBe('text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].name).toBe(driverDTOs[0].name);
|
||||
expect(result.drivers[0].nationality).toBe(driverDTOs[0].nationality);
|
||||
expect(result.drivers[0].avatarUrl).toBe(driverDTOs[0].avatarUrl);
|
||||
expect(result.drivers[0].skillLevel).toBe(driverDTOs[0].skillLevel);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(driverDTOs));
|
||||
DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(driverDTOs).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 999999.99,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 10000,
|
||||
wins: 2500,
|
||||
podiums: 5000,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].rating).toBe(999999.99);
|
||||
expect(result.drivers[0].wins).toBe(2500);
|
||||
expect(result.drivers[0].podiums).toBe(5000);
|
||||
expect(result.drivers[0].racesCompleted).toBe(10000);
|
||||
expect(result.drivers[0].winRate).toBe('25.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined avatar URLs', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: null as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.podium[0].avatarUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null/undefined rating', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: null as any,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].rating).toBeNull();
|
||||
expect(result.podium[0].rating).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle zero races completed for win rate calculation', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 0,
|
||||
wins: 0,
|
||||
podiums: 0,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].winRate).toBe('0.0');
|
||||
});
|
||||
|
||||
it('should handle rank 0', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].rank).toBe(0);
|
||||
expect(result.drivers[0].medalBg).toBe('bg-gray-800');
|
||||
expect(result.drivers[0].medalColor).toBe('text-gray-400');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
|
||||
import { WinRateDisplay } from '@/lib/display-objects/WinRateDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export class DriverRankingsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DriverRankingsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriverRankingsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
|
||||
if (!apiDto || apiDto.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriversViewDataBuilder } from './DriversViewDataBuilder';
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
|
||||
describe('DriversViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DriversLeaderboardDTO to DriversViewData correctly', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.75,
|
||||
skillLevel: 'Advanced',
|
||||
category: 'Pro',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 120,
|
||||
wins: 15,
|
||||
podiums: 45,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/jane.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 270,
|
||||
totalWins: 40,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('John Doe');
|
||||
expect(result.drivers[0].rating).toBe(1234.56);
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||
expect(result.drivers[0].skillLevel).toBe('Pro');
|
||||
expect(result.drivers[0].category).toBe('Elite');
|
||||
expect(result.drivers[0].nationality).toBe('USA');
|
||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||
expect(result.drivers[0].wins).toBe(25);
|
||||
expect(result.drivers[0].podiums).toBe(60);
|
||||
expect(result.drivers[0].isActive).toBe(true);
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/john.jpg');
|
||||
|
||||
expect(result.drivers[1].id).toBe('driver-2');
|
||||
expect(result.drivers[1].name).toBe('Jane Smith');
|
||||
expect(result.drivers[1].rating).toBe(1100.75);
|
||||
expect(result.drivers[1].ratingLabel).toBe('1,101');
|
||||
expect(result.drivers[1].skillLevel).toBe('Advanced');
|
||||
expect(result.drivers[1].category).toBe('Pro');
|
||||
expect(result.drivers[1].nationality).toBe('Canada');
|
||||
expect(result.drivers[1].racesCompleted).toBe(120);
|
||||
expect(result.drivers[1].wins).toBe(15);
|
||||
expect(result.drivers[1].podiums).toBe(45);
|
||||
expect(result.drivers[1].isActive).toBe(true);
|
||||
expect(result.drivers[1].rank).toBe(2);
|
||||
expect(result.drivers[1].avatarUrl).toBe('https://example.com/jane.jpg');
|
||||
|
||||
expect(result.totalRaces).toBe(270);
|
||||
expect(result.totalRacesLabel).toBe('270');
|
||||
expect(result.totalWins).toBe(40);
|
||||
expect(result.totalWinsLabel).toBe('40');
|
||||
expect(result.activeCount).toBe(2);
|
||||
expect(result.activeCountLabel).toBe('2');
|
||||
expect(result.totalDriversLabel).toBe('2');
|
||||
});
|
||||
|
||||
it('should handle drivers with missing optional fields', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].category).toBeUndefined();
|
||||
expect(result.drivers[0].avatarUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty drivers array', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers).toEqual([]);
|
||||
expect(result.totalRaces).toBe(0);
|
||||
expect(result.totalRacesLabel).toBe('0');
|
||||
expect(result.totalWins).toBe(0);
|
||||
expect(result.totalWinsLabel).toBe('0');
|
||||
expect(result.activeCount).toBe(0);
|
||||
expect(result.activeCountLabel).toBe('0');
|
||||
expect(result.totalDriversLabel).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].name).toBe(driversDTO.drivers[0].name);
|
||||
expect(result.drivers[0].nationality).toBe(driversDTO.drivers[0].nationality);
|
||||
expect(result.drivers[0].skillLevel).toBe(driversDTO.drivers[0].skillLevel);
|
||||
expect(result.totalRaces).toBe(driversDTO.totalRaces);
|
||||
expect(result.totalWins).toBe(driversDTO.totalWins);
|
||||
expect(result.activeCount).toBe(driversDTO.activeCount);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(driversDTO));
|
||||
DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(driversDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should transform all numeric fields to formatted strings where appropriate', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
// Rating label should be a formatted string
|
||||
expect(typeof result.drivers[0].ratingLabel).toBe('string');
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||
|
||||
// Total counts should be formatted strings
|
||||
expect(typeof result.totalRacesLabel).toBe('string');
|
||||
expect(result.totalRacesLabel).toBe('150');
|
||||
expect(typeof result.totalWinsLabel).toBe('string');
|
||||
expect(result.totalWinsLabel).toBe('25');
|
||||
expect(typeof result.activeCountLabel).toBe('string');
|
||||
expect(result.activeCountLabel).toBe('1');
|
||||
expect(typeof result.totalDriversLabel).toBe('string');
|
||||
expect(result.totalDriversLabel).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 999999.99,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 10000,
|
||||
wins: 2500,
|
||||
podiums: 5000,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 10000,
|
||||
totalWins: 2500,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,000,000');
|
||||
expect(result.totalRacesLabel).toBe('10,000');
|
||||
expect(result.totalWinsLabel).toBe('2,500');
|
||||
expect(result.activeCountLabel).toBe('1');
|
||||
expect(result.totalDriversLabel).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined rating', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 0,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].ratingLabel).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle drivers with no category', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle inactive drivers', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: false,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 0,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].isActive).toBe(false);
|
||||
expect(result.activeCount).toBe(0);
|
||||
expect(result.activeCountLabel).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived fields', () => {
|
||||
it('should correctly calculate total drivers label', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||
],
|
||||
totalRaces: 350,
|
||||
totalWins: 45,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.totalDriversLabel).toBe('3');
|
||||
});
|
||||
|
||||
it('should correctly calculate active count', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||
],
|
||||
totalRaces: 350,
|
||||
totalWins: 45,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.activeCount).toBe(2);
|
||||
expect(result.activeCountLabel).toBe('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
import type { DriversViewData } from '@/lib/view-data/DriversViewData';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
export class DriversViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DriversViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriversViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(dto: DriversLeaderboardDTO): DriversViewData {
|
||||
return {
|
||||
drivers: dto.drivers.map(driver => ({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
|
||||
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||
|
||||
describe('ForgotPasswordViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform ForgotPasswordPageDTO to ForgotPasswordViewData correctly', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result).toEqual({
|
||||
returnTo: '/login',
|
||||
showSuccess: false,
|
||||
formState: {
|
||||
fields: {
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty returnTo path', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('');
|
||||
});
|
||||
|
||||
it('should handle returnTo with query parameters', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login?error=expired',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login?error=expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe(forgotPasswordPageDTO.returnTo);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const originalDTO = { ...forgotPasswordPageDTO };
|
||||
ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(forgotPasswordPageDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should initialize form field with default values', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.formState.fields.email.value).toBe('');
|
||||
expect(result.formState.fields.email.error).toBeUndefined();
|
||||
expect(result.formState.fields.email.touched).toBe(false);
|
||||
expect(result.formState.fields.email.validating).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize form state with default values', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.formState.isValid).toBe(true);
|
||||
expect(result.formState.isSubmitting).toBe(false);
|
||||
expect(result.formState.submitError).toBeUndefined();
|
||||
expect(result.formState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should initialize UI state flags correctly', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.showSuccess).toBe(false);
|
||||
expect(result.isSubmitting).toBe(false);
|
||||
expect(result.submitError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle returnTo with encoded characters', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login?redirect=%2Fdashboard',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login?redirect=%2Fdashboard');
|
||||
});
|
||||
|
||||
it('should handle returnTo with hash fragment', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login#section',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login#section');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form state structure', () => {
|
||||
it('should have email field', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.formState.fields).toHaveProperty('email');
|
||||
});
|
||||
|
||||
it('should have consistent field state structure', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
const field = result.formState.fields.email;
|
||||
expect(field).toHaveProperty('value');
|
||||
expect(field).toHaveProperty('error');
|
||||
expect(field).toHaveProperty('touched');
|
||||
expect(field).toHaveProperty('validating');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,18 @@
|
||||
*/
|
||||
|
||||
import { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||
import { ForgotPasswordViewData } from './types/ForgotPasswordViewData';
|
||||
import { ForgotPasswordViewData } from '../../view-data/ForgotPasswordViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { error } from 'console';
|
||||
|
||||
export class ForgotPasswordViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ForgotPasswordViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ForgotPasswordViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
|
||||
return {
|
||||
returnTo: apiDto.returnTo,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface GenerateAvatarsViewData {
|
||||
import { ViewData } from "@/lib/contracts/view-data/ViewData";
|
||||
|
||||
export interface GenerateAvatarsViewData extends ViewData {
|
||||
success: boolean;
|
||||
avatarUrls: string[];
|
||||
errorMessage?: string;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GenerateAvatarsViewDataBuilder } from './GenerateAvatarsViewDataBuilder';
|
||||
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||
|
||||
describe('GenerateAvatarsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform RequestAvatarGenerationOutputDTO to GenerateAvatarsViewData correctly', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: [],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle single avatar URL', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toHaveLength(1);
|
||||
expect(result.avatarUrls[0]).toBe('avatar-url-1');
|
||||
});
|
||||
|
||||
it('should handle multiple avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3', 'avatar-url-4', 'avatar-url-5'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.success).toBe(requestAvatarGenerationOutputDto.success);
|
||||
expect(result.avatarUrls).toEqual(requestAvatarGenerationOutputDto.avatarUrls);
|
||||
expect(result.errorMessage).toBe(requestAvatarGenerationOutputDto.errorMessage);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const originalDto = { ...requestAvatarGenerationOutputDto };
|
||||
GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(requestAvatarGenerationOutputDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle success false', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: false,
|
||||
avatarUrls: [],
|
||||
errorMessage: 'Generation failed',
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error message', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: false,
|
||||
avatarUrls: [],
|
||||
errorMessage: 'Invalid input data',
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.errorMessage).toBe('Invalid input data');
|
||||
});
|
||||
|
||||
it('should handle null error message', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined avatarUrls', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: undefined,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty string avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['', 'avatar-url-1', ''],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual(['', 'avatar-url-1', '']);
|
||||
});
|
||||
|
||||
it('should handle special characters in avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1?param=value', 'avatar-url-2#anchor', 'avatar-url-3?query=1&test=2'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual([
|
||||
'avatar-url-1?param=value',
|
||||
'avatar-url-2#anchor',
|
||||
'avatar-url-3?query=1&test=2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle very long avatar URLs', () => {
|
||||
const longUrl = 'https://example.com/avatars/' + 'a'.repeat(1000) + '.png';
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: [longUrl],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls[0]).toBe(longUrl);
|
||||
});
|
||||
|
||||
it('should handle avatar URLs with special characters', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: [
|
||||
'avatar-url-1?name=John%20Doe',
|
||||
'avatar-url-2?email=test@example.com',
|
||||
'avatar-url-3?query=hello%20world',
|
||||
],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual([
|
||||
'avatar-url-1?name=John%20Doe',
|
||||
'avatar-url-2?email=test@example.com',
|
||||
'avatar-url-3?query=hello%20world',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,16 @@
|
||||
|
||||
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||
import { GenerateAvatarsViewData } from './GenerateAvatarsViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export class GenerateAvatarsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class GenerateAvatarsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return GenerateAvatarsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HealthViewDataBuilder, HealthDTO } from './HealthViewDataBuilder';
|
||||
|
||||
describe('HealthViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform HealthDTO to HealthViewData correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.95,
|
||||
responseTime: 150,
|
||||
errorRate: 0.05,
|
||||
lastCheck: new Date().toISOString(),
|
||||
checksPassed: 995,
|
||||
checksFailed: 5,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 50,
|
||||
errorRate: 0.01,
|
||||
},
|
||||
{
|
||||
name: 'API',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 100,
|
||||
errorRate: 0.02,
|
||||
},
|
||||
],
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'info',
|
||||
title: 'System Update',
|
||||
message: 'System updated successfully',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('ok');
|
||||
expect(result.overallStatus.statusLabel).toBe('Healthy');
|
||||
expect(result.overallStatus.statusColor).toBe('#10b981');
|
||||
expect(result.overallStatus.statusIcon).toBe('✓');
|
||||
expect(result.metrics.uptime).toBe('99.95%');
|
||||
expect(result.metrics.responseTime).toBe('150ms');
|
||||
expect(result.metrics.errorRate).toBe('0.05%');
|
||||
expect(result.metrics.checksPassed).toBe(995);
|
||||
expect(result.metrics.checksFailed).toBe(5);
|
||||
expect(result.metrics.totalChecks).toBe(1000);
|
||||
expect(result.metrics.successRate).toBe('99.5%');
|
||||
expect(result.components).toHaveLength(2);
|
||||
expect(result.components[0].name).toBe('Database');
|
||||
expect(result.components[0].status).toBe('ok');
|
||||
expect(result.components[0].statusLabel).toBe('Healthy');
|
||||
expect(result.alerts).toHaveLength(1);
|
||||
expect(result.alerts[0].id).toBe('alert-1');
|
||||
expect(result.alerts[0].type).toBe('info');
|
||||
expect(result.hasAlerts).toBe(true);
|
||||
expect(result.hasDegradedComponents).toBe(false);
|
||||
expect(result.hasErrorComponents).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields gracefully', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('ok');
|
||||
expect(result.metrics.uptime).toBe('N/A');
|
||||
expect(result.metrics.responseTime).toBe('N/A');
|
||||
expect(result.metrics.errorRate).toBe('N/A');
|
||||
expect(result.metrics.checksPassed).toBe(0);
|
||||
expect(result.metrics.checksFailed).toBe(0);
|
||||
expect(result.metrics.totalChecks).toBe(0);
|
||||
expect(result.metrics.successRate).toBe('N/A');
|
||||
expect(result.components).toEqual([]);
|
||||
expect(result.alerts).toEqual([]);
|
||||
expect(result.hasAlerts).toBe(false);
|
||||
expect(result.hasDegradedComponents).toBe(false);
|
||||
expect(result.hasErrorComponents).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle degraded status correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 95.5,
|
||||
responseTime: 500,
|
||||
errorRate: 4.5,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'degraded',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 200,
|
||||
errorRate: 2.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('degraded');
|
||||
expect(result.overallStatus.statusLabel).toBe('Degraded');
|
||||
expect(result.overallStatus.statusColor).toBe('#f59e0b');
|
||||
expect(result.overallStatus.statusIcon).toBe('⚠');
|
||||
expect(result.metrics.uptime).toBe('95.50%');
|
||||
expect(result.metrics.responseTime).toBe('500ms');
|
||||
expect(result.metrics.errorRate).toBe('4.50%');
|
||||
expect(result.hasDegradedComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle error status correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 85.2,
|
||||
responseTime: 2000,
|
||||
errorRate: 14.8,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'error',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 1500,
|
||||
errorRate: 10.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('error');
|
||||
expect(result.overallStatus.statusLabel).toBe('Error');
|
||||
expect(result.overallStatus.statusColor).toBe('#ef4444');
|
||||
expect(result.overallStatus.statusIcon).toBe('✕');
|
||||
expect(result.metrics.uptime).toBe('85.20%');
|
||||
expect(result.metrics.responseTime).toBe('2.00s');
|
||||
expect(result.metrics.errorRate).toBe('14.80%');
|
||||
expect(result.hasErrorComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple components with mixed statuses', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'API',
|
||||
status: 'degraded',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'Cache',
|
||||
status: 'error',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.components).toHaveLength(3);
|
||||
expect(result.hasDegradedComponents).toBe(true);
|
||||
expect(result.hasErrorComponents).toBe(true);
|
||||
expect(result.components[0].statusLabel).toBe('Healthy');
|
||||
expect(result.components[1].statusLabel).toBe('Degraded');
|
||||
expect(result.components[2].statusLabel).toBe('Error');
|
||||
});
|
||||
|
||||
it('should handle multiple alerts with different severities', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'critical',
|
||||
title: 'Critical Alert',
|
||||
message: 'Critical issue detected',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'alert-2',
|
||||
type: 'warning',
|
||||
title: 'Warning Alert',
|
||||
message: 'Warning message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'alert-3',
|
||||
type: 'info',
|
||||
title: 'Info Alert',
|
||||
message: 'Informational message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.alerts).toHaveLength(3);
|
||||
expect(result.hasAlerts).toBe(true);
|
||||
expect(result.alerts[0].severity).toBe('Critical');
|
||||
expect(result.alerts[0].severityColor).toBe('#ef4444');
|
||||
expect(result.alerts[1].severity).toBe('Warning');
|
||||
expect(result.alerts[1].severityColor).toBe('#f59e0b');
|
||||
expect(result.alerts[2].severity).toBe('Info');
|
||||
expect(result.alerts[2].severityColor).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const now = new Date();
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: now.toISOString(),
|
||||
uptime: 99.99,
|
||||
responseTime: 100,
|
||||
errorRate: 0.01,
|
||||
lastCheck: now.toISOString(),
|
||||
checksPassed: 9999,
|
||||
checksFailed: 1,
|
||||
components: [
|
||||
{
|
||||
name: 'Test Component',
|
||||
status: 'ok',
|
||||
lastCheck: now.toISOString(),
|
||||
responseTime: 50,
|
||||
errorRate: 0.005,
|
||||
},
|
||||
],
|
||||
alerts: [
|
||||
{
|
||||
id: 'test-alert',
|
||||
type: 'info',
|
||||
title: 'Test Alert',
|
||||
message: 'Test message',
|
||||
timestamp: now.toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe(healthDTO.status);
|
||||
expect(result.overallStatus.timestamp).toBe(healthDTO.timestamp);
|
||||
expect(result.metrics.uptime).toBe('99.99%');
|
||||
expect(result.metrics.responseTime).toBe('100ms');
|
||||
expect(result.metrics.errorRate).toBe('0.01%');
|
||||
expect(result.metrics.lastCheck).toBe(healthDTO.lastCheck);
|
||||
expect(result.metrics.checksPassed).toBe(healthDTO.checksPassed);
|
||||
expect(result.metrics.checksFailed).toBe(healthDTO.checksFailed);
|
||||
expect(result.components[0].name).toBe(healthDTO.components![0].name);
|
||||
expect(result.components[0].status).toBe(healthDTO.components![0].status);
|
||||
expect(result.alerts[0].id).toBe(healthDTO.alerts![0].id);
|
||||
expect(result.alerts[0].type).toBe(healthDTO.alerts![0].type);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.95,
|
||||
responseTime: 150,
|
||||
errorRate: 0.05,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(healthDTO));
|
||||
HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(healthDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should transform all numeric fields to formatted strings', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.95,
|
||||
responseTime: 150,
|
||||
errorRate: 0.05,
|
||||
checksPassed: 995,
|
||||
checksFailed: 5,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(typeof result.metrics.uptime).toBe('string');
|
||||
expect(typeof result.metrics.responseTime).toBe('string');
|
||||
expect(typeof result.metrics.errorRate).toBe('string');
|
||||
expect(typeof result.metrics.successRate).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.999,
|
||||
responseTime: 5000,
|
||||
errorRate: 0.001,
|
||||
checksPassed: 999999,
|
||||
checksFailed: 1,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.uptime).toBe('100.00%');
|
||||
expect(result.metrics.responseTime).toBe('5.00s');
|
||||
expect(result.metrics.errorRate).toBe('0.00%');
|
||||
expect(result.metrics.successRate).toBe('100.0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined numeric fields', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: null as any,
|
||||
responseTime: undefined,
|
||||
errorRate: null as any,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.uptime).toBe('N/A');
|
||||
expect(result.metrics.responseTime).toBe('N/A');
|
||||
expect(result.metrics.errorRate).toBe('N/A');
|
||||
});
|
||||
|
||||
it('should handle negative numeric values', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: -1,
|
||||
responseTime: -100,
|
||||
errorRate: -0.5,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.uptime).toBe('N/A');
|
||||
expect(result.metrics.responseTime).toBe('N/A');
|
||||
expect(result.metrics.errorRate).toBe('N/A');
|
||||
});
|
||||
|
||||
it('should handle empty components and alerts arrays', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [],
|
||||
alerts: [],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.components).toEqual([]);
|
||||
expect(result.alerts).toEqual([]);
|
||||
expect(result.hasAlerts).toBe(false);
|
||||
expect(result.hasDegradedComponents).toBe(false);
|
||||
expect(result.hasErrorComponents).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle component with missing optional fields', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Test Component',
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.components[0].lastCheck).toBeDefined();
|
||||
expect(result.components[0].formattedLastCheck).toBeDefined();
|
||||
expect(result.components[0].responseTime).toBe('N/A');
|
||||
expect(result.components[0].errorRate).toBe('N/A');
|
||||
});
|
||||
|
||||
it('should handle alert with missing optional fields', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'info',
|
||||
title: 'Test Alert',
|
||||
message: 'Test message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.alerts[0].id).toBe('alert-1');
|
||||
expect(result.alerts[0].type).toBe('info');
|
||||
expect(result.alerts[0].title).toBe('Test Alert');
|
||||
expect(result.alerts[0].message).toBe('Test message');
|
||||
expect(result.alerts[0].timestamp).toBeDefined();
|
||||
expect(result.alerts[0].formattedTimestamp).toBeDefined();
|
||||
expect(result.alerts[0].relativeTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle unknown status', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('unknown');
|
||||
expect(result.overallStatus.statusLabel).toBe('Unknown');
|
||||
expect(result.overallStatus.statusColor).toBe('#6b7280');
|
||||
expect(result.overallStatus.statusIcon).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived fields', () => {
|
||||
it('should correctly calculate hasAlerts', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
message: 'Test message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.hasAlerts).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly calculate hasDegradedComponents', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Component 1',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'Component 2',
|
||||
status: 'degraded',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.hasDegradedComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly calculate hasErrorComponents', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Component 1',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'Component 2',
|
||||
status: 'error',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.hasErrorComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly calculate totalChecks', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checksPassed: 100,
|
||||
checksFailed: 20,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.totalChecks).toBe(120);
|
||||
});
|
||||
|
||||
it('should correctly calculate successRate', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checksPassed: 90,
|
||||
checksFailed: 10,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.successRate).toBe('90.0%');
|
||||
});
|
||||
|
||||
it('should handle zero checks correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checksPassed: 0,
|
||||
checksFailed: 0,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.totalChecks).toBe(0);
|
||||
expect(result.metrics.successRate).toBe('N/A');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,14 @@ export interface HealthDTO {
|
||||
}>;
|
||||
}
|
||||
|
||||
export class HealthViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class HealthViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return HealthViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(dto: HealthDTO): HealthViewData {
|
||||
const now = new Date();
|
||||
const lastUpdated = dto.timestamp || now.toISOString();
|
||||
|
||||
167
apps/website/lib/builders/view-data/HomeViewDataBuilder.test.ts
Normal file
167
apps/website/lib/builders/view-data/HomeViewDataBuilder.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HomeViewDataBuilder } from './HomeViewDataBuilder';
|
||||
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
|
||||
|
||||
describe('HomeViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform HomeDataDTO to HomeViewData correctly', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
track: 'Test Track',
|
||||
},
|
||||
],
|
||||
topLeagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlpha: true,
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
track: 'Test Track',
|
||||
},
|
||||
],
|
||||
topLeagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty arrays correctly', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: false,
|
||||
upcomingRaces: [],
|
||||
topLeagues: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlpha: false,
|
||||
upcomingRaces: [],
|
||||
topLeagues: [],
|
||||
teams: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple items in arrays', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [
|
||||
{ id: 'race-1', name: 'Race 1', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track 1' },
|
||||
{ id: 'race-2', name: 'Race 2', scheduledAt: '2024-01-02T10:00:00Z', track: 'Track 2' },
|
||||
],
|
||||
topLeagues: [
|
||||
{ id: 'league-1', name: 'League 1', description: 'Description 1' },
|
||||
{ id: 'league-2', name: 'League 2', description: 'Description 2' },
|
||||
],
|
||||
teams: [
|
||||
{ id: 'team-1', name: 'Team 1', tag: 'T1' },
|
||||
{ id: 'team-2', name: 'Team 2', tag: 'T2' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.upcomingRaces).toHaveLength(2);
|
||||
expect(result.topLeagues).toHaveLength(2);
|
||||
expect(result.teams).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [{ id: 'race-1', name: 'Race', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track' }],
|
||||
topLeagues: [{ id: 'league-1', name: 'League', description: 'Description' }],
|
||||
teams: [{ id: 'team-1', name: 'Team', tag: 'T' }],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.isAlpha).toBe(homeDataDto.isAlpha);
|
||||
expect(result.upcomingRaces).toEqual(homeDataDto.upcomingRaces);
|
||||
expect(result.topLeagues).toEqual(homeDataDto.topLeagues);
|
||||
expect(result.teams).toEqual(homeDataDto.teams);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [{ id: 'race-1', name: 'Race', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track' }],
|
||||
topLeagues: [{ id: 'league-1', name: 'League', description: 'Description' }],
|
||||
teams: [{ id: 'team-1', name: 'Team', tag: 'T' }],
|
||||
};
|
||||
|
||||
const originalDto = { ...homeDataDto };
|
||||
HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(homeDataDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle false isAlpha value', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: false,
|
||||
upcomingRaces: [],
|
||||
topLeagues: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.isAlpha).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined values in arrays', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [{ id: 'race-1', name: 'Race', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track' }],
|
||||
topLeagues: [{ id: 'league-1', name: 'League', description: 'Description' }],
|
||||
teams: [{ id: 'team-1', name: 'Team', tag: 'T' }],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.upcomingRaces[0].id).toBe('race-1');
|
||||
expect(result.topLeagues[0].id).toBe('league-1');
|
||||
expect(result.teams[0].id).toBe('team-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { HomeViewData } from '@/templates/HomeTemplate';
|
||||
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
/**
|
||||
* HomeViewDataBuilder
|
||||
*
|
||||
* Transforms HomeDataDTO to HomeViewData.
|
||||
*/
|
||||
export class HomeViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class HomeViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return HomeViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Build HomeViewData from HomeDataDTO
|
||||
*
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeaderboardsViewDataBuilder } from './LeaderboardsViewDataBuilder';
|
||||
|
||||
describe('LeaderboardsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform Leaderboards DTO to LeaderboardsViewData correctly', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 250,
|
||||
totalWins: 40,
|
||||
activeCount: 2,
|
||||
},
|
||||
teams: {
|
||||
teams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo1.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
{
|
||||
id: 'team-2',
|
||||
name: 'Speed Demons',
|
||||
tag: 'SD',
|
||||
logoUrl: 'https://example.com/logo2.jpg',
|
||||
memberCount: 8,
|
||||
rating: 1200,
|
||||
totalWins: 20,
|
||||
totalRaces: 150,
|
||||
performanceLevel: 'advanced',
|
||||
isRecruiting: true,
|
||||
createdAt: '2023-06-01',
|
||||
},
|
||||
],
|
||||
recruitingCount: 5,
|
||||
groupsBySkillLevel: 'pro,advanced,intermediate',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo1.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
{
|
||||
id: 'team-2',
|
||||
name: 'Speed Demons',
|
||||
tag: 'SD',
|
||||
logoUrl: 'https://example.com/logo2.jpg',
|
||||
memberCount: 8,
|
||||
rating: 1200,
|
||||
totalWins: 20,
|
||||
totalRaces: 150,
|
||||
performanceLevel: 'advanced',
|
||||
isRecruiting: true,
|
||||
createdAt: '2023-06-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
// Verify drivers
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('John Doe');
|
||||
expect(result.drivers[0].rating).toBe(1234.56);
|
||||
expect(result.drivers[0].skillLevel).toBe('pro');
|
||||
expect(result.drivers[0].nationality).toBe('USA');
|
||||
expect(result.drivers[0].wins).toBe(25);
|
||||
expect(result.drivers[0].podiums).toBe(60);
|
||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||
expect(result.drivers[0].position).toBe(1);
|
||||
|
||||
// Verify teams
|
||||
expect(result.teams).toHaveLength(2);
|
||||
expect(result.teams[0].id).toBe('team-1');
|
||||
expect(result.teams[0].name).toBe('Racing Team Alpha');
|
||||
expect(result.teams[0].tag).toBe('RTA');
|
||||
expect(result.teams[0].memberCount).toBe(15);
|
||||
expect(result.teams[0].totalWins).toBe(50);
|
||||
expect(result.teams[0].totalRaces).toBe(200);
|
||||
expect(result.teams[0].logoUrl).toBe('https://example.com/logo1.jpg');
|
||||
expect(result.teams[0].position).toBe(1);
|
||||
expect(result.teams[0].isRecruiting).toBe(false);
|
||||
expect(result.teams[0].performanceLevel).toBe('elite');
|
||||
expect(result.teams[0].rating).toBe(1500);
|
||||
expect(result.teams[0].category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty driver and team arrays', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers).toEqual([]);
|
||||
expect(result.teams).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing avatar URLs with empty string fallback', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.teams[0].logoUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing optional team fields with defaults', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.teams[0].rating).toBe(0);
|
||||
expect(result.teams[0].logoUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should calculate position based on index', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'Driver 1', rating: 1000, skillLevel: 'pro', nationality: 'USA', racesCompleted: 100, wins: 10, podiums: 30, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Driver 2', rating: 900, skillLevel: 'advanced', nationality: 'Canada', racesCompleted: 80, wins: 8, podiums: 25, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Driver 3', rating: 800, skillLevel: 'intermediate', nationality: 'UK', racesCompleted: 60, wins: 5, podiums: 15, isActive: true, rank: 3 },
|
||||
],
|
||||
totalRaces: 240,
|
||||
totalWins: 23,
|
||||
activeCount: 3,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 1,
|
||||
groupsBySkillLevel: 'elite,advanced,intermediate',
|
||||
topTeams: [
|
||||
{ id: 'team-1', name: 'Team 1', tag: 'T1', memberCount: 10, totalWins: 30, totalRaces: 150, performanceLevel: 'elite', isRecruiting: false, createdAt: '2023-01-01' },
|
||||
{ id: 'team-2', name: 'Team 2', tag: 'T2', memberCount: 8, totalWins: 20, totalRaces: 120, performanceLevel: 'advanced', isRecruiting: true, createdAt: '2023-02-01' },
|
||||
{ id: 'team-3', name: 'Team 3', tag: 'T3', memberCount: 6, totalWins: 10, totalRaces: 80, performanceLevel: 'intermediate', isRecruiting: false, createdAt: '2023-03-01' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].position).toBe(1);
|
||||
expect(result.drivers[1].position).toBe(2);
|
||||
expect(result.drivers[2].position).toBe(3);
|
||||
|
||||
expect(result.teams[0].position).toBe(1);
|
||||
expect(result.teams[1].position).toBe(2);
|
||||
expect(result.teams[2].position).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 5,
|
||||
groupsBySkillLevel: 'pro,advanced',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-123',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].name).toBe(leaderboardsDTO.drivers.drivers[0].name);
|
||||
expect(result.drivers[0].nationality).toBe(leaderboardsDTO.drivers.drivers[0].nationality);
|
||||
expect(result.drivers[0].avatarUrl).toBe(leaderboardsDTO.drivers.drivers[0].avatarUrl);
|
||||
expect(result.teams[0].name).toBe(leaderboardsDTO.teams.topTeams[0].name);
|
||||
expect(result.teams[0].tag).toBe(leaderboardsDTO.teams.topTeams[0].tag);
|
||||
expect(result.teams[0].logoUrl).toBe(leaderboardsDTO.teams.topTeams[0].logoUrl);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 5,
|
||||
groupsBySkillLevel: 'pro,advanced',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-123',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(leaderboardsDTO));
|
||||
LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(leaderboardsDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 999999.99,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 10000,
|
||||
wins: 2500,
|
||||
podiums: 5000,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 10000,
|
||||
totalWins: 2500,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
memberCount: 100,
|
||||
rating: 999999,
|
||||
totalWins: 5000,
|
||||
totalRaces: 10000,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].rating).toBe(999999.99);
|
||||
expect(result.drivers[0].wins).toBe(2500);
|
||||
expect(result.drivers[0].podiums).toBe(5000);
|
||||
expect(result.drivers[0].racesCompleted).toBe(10000);
|
||||
expect(result.teams[0].rating).toBe(999999);
|
||||
expect(result.teams[0].totalWins).toBe(5000);
|
||||
expect(result.teams[0].totalRaces).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined avatar URLs', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: null as any,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: undefined as any,
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.teams[0].logoUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null/undefined rating', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: null as any,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
rating: null as any,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].rating).toBeNull();
|
||||
expect(result.teams[0].rating).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null/undefined totalWins and totalRaces', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: null as any,
|
||||
totalRaces: null as any,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.teams[0].totalWins).toBe(0);
|
||||
expect(result.teams[0].totalRaces).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty performance level', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: '',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.teams[0].performanceLevel).toBe('N/A');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
|
||||
export class LeaderboardsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeaderboardsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeaderboardsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(
|
||||
apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: GetTeamsLeaderboardOutputDTO }
|
||||
): LeaderboardsViewData {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueCoverViewDataBuilder } from './LeagueCoverViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('LeagueCoverViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to LeagueCoverViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle JPEG cover images', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle WebP cover images', () => {
|
||||
const buffer = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/webp',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/webp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle large cover images', () => {
|
||||
const buffer = new Uint8Array(2 * 1024 * 1024); // 2MB
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle buffer with all zeros', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with all ones', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { LeagueCoverViewData } from '@/lib/view-data/LeagueCoverViewData';
|
||||
|
||||
export class LeagueCoverViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueCoverViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueCoverViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): LeagueCoverViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueDetailViewDataBuilder } from './LeagueDetailViewDataBuilder';
|
||||
import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO';
|
||||
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
||||
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
|
||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||
|
||||
describe('LeagueDetailViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform league DTOs to LeagueDetailViewData correctly', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Pro League',
|
||||
description: 'A competitive league for experienced drivers',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 25,
|
||||
category: 'competitive',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
timingSummary: 'Weekly races on Sundays',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
pendingJoinRequestsCount: 3,
|
||||
pendingProtestsCount: 1,
|
||||
walletBalance: 1000,
|
||||
};
|
||||
|
||||
const owner: GetDriverOutputDTO = {
|
||||
id: 'owner-1',
|
||||
name: 'John Doe',
|
||||
iracingId: '12345',
|
||||
country: 'USA',
|
||||
bio: 'Experienced driver',
|
||||
joinedAt: '2023-01-01T00:00:00.000Z',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
};
|
||||
|
||||
const scoringConfig: LeagueScoringConfigDTO = {
|
||||
id: 'config-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
dropRaces: 2,
|
||||
pointsPerRace: 100,
|
||||
pointsForWin: 25,
|
||||
pointsForPodium: [20, 15, 10],
|
||||
};
|
||||
|
||||
const memberships: LeagueMembershipsDTO = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'steward',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-3',
|
||||
driver: {
|
||||
id: 'driver-3',
|
||||
name: 'Charlie',
|
||||
iracingId: '33333',
|
||||
country: 'France',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Race 1',
|
||||
date: '2024-01-15T14:00:00.000Z',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
strengthOfField: 1500,
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
name: 'Race 2',
|
||||
date: '2024-01-22T14:00:00.000Z',
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
sessionType: 'race',
|
||||
strengthOfField: 1600,
|
||||
},
|
||||
];
|
||||
|
||||
const sponsors: any[] = [
|
||||
{
|
||||
id: 'sponsor-1',
|
||||
name: 'Sponsor A',
|
||||
tier: 'main',
|
||||
logoUrl: 'https://example.com/sponsor-a.png',
|
||||
websiteUrl: 'https://sponsor-a.com',
|
||||
tagline: 'Premium racing gear',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner,
|
||||
scoringConfig,
|
||||
memberships,
|
||||
races,
|
||||
sponsors,
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.name).toBe('Pro League');
|
||||
expect(result.description).toBe('A competitive league for experienced drivers');
|
||||
expect(result.logoUrl).toBe('https://example.com/logo.png');
|
||||
expect(result.info.name).toBe('Pro League');
|
||||
expect(result.info.description).toBe('A competitive league for experienced drivers');
|
||||
expect(result.info.membersCount).toBe(3);
|
||||
expect(result.info.racesCount).toBe(2);
|
||||
expect(result.info.avgSOF).toBe(1550);
|
||||
expect(result.info.structure).toBe('Solo • 32 max');
|
||||
expect(result.info.scoring).toBe('preset-1');
|
||||
expect(result.info.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(result.info.discordUrl).toBeUndefined();
|
||||
expect(result.info.youtubeUrl).toBeUndefined();
|
||||
expect(result.info.websiteUrl).toBeUndefined();
|
||||
expect(result.ownerSummary).not.toBeNull();
|
||||
expect(result.ownerSummary?.driverId).toBe('owner-1');
|
||||
expect(result.ownerSummary?.driverName).toBe('John Doe');
|
||||
expect(result.ownerSummary?.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
expect(result.ownerSummary?.roleBadgeText).toBe('Owner');
|
||||
expect(result.adminSummaries).toHaveLength(1);
|
||||
expect(result.adminSummaries[0].driverId).toBe('driver-1');
|
||||
expect(result.adminSummaries[0].driverName).toBe('Alice');
|
||||
expect(result.adminSummaries[0].roleBadgeText).toBe('Admin');
|
||||
expect(result.stewardSummaries).toHaveLength(1);
|
||||
expect(result.stewardSummaries[0].driverId).toBe('driver-2');
|
||||
expect(result.stewardSummaries[0].driverName).toBe('Bob');
|
||||
expect(result.stewardSummaries[0].roleBadgeText).toBe('Steward');
|
||||
expect(result.memberSummaries).toHaveLength(1);
|
||||
expect(result.memberSummaries[0].driverId).toBe('driver-3');
|
||||
expect(result.memberSummaries[0].driverName).toBe('Charlie');
|
||||
expect(result.memberSummaries[0].roleBadgeText).toBe('Member');
|
||||
expect(result.sponsors).toHaveLength(1);
|
||||
expect(result.sponsors[0].id).toBe('sponsor-1');
|
||||
expect(result.sponsors[0].name).toBe('Sponsor A');
|
||||
expect(result.sponsors[0].tier).toBe('main');
|
||||
expect(result.walletBalance).toBe(1000);
|
||||
expect(result.pendingProtestsCount).toBe(1);
|
||||
expect(result.pendingJoinRequestsCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle league with no owner', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.ownerSummary).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle league with no scoring config', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.scoring).toBe('Standard');
|
||||
});
|
||||
|
||||
it('should handle league with no races', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.racesCount).toBe(0);
|
||||
expect(result.info.avgSOF).toBeNull();
|
||||
expect(result.runningRaces).toEqual([]);
|
||||
expect(result.nextRace).toBeUndefined();
|
||||
expect(result.seasonProgress).toEqual({
|
||||
completedRaces: 0,
|
||||
totalRaces: 0,
|
||||
percentage: 0,
|
||||
});
|
||||
expect(result.recentResults).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 20,
|
||||
category: 'test',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'Test Type',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'Test drop policy',
|
||||
scoringPatternSummary: 'Test pattern',
|
||||
},
|
||||
timingSummary: 'Test timing',
|
||||
logoUrl: 'https://example.com/test.png',
|
||||
pendingJoinRequestsCount: 5,
|
||||
pendingProtestsCount: 2,
|
||||
walletBalance: 500,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe(league.id);
|
||||
expect(result.name).toBe(league.name);
|
||||
expect(result.description).toBe(league.description);
|
||||
expect(result.logoUrl).toBe(league.logoUrl);
|
||||
expect(result.walletBalance).toBe(league.walletBalance);
|
||||
expect(result.pendingProtestsCount).toBe(league.pendingProtestsCount);
|
||||
expect(result.pendingJoinRequestsCount).toBe(league.pendingJoinRequestsCount);
|
||||
});
|
||||
|
||||
it('should not modify the input DTOs', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 20,
|
||||
};
|
||||
|
||||
const originalLeague = JSON.parse(JSON.stringify(league));
|
||||
LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(league).toEqual(originalLeague);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle league with missing optional fields', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Minimal League',
|
||||
description: '',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.description).toBe('');
|
||||
expect(result.logoUrl).toBeUndefined();
|
||||
expect(result.info.description).toBe('');
|
||||
expect(result.info.discordUrl).toBeUndefined();
|
||||
expect(result.info.youtubeUrl).toBeUndefined();
|
||||
expect(result.info.websiteUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle races with missing strengthOfField', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Race 1',
|
||||
date: '2024-01-15T14:00:00.000Z',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races,
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.avgSOF).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle races with zero strengthOfField', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Race 1',
|
||||
date: '2024-01-15T14:00:00.000Z',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
strengthOfField: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races,
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.avgSOF).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle races with different dates for next race calculation', () => {
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now
|
||||
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Past Race',
|
||||
date: pastDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
name: 'Future Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races,
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.nextRace).toBeDefined();
|
||||
expect(result.nextRace?.id).toBe('race-2');
|
||||
expect(result.nextRace?.name).toBe('Future Race');
|
||||
expect(result.seasonProgress.completedRaces).toBe(1);
|
||||
expect(result.seasonProgress.totalRaces).toBe(2);
|
||||
expect(result.seasonProgress.percentage).toBe(50);
|
||||
expect(result.recentResults).toHaveLength(1);
|
||||
expect(result.recentResults[0].raceId).toBe('race-1');
|
||||
});
|
||||
|
||||
it('should handle members with different roles', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const memberships: LeagueMembershipsDTO = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Admin',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Steward',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'steward',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-3',
|
||||
driver: {
|
||||
id: 'driver-3',
|
||||
name: 'Member',
|
||||
iracingId: '33333',
|
||||
country: 'France',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships,
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.adminSummaries).toHaveLength(1);
|
||||
expect(result.stewardSummaries).toHaveLength(1);
|
||||
expect(result.memberSummaries).toHaveLength(1);
|
||||
expect(result.info.membersCount).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,14 @@ import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryD
|
||||
* Transforms API DTOs into LeagueDetailViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueDetailViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueDetailViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueDetailViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(input: {
|
||||
league: LeagueWithCapacityAndScoringDTO;
|
||||
owner: GetDriverOutputDTO | null;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueLogoViewDataBuilder } from './LeagueLogoViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('LeagueLogoViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to LeagueLogoViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle SVG league logos', () => {
|
||||
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>');
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('should handle transparent PNG logos', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle small logo files', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with special characters', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, 0xfc]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { LeagueLogoViewData } from '@/lib/view-data/LeagueLogoViewData';
|
||||
|
||||
export class LeagueLogoViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueLogoViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueLogoViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): LeagueLogoViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueRosterAdminViewDataBuilder } from './LeagueRosterAdminViewDataBuilder';
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||
|
||||
describe('LeagueRosterAdminViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform roster DTOs to LeagueRosterAdminViewData correctly', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.members).toHaveLength(2);
|
||||
expect(result.members[0].driverId).toBe('driver-1');
|
||||
expect(result.members[0].driver.id).toBe('driver-1');
|
||||
expect(result.members[0].driver.name).toBe('Alice');
|
||||
expect(result.members[0].role).toBe('admin');
|
||||
expect(result.members[0].joinedAt).toBe('2023-06-01T00:00:00.000Z');
|
||||
expect(result.members[0].formattedJoinedAt).toBeDefined();
|
||||
expect(result.members[1].driverId).toBe('driver-2');
|
||||
expect(result.members[1].driver.id).toBe('driver-2');
|
||||
expect(result.members[1].driver.name).toBe('Bob');
|
||||
expect(result.members[1].role).toBe('member');
|
||||
expect(result.members[1].joinedAt).toBe('2023-07-01T00:00:00.000Z');
|
||||
expect(result.members[1].formattedJoinedAt).toBeDefined();
|
||||
expect(result.joinRequests).toHaveLength(1);
|
||||
expect(result.joinRequests[0].id).toBe('request-1');
|
||||
expect(result.joinRequests[0].driver.id).toBe('driver-3');
|
||||
expect(result.joinRequests[0].driver.name).toBe('Unknown Driver');
|
||||
expect(result.joinRequests[0].requestedAt).toBe('2024-01-15T10:00:00.000Z');
|
||||
expect(result.joinRequests[0].formattedRequestedAt).toBeDefined();
|
||||
expect(result.joinRequests[0].message).toBe('I would like to join this league');
|
||||
});
|
||||
|
||||
it('should handle empty members and join requests', () => {
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.members).toHaveLength(0);
|
||||
expect(result.joinRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle members without driver details', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: undefined as any,
|
||||
role: 'member',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(result.members[0].driver.name).toBe('Unknown Driver');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.members[0].driverId).toBe(members[0].driverId);
|
||||
expect(result.members[0].driver.id).toBe(members[0].driver.id);
|
||||
expect(result.members[0].driver.name).toBe(members[0].driver.name);
|
||||
expect(result.members[0].role).toBe(members[0].role);
|
||||
expect(result.members[0].joinedAt).toBe(members[0].joinedAt);
|
||||
expect(result.joinRequests[0].id).toBe(joinRequests[0].id);
|
||||
expect(result.joinRequests[0].requestedAt).toBe(joinRequests[0].requestedAt);
|
||||
expect(result.joinRequests[0].message).toBe(joinRequests[0].message);
|
||||
});
|
||||
|
||||
it('should not modify the input DTOs', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const originalMembers = JSON.parse(JSON.stringify(members));
|
||||
const originalRequests = JSON.parse(JSON.stringify(joinRequests));
|
||||
|
||||
LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(members).toEqual(originalMembers);
|
||||
expect(joinRequests).toEqual(originalRequests);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle members with missing driver field', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: undefined as any,
|
||||
role: 'member',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(result.members[0].driver.name).toBe('Unknown Driver');
|
||||
});
|
||||
|
||||
it('should handle join requests with missing driver field', () => {
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members: [],
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.joinRequests[0].driver.name).toBe('Unknown Driver');
|
||||
});
|
||||
|
||||
it('should handle join requests without message', () => {
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members: [],
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.joinRequests[0].message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,14 @@ import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
* Transforms API DTOs into LeagueRosterAdminViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueRosterAdminViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueRosterAdminViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueRosterAdminViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(input: {
|
||||
leagueId: string;
|
||||
members: LeagueRosterMemberDTO[];
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueScheduleViewDataBuilder } from './LeagueScheduleViewDataBuilder';
|
||||
|
||||
describe('LeagueScheduleViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform schedule DTO to LeagueScheduleViewData correctly', () => {
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Past Race',
|
||||
date: pastDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
name: 'Future Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto, 'driver-1', true);
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.races).toHaveLength(2);
|
||||
expect(result.races[0].id).toBe('race-1');
|
||||
expect(result.races[0].name).toBe('Past Race');
|
||||
expect(result.races[0].scheduledAt).toBe(pastDate.toISOString());
|
||||
expect(result.races[0].track).toBe('Spa');
|
||||
expect(result.races[0].car).toBe('Porsche 911 GT3');
|
||||
expect(result.races[0].sessionType).toBe('race');
|
||||
expect(result.races[0].isPast).toBe(true);
|
||||
expect(result.races[0].isUpcoming).toBe(false);
|
||||
expect(result.races[0].status).toBe('completed');
|
||||
expect(result.races[0].isUserRegistered).toBe(false);
|
||||
expect(result.races[0].canRegister).toBe(false);
|
||||
expect(result.races[0].canEdit).toBe(true);
|
||||
expect(result.races[0].canReschedule).toBe(true);
|
||||
expect(result.races[1].id).toBe('race-2');
|
||||
expect(result.races[1].name).toBe('Future Race');
|
||||
expect(result.races[1].scheduledAt).toBe(futureDate.toISOString());
|
||||
expect(result.races[1].track).toBe('Monza');
|
||||
expect(result.races[1].car).toBe('Ferrari 488 GT3');
|
||||
expect(result.races[1].sessionType).toBe('race');
|
||||
expect(result.races[1].isPast).toBe(false);
|
||||
expect(result.races[1].isUpcoming).toBe(true);
|
||||
expect(result.races[1].status).toBe('scheduled');
|
||||
expect(result.races[1].isUserRegistered).toBe(false);
|
||||
expect(result.races[1].canRegister).toBe(true);
|
||||
expect(result.races[1].canEdit).toBe(true);
|
||||
expect(result.races[1].canReschedule).toBe(true);
|
||||
expect(result.currentDriverId).toBe('driver-1');
|
||||
expect(result.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty races list', () => {
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.races).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle non-admin user', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Future Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto, 'driver-1', false);
|
||||
|
||||
expect(result.races[0].canEdit).toBe(false);
|
||||
expect(result.races[0].canReschedule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.leagueId).toBe(apiDto.leagueId);
|
||||
expect(result.races[0].id).toBe(apiDto.races[0].id);
|
||||
expect(result.races[0].name).toBe(apiDto.races[0].name);
|
||||
expect(result.races[0].scheduledAt).toBe(apiDto.races[0].date);
|
||||
expect(result.races[0].track).toBe(apiDto.races[0].track);
|
||||
expect(result.races[0].car).toBe(apiDto.races[0].car);
|
||||
expect(result.races[0].sessionType).toBe(apiDto.races[0].sessionType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const originalDto = JSON.parse(JSON.stringify(apiDto));
|
||||
LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle races with missing optional fields', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.races[0].track).toBe('Spa');
|
||||
expect(result.races[0].car).toBe('Porsche 911 GT3');
|
||||
expect(result.races[0].sessionType).toBe('race');
|
||||
});
|
||||
|
||||
it('should handle races at exactly the current time', () => {
|
||||
const now = new Date();
|
||||
const currentRaceDate = new Date(now.getTime());
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Current Race',
|
||||
date: currentRaceDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
// Race at current time should be considered past
|
||||
expect(result.races[0].isPast).toBe(true);
|
||||
expect(result.races[0].isUpcoming).toBe(false);
|
||||
expect(result.races[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
|
||||
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
|
||||
|
||||
export class LeagueScheduleViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueScheduleViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueScheduleViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueScheduleApiDto, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData {
|
||||
const now = new Date();
|
||||
|
||||
@@ -9,7 +16,7 @@ export class LeagueScheduleViewDataBuilder {
|
||||
leagueId: apiDto.leagueId,
|
||||
races: apiDto.races.map((race) => {
|
||||
const scheduledAt = new Date(race.date);
|
||||
const isPast = scheduledAt.getTime() < now.getTime();
|
||||
const isPast = scheduledAt.getTime() <= now.getTime();
|
||||
const isUpcoming = !isPast;
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueSettingsViewDataBuilder } from './LeagueSettingsViewDataBuilder';
|
||||
import type { LeagueSettingsApiDto } from '@/lib/types/tbd/LeagueSettingsApiDto';
|
||||
|
||||
describe('LeagueSettingsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform LeagueSettingsApiDto to LeagueSettingsViewData correctly', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-123',
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
leagueId: 'league-123',
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle minimal configuration', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-456',
|
||||
league: {
|
||||
id: 'league-456',
|
||||
name: 'Minimal League',
|
||||
description: '',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 16,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.leagueId).toBe('league-456');
|
||||
expect(result.league.name).toBe('Minimal League');
|
||||
expect(result.config.maxDrivers).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-789',
|
||||
league: {
|
||||
id: 'league-789',
|
||||
name: 'Full League',
|
||||
description: 'Full Description',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 24,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 45,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.leagueId).toBe(leagueSettingsApiDto.leagueId);
|
||||
expect(result.league).toEqual(leagueSettingsApiDto.league);
|
||||
expect(result.config).toEqual(leagueSettingsApiDto.config);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-101',
|
||||
league: {
|
||||
id: 'league-101',
|
||||
name: 'Test League',
|
||||
description: 'Test',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 20,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 25,
|
||||
},
|
||||
};
|
||||
|
||||
const originalDto = { ...leagueSettingsApiDto };
|
||||
LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(leagueSettingsApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle different qualifying formats', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-102',
|
||||
league: {
|
||||
id: 'league-102',
|
||||
name: 'Test League',
|
||||
description: 'Test',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 20,
|
||||
qualifyingFormat: 'Closed',
|
||||
raceLength: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.config.qualifyingFormat).toBe('Closed');
|
||||
});
|
||||
|
||||
it('should handle large driver counts', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-103',
|
||||
league: {
|
||||
id: 'league-103',
|
||||
name: 'Test League',
|
||||
description: 'Test',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 100,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 60,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.config.maxDrivers).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
|
||||
import { LeagueSettingsApiDto } from '@/lib/types/tbd/LeagueSettingsApiDto';
|
||||
import { LeagueSettingsViewData } from '@/lib/view-data/LeagueSettingsViewData';
|
||||
|
||||
export class LeagueSettingsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueSettingsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueSettingsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueSettingsApiDto): LeagueSettingsViewData {
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueSponsorshipsViewDataBuilder } from './LeagueSponsorshipsViewDataBuilder';
|
||||
import type { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
|
||||
|
||||
describe('LeagueSponsorshipsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform LeagueSponsorshipsApiDto to LeagueSponsorshipsViewData correctly', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-123',
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
leagueId: 'league-123',
|
||||
activeTab: 'overview',
|
||||
onTabChange: expect.any(Function),
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
formattedRequestedAt: expect.any(String),
|
||||
statusLabel: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty sponsorship requests', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-456',
|
||||
league: {
|
||||
id: 'league-456',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple sponsorship requests', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-789',
|
||||
league: {
|
||||
id: 'league-789',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Sponsor 1',
|
||||
sponsorLogo: 'logo-1',
|
||||
message: 'Message 1',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'request-2',
|
||||
sponsorId: 'sponsor-2',
|
||||
sponsorName: 'Sponsor 2',
|
||||
sponsorLogo: 'logo-2',
|
||||
message: 'Message 2',
|
||||
requestedAt: '2024-01-02T10:00:00Z',
|
||||
status: 'approved',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-101',
|
||||
league: {
|
||||
id: 'league-101',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.leagueId).toBe(leagueSponsorshipsApiDto.leagueId);
|
||||
expect(result.league).toEqual(leagueSponsorshipsApiDto.league);
|
||||
expect(result.sponsorshipSlots).toEqual(leagueSponsorshipsApiDto.sponsorshipSlots);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-102',
|
||||
league: {
|
||||
id: 'league-102',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [],
|
||||
};
|
||||
|
||||
const originalDto = { ...leagueSponsorshipsApiDto };
|
||||
LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(leagueSponsorshipsApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle requests without sponsor logo', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-103',
|
||||
league: {
|
||||
id: 'league-103',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: null,
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests[0].sponsorLogoUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle requests without message', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-104',
|
||||
league: {
|
||||
id: 'league-104',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: null,
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests[0].message).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,16 @@
|
||||
import { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData';
|
||||
import { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
|
||||
import { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
|
||||
import { LeagueSponsorshipsViewData } from '@/lib/view-data/LeagueSponsorshipsViewData';
|
||||
|
||||
export class LeagueSponsorshipsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueSponsorshipsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueSponsorshipsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueSponsorshipsApiDto): LeagueSponsorshipsViewData {
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueStandingsViewDataBuilder } from './LeagueStandingsViewDataBuilder';
|
||||
|
||||
describe('LeagueStandingsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform standings DTOs to LeagueStandingsViewData correctly', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: ['race-1', 'race-2'],
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
},
|
||||
points: 1100,
|
||||
position: 2,
|
||||
wins: 3,
|
||||
podiums: 8,
|
||||
races: 15,
|
||||
positionChange: -1,
|
||||
lastRacePoints: 15,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.isTeamChampionship).toBe(false);
|
||||
expect(result.currentDriverId).toBeNull();
|
||||
expect(result.isAdmin).toBe(false);
|
||||
expect(result.standings).toHaveLength(2);
|
||||
expect(result.standings[0].driverId).toBe('driver-1');
|
||||
expect(result.standings[0].position).toBe(1);
|
||||
expect(result.standings[0].totalPoints).toBe(1250);
|
||||
expect(result.standings[0].racesFinished).toBe(15);
|
||||
expect(result.standings[0].racesStarted).toBe(15);
|
||||
expect(result.standings[0].avgFinish).toBeNull();
|
||||
expect(result.standings[0].penaltyPoints).toBe(0);
|
||||
expect(result.standings[0].bonusPoints).toBe(0);
|
||||
expect(result.standings[0].positionChange).toBe(2);
|
||||
expect(result.standings[0].lastRacePoints).toBe(25);
|
||||
expect(result.standings[0].droppedRaceIds).toEqual(['race-1', 'race-2']);
|
||||
expect(result.standings[0].wins).toBe(5);
|
||||
expect(result.standings[0].podiums).toBe(10);
|
||||
expect(result.standings[1].driverId).toBe('driver-2');
|
||||
expect(result.standings[1].position).toBe(2);
|
||||
expect(result.standings[1].totalPoints).toBe(1100);
|
||||
expect(result.standings[1].racesFinished).toBe(15);
|
||||
expect(result.standings[1].racesStarted).toBe(15);
|
||||
expect(result.standings[1].avgFinish).toBeNull();
|
||||
expect(result.standings[1].penaltyPoints).toBe(0);
|
||||
expect(result.standings[1].bonusPoints).toBe(0);
|
||||
expect(result.standings[1].positionChange).toBe(-1);
|
||||
expect(result.standings[1].lastRacePoints).toBe(15);
|
||||
expect(result.standings[1].droppedRaceIds).toEqual([]);
|
||||
expect(result.standings[1].wins).toBe(3);
|
||||
expect(result.standings[1].podiums).toBe(8);
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
expect(result.drivers[0].iracingId).toBe('11111');
|
||||
expect(result.drivers[0].country).toBe('UK');
|
||||
expect(result.drivers[0].avatarUrl).toBeNull();
|
||||
expect(result.drivers[1].id).toBe('driver-2');
|
||||
expect(result.drivers[1].name).toBe('Bob');
|
||||
expect(result.drivers[1].iracingId).toBe('22222');
|
||||
expect(result.drivers[1].country).toBe('Germany');
|
||||
expect(result.drivers[1].avatarUrl).toBeNull();
|
||||
expect(result.memberships).toHaveLength(2);
|
||||
expect(result.memberships[0].driverId).toBe('driver-1');
|
||||
expect(result.memberships[0].leagueId).toBe('league-1');
|
||||
expect(result.memberships[0].role).toBe('member');
|
||||
expect(result.memberships[0].joinedAt).toBe('2023-06-01T00:00:00.000Z');
|
||||
expect(result.memberships[0].status).toBe('active');
|
||||
expect(result.memberships[1].driverId).toBe('driver-2');
|
||||
expect(result.memberships[1].leagueId).toBe('league-1');
|
||||
expect(result.memberships[1].role).toBe('member');
|
||||
expect(result.memberships[1].joinedAt).toBe('2023-07-01T00:00:00.000Z');
|
||||
expect(result.memberships[1].status).toBe('active');
|
||||
});
|
||||
|
||||
it('should handle empty standings and memberships', () => {
|
||||
const standingsDto = {
|
||||
standings: [],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.standings).toHaveLength(0);
|
||||
expect(result.drivers).toHaveLength(0);
|
||||
expect(result.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle team championship mode', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
true
|
||||
);
|
||||
|
||||
expect(result.isTeamChampionship).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: ['race-1'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.standings[0].driverId).toBe(standingsDto.standings[0].driverId);
|
||||
expect(result.standings[0].position).toBe(standingsDto.standings[0].position);
|
||||
expect(result.standings[0].totalPoints).toBe(standingsDto.standings[0].points);
|
||||
expect(result.standings[0].racesFinished).toBe(standingsDto.standings[0].races);
|
||||
expect(result.standings[0].racesStarted).toBe(standingsDto.standings[0].races);
|
||||
expect(result.standings[0].positionChange).toBe(standingsDto.standings[0].positionChange);
|
||||
expect(result.standings[0].lastRacePoints).toBe(standingsDto.standings[0].lastRacePoints);
|
||||
expect(result.standings[0].droppedRaceIds).toEqual(standingsDto.standings[0].droppedRaceIds);
|
||||
expect(result.standings[0].wins).toBe(standingsDto.standings[0].wins);
|
||||
expect(result.standings[0].podiums).toBe(standingsDto.standings[0].podiums);
|
||||
expect(result.drivers[0].id).toBe(standingsDto.standings[0].driver.id);
|
||||
expect(result.drivers[0].name).toBe(standingsDto.standings[0].driver.name);
|
||||
expect(result.drivers[0].iracingId).toBe(standingsDto.standings[0].driver.iracingId);
|
||||
expect(result.drivers[0].country).toBe(standingsDto.standings[0].driver.country);
|
||||
});
|
||||
|
||||
it('should not modify the input DTOs', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: ['race-1'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const originalStandings = JSON.parse(JSON.stringify(standingsDto));
|
||||
const originalMemberships = JSON.parse(JSON.stringify(membershipsDto));
|
||||
|
||||
LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(standingsDto).toEqual(originalStandings);
|
||||
expect(membershipsDto).toEqual(originalMemberships);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle standings with missing optional fields', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.standings[0].positionChange).toBe(0);
|
||||
expect(result.standings[0].lastRacePoints).toBe(0);
|
||||
expect(result.standings[0].droppedRaceIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle standings with missing driver field', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: undefined as any,
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.drivers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle duplicate drivers in standings', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1100,
|
||||
position: 2,
|
||||
wins: 3,
|
||||
podiums: 8,
|
||||
races: 15,
|
||||
positionChange: -1,
|
||||
lastRacePoints: 15,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
// Should only have one driver entry
|
||||
expect(result.drivers).toHaveLength(1);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
});
|
||||
|
||||
it('should handle members with different roles', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.memberships[0].role).toBe('admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,14 @@ interface LeagueMembershipsApiDto {
|
||||
* Transforms API DTOs into LeagueStandingsViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueStandingsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueStandingsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueStandingsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(
|
||||
standingsDto: LeagueStandingsApiDto,
|
||||
membershipsDto: LeagueMembershipsApiDto,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueWalletViewDataBuilder } from './LeagueWalletViewDataBuilder';
|
||||
import type { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
|
||||
|
||||
describe('LeagueWalletViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform LeagueWalletApiDto to LeagueWalletViewData correctly', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-123',
|
||||
balance: 5000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 1000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Sponsorship payment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
leagueId: 'league-123',
|
||||
balance: 5000,
|
||||
formattedBalance: expect.any(String),
|
||||
totalRevenue: 5000,
|
||||
formattedTotalRevenue: expect.any(String),
|
||||
totalFees: 0,
|
||||
formattedTotalFees: expect.any(String),
|
||||
pendingPayouts: 0,
|
||||
formattedPendingPayouts: expect.any(String),
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 1000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Sponsorship payment',
|
||||
formattedAmount: expect.any(String),
|
||||
amountColor: 'green',
|
||||
formattedDate: expect.any(String),
|
||||
statusColor: 'green',
|
||||
typeColor: 'blue',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-456',
|
||||
balance: 0,
|
||||
currency: 'USD',
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions).toHaveLength(0);
|
||||
expect(result.balance).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-789',
|
||||
balance: 10000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 5000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Sponsorship payment',
|
||||
},
|
||||
{
|
||||
id: 'txn-2',
|
||||
amount: -1000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-02T10:00:00Z',
|
||||
description: 'Payout',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-101',
|
||||
balance: 7500,
|
||||
currency: 'EUR',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 2500,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Test transaction',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.leagueId).toBe(leagueWalletApiDto.leagueId);
|
||||
expect(result.balance).toBe(leagueWalletApiDto.balance);
|
||||
expect(result.currency).toBe(leagueWalletApiDto.currency);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-102',
|
||||
balance: 5000,
|
||||
currency: 'USD',
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const originalDto = { ...leagueWalletApiDto };
|
||||
LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(leagueWalletApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle negative balance', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-103',
|
||||
balance: -500,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: -500,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Overdraft',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.balance).toBe(-500);
|
||||
expect(result.transactions[0].amountColor).toBe('red');
|
||||
});
|
||||
|
||||
it('should handle pending transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-104',
|
||||
balance: 1000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 500,
|
||||
status: 'pending',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Pending payment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions[0].statusColor).toBe('yellow');
|
||||
});
|
||||
|
||||
it('should handle failed transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-105',
|
||||
balance: 1000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 500,
|
||||
status: 'failed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Failed payment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions[0].statusColor).toBe('red');
|
||||
});
|
||||
|
||||
it('should handle different currencies', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-106',
|
||||
balance: 1000,
|
||||
currency: 'EUR',
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.currency).toBe('EUR');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,16 @@
|
||||
import { LeagueWalletViewData, LeagueWalletTransactionViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
|
||||
import { CurrencyDisplay } from '@/lib/display-objects/CurrencyDisplay';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
|
||||
import { LeagueWalletTransactionViewData, LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData';
|
||||
|
||||
export class LeagueWalletViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueWalletViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueWalletViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueWalletApiDto): LeagueWalletViewData {
|
||||
const transactions: LeagueWalletTransactionViewData[] = apiDto.transactions.map(t => ({
|
||||
...t,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeaguesViewDataBuilder } from './LeaguesViewDataBuilder';
|
||||
import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/generated/AllLeaguesWithCapacityAndScoringDTO';
|
||||
|
||||
describe('LeaguesViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform AllLeaguesWithCapacityAndScoringDTO to LeaguesViewData correctly', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Pro League',
|
||||
description: 'A competitive league for experienced drivers',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 25,
|
||||
category: 'competitive',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
timingSummary: 'Weekly races on Sundays',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
pendingJoinRequestsCount: 3,
|
||||
pendingProtestsCount: 1,
|
||||
walletBalance: 1000,
|
||||
},
|
||||
{
|
||||
id: 'league-2',
|
||||
name: 'Rookie League',
|
||||
description: null,
|
||||
ownerId: 'owner-2',
|
||||
createdAt: '2024-02-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 16,
|
||||
qualifyingFormat: 'Solo • 16 max',
|
||||
},
|
||||
usedSlots: 10,
|
||||
category: 'rookie',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-2',
|
||||
scoringPresetName: 'Rookie',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
timingSummary: 'Bi-weekly races',
|
||||
logoUrl: null,
|
||||
pendingJoinRequestsCount: 0,
|
||||
pendingProtestsCount: 0,
|
||||
walletBalance: 0,
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues).toHaveLength(2);
|
||||
expect(result.leagues[0]).toEqual({
|
||||
id: 'league-1',
|
||||
name: 'Pro League',
|
||||
description: 'A competitive league for experienced drivers',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
maxDrivers: 32,
|
||||
usedDriverSlots: 25,
|
||||
activeDriversCount: undefined,
|
||||
nextRaceAt: undefined,
|
||||
maxTeams: undefined,
|
||||
usedTeamSlots: undefined,
|
||||
structureSummary: 'Solo • 32 max',
|
||||
timingSummary: 'Weekly races on Sundays',
|
||||
category: 'competitive',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
});
|
||||
expect(result.leagues[1]).toEqual({
|
||||
id: 'league-2',
|
||||
name: 'Rookie League',
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
ownerId: 'owner-2',
|
||||
createdAt: '2024-02-01T00:00:00.000Z',
|
||||
maxDrivers: 16,
|
||||
usedDriverSlots: 10,
|
||||
activeDriversCount: undefined,
|
||||
nextRaceAt: undefined,
|
||||
maxTeams: undefined,
|
||||
usedTeamSlots: undefined,
|
||||
structureSummary: 'Solo • 16 max',
|
||||
timingSummary: 'Bi-weekly races',
|
||||
category: 'rookie',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-2',
|
||||
scoringPresetName: 'Rookie',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty leagues list', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle leagues with missing optional fields', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Minimal League',
|
||||
description: '',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 20,
|
||||
},
|
||||
usedSlots: 5,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].description).toBe(null);
|
||||
expect(result.leagues[0].logoUrl).toBe(null);
|
||||
expect(result.leagues[0].category).toBe(null);
|
||||
expect(result.leagues[0].scoring).toBeUndefined();
|
||||
expect(result.leagues[0].timingSummary).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 20,
|
||||
category: 'test',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'Test Type',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'Test drop policy',
|
||||
scoringPatternSummary: 'Test pattern',
|
||||
},
|
||||
timingSummary: 'Test timing',
|
||||
logoUrl: 'https://example.com/test.png',
|
||||
pendingJoinRequestsCount: 5,
|
||||
pendingProtestsCount: 2,
|
||||
walletBalance: 500,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].id).toBe(leaguesDTO.leagues[0].id);
|
||||
expect(result.leagues[0].name).toBe(leaguesDTO.leagues[0].name);
|
||||
expect(result.leagues[0].description).toBe(leaguesDTO.leagues[0].description);
|
||||
expect(result.leagues[0].logoUrl).toBe(leaguesDTO.leagues[0].logoUrl);
|
||||
expect(result.leagues[0].ownerId).toBe(leaguesDTO.leagues[0].ownerId);
|
||||
expect(result.leagues[0].createdAt).toBe(leaguesDTO.leagues[0].createdAt);
|
||||
expect(result.leagues[0].maxDrivers).toBe(leaguesDTO.leagues[0].settings.maxDrivers);
|
||||
expect(result.leagues[0].usedDriverSlots).toBe(leaguesDTO.leagues[0].usedSlots);
|
||||
expect(result.leagues[0].structureSummary).toBe(leaguesDTO.leagues[0].settings.qualifyingFormat);
|
||||
expect(result.leagues[0].timingSummary).toBe(leaguesDTO.leagues[0].timingSummary);
|
||||
expect(result.leagues[0].category).toBe(leaguesDTO.leagues[0].category);
|
||||
expect(result.leagues[0].scoring).toEqual(leaguesDTO.leagues[0].scoring);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 20,
|
||||
category: 'test',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'Test Type',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'Test drop policy',
|
||||
scoringPatternSummary: 'Test pattern',
|
||||
},
|
||||
timingSummary: 'Test timing',
|
||||
logoUrl: 'https://example.com/test.png',
|
||||
pendingJoinRequestsCount: 5,
|
||||
pendingProtestsCount: 2,
|
||||
walletBalance: 500,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(leaguesDTO));
|
||||
LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(leaguesDTO).toEqual(originalDTO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle leagues with very long descriptions', () => {
|
||||
const longDescription = 'A'.repeat(1000);
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: longDescription,
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 20,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].description).toBe(longDescription);
|
||||
});
|
||||
|
||||
it('should handle leagues with special characters in name', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'League & Co. (2024)',
|
||||
description: 'Test league',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 20,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].name).toBe('League & Co. (2024)');
|
||||
});
|
||||
|
||||
it('should handle leagues with zero used slots', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Empty League',
|
||||
description: 'No members yet',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 0,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].usedDriverSlots).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle leagues with maximum capacity', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Full League',
|
||||
description: 'At maximum capacity',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 32,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].usedDriverSlots).toBe(32);
|
||||
expect(result.leagues[0].maxDrivers).toBe(32);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,14 @@ import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
* Transforms AllLeaguesWithCapacityAndScoringDTO (API DTO) into LeaguesViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeaguesViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeaguesViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeaguesViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
|
||||
return {
|
||||
leagues: apiDto.leagues.map((league) => ({
|
||||
|
||||
205
apps/website/lib/builders/view-data/LoginViewDataBuilder.test.ts
Normal file
205
apps/website/lib/builders/view-data/LoginViewDataBuilder.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LoginViewDataBuilder } from './LoginViewDataBuilder';
|
||||
import type { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
|
||||
|
||||
describe('LoginViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform LoginPageDTO to LoginViewData correctly', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result).toEqual({
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
showPassword: false,
|
||||
showErrorDetails: false,
|
||||
formState: {
|
||||
fields: {
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
password: { value: '', error: undefined, touched: false, validating: false },
|
||||
rememberMe: { value: false, error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle insufficient permissions flag correctly', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/admin',
|
||||
hasInsufficientPermissions: true,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.hasInsufficientPermissions).toBe(true);
|
||||
expect(result.returnTo).toBe('/admin');
|
||||
});
|
||||
|
||||
it('should handle empty returnTo path', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('');
|
||||
expect(result.hasInsufficientPermissions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe(loginPageDTO.returnTo);
|
||||
expect(result.hasInsufficientPermissions).toBe(loginPageDTO.hasInsufficientPermissions);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const originalDTO = { ...loginPageDTO };
|
||||
LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(loginPageDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should initialize form fields with default values', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.formState.fields.email.value).toBe('');
|
||||
expect(result.formState.fields.email.error).toBeUndefined();
|
||||
expect(result.formState.fields.email.touched).toBe(false);
|
||||
expect(result.formState.fields.email.validating).toBe(false);
|
||||
|
||||
expect(result.formState.fields.password.value).toBe('');
|
||||
expect(result.formState.fields.password.error).toBeUndefined();
|
||||
expect(result.formState.fields.password.touched).toBe(false);
|
||||
expect(result.formState.fields.password.validating).toBe(false);
|
||||
|
||||
expect(result.formState.fields.rememberMe.value).toBe(false);
|
||||
expect(result.formState.fields.rememberMe.error).toBeUndefined();
|
||||
expect(result.formState.fields.rememberMe.touched).toBe(false);
|
||||
expect(result.formState.fields.rememberMe.validating).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize form state with default values', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.formState.isValid).toBe(true);
|
||||
expect(result.formState.isSubmitting).toBe(false);
|
||||
expect(result.formState.submitError).toBeUndefined();
|
||||
expect(result.formState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should initialize UI state flags correctly', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.showPassword).toBe(false);
|
||||
expect(result.showErrorDetails).toBe(false);
|
||||
expect(result.isSubmitting).toBe(false);
|
||||
expect(result.submitError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle special characters in returnTo path', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard?param=value&other=test',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/dashboard?param=value&other=test');
|
||||
});
|
||||
|
||||
it('should handle returnTo with hash fragment', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard#section',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/dashboard#section');
|
||||
});
|
||||
|
||||
it('should handle returnTo with encoded characters', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard?redirect=%2Fadmin',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form state structure', () => {
|
||||
it('should have all required form fields', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
expect(result.formState.fields).toHaveProperty('email');
|
||||
expect(result.formState.fields).toHaveProperty('password');
|
||||
expect(result.formState.fields).toHaveProperty('rememberMe');
|
||||
});
|
||||
|
||||
it('should have consistent field state structure', () => {
|
||||
const loginPageDTO: LoginPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
hasInsufficientPermissions: false,
|
||||
};
|
||||
|
||||
const result = LoginViewDataBuilder.build(loginPageDTO);
|
||||
|
||||
const fields = result.formState.fields;
|
||||
Object.values(fields).forEach((field) => {
|
||||
expect(field).toHaveProperty('value');
|
||||
expect(field).toHaveProperty('error');
|
||||
expect(field).toHaveProperty('touched');
|
||||
expect(field).toHaveProperty('validating');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,18 @@
|
||||
*/
|
||||
|
||||
import { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
|
||||
import { LoginViewData } from './types/LoginViewData';
|
||||
import { LoginViewData } from '../../view-data/LoginViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { error } from 'console';
|
||||
|
||||
export class LoginViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LoginViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LoginViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LoginPageDTO): LoginViewData {
|
||||
return {
|
||||
returnTo: apiDto.returnTo,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OnboardingPageViewDataBuilder } from './OnboardingPageViewDataBuilder';
|
||||
|
||||
describe('OnboardingPageViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform driver data to ViewData correctly when driver exists', () => {
|
||||
const apiDto = { id: 'driver-123', name: 'Test Driver' };
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object as driver data', () => {
|
||||
const apiDto = {};
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null driver data', () => {
|
||||
const apiDto = null;
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined driver data', () => {
|
||||
const apiDto = undefined;
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all driver data fields in the output', () => {
|
||||
const apiDto = {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
email: 'test@example.com',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isAlreadyOnboarded).toBe(true);
|
||||
});
|
||||
|
||||
it('should not modify the input driver data', () => {
|
||||
const apiDto = { id: 'driver-123', name: 'Test Driver' };
|
||||
const originalDto = { ...apiDto };
|
||||
|
||||
OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string as driver data', () => {
|
||||
const apiDto = '';
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero as driver data', () => {
|
||||
const apiDto = 0;
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle false as driver data', () => {
|
||||
const apiDto = false;
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array as driver data', () => {
|
||||
const apiDto = ['driver-123'];
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle function as driver data', () => {
|
||||
const apiDto = () => {};
|
||||
|
||||
const result = OnboardingPageViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlreadyOnboarded: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,14 @@
|
||||
|
||||
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
|
||||
|
||||
export class OnboardingPageViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class OnboardingPageViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return OnboardingPageViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Transform driver data into ViewData
|
||||
*
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OnboardingViewDataBuilder } from './OnboardingViewDataBuilder';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
|
||||
describe('OnboardingViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform successful onboarding check to ViewData correctly', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.ok({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle already onboarded user correctly', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.ok({
|
||||
isAlreadyOnboarded: true,
|
||||
});
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toEqual({
|
||||
isAlreadyOnboarded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing isAlreadyOnboarded field with default false', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded?: boolean }, any> = Result.ok({});
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should propagate unauthorized error', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.err('unauthorized');
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('should propagate notFound error', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.err('notFound');
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('notFound');
|
||||
});
|
||||
|
||||
it('should propagate serverError', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.err('serverError');
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('serverError');
|
||||
});
|
||||
|
||||
it('should propagate networkError', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.err('networkError');
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('networkError');
|
||||
});
|
||||
|
||||
it('should propagate validationError', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.err('validationError');
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('validationError');
|
||||
});
|
||||
|
||||
it('should propagate unknown error', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.err('unknown');
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.ok({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.unwrap().isAlreadyOnboarded).toBe(false);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean }, any> = Result.ok({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
|
||||
const originalDto = { ...apiDto.unwrap() };
|
||||
OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto.unwrap()).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null isAlreadyOnboarded as false', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean | null }, any> = Result.ok({
|
||||
isAlreadyOnboarded: null,
|
||||
});
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined isAlreadyOnboarded as false', () => {
|
||||
const apiDto: Result<{ isAlreadyOnboarded: boolean | undefined }, any> = Result.ok({
|
||||
isAlreadyOnboarded: undefined,
|
||||
});
|
||||
|
||||
const result = OnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toEqual({
|
||||
isAlreadyOnboarded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,14 @@ import { Result } from '@/lib/contracts/Result';
|
||||
import { PresentationError } from '@/lib/contracts/page-queries/PresentationError';
|
||||
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
|
||||
|
||||
export class OnboardingViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class OnboardingViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return OnboardingViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: Result<{ isAlreadyOnboarded: boolean }, PresentationError>): Result<OnboardingPageViewData, PresentationError> {
|
||||
if (apiDto.isErr()) {
|
||||
return Result.err(apiDto.getError());
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ProfileLeaguesViewDataBuilder } from './ProfileLeaguesViewDataBuilder';
|
||||
|
||||
describe('ProfileLeaguesViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform ProfileLeaguesPageDto to ProfileLeaguesViewData correctly', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Owned League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'owner' as const,
|
||||
},
|
||||
],
|
||||
memberLeagues: [
|
||||
{
|
||||
leagueId: 'league-2',
|
||||
name: 'Member League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'member' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Owned League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'owner',
|
||||
},
|
||||
],
|
||||
memberLeagues: [
|
||||
{
|
||||
leagueId: 'league-2',
|
||||
name: 'Member League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'member',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty owned leagues', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [],
|
||||
memberLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Member League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'member' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(result.ownedLeagues).toHaveLength(0);
|
||||
expect(result.memberLeagues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle empty member leagues', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Owned League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'owner' as const,
|
||||
},
|
||||
],
|
||||
memberLeagues: [],
|
||||
};
|
||||
|
||||
const result = ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(result.ownedLeagues).toHaveLength(1);
|
||||
expect(result.memberLeagues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple leagues in both arrays', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Owned League 1',
|
||||
description: 'Description 1',
|
||||
membershipRole: 'owner' as const,
|
||||
},
|
||||
{
|
||||
leagueId: 'league-2',
|
||||
name: 'Owned League 2',
|
||||
description: 'Description 2',
|
||||
membershipRole: 'admin' as const,
|
||||
},
|
||||
],
|
||||
memberLeagues: [
|
||||
{
|
||||
leagueId: 'league-3',
|
||||
name: 'Member League 1',
|
||||
description: 'Description 3',
|
||||
membershipRole: 'member' as const,
|
||||
},
|
||||
{
|
||||
leagueId: 'league-4',
|
||||
name: 'Member League 2',
|
||||
description: 'Description 4',
|
||||
membershipRole: 'steward' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(result.ownedLeagues).toHaveLength(2);
|
||||
expect(result.memberLeagues).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'owner' as const,
|
||||
},
|
||||
],
|
||||
memberLeagues: [
|
||||
{
|
||||
leagueId: 'league-2',
|
||||
name: 'Test League 2',
|
||||
description: 'Test Description 2',
|
||||
membershipRole: 'member' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(result.ownedLeagues[0].leagueId).toBe(profileLeaguesPageDto.ownedLeagues[0].leagueId);
|
||||
expect(result.ownedLeagues[0].name).toBe(profileLeaguesPageDto.ownedLeagues[0].name);
|
||||
expect(result.ownedLeagues[0].description).toBe(profileLeaguesPageDto.ownedLeagues[0].description);
|
||||
expect(result.ownedLeagues[0].membershipRole).toBe(profileLeaguesPageDto.ownedLeagues[0].membershipRole);
|
||||
expect(result.memberLeagues[0].leagueId).toBe(profileLeaguesPageDto.memberLeagues[0].leagueId);
|
||||
expect(result.memberLeagues[0].name).toBe(profileLeaguesPageDto.memberLeagues[0].name);
|
||||
expect(result.memberLeagues[0].description).toBe(profileLeaguesPageDto.memberLeagues[0].description);
|
||||
expect(result.memberLeagues[0].membershipRole).toBe(profileLeaguesPageDto.memberLeagues[0].membershipRole);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'owner' as const,
|
||||
},
|
||||
],
|
||||
memberLeagues: [
|
||||
{
|
||||
leagueId: 'league-2',
|
||||
name: 'Test League 2',
|
||||
description: 'Test Description 2',
|
||||
membershipRole: 'member' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const originalDto = { ...profileLeaguesPageDto };
|
||||
ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(profileLeaguesPageDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle different membership roles', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
membershipRole: 'owner' as const,
|
||||
},
|
||||
{
|
||||
leagueId: 'league-2',
|
||||
name: 'Test League 2',
|
||||
description: 'Test Description 2',
|
||||
membershipRole: 'admin' as const,
|
||||
},
|
||||
{
|
||||
leagueId: 'league-3',
|
||||
name: 'Test League 3',
|
||||
description: 'Test Description 3',
|
||||
membershipRole: 'steward' as const,
|
||||
},
|
||||
{
|
||||
leagueId: 'league-4',
|
||||
name: 'Test League 4',
|
||||
description: 'Test Description 4',
|
||||
membershipRole: 'member' as const,
|
||||
},
|
||||
],
|
||||
memberLeagues: [],
|
||||
};
|
||||
|
||||
const result = ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(result.ownedLeagues[0].membershipRole).toBe('owner');
|
||||
expect(result.ownedLeagues[1].membershipRole).toBe('admin');
|
||||
expect(result.ownedLeagues[2].membershipRole).toBe('steward');
|
||||
expect(result.ownedLeagues[3].membershipRole).toBe('member');
|
||||
});
|
||||
|
||||
it('should handle empty description', () => {
|
||||
const profileLeaguesPageDto = {
|
||||
ownedLeagues: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
name: 'Test League',
|
||||
description: '',
|
||||
membershipRole: 'owner' as const,
|
||||
},
|
||||
],
|
||||
memberLeagues: [],
|
||||
};
|
||||
|
||||
const result = ProfileLeaguesViewDataBuilder.build(profileLeaguesPageDto);
|
||||
|
||||
expect(result.ownedLeagues[0].description).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,14 @@ interface ProfileLeaguesPageDto {
|
||||
* ViewData Builder for Profile Leagues page
|
||||
* Transforms Page DTO to ViewData for templates
|
||||
*/
|
||||
export class ProfileLeaguesViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ProfileLeaguesViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ProfileLeaguesViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ProfileLeaguesPageDto): ProfileLeaguesViewData {
|
||||
return {
|
||||
ownedLeagues: apiDto.ownedLeagues.map((league: { leagueId: string; name: string; description: string; membershipRole: 'owner' | 'admin' | 'steward' | 'member'; }) => ({
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ProfileViewDataBuilder } from './ProfileViewDataBuilder';
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
|
||||
describe('ProfileViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform GetDriverProfileOutputDTO to ProfileViewData correctly', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: 'Test bio',
|
||||
iracingId: 12345,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: 100,
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
dnfs: 5,
|
||||
avgFinish: 5.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 20,
|
||||
finishRate: 90,
|
||||
winRate: 20,
|
||||
podiumRate: 40,
|
||||
percentile: 95,
|
||||
rating: 1500,
|
||||
consistency: 85,
|
||||
overallRank: 100,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
topTen: 30,
|
||||
dnfs: 5,
|
||||
other: 15,
|
||||
},
|
||||
teamMemberships: [
|
||||
{
|
||||
teamId: 'team-1',
|
||||
teamName: 'Test Team',
|
||||
teamTag: 'TT',
|
||||
role: 'driver',
|
||||
joinedAt: '2024-01-01',
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
socialSummary: {
|
||||
friendsCount: 10,
|
||||
friends: [
|
||||
{
|
||||
id: 'friend-1',
|
||||
name: 'Friend 1',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [
|
||||
{
|
||||
platform: 'twitter',
|
||||
handle: '@test',
|
||||
url: 'https://twitter.com/test',
|
||||
},
|
||||
],
|
||||
achievements: [
|
||||
{
|
||||
id: 'ach-1',
|
||||
title: 'Achievement',
|
||||
description: 'Test achievement',
|
||||
icon: 'trophy',
|
||||
rarity: 'rare',
|
||||
earnedAt: '2024-01-01',
|
||||
},
|
||||
],
|
||||
racingStyle: 'Aggressive',
|
||||
favoriteTrack: 'Test Track',
|
||||
favoriteCar: 'Test Car',
|
||||
timezone: 'UTC',
|
||||
availableHours: 10,
|
||||
lookingForTeam: true,
|
||||
openToRequests: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.driver.id).toBe('driver-123');
|
||||
expect(result.driver.name).toBe('Test Driver');
|
||||
expect(result.driver.countryCode).toBe('US');
|
||||
expect(result.driver.bio).toBe('Test bio');
|
||||
expect(result.driver.iracingId).toBe('12345');
|
||||
expect(result.stats).not.toBeNull();
|
||||
expect(result.stats?.ratingLabel).toBe('1500');
|
||||
expect(result.teamMemberships).toHaveLength(1);
|
||||
expect(result.extendedProfile).not.toBeNull();
|
||||
expect(result.extendedProfile?.socialHandles).toHaveLength(1);
|
||||
expect(result.extendedProfile?.achievements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle null driver (no profile)', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: null,
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.driver.id).toBe('');
|
||||
expect(result.driver.name).toBe('');
|
||||
expect(result.driver.countryCode).toBe('');
|
||||
expect(result.driver.bio).toBeNull();
|
||||
expect(result.driver.iracingId).toBeNull();
|
||||
expect(result.stats).toBeNull();
|
||||
expect(result.teamMemberships).toHaveLength(0);
|
||||
expect(result.extendedProfile).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle null stats', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: null,
|
||||
},
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.stats).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: 'Test bio',
|
||||
iracingId: 12345,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: 100,
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
dnfs: 5,
|
||||
avgFinish: 5.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 20,
|
||||
finishRate: 90,
|
||||
winRate: 20,
|
||||
podiumRate: 40,
|
||||
percentile: 95,
|
||||
rating: 1500,
|
||||
consistency: 85,
|
||||
overallRank: 100,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
topTen: 30,
|
||||
dnfs: 5,
|
||||
other: 15,
|
||||
},
|
||||
teamMemberships: [
|
||||
{
|
||||
teamId: 'team-1',
|
||||
teamName: 'Test Team',
|
||||
teamTag: 'TT',
|
||||
role: 'driver',
|
||||
joinedAt: '2024-01-01',
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
socialSummary: {
|
||||
friendsCount: 10,
|
||||
friends: [
|
||||
{
|
||||
id: 'friend-1',
|
||||
name: 'Friend 1',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [
|
||||
{
|
||||
platform: 'twitter',
|
||||
handle: '@test',
|
||||
url: 'https://twitter.com/test',
|
||||
},
|
||||
],
|
||||
achievements: [
|
||||
{
|
||||
id: 'ach-1',
|
||||
title: 'Achievement',
|
||||
description: 'Test achievement',
|
||||
icon: 'trophy',
|
||||
rarity: 'rare',
|
||||
earnedAt: '2024-01-01',
|
||||
},
|
||||
],
|
||||
racingStyle: 'Aggressive',
|
||||
favoriteTrack: 'Test Track',
|
||||
favoriteCar: 'Test Car',
|
||||
timezone: 'UTC',
|
||||
availableHours: 10,
|
||||
lookingForTeam: true,
|
||||
openToRequests: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.driver.id).toBe(profileDto.currentDriver?.id);
|
||||
expect(result.driver.name).toBe(profileDto.currentDriver?.name);
|
||||
expect(result.driver.countryCode).toBe(profileDto.currentDriver?.country);
|
||||
expect(result.driver.bio).toBe(profileDto.currentDriver?.bio);
|
||||
expect(result.driver.iracingId).toBe(String(profileDto.currentDriver?.iracingId));
|
||||
expect(result.stats?.totalRacesLabel).toBe('50');
|
||||
expect(result.stats?.winsLabel).toBe('10');
|
||||
expect(result.teamMemberships).toHaveLength(1);
|
||||
expect(result.extendedProfile?.socialHandles).toHaveLength(1);
|
||||
expect(result.extendedProfile?.achievements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: 'Test bio',
|
||||
iracingId: 12345,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: 100,
|
||||
},
|
||||
stats: {
|
||||
totalRaces: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
dnfs: 5,
|
||||
avgFinish: 5.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 20,
|
||||
finishRate: 90,
|
||||
winRate: 20,
|
||||
podiumRate: 40,
|
||||
percentile: 95,
|
||||
rating: 1500,
|
||||
consistency: 85,
|
||||
overallRank: 100,
|
||||
},
|
||||
finishDistribution: {
|
||||
totalRaces: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
topTen: 30,
|
||||
dnfs: 5,
|
||||
other: 15,
|
||||
},
|
||||
teamMemberships: [
|
||||
{
|
||||
teamId: 'team-1',
|
||||
teamName: 'Test Team',
|
||||
teamTag: 'TT',
|
||||
role: 'driver',
|
||||
joinedAt: '2024-01-01',
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
socialSummary: {
|
||||
friendsCount: 10,
|
||||
friends: [
|
||||
{
|
||||
id: 'friend-1',
|
||||
name: 'Friend 1',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [
|
||||
{
|
||||
platform: 'twitter',
|
||||
handle: '@test',
|
||||
url: 'https://twitter.com/test',
|
||||
},
|
||||
],
|
||||
achievements: [
|
||||
{
|
||||
id: 'ach-1',
|
||||
title: 'Achievement',
|
||||
description: 'Test achievement',
|
||||
icon: 'trophy',
|
||||
rarity: 'rare',
|
||||
earnedAt: '2024-01-01',
|
||||
},
|
||||
],
|
||||
racingStyle: 'Aggressive',
|
||||
favoriteTrack: 'Test Track',
|
||||
favoriteCar: 'Test Car',
|
||||
timezone: 'UTC',
|
||||
availableHours: 10,
|
||||
lookingForTeam: true,
|
||||
openToRequests: true,
|
||||
},
|
||||
};
|
||||
|
||||
const originalDto = { ...profileDto };
|
||||
ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(profileDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle driver without avatar', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: null,
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: null,
|
||||
},
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.driver.avatarUrl).toContain('default');
|
||||
});
|
||||
|
||||
it('should handle driver without iracingId', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: null,
|
||||
},
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.driver.iracingId).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle driver without global rank', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: null,
|
||||
},
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.driver.globalRankLabel).toBe('—');
|
||||
});
|
||||
|
||||
it('should handle empty team memberships', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: null,
|
||||
},
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.teamMemberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty friends list', () => {
|
||||
const profileDto: GetDriverProfileOutputDTO = {
|
||||
currentDriver: {
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarUrl: 'avatar-url',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAt: '2024-01-01',
|
||||
globalRank: null,
|
||||
},
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: {
|
||||
socialHandles: [],
|
||||
achievements: [],
|
||||
racingStyle: null,
|
||||
favoriteTrack: null,
|
||||
favoriteCar: null,
|
||||
timezone: null,
|
||||
availableHours: null,
|
||||
lookingForTeam: false,
|
||||
openToRequests: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = ProfileViewDataBuilder.build(profileDto);
|
||||
|
||||
expect(result.extendedProfile?.friends).toHaveLength(0);
|
||||
expect(result.extendedProfile?.friendsCountLabel).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@ import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
export class ProfileViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ProfileViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ProfileViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: GetDriverProfileOutputDTO): ProfileViewData {
|
||||
const driver = apiDto.currentDriver;
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ProtestDetailViewDataBuilder } from './ProtestDetailViewDataBuilder';
|
||||
|
||||
describe('ProtestDetailViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform ProtestDetailApiDto to ProtestDetailViewData correctly', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-123',
|
||||
leagueId: 'league-456',
|
||||
status: 'pending',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [
|
||||
{
|
||||
type: 'time_penalty',
|
||||
label: 'Time Penalty',
|
||||
description: 'Add time to race result',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
protestId: 'protest-123',
|
||||
leagueId: 'league-456',
|
||||
status: 'pending',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [
|
||||
{
|
||||
type: 'time_penalty',
|
||||
label: 'Time Penalty',
|
||||
description: 'Add time to race result',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle resolved status', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-456',
|
||||
leagueId: 'league-789',
|
||||
status: 'resolved',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 10,
|
||||
description: 'Contact at turn 5',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-3',
|
||||
name: 'Driver 3',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-4',
|
||||
name: 'Driver 4',
|
||||
},
|
||||
race: {
|
||||
id: 'race-2',
|
||||
name: 'Test Race 2',
|
||||
scheduledAt: '2024-01-02T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [],
|
||||
};
|
||||
|
||||
const result = ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(result.status).toBe('resolved');
|
||||
});
|
||||
|
||||
it('should handle multiple penalty types', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-789',
|
||||
leagueId: 'league-101',
|
||||
status: 'pending',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 15,
|
||||
description: 'Contact at turn 7',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-5',
|
||||
name: 'Driver 5',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-6',
|
||||
name: 'Driver 6',
|
||||
},
|
||||
race: {
|
||||
id: 'race-3',
|
||||
name: 'Test Race 3',
|
||||
scheduledAt: '2024-01-03T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [
|
||||
{
|
||||
type: 'time_penalty',
|
||||
label: 'Time Penalty',
|
||||
description: 'Add time to race result',
|
||||
},
|
||||
{
|
||||
type: 'grid_penalty',
|
||||
label: 'Grid Penalty',
|
||||
description: 'Drop grid positions',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(result.penaltyTypes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-101',
|
||||
leagueId: 'league-102',
|
||||
status: 'pending',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [
|
||||
{
|
||||
type: 'time_penalty',
|
||||
label: 'Time Penalty',
|
||||
description: 'Add time to race result',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(result.protestId).toBe(protestDetailApiDto.id);
|
||||
expect(result.leagueId).toBe(protestDetailApiDto.leagueId);
|
||||
expect(result.status).toBe(protestDetailApiDto.status);
|
||||
expect(result.submittedAt).toBe(protestDetailApiDto.submittedAt);
|
||||
expect(result.incident).toEqual(protestDetailApiDto.incident);
|
||||
expect(result.protestingDriver).toEqual(protestDetailApiDto.protestingDriver);
|
||||
expect(result.accusedDriver).toEqual(protestDetailApiDto.accusedDriver);
|
||||
expect(result.race).toEqual(protestDetailApiDto.race);
|
||||
expect(result.penaltyTypes).toEqual(protestDetailApiDto.penaltyTypes);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-102',
|
||||
leagueId: 'league-103',
|
||||
status: 'pending',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [],
|
||||
};
|
||||
|
||||
const originalDto = { ...protestDetailApiDto };
|
||||
ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(protestDetailApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle different status values', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-103',
|
||||
leagueId: 'league-104',
|
||||
status: 'rejected',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [],
|
||||
};
|
||||
|
||||
const result = ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
});
|
||||
|
||||
it('should handle lap 0', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-104',
|
||||
leagueId: 'league-105',
|
||||
status: 'pending',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 0,
|
||||
description: 'Contact at start',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [],
|
||||
};
|
||||
|
||||
const result = ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(result.incident.lap).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty description', () => {
|
||||
const protestDetailApiDto = {
|
||||
id: 'protest-105',
|
||||
leagueId: 'league-106',
|
||||
status: 'pending',
|
||||
submittedAt: '2024-01-01T10:00:00Z',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: '',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
penaltyTypes: [],
|
||||
};
|
||||
|
||||
const result = ProtestDetailViewDataBuilder.build(protestDetailApiDto);
|
||||
|
||||
expect(result.incident.description).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProtestDetailViewData } from '@/lib/view-data/leagues/ProtestDetailViewData';
|
||||
import { ProtestDetailViewData } from '@/lib/view-data/ProtestDetailViewData';
|
||||
|
||||
interface ProtestDetailApiDto {
|
||||
id: string;
|
||||
@@ -29,7 +29,14 @@ interface ProtestDetailApiDto {
|
||||
}>;
|
||||
}
|
||||
|
||||
export class ProtestDetailViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ProtestDetailViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ProtestDetailViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ProtestDetailApiDto): ProtestDetailViewData {
|
||||
return {
|
||||
protestId: apiDto.id,
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceDetailViewDataBuilder } from './RaceDetailViewDataBuilder';
|
||||
|
||||
describe('RaceDetailViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform API DTO to RaceDetailViewData correctly', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
league: {
|
||||
id: 'league-456',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Open',
|
||||
},
|
||||
},
|
||||
entryList: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
rating: 1500,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: true,
|
||||
},
|
||||
userResult: {
|
||||
position: 5,
|
||||
startPosition: 10,
|
||||
positionChange: 5,
|
||||
incidents: 2,
|
||||
isClean: false,
|
||||
isPodium: false,
|
||||
ratingChange: 10,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
league: {
|
||||
id: 'league-456',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Open',
|
||||
},
|
||||
},
|
||||
entryList: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
rating: 1500,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: true,
|
||||
},
|
||||
userResult: {
|
||||
position: 5,
|
||||
startPosition: 10,
|
||||
positionChange: 5,
|
||||
incidents: 2,
|
||||
isClean: false,
|
||||
isPodium: false,
|
||||
ratingChange: 10,
|
||||
},
|
||||
canReopenRace: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle race without league', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-456',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.league).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle race without user result', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-789',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.userResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple entries in entry list', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-101',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
entryList: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
rating: 1500,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Driver 2',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'UK',
|
||||
rating: 1600,
|
||||
isCurrentUser: true,
|
||||
},
|
||||
],
|
||||
registration: {
|
||||
isUserRegistered: true,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.entryList).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-102',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
league: {
|
||||
id: 'league-103',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Open',
|
||||
},
|
||||
},
|
||||
entryList: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
rating: 1500,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: true,
|
||||
},
|
||||
userResult: {
|
||||
position: 5,
|
||||
startPosition: 10,
|
||||
positionChange: 5,
|
||||
incidents: 2,
|
||||
isClean: false,
|
||||
isPodium: false,
|
||||
ratingChange: 10,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.race.id).toBe(apiDto.race.id);
|
||||
expect(result.race.track).toBe(apiDto.race.track);
|
||||
expect(result.race.car).toBe(apiDto.race.car);
|
||||
expect(result.race.scheduledAt).toBe(apiDto.race.scheduledAt);
|
||||
expect(result.race.status).toBe(apiDto.race.status);
|
||||
expect(result.race.sessionType).toBe(apiDto.race.sessionType);
|
||||
expect(result.league?.id).toBe(apiDto.league.id);
|
||||
expect(result.league?.name).toBe(apiDto.league.name);
|
||||
expect(result.registration.isUserRegistered).toBe(apiDto.registration.isUserRegistered);
|
||||
expect(result.registration.canRegister).toBe(apiDto.registration.canRegister);
|
||||
expect(result.canReopenRace).toBe(apiDto.canReopenRace);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-104',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const originalDto = { ...apiDto };
|
||||
RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null API DTO', () => {
|
||||
const result = RaceDetailViewDataBuilder.build(null);
|
||||
|
||||
expect(result.race.id).toBe('');
|
||||
expect(result.race.track).toBe('');
|
||||
expect(result.race.car).toBe('');
|
||||
expect(result.race.scheduledAt).toBe('');
|
||||
expect(result.race.status).toBe('scheduled');
|
||||
expect(result.race.sessionType).toBe('race');
|
||||
expect(result.entryList).toHaveLength(0);
|
||||
expect(result.registration.isUserRegistered).toBe(false);
|
||||
expect(result.registration.canRegister).toBe(false);
|
||||
expect(result.canReopenRace).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined API DTO', () => {
|
||||
const result = RaceDetailViewDataBuilder.build(undefined);
|
||||
|
||||
expect(result.race.id).toBe('');
|
||||
expect(result.race.track).toBe('');
|
||||
expect(result.race.car).toBe('');
|
||||
expect(result.race.scheduledAt).toBe('');
|
||||
expect(result.race.status).toBe('scheduled');
|
||||
expect(result.race.sessionType).toBe('race');
|
||||
expect(result.entryList).toHaveLength(0);
|
||||
expect(result.registration.isUserRegistered).toBe(false);
|
||||
expect(result.registration.canRegister).toBe(false);
|
||||
expect(result.canReopenRace).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle race without entry list', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-105',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'race',
|
||||
},
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.entryList).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle different race statuses', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-106',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'running',
|
||||
sessionType: 'race',
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.race.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should handle different session types', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-107',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'scheduled',
|
||||
sessionType: 'qualifying',
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: false,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.race.sessionType).toBe('qualifying');
|
||||
});
|
||||
|
||||
it('should handle canReopenRace true', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-108',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
status: 'completed',
|
||||
sessionType: 'race',
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
canReopenRace: true,
|
||||
};
|
||||
|
||||
const result = RaceDetailViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.canReopenRace).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RaceDetailViewData, RaceDetailRace, RaceDetailLeague, RaceDetailEntry, RaceDetailRegistration, RaceDetailUserResult } from '@/lib/view-data/races/RaceDetailViewData';
|
||||
import { RaceDetailEntry, RaceDetailLeague, RaceDetailRace, RaceDetailRegistration, RaceDetailUserResult, RaceDetailViewData } from '@/lib/view-data/RaceDetailViewData';
|
||||
|
||||
/**
|
||||
* Race Detail View Data Builder
|
||||
@@ -6,7 +6,14 @@ import { RaceDetailViewData, RaceDetailRace, RaceDetailLeague, RaceDetailEntry,
|
||||
* Transforms API DTO into ViewData for the race detail template.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class RaceDetailViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RaceDetailViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RaceDetailViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: any): RaceDetailViewData {
|
||||
if (!apiDto || !apiDto.race) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceResultsViewDataBuilder } from './RaceResultsViewDataBuilder';
|
||||
|
||||
describe('RaceResultsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform API DTO to RaceResultsViewData correctly', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driverName: 'Driver 2',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
pointsSystem: {
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
},
|
||||
fastestLapTime: 120000,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
raceTrack: 'Test Track',
|
||||
raceScheduledAt: '2024-01-01T10:00:00Z',
|
||||
totalDrivers: 20,
|
||||
leagueName: 'Test League',
|
||||
raceSOF: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
driverAvatar: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driverName: 'Driver 2',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
pointsSystem: {
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
},
|
||||
fastestLapTime: 120000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty results and penalties', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 0,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: null,
|
||||
results: [],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results).toHaveLength(0);
|
||||
expect(result.penalties).toHaveLength(0);
|
||||
expect(result.raceSOF).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple results and penalties', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
{
|
||||
position: 2,
|
||||
driverId: 'driver-2',
|
||||
driverName: 'Driver 2',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'UK',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:24.000',
|
||||
fastestLap: '1:21.000',
|
||||
points: 18,
|
||||
incidents: 1,
|
||||
isCurrentUser: true,
|
||||
},
|
||||
],
|
||||
penalties: [
|
||||
{
|
||||
driverId: 'driver-3',
|
||||
driverName: 'Driver 3',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-4',
|
||||
driverName: 'Driver 4',
|
||||
type: 'grid_penalty',
|
||||
value: 3,
|
||||
reason: 'Qualifying infringement',
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
pointsSystem: {
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
},
|
||||
fastestLapTime: 120000,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.penalties).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
},
|
||||
fastestLapTime: 120000,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.raceTrack).toBe(apiDto.race.track);
|
||||
expect(result.raceScheduledAt).toBe(apiDto.race.scheduledAt);
|
||||
expect(result.totalDrivers).toBe(apiDto.stats.totalDrivers);
|
||||
expect(result.leagueName).toBe(apiDto.league.name);
|
||||
expect(result.raceSOF).toBe(apiDto.strengthOfField);
|
||||
expect(result.pointsSystem).toEqual(apiDto.pointsSystem);
|
||||
expect(result.fastestLapTime).toBe(apiDto.fastestLapTime);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const originalDto = { ...apiDto };
|
||||
RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null API DTO', () => {
|
||||
const result = RaceResultsViewDataBuilder.build(null);
|
||||
|
||||
expect(result.raceSOF).toBeNull();
|
||||
expect(result.results).toHaveLength(0);
|
||||
expect(result.penalties).toHaveLength(0);
|
||||
expect(result.pointsSystem).toEqual({});
|
||||
expect(result.fastestLapTime).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle undefined API DTO', () => {
|
||||
const result = RaceResultsViewDataBuilder.build(undefined);
|
||||
|
||||
expect(result.raceSOF).toBeNull();
|
||||
expect(result.results).toHaveLength(0);
|
||||
expect(result.penalties).toHaveLength(0);
|
||||
expect(result.pointsSystem).toEqual({});
|
||||
expect(result.fastestLapTime).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle results without country', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: null,
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].country).toBe('US');
|
||||
});
|
||||
|
||||
it('should handle results without car', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: null,
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].car).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should handle results without laps', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: null,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].laps).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle results without time', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: null,
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].time).toBe('0:00.00');
|
||||
});
|
||||
|
||||
it('should handle results without fastest lap', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: null,
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].fastestLap).toBe('0.00');
|
||||
});
|
||||
|
||||
it('should handle results without points', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: null,
|
||||
incidents: 0,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].points).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle results without incidents', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: null,
|
||||
isCurrentUser: false,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].incidents).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle results without isCurrentUser', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [
|
||||
{
|
||||
position: 1,
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
avatarUrl: 'avatar-url',
|
||||
country: 'US',
|
||||
car: 'Test Car',
|
||||
laps: 30,
|
||||
time: '1:23.456',
|
||||
fastestLap: '1:20.000',
|
||||
points: 25,
|
||||
incidents: 0,
|
||||
isCurrentUser: null,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.results[0].isCurrentUser).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle penalties without driver name', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [],
|
||||
penalties: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driverName: null,
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].driverName).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should handle penalties without value', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [],
|
||||
penalties: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
type: 'time_penalty',
|
||||
value: null,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].value).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle penalties without reason', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [],
|
||||
penalties: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: null,
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].reason).toBe('Penalty applied');
|
||||
});
|
||||
|
||||
it('should handle different penalty types', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
stats: {
|
||||
totalDrivers: 20,
|
||||
},
|
||||
league: {
|
||||
name: 'Test League',
|
||||
},
|
||||
strengthOfField: 1500,
|
||||
results: [],
|
||||
penalties: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver 1',
|
||||
type: 'grid_penalty',
|
||||
value: 3,
|
||||
reason: 'Qualifying infringement',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driverName: 'Driver 2',
|
||||
type: 'points_deduction',
|
||||
value: 10,
|
||||
reason: 'Dangerous driving',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-3',
|
||||
driverName: 'Driver 3',
|
||||
type: 'disqualification',
|
||||
value: 0,
|
||||
reason: 'Technical infringement',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-4',
|
||||
driverName: 'Driver 4',
|
||||
type: 'warning',
|
||||
value: 0,
|
||||
reason: 'Minor infraction',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-5',
|
||||
driverName: 'Driver 5',
|
||||
type: 'license_points',
|
||||
value: 2,
|
||||
reason: 'Multiple incidents',
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
};
|
||||
|
||||
const result = RaceResultsViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].type).toBe('grid_penalty');
|
||||
expect(result.penalties[1].type).toBe('points_deduction');
|
||||
expect(result.penalties[2].type).toBe('disqualification');
|
||||
expect(result.penalties[3].type).toBe('warning');
|
||||
expect(result.penalties[4].type).toBe('license_points');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RaceResultsViewData, RaceResultsResult, RaceResultsPenalty } from '@/lib/view-data/races/RaceResultsViewData';
|
||||
import { RaceResultsPenalty, RaceResultsResult, RaceResultsViewData } from '@/lib/view-data/RaceResultsViewData';
|
||||
|
||||
/**
|
||||
* Race Results View Data Builder
|
||||
@@ -6,7 +6,14 @@ import { RaceResultsViewData, RaceResultsResult, RaceResultsPenalty } from '@/li
|
||||
* Transforms API DTO into ViewData for the race results template.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class RaceResultsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RaceResultsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RaceResultsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: unknown): RaceResultsViewData {
|
||||
if (!apiDto) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceStewardingViewDataBuilder } from './RaceStewardingViewDataBuilder';
|
||||
|
||||
describe('RaceStewardingViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform API DTO to RaceStewardingViewData correctly', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-456',
|
||||
},
|
||||
pendingProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
resolvedProtests: [
|
||||
{
|
||||
id: 'protest-2',
|
||||
protestingDriverId: 'driver-3',
|
||||
accusedDriverId: 'driver-4',
|
||||
incident: {
|
||||
lap: 10,
|
||||
description: 'Contact at turn 5',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'resolved',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: 'Penalty applied',
|
||||
},
|
||||
],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-5',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
'driver-3': { id: 'driver-3', name: 'Driver 3' },
|
||||
'driver-4': { id: 'driver-4', name: 'Driver 4' },
|
||||
'driver-5': { id: 'driver-5', name: 'Driver 5' },
|
||||
},
|
||||
pendingCount: 1,
|
||||
resolvedCount: 1,
|
||||
penaltiesCount: 1,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-456',
|
||||
},
|
||||
pendingProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
resolvedProtests: [
|
||||
{
|
||||
id: 'protest-2',
|
||||
protestingDriverId: 'driver-3',
|
||||
accusedDriverId: 'driver-4',
|
||||
incident: {
|
||||
lap: 10,
|
||||
description: 'Contact at turn 5',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'resolved',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: 'Penalty applied',
|
||||
},
|
||||
],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-5',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
'driver-3': { id: 'driver-3', name: 'Driver 3' },
|
||||
'driver-4': { id: 'driver-4', name: 'Driver 4' },
|
||||
'driver-5': { id: 'driver-5', name: 'Driver 5' },
|
||||
},
|
||||
pendingCount: 1,
|
||||
resolvedCount: 1,
|
||||
penaltiesCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty protests and penalties', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-456',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-789',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [],
|
||||
driverMap: {},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.pendingProtests).toHaveLength(0);
|
||||
expect(result.resolvedProtests).toHaveLength(0);
|
||||
expect(result.penalties).toHaveLength(0);
|
||||
expect(result.pendingCount).toBe(0);
|
||||
expect(result.resolvedCount).toBe(0);
|
||||
expect(result.penaltiesCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple protests and penalties', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-789',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-101',
|
||||
},
|
||||
pendingProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
{
|
||||
id: 'protest-2',
|
||||
protestingDriverId: 'driver-3',
|
||||
accusedDriverId: 'driver-4',
|
||||
incident: {
|
||||
lap: 10,
|
||||
description: 'Contact at turn 5',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
resolvedProtests: [
|
||||
{
|
||||
id: 'protest-3',
|
||||
protestingDriverId: 'driver-5',
|
||||
accusedDriverId: 'driver-6',
|
||||
incident: {
|
||||
lap: 15,
|
||||
description: 'Contact at turn 7',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'resolved',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: 'Penalty applied',
|
||||
},
|
||||
],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-7',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
{
|
||||
id: 'penalty-2',
|
||||
driverId: 'driver-8',
|
||||
type: 'grid_penalty',
|
||||
value: 3,
|
||||
reason: 'Qualifying infringement',
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
'driver-3': { id: 'driver-3', name: 'Driver 3' },
|
||||
'driver-4': { id: 'driver-4', name: 'Driver 4' },
|
||||
'driver-5': { id: 'driver-5', name: 'Driver 5' },
|
||||
'driver-6': { id: 'driver-6', name: 'Driver 6' },
|
||||
'driver-7': { id: 'driver-7', name: 'Driver 7' },
|
||||
'driver-8': { id: 'driver-8', name: 'Driver 8' },
|
||||
},
|
||||
pendingCount: 2,
|
||||
resolvedCount: 1,
|
||||
penaltiesCount: 2,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.pendingProtests).toHaveLength(2);
|
||||
expect(result.resolvedProtests).toHaveLength(1);
|
||||
expect(result.penalties).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-102',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-103',
|
||||
},
|
||||
pendingProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
resolvedProtests: [],
|
||||
penalties: [],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
},
|
||||
pendingCount: 1,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.race?.id).toBe(apiDto.race.id);
|
||||
expect(result.race?.track).toBe(apiDto.race.track);
|
||||
expect(result.race?.scheduledAt).toBe(apiDto.race.scheduledAt);
|
||||
expect(result.league?.id).toBe(apiDto.league.id);
|
||||
expect(result.pendingCount).toBe(apiDto.pendingCount);
|
||||
expect(result.resolvedCount).toBe(apiDto.resolvedCount);
|
||||
expect(result.penaltiesCount).toBe(apiDto.penaltiesCount);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-104',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-105',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [],
|
||||
driverMap: {},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const originalDto = { ...apiDto };
|
||||
RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null API DTO', () => {
|
||||
const result = RaceStewardingViewDataBuilder.build(null);
|
||||
|
||||
expect(result.race).toBeNull();
|
||||
expect(result.league).toBeNull();
|
||||
expect(result.pendingProtests).toHaveLength(0);
|
||||
expect(result.resolvedProtests).toHaveLength(0);
|
||||
expect(result.penalties).toHaveLength(0);
|
||||
expect(result.driverMap).toEqual({});
|
||||
expect(result.pendingCount).toBe(0);
|
||||
expect(result.resolvedCount).toBe(0);
|
||||
expect(result.penaltiesCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle undefined API DTO', () => {
|
||||
const result = RaceStewardingViewDataBuilder.build(undefined);
|
||||
|
||||
expect(result.race).toBeNull();
|
||||
expect(result.league).toBeNull();
|
||||
expect(result.pendingProtests).toHaveLength(0);
|
||||
expect(result.resolvedProtests).toHaveLength(0);
|
||||
expect(result.penalties).toHaveLength(0);
|
||||
expect(result.driverMap).toEqual({});
|
||||
expect(result.pendingCount).toBe(0);
|
||||
expect(result.resolvedCount).toBe(0);
|
||||
expect(result.penaltiesCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle race without league', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-106',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [],
|
||||
driverMap: {},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.league).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle protests without proof video', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-107',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-108',
|
||||
},
|
||||
pendingProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: null,
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
resolvedProtests: [],
|
||||
penalties: [],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
},
|
||||
pendingCount: 1,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.pendingProtests[0].proofVideoUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle protests without decision notes', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-109',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-110',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'resolved',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 1,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.resolvedProtests[0].decisionNotes).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle penalties without notes', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-111',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-112',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 1,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].notes).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle penalties without value', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-113',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-114',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'disqualification',
|
||||
value: null,
|
||||
reason: 'Technical infringement',
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 1,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].value).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle penalties without reason', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-115',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-116',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'warning',
|
||||
value: 0,
|
||||
reason: null,
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 1,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].reason).toBe('');
|
||||
});
|
||||
|
||||
it('should handle different protest statuses', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-117',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-118',
|
||||
},
|
||||
pendingProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
resolvedProtests: [
|
||||
{
|
||||
id: 'protest-2',
|
||||
protestingDriverId: 'driver-3',
|
||||
accusedDriverId: 'driver-4',
|
||||
incident: {
|
||||
lap: 10,
|
||||
description: 'Contact at turn 5',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'resolved',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: 'Penalty applied',
|
||||
},
|
||||
{
|
||||
id: 'protest-3',
|
||||
protestingDriverId: 'driver-5',
|
||||
accusedDriverId: 'driver-6',
|
||||
incident: {
|
||||
lap: 15,
|
||||
description: 'Contact at turn 7',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'rejected',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: 'Insufficient evidence',
|
||||
},
|
||||
],
|
||||
penalties: [],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
'driver-3': { id: 'driver-3', name: 'Driver 3' },
|
||||
'driver-4': { id: 'driver-4', name: 'Driver 4' },
|
||||
'driver-5': { id: 'driver-5', name: 'Driver 5' },
|
||||
'driver-6': { id: 'driver-6', name: 'Driver 6' },
|
||||
},
|
||||
pendingCount: 1,
|
||||
resolvedCount: 2,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.pendingProtests[0].status).toBe('pending');
|
||||
expect(result.resolvedProtests[0].status).toBe('resolved');
|
||||
expect(result.resolvedProtests[1].status).toBe('rejected');
|
||||
});
|
||||
|
||||
it('should handle different penalty types', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-119',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-120',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
{
|
||||
id: 'penalty-2',
|
||||
driverId: 'driver-2',
|
||||
type: 'grid_penalty',
|
||||
value: 3,
|
||||
reason: 'Qualifying infringement',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'penalty-3',
|
||||
driverId: 'driver-3',
|
||||
type: 'points_deduction',
|
||||
value: 10,
|
||||
reason: 'Dangerous driving',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'penalty-4',
|
||||
driverId: 'driver-4',
|
||||
type: 'disqualification',
|
||||
value: 0,
|
||||
reason: 'Technical infringement',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'penalty-5',
|
||||
driverId: 'driver-5',
|
||||
type: 'warning',
|
||||
value: 0,
|
||||
reason: 'Minor infraction',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'penalty-6',
|
||||
driverId: 'driver-6',
|
||||
type: 'license_points',
|
||||
value: 2,
|
||||
reason: 'Multiple incidents',
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
'driver-3': { id: 'driver-3', name: 'Driver 3' },
|
||||
'driver-4': { id: 'driver-4', name: 'Driver 4' },
|
||||
'driver-5': { id: 'driver-5', name: 'Driver 5' },
|
||||
'driver-6': { id: 'driver-6', name: 'Driver 6' },
|
||||
},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 6,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.penalties[0].type).toBe('time_penalty');
|
||||
expect(result.penalties[1].type).toBe('grid_penalty');
|
||||
expect(result.penalties[2].type).toBe('points_deduction');
|
||||
expect(result.penalties[3].type).toBe('disqualification');
|
||||
expect(result.penalties[4].type).toBe('warning');
|
||||
expect(result.penalties[5].type).toBe('license_points');
|
||||
});
|
||||
|
||||
it('should handle empty driver map', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-121',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-122',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [],
|
||||
driverMap: {},
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.driverMap).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle count values from DTO', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-124',
|
||||
},
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
penalties: [],
|
||||
driverMap: {},
|
||||
pendingCount: 5,
|
||||
resolvedCount: 10,
|
||||
penaltiesCount: 3,
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.pendingCount).toBe(5);
|
||||
expect(result.resolvedCount).toBe(10);
|
||||
expect(result.penaltiesCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should calculate counts from arrays when not provided', () => {
|
||||
const apiDto = {
|
||||
race: {
|
||||
id: 'race-125',
|
||||
track: 'Test Track',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
},
|
||||
league: {
|
||||
id: 'league-126',
|
||||
},
|
||||
pendingProtests: [
|
||||
{
|
||||
id: 'protest-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact at turn 3',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: null,
|
||||
},
|
||||
],
|
||||
resolvedProtests: [
|
||||
{
|
||||
id: 'protest-2',
|
||||
protestingDriverId: 'driver-3',
|
||||
accusedDriverId: 'driver-4',
|
||||
incident: {
|
||||
lap: 10,
|
||||
description: 'Contact at turn 5',
|
||||
},
|
||||
filedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'resolved',
|
||||
proofVideoUrl: 'video-url',
|
||||
decisionNotes: 'Penalty applied',
|
||||
},
|
||||
],
|
||||
penalties: [
|
||||
{
|
||||
id: 'penalty-1',
|
||||
driverId: 'driver-5',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Track limits',
|
||||
notes: 'Warning issued',
|
||||
},
|
||||
],
|
||||
driverMap: {
|
||||
'driver-1': { id: 'driver-1', name: 'Driver 1' },
|
||||
'driver-2': { id: 'driver-2', name: 'Driver 2' },
|
||||
'driver-3': { id: 'driver-3', name: 'Driver 3' },
|
||||
'driver-4': { id: 'driver-4', name: 'Driver 4' },
|
||||
'driver-5': { id: 'driver-5', name: 'Driver 5' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = RaceStewardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.pendingCount).toBe(1);
|
||||
expect(result.resolvedCount).toBe(1);
|
||||
expect(result.penaltiesCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RaceStewardingViewData, Protest, Penalty, Driver } from '@/lib/view-data/races/RaceStewardingViewData';
|
||||
import { Driver, Penalty, Protest, RaceStewardingViewData } from '@/lib/view-data/RaceStewardingViewData';
|
||||
|
||||
/**
|
||||
* Race Stewarding View Data Builder
|
||||
@@ -6,7 +6,14 @@ import { RaceStewardingViewData, Protest, Penalty, Driver } from '@/lib/view-dat
|
||||
* Transforms API DTO into ViewData for the race stewarding template.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class RaceStewardingViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RaceStewardingViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RaceStewardingViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: unknown): RaceStewardingViewData {
|
||||
if (!apiDto) {
|
||||
return {
|
||||
|
||||
187
apps/website/lib/builders/view-data/RacesViewDataBuilder.test.ts
Normal file
187
apps/website/lib/builders/view-data/RacesViewDataBuilder.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RacesViewDataBuilder } from './RacesViewDataBuilder';
|
||||
import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO';
|
||||
|
||||
describe('RacesViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform RacesPageDataDTO to RacesViewData correctly', () => {
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto: RacesPageDataDTO = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
scheduledAt: pastDate.toISOString(),
|
||||
status: 'completed',
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Pro League',
|
||||
strengthOfField: 1500,
|
||||
isUpcoming: false,
|
||||
isLive: false,
|
||||
isPast: true,
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
scheduledAt: futureDate.toISOString(),
|
||||
status: 'scheduled',
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Pro League',
|
||||
strengthOfField: 1600,
|
||||
isUpcoming: true,
|
||||
isLive: false,
|
||||
isPast: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = RacesViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.races).toHaveLength(2);
|
||||
expect(result.totalCount).toBe(2);
|
||||
expect(result.completedCount).toBe(1);
|
||||
expect(result.scheduledCount).toBe(1);
|
||||
expect(result.leagues).toHaveLength(1);
|
||||
expect(result.leagues[0]).toEqual({ id: 'league-1', name: 'Pro League' });
|
||||
expect(result.upcomingRaces).toHaveLength(1);
|
||||
expect(result.upcomingRaces[0].id).toBe('race-2');
|
||||
expect(result.recentResults).toHaveLength(1);
|
||||
expect(result.recentResults[0].id).toBe('race-1');
|
||||
expect(result.racesByDate).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty races list', () => {
|
||||
const apiDto: RacesPageDataDTO = {
|
||||
races: [],
|
||||
};
|
||||
|
||||
const result = RacesViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.races).toHaveLength(0);
|
||||
expect(result.totalCount).toBe(0);
|
||||
expect(result.leagues).toHaveLength(0);
|
||||
expect(result.racesByDate).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const now = new Date();
|
||||
const apiDto: RacesPageDataDTO = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
scheduledAt: now.toISOString(),
|
||||
status: 'scheduled',
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Pro League',
|
||||
strengthOfField: 1500,
|
||||
isUpcoming: true,
|
||||
isLive: false,
|
||||
isPast: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = RacesViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.races[0].id).toBe(apiDto.races[0].id);
|
||||
expect(result.races[0].track).toBe(apiDto.races[0].track);
|
||||
expect(result.races[0].car).toBe(apiDto.races[0].car);
|
||||
expect(result.races[0].scheduledAt).toBe(apiDto.races[0].scheduledAt);
|
||||
expect(result.races[0].status).toBe(apiDto.races[0].status);
|
||||
expect(result.races[0].leagueId).toBe(apiDto.races[0].leagueId);
|
||||
expect(result.races[0].leagueName).toBe(apiDto.races[0].leagueName);
|
||||
expect(result.races[0].strengthOfField).toBe(apiDto.races[0].strengthOfField);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const now = new Date();
|
||||
const apiDto: RacesPageDataDTO = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
scheduledAt: now.toISOString(),
|
||||
status: 'scheduled',
|
||||
isUpcoming: true,
|
||||
isLive: false,
|
||||
isPast: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const originalDto = JSON.parse(JSON.stringify(apiDto));
|
||||
RacesViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle races with missing optional fields', () => {
|
||||
const now = new Date();
|
||||
const apiDto: RacesPageDataDTO = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
scheduledAt: now.toISOString(),
|
||||
status: 'scheduled',
|
||||
isUpcoming: true,
|
||||
isLive: false,
|
||||
isPast: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = RacesViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.races[0].leagueId).toBeUndefined();
|
||||
expect(result.races[0].leagueName).toBeUndefined();
|
||||
expect(result.races[0].strengthOfField).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple races on the same date', () => {
|
||||
const date = '2024-01-15T14:00:00.000Z';
|
||||
const apiDto: RacesPageDataDTO = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Spa',
|
||||
car: 'Porsche',
|
||||
scheduledAt: date,
|
||||
status: 'scheduled',
|
||||
isUpcoming: true,
|
||||
isLive: false,
|
||||
isPast: false,
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
track: 'Monza',
|
||||
car: 'Ferrari',
|
||||
scheduledAt: date,
|
||||
status: 'scheduled',
|
||||
isUpcoming: true,
|
||||
isLive: false,
|
||||
isPast: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = RacesViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.racesByDate).toHaveLength(1);
|
||||
expect(result.racesByDate[0].races).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,14 @@ import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay';
|
||||
import { RelativeTimeDisplay } from '@/lib/display-objects/RelativeTimeDisplay';
|
||||
|
||||
export class RacesViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RacesViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RacesViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: RacesPageDataDTO): RacesViewData {
|
||||
const now = new Date();
|
||||
const races = apiDto.races.map((race): RaceViewData => {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ResetPasswordViewDataBuilder } from './ResetPasswordViewDataBuilder';
|
||||
import type { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
|
||||
|
||||
describe('ResetPasswordViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform ResetPasswordPageDTO to ResetPasswordViewData correctly', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
showSuccess: false,
|
||||
formState: {
|
||||
fields: {
|
||||
newPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
confirmPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty returnTo path', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('');
|
||||
});
|
||||
|
||||
it('should handle returnTo with query parameters', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login?success=true',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login?success=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.token).toBe(resetPasswordPageDTO.token);
|
||||
expect(result.returnTo).toBe(resetPasswordPageDTO.returnTo);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const originalDTO = { ...resetPasswordPageDTO };
|
||||
ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(resetPasswordPageDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should initialize form fields with default values', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.formState.fields.newPassword.value).toBe('');
|
||||
expect(result.formState.fields.newPassword.error).toBeUndefined();
|
||||
expect(result.formState.fields.newPassword.touched).toBe(false);
|
||||
expect(result.formState.fields.newPassword.validating).toBe(false);
|
||||
|
||||
expect(result.formState.fields.confirmPassword.value).toBe('');
|
||||
expect(result.formState.fields.confirmPassword.error).toBeUndefined();
|
||||
expect(result.formState.fields.confirmPassword.touched).toBe(false);
|
||||
expect(result.formState.fields.confirmPassword.validating).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize form state with default values', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.formState.isValid).toBe(true);
|
||||
expect(result.formState.isSubmitting).toBe(false);
|
||||
expect(result.formState.submitError).toBeUndefined();
|
||||
expect(result.formState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should initialize UI state flags correctly', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.showSuccess).toBe(false);
|
||||
expect(result.isSubmitting).toBe(false);
|
||||
expect(result.submitError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle token with special characters', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc-123_def.456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.token).toBe('abc-123_def.456');
|
||||
});
|
||||
|
||||
it('should handle token with URL-encoded characters', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc%20123%40def',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.token).toBe('abc%20123%40def');
|
||||
});
|
||||
|
||||
it('should handle returnTo with encoded characters', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login?redirect=%2Fdashboard',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login?redirect=%2Fdashboard');
|
||||
});
|
||||
|
||||
it('should handle returnTo with hash fragment', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login#section',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login#section');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form state structure', () => {
|
||||
it('should have all required form fields', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
expect(result.formState.fields).toHaveProperty('newPassword');
|
||||
expect(result.formState.fields).toHaveProperty('confirmPassword');
|
||||
});
|
||||
|
||||
it('should have consistent field state structure', () => {
|
||||
const resetPasswordPageDTO: ResetPasswordPageDTO = {
|
||||
token: 'abc123def456',
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ResetPasswordViewDataBuilder.build(resetPasswordPageDTO);
|
||||
|
||||
const fields = result.formState.fields;
|
||||
Object.values(fields).forEach((field) => {
|
||||
expect(field).toHaveProperty('value');
|
||||
expect(field).toHaveProperty('error');
|
||||
expect(field).toHaveProperty('touched');
|
||||
expect(field).toHaveProperty('validating');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,18 @@
|
||||
*/
|
||||
|
||||
import { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
|
||||
import { ResetPasswordViewData } from './types/ResetPasswordViewData';
|
||||
import { ResetPasswordViewData } from '../../view-data/ResetPasswordViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { error } from 'console';
|
||||
|
||||
export class ResetPasswordViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ResetPasswordViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ResetPasswordViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ResetPasswordPageDTO): ResetPasswordViewData {
|
||||
return {
|
||||
token: apiDto.token,
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RulebookViewDataBuilder } from './RulebookViewDataBuilder';
|
||||
import type { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
|
||||
|
||||
describe('RulebookViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform RulebookApiDto to RulebookViewData correctly', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-123',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
{ sessionType: 'race', position: 2, points: 18 },
|
||||
{ sessionType: 'race', position: 3, points: 15 },
|
||||
],
|
||||
bonusSummary: [
|
||||
{ type: 'fastest_lap', points: 5, description: 'Fastest lap' },
|
||||
],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'Drop 2 worst results',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
leagueId: 'league-123',
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championshipsCount: 1,
|
||||
sessionTypes: 'race',
|
||||
dropPolicySummary: 'Drop 2 worst results',
|
||||
hasActiveDropPolicy: true,
|
||||
positionPoints: [
|
||||
{ position: 1, points: 25 },
|
||||
{ position: 2, points: 18 },
|
||||
{ position: 3, points: 15 },
|
||||
],
|
||||
bonusPoints: [
|
||||
{ type: 'fastest_lap', points: 5, description: 'Fastest lap' },
|
||||
],
|
||||
hasBonusPoints: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle championship without driver type', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-456',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'team',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.positionPoints).toEqual([{ position: 1, points: 25 }]);
|
||||
});
|
||||
|
||||
it('should handle multiple championships', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-789',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
{
|
||||
type: 'team',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.championshipsCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle empty bonus points', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-101',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.bonusPoints).toEqual([]);
|
||||
expect(result.hasBonusPoints).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-102',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [
|
||||
{ type: 'fastest_lap', points: 5, description: 'Fastest lap' },
|
||||
],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'Drop 2 worst results',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.leagueId).toBe(rulebookApiDto.leagueId);
|
||||
expect(result.gameName).toBe(rulebookApiDto.scoringConfig.gameName);
|
||||
expect(result.scoringPresetName).toBe(rulebookApiDto.scoringConfig.scoringPresetName);
|
||||
expect(result.dropPolicySummary).toBe(rulebookApiDto.scoringConfig.dropPolicySummary);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-103',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const originalDto = { ...rulebookApiDto };
|
||||
RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(rulebookApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty drop policy', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-104',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: '',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.hasActiveDropPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle drop policy with "All" keyword', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-105',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'Drop all results',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.hasActiveDropPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple session types', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-106',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race', 'qualifying', 'practice'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.sessionTypes).toBe('race, qualifying, practice');
|
||||
});
|
||||
|
||||
it('should handle single session type', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-107',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.sessionTypes).toBe('race');
|
||||
});
|
||||
|
||||
it('should handle empty points preview', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-108',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.positionPoints).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle points preview with different session types', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-109',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
{ sessionType: 'qualifying', position: 1, points: 10 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.positionPoints).toEqual([{ position: 1, points: 25 }]);
|
||||
});
|
||||
|
||||
it('should handle points preview with non-sequential positions', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-110',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
{ sessionType: 'race', position: 3, points: 15 },
|
||||
{ sessionType: 'race', position: 2, points: 18 },
|
||||
],
|
||||
bonusSummary: [],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.positionPoints).toEqual([
|
||||
{ position: 1, points: 25 },
|
||||
{ position: 2, points: 18 },
|
||||
{ position: 3, points: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple bonus points', () => {
|
||||
const rulebookApiDto: RulebookApiDto = {
|
||||
leagueId: 'league-111',
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Standard',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'race', position: 1, points: 25 },
|
||||
],
|
||||
bonusSummary: [
|
||||
{ type: 'fastest_lap', points: 5, description: 'Fastest lap' },
|
||||
{ type: 'pole_position', points: 3, description: 'Pole position' },
|
||||
{ type: 'clean_race', points: 2, description: 'Clean race' },
|
||||
],
|
||||
},
|
||||
],
|
||||
dropPolicySummary: 'No drops',
|
||||
},
|
||||
};
|
||||
|
||||
const result = RulebookViewDataBuilder.build(rulebookApiDto);
|
||||
|
||||
expect(result.bonusPoints).toHaveLength(3);
|
||||
expect(result.hasBonusPoints).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
|
||||
import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
|
||||
import { RulebookViewData } from '@/lib/view-data/RulebookViewData';
|
||||
|
||||
export class RulebookViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RulebookViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RulebookViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: RulebookApiDto): RulebookViewData {
|
||||
const primaryChampionship = apiDto.scoringConfig.championships.find(c => c.type === 'driver') ?? apiDto.scoringConfig.championships[0];
|
||||
const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SignupViewDataBuilder } from './SignupViewDataBuilder';
|
||||
import type { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
|
||||
|
||||
describe('SignupViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform SignupPageDTO to SignupViewData correctly', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result).toEqual({
|
||||
returnTo: '/dashboard',
|
||||
formState: {
|
||||
fields: {
|
||||
firstName: { value: '', error: undefined, touched: false, validating: false },
|
||||
lastName: { value: '', error: undefined, touched: false, validating: false },
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
password: { value: '', error: undefined, touched: false, validating: false },
|
||||
confirmPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty returnTo path', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('');
|
||||
});
|
||||
|
||||
it('should handle returnTo with query parameters', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard?welcome=true',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/dashboard?welcome=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe(signupPageDTO.returnTo);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const originalDTO = { ...signupPageDTO };
|
||||
SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(signupPageDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should initialize all signup form fields with default values', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.formState.fields.firstName.value).toBe('');
|
||||
expect(result.formState.fields.firstName.error).toBeUndefined();
|
||||
expect(result.formState.fields.firstName.touched).toBe(false);
|
||||
expect(result.formState.fields.firstName.validating).toBe(false);
|
||||
|
||||
expect(result.formState.fields.lastName.value).toBe('');
|
||||
expect(result.formState.fields.lastName.error).toBeUndefined();
|
||||
expect(result.formState.fields.lastName.touched).toBe(false);
|
||||
expect(result.formState.fields.lastName.validating).toBe(false);
|
||||
|
||||
expect(result.formState.fields.email.value).toBe('');
|
||||
expect(result.formState.fields.email.error).toBeUndefined();
|
||||
expect(result.formState.fields.email.touched).toBe(false);
|
||||
expect(result.formState.fields.email.validating).toBe(false);
|
||||
|
||||
expect(result.formState.fields.password.value).toBe('');
|
||||
expect(result.formState.fields.password.error).toBeUndefined();
|
||||
expect(result.formState.fields.password.touched).toBe(false);
|
||||
expect(result.formState.fields.password.validating).toBe(false);
|
||||
|
||||
expect(result.formState.fields.confirmPassword.value).toBe('');
|
||||
expect(result.formState.fields.confirmPassword.error).toBeUndefined();
|
||||
expect(result.formState.fields.confirmPassword.touched).toBe(false);
|
||||
expect(result.formState.fields.confirmPassword.validating).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize form state with default values', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.formState.isValid).toBe(true);
|
||||
expect(result.formState.isSubmitting).toBe(false);
|
||||
expect(result.formState.submitError).toBeUndefined();
|
||||
expect(result.formState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should initialize UI state flags correctly', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.isSubmitting).toBe(false);
|
||||
expect(result.submitError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle returnTo with encoded characters', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard?redirect=%2Fadmin',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
});
|
||||
|
||||
it('should handle returnTo with hash fragment', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard#section',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/dashboard#section');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form state structure', () => {
|
||||
it('should have all required form fields', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
expect(result.formState.fields).toHaveProperty('firstName');
|
||||
expect(result.formState.fields).toHaveProperty('lastName');
|
||||
expect(result.formState.fields).toHaveProperty('email');
|
||||
expect(result.formState.fields).toHaveProperty('password');
|
||||
expect(result.formState.fields).toHaveProperty('confirmPassword');
|
||||
});
|
||||
|
||||
it('should have consistent field state structure', () => {
|
||||
const signupPageDTO: SignupPageDTO = {
|
||||
returnTo: '/dashboard',
|
||||
};
|
||||
|
||||
const result = SignupViewDataBuilder.build(signupPageDTO);
|
||||
|
||||
const fields = result.formState.fields;
|
||||
Object.values(fields).forEach((field) => {
|
||||
expect(field).toHaveProperty('value');
|
||||
expect(field).toHaveProperty('error');
|
||||
expect(field).toHaveProperty('touched');
|
||||
expect(field).toHaveProperty('validating');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,14 @@
|
||||
*/
|
||||
|
||||
import { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
|
||||
import { SignupViewData } from './types/SignupViewData';
|
||||
import { SignupViewData } from '../../view-data/SignupViewData';
|
||||
import { ViewDataBuilder } from '../../contracts/builders/ViewDataBuilder';
|
||||
|
||||
export class SignupViewDataBuilder implements ViewDataBuilder<SignupPageDTO, SignupViewData> {
|
||||
build(apiDto: SignupPageDTO): SignupViewData {
|
||||
return SignupViewDataBuilder.build(apiDto);
|
||||
}
|
||||
|
||||
export class SignupViewDataBuilder {
|
||||
static build(apiDto: SignupPageDTO): SignupViewData {
|
||||
return {
|
||||
returnTo: apiDto.returnTo,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user