Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion FrontEnd/my-app/app/[locale]/quests/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function QuestDetailPage() {
setIsLoading(true);
setError(null);
const data = await getQuestById(questId);
setQuest(data as unknown as Quest);
setQuest(data);
trackEvent(ANALYTICS_EVENTS.QUEST_VIEW, { questId });
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch quest'));
Expand Down
6 changes: 3 additions & 3 deletions FrontEnd/my-app/components/homepage/FeaturedQuests.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('FeaturedQuests - Error Boundary and Timeout Integration', () => {
const mockError = new Error('API request failed');
mockGetQuests
.mockRejectedValueOnce(mockError)
.mockResolvedValueOnce({ quests: [] });
.mockResolvedValueOnce({ data: [] });

render(<FeaturedQuests />);

Expand Down Expand Up @@ -96,7 +96,7 @@ describe('FeaturedQuests - Error Boundary and Timeout Integration', () => {
);
mockGetQuests
.mockRejectedValueOnce(timeoutError)
.mockResolvedValueOnce({ quests: [] });
.mockResolvedValueOnce({ data: [] });

render(<FeaturedQuests />);

Expand Down Expand Up @@ -130,7 +130,7 @@ describe('FeaturedQuests - Error Boundary and Timeout Integration', () => {
);
mockGetQuests
.mockRejectedValueOnce(timeoutError)
.mockResolvedValueOnce({ quests: [] });
.mockResolvedValueOnce({ data: [] });

render(<FeaturedQuests />);

Expand Down
7 changes: 2 additions & 5 deletions FrontEnd/my-app/components/homepage/FeaturedQuests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,11 @@ function FeaturedQuestsContent() {
customTimeout,
{
onRevalidate: (fresh) => {
const freshItems =
(fresh as any).data ?? (fresh as any).quests ?? [];
setQuests(freshItems);
setQuests(fresh.data);
},
}
);
const items = (res as any).data ?? (res as any).quests ?? [];
setQuests(items);
setQuests(res.data);
setRetryCount(0); // Reset retry count on success
} catch (err) {
// Don't show error if request was cancelled (e.g., component unmounting)
Expand Down
2 changes: 1 addition & 1 deletion FrontEnd/my-app/components/quest/QuestHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function QuestHeader({
>
{category}
</span>
{difficulty && difficultyInfo && (
{difficultyInfo && (
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${difficultyInfo.className}`}
aria-label={`Difficulty: ${difficultyInfo.label}`}
Expand Down
4 changes: 2 additions & 2 deletions FrontEnd/my-app/lib/api/admin.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type {
Quest,
AdminQuest as Quest,
QuestFormData,
AdminStats,
AdminUser,
BulkOperation,
QuestStatus,
AdminQuestStatus as QuestStatus,
} from '../types/admin';

// Simulated delay for mock API calls
Expand Down
221 changes: 221 additions & 0 deletions FrontEnd/my-app/lib/api/mappers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import type {
QuestResponse,
PaginatedQuestsResponse,
UserResponse,
UserStatsResponse,
SubmissionResponse,
} from '@/lib/types/api.types';
import {
QuestStatus,
QuestDifficulty,
type Quest,
type PaginatedResponse,
} from '@/lib/types/quest';
import type {
UserProfile,
} from '@/lib/types/profile';
import type {
Submission,
ApiSubmissionStatus,
} from '@/lib/types/submission';
import type {
Badge,
} from '@/lib/types/dashboard';

/**
* Maps a raw QuestResponse (DTO) to the UI Quest domain model.
* Normalizes enums and provides defaults for optional/nullable fields.
*/
export function mapQuest(dto: any): Quest {
if (!dto) {
throw new Error('mapQuest: dto is null or undefined');
}

// Handle nested data wrappers if they exist in legacy code/tests
const raw = dto.data && typeof dto.data === 'object' && !Array.isArray(dto.data) ? dto.data : dto;

// Status mapping and normalization
let status: QuestStatus = QuestStatus.ACTIVE;
if (raw.status) {
const s = String(raw.status).trim();
const normalized = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
if (Object.values(QuestStatus).includes(normalized as any)) {
status = normalized as QuestStatus;
}
}

// Difficulty mapping and normalization
let difficulty: QuestDifficulty | undefined = undefined;
if (raw.difficulty) {
const d = String(raw.difficulty).trim().toLowerCase();
if (Object.values(QuestDifficulty).includes(d as any)) {
difficulty = d as QuestDifficulty;
}
}

return {
id: raw.id || '',
contractQuestId: raw.contractQuestId || '',
title: raw.title || '',
description: raw.description || '',
category: raw.category || '',
rewardAsset: raw.rewardAsset || '',
rewardAmount: raw.rewardAmount !== undefined ? raw.rewardAmount : '0',
xpReward: raw.xpReward,
verifierAddress: raw.verifierAddress || '',
deadline: raw.deadline,
status,
difficulty,
totalClaims: raw.totalClaims || 0,
totalSubmissions: raw.totalSubmissions || 0,
approvedSubmissions: raw.approvedSubmissions || 0,
rejectedSubmissions: raw.rejectedSubmissions || 0,
maxParticipants: raw.maxParticipants,
currentParticipants: raw.currentParticipants,
requirements: raw.requirements || [],
tags: raw.tags || [],
creator: raw.creator,
skills: raw.skills || [],
createdAt: raw.createdAt || new Date().toISOString(),
updatedAt: raw.updatedAt || new Date().toISOString(),
};
}

/**
* Maps a raw PaginatedQuestsResponse to the UI PaginatedResponse<Quest> model.
*/
export function mapPaginatedQuests(dto: any): PaginatedResponse<Quest> {
if (!dto) {
return {
data: [],
total: 0,
page: 1,
limit: 12,
totalPages: 0,
};
}

// Support both backend format (`quests`) and legacy mock server format (`data`)
const questsRaw = dto.quests || dto.data || [];
const data = Array.isArray(questsRaw) ? questsRaw.map(mapQuest) : [];

const meta = dto.meta || {};
const page = dto.page ?? meta.page ?? 1;
const limit = dto.limit ?? meta.limit ?? 12;
const total = dto.total ?? meta.total ?? data.length;
const totalPages = dto.totalPages ?? meta.totalPages ?? Math.ceil(total / limit);

return {
data,
total,
page,
limit,
totalPages,
};
}

/**
* Maps a raw UserResponse to a UI UserProfile domain model.
*/
export function mapUserProfile(
dto: UserResponse,
isOwnProfile = false,
isFollowing = false
): UserProfile {
return {
id: dto.id || '',
username: dto.username || '',
stellarAddress: dto.stellarAddress || '',
avatar: dto.avatarUrl || '',
bio: dto.bio || '',
level: dto.level || 1,
xp: dto.xp || 0,
totalEarnings: parseFloat(dto.totalEarned || '0'),
questsCompleted: dto.questsCompleted || 0,
currentStreak: 0, // default, not in UserResponse
joinDate: dto.createdAt || new Date().toISOString(),
lastActive: dto.lastActiveAt || dto.updatedAt || new Date().toISOString(),
isFollowing: isOwnProfile ? false : isFollowing,
followersCount: 0, // default
followingCount: 0, // default
isOwnProfile,
};
}

/**
* Maps a raw UserStatsResponse to a UI UserStats domain model.
*/
export function mapUserStats(dto: UserStatsResponse): UserStatsResponse {
return {
xp: dto.xp || 0,
level: dto.level || 1,
totalEarned: String(dto.totalEarned || '0'),
questsCompleted: dto.questsCompleted || 0,
failedQuests: dto.failedQuests || 0,
successRate: dto.successRate || 0,
badges: dto.badges || [],
lastActiveAt: dto.lastActiveAt,
};
}

/**
* Maps a raw SubmissionResponse to a UI Submission domain model.
*/
export function mapSubmission(dto: SubmissionResponse): Submission {
return {
id: dto.id || '',
questId: dto.questId || '',
userId: dto.userId || '',
status: dto.status as ApiSubmissionStatus,
proof: dto.proof || {},
rejectionReason: dto.rejectionReason,
approvedAt: dto.approvedAt,
approvedBy: dto.approvedBy,
rejectedAt: dto.rejectedAt,
rejectedBy: dto.rejectedBy,
createdAt: dto.createdAt || new Date().toISOString(),
updatedAt: dto.updatedAt || new Date().toISOString(),
quest: dto.quest ? {
id: dto.quest.id,
title: dto.quest.title,
rewardAmount: dto.quest.rewardAmount,
rewardAsset: dto.quest.rewardAsset,
} : undefined,
};
}

/**
* Helper to map a Badge ID or registry entry to a full Badge.
*/
export function mapBadgeIdToBadge(id: string): Badge {
const registry: Record<string, Omit<Badge, 'id' | 'earnedAt'>> = {
'badge-1': {
name: 'Fast Finisher',
description: 'Completed 10 quests before deadline.',
icon: 'bolt',
rarity: 'rare',
},
'badge-2': {
name: 'Code Guardian',
description: 'Delivered multiple high-quality review submissions.',
icon: 'shield',
rarity: 'epic',
},
};

const registered = registry[id] || {
name: id
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' '),
description: 'Earned achievement badge.',
icon: 'award',
rarity: 'common',
};

return {
id,
...registered,
earnedAt: new Date().toISOString(),
};
}
27 changes: 13 additions & 14 deletions FrontEnd/my-app/lib/api/quests.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,22 @@ afterEach(() => {

describe('Quests API Integration Tests', () => {
it('should fetch quests successfully with mock data', async () => {
const response = (await getQuests()) as any;
const response = await getQuests();

expect(response).toBeDefined();
expect(response.data).toHaveLength(1);
expect(response.data[0].id).toBe('quest-1');
expect(response.data[0].title).toBe('Test Quest 1');
expect(response.meta.total).toBe(1);
expect(response.total).toBe(1);
});

it('should fetch a single quest by ID successfully', async () => {
const response = (await getQuestById('quest-123')) as any;
const response = await getQuestById('quest-123');

expect(response).toBeDefined();
expect(response.data).toBeDefined();
expect(response.data.id).toBe('quest-123');
expect(response.data.title).toBe('Test Quest quest-123');
expect(response.data.rewardAmount).toBe(50);
expect(response.id).toBe('quest-123');
expect(response.title).toBe('Test Quest quest-123');
expect(response.rewardAmount).toBe(50);
});

it('should reuse fresh cached quest listings without another request', async () => {
Expand All @@ -65,8 +64,8 @@ describe('Quests API Integration Tests', () => {
})
);

const first = (await getQuests({ limit: 10 })) as any;
const second = (await getQuests({ limit: 10 })) as any;
const first = await getQuests({ limit: 10 });
const second = await getQuests({ limit: 10 });

expect(requestCount).toBe(1);
expect(first.data[0].id).toBe('quest-1');
Expand All @@ -85,11 +84,11 @@ describe('Quests API Integration Tests', () => {
})
);

const fresh = (await getQuests()) as any;
const fresh = await getQuests();
now = 3 * 60 * 1000 + 1;
const stale = (await getQuests(undefined, undefined, undefined, {
const stale = await getQuests(undefined, undefined, undefined, {
onRevalidate,
})) as any;
});

expect(stale.data[0].id).toBe(fresh.data[0].id);
expect(stale.data[0].id).toBe('quest-1');
Expand All @@ -115,9 +114,9 @@ describe('Quests API Integration Tests', () => {
})
);

const first = (await getQuests()) as any;
const first = await getQuests();
now = 13 * 60 * 1000 + 1;
const second = (await getQuests()) as any;
const second = await getQuests();

expect(requestCount).toBe(2);
expect(first.data[0].id).toBe('quest-1');
Expand Down
8 changes: 4 additions & 4 deletions FrontEnd/my-app/lib/api/quests.serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,8 @@ describe('getQuests – integration via MSW fixture', () => {

const result = await getQuests();

expect(Array.isArray(result.quests)).toBe(true);
expect(result.quests).toHaveLength(questPaginatedFixture.quests.length);
expect(Array.isArray(result.data)).toBe(true);
expect(result.data).toHaveLength(questPaginatedFixture.quests.length);
expect(result.total).toBe(questPaginatedFixture.total);
expect(result.page).toBe(questPaginatedFixture.page);
expect(result.limit).toBe(questPaginatedFixture.limit);
Expand All @@ -527,7 +527,7 @@ describe('getQuests – integration via MSW fixture', () => {

const result = await getQuests();

result.quests.forEach((quest) => {
result.data.forEach((quest) => {
assertRequiredQuestFields(quest);
});
});
Expand All @@ -541,7 +541,7 @@ describe('getQuests – integration via MSW fixture', () => {

const result = await getQuests();

expect(result.quests).toHaveLength(0);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});

Expand Down
Loading
Loading