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
55 changes: 44 additions & 11 deletions FrontEnd/my-app/lib/api/quests.serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,10 @@ describe('getQuestById – integration via MSW fixture', () => {
it('deserializes a full quest response correctly', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests/:id`, () =>
HttpResponse.json(questFullFixture)
HttpResponse.json({
data: questFullFixture,
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -413,7 +416,10 @@ describe('getQuestById – integration via MSW fixture', () => {
it('deserializes a minimal quest response without throwing', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests/:id`, () =>
HttpResponse.json(questMinimalFixture)
HttpResponse.json({
data: questMinimalFixture,
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -427,7 +433,10 @@ describe('getQuestById – integration via MSW fixture', () => {
it('deserializes a quest with null optional fields without throwing', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests/:id`, () =>
HttpResponse.json(questNullOptionalsFixture)
HttpResponse.json({
data: questNullOptionalsFixture,
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -442,7 +451,10 @@ describe('getQuestById – integration via MSW fixture', () => {
it('preserves rewardAmount as a string when backend sends a string', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests/:id`, () =>
HttpResponse.json({ ...questFullFixture, rewardAmount: '750' })
HttpResponse.json({
data: { ...questFullFixture, rewardAmount: '750' },
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -454,7 +466,10 @@ describe('getQuestById – integration via MSW fixture', () => {
it('preserves rewardAmount as a number when backend sends a number', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests/:id`, () =>
HttpResponse.json({ ...questMinimalFixture, rewardAmount: 200 })
HttpResponse.json({
data: { ...questMinimalFixture, rewardAmount: 200 },
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -468,7 +483,10 @@ describe('getQuestById – integration via MSW fixture', () => {
cacheManager.clear();
server.use(
http.get(`${API_BASE}/api/v1/quests/:id`, () =>
HttpResponse.json({ ...questFullFixture, status })
HttpResponse.json({
data: { ...questFullFixture, status },
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -482,7 +500,10 @@ describe('getQuestById – integration via MSW fixture', () => {
cacheManager.clear();
server.use(
http.get(`${API_BASE}/api/v1/quests/:id`, () =>
HttpResponse.json({ ...questFullFixture, difficulty })
HttpResponse.json({
data: { ...questFullFixture, difficulty },
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -504,7 +525,10 @@ describe('getQuests – integration via MSW fixture', () => {
it('deserializes a paginated quest list correctly', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests`, () =>
HttpResponse.json(questPaginatedFixture)
HttpResponse.json({
data: questPaginatedFixture,
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -521,7 +545,10 @@ describe('getQuests – integration via MSW fixture', () => {
it('each quest in the list has required fields', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests`, () =>
HttpResponse.json(questPaginatedFixture)
HttpResponse.json({
data: questPaginatedFixture,
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -535,7 +562,10 @@ describe('getQuests – integration via MSW fixture', () => {
it('deserializes an empty quest list without throwing', async () => {
server.use(
http.get(`${API_BASE}/api/v1/quests`, () =>
HttpResponse.json(questPaginatedEmptyFixture)
HttpResponse.json({
data: questPaginatedEmptyFixture,
meta: { timestamp: Date.now() },
})
)
);

Expand All @@ -551,7 +581,10 @@ describe('getQuests – integration via MSW fixture', () => {
server.use(
http.get(`${API_BASE}/api/v1/quests`, ({ request }) => {
capturedUrl = request.url;
return HttpResponse.json(questPaginatedEmptyFixture);
return HttpResponse.json({
data: questPaginatedEmptyFixture,
meta: { timestamp: Date.now() },
});
})
);

Expand Down
172 changes: 159 additions & 13 deletions FrontEnd/my-app/lib/api/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
CreateQuestRequest,
UpdateQuestRequest,
QuestQueryParams,
QuestStatus,
} from '@/lib/types/api.types';

const QUEST_LIST_TTL_MS = 3 * 60 * 1000;
Expand All @@ -41,6 +42,132 @@ export type {
PaginatedResponse,
} from '@/lib/types/quest';

// ---------------------------------------------------------------------------
// Serialization / Deserialization helpers
// ---------------------------------------------------------------------------

function deserializeQuest(data: any): QuestResponse {
if (!data) throw new Error('Cannot deserialize null or undefined quest data');

// Normalize status from backend representation ('active' | 'draft' | 'completed' | 'archived')
// to frontend representation ('Active' | 'Paused' | 'Completed' | 'Expired')
let status: QuestStatus = 'Active';
if (data.status) {
const rawStatus = String(data.status).toLowerCase();
if (rawStatus === 'active') {
status = 'Active';
} else if (rawStatus === 'draft' || rawStatus === 'paused') {
status = 'Paused';
} else if (rawStatus === 'completed') {
status = 'Completed';
} else if (rawStatus === 'archived' || rawStatus === 'expired') {
status = 'Expired';
} else {
const capitalized = data.status.charAt(0).toUpperCase() + data.status.slice(1).toLowerCase();
if (['Active', 'Paused', 'Completed', 'Expired'].includes(capitalized)) {
status = capitalized as QuestStatus;
}
}
}

return {
id: data.id,
contractQuestId: data.contractQuestId || data.contractTaskId || '0',
title: data.title,
description: data.description,
category: data.category || 'General',
difficulty: data.difficulty || undefined,
rewardAsset: data.rewardAsset || 'XLM',
rewardAmount: data.rewardAmount,
xpReward: data.xpReward != null ? Number(data.xpReward) : undefined,
verifierAddress: data.verifierAddress || data.createdBy || '',
deadline: data.deadline || null,
status,
totalClaims: data.totalClaims != null ? Number(data.totalClaims) : 0,
totalSubmissions: data.totalSubmissions != null ? Number(data.totalSubmissions) : 0,
approvedSubmissions: data.approvedSubmissions != null ? Number(data.approvedSubmissions) : 0,
rejectedSubmissions: data.rejectedSubmissions != null ? Number(data.rejectedSubmissions) : 0,
maxParticipants: data.maxParticipants != null ? Number(data.maxParticipants) : undefined,
currentParticipants: data.currentParticipants != null ? Number(data.currentParticipants) : undefined,
requirements: Array.isArray(data.requirements) ? data.requirements : [],
tags: Array.isArray(data.tags) ? data.tags : [],
creator: data.creator ? {
id: data.creator.id,
name: data.creator.name,
avatarUrl: data.creator.avatarUrl || undefined,
} : data.createdBy ? {
id: data.createdBy,
name: 'StellarEarn Creator',
} : undefined,
skills: Array.isArray(data.skills) ? data.skills : [],
createdAt: data.createdAt,
updatedAt: data.updatedAt,
};
}

function deserializePaginatedQuests(response: any): PaginatedQuestsResponse {
if (!response) throw new Error('Cannot deserialize null or undefined paginated response');

// Handle both backend wrapped response format and direct mock format
const rawData = response.data;

let questsList: any[] = [];
let total = 0;
let page = 1;
let limit = 10;
let totalPages = 1;

if (rawData && typeof rawData === 'object') {
if (Array.isArray(rawData.data)) {
questsList = rawData.data;
limit = rawData.limit ?? 10;
total = rawData.total ?? questsList.length;
page = rawData.page ?? 1;
totalPages = rawData.totalPages ?? 1;
} else if (Array.isArray(rawData.quests)) {
questsList = rawData.quests;
total = rawData.total ?? questsList.length;
page = rawData.page ?? 1;
limit = rawData.limit ?? 10;
totalPages = rawData.totalPages ?? 1;
} else if (Array.isArray(response.quests)) {
questsList = response.quests;
total = response.total ?? questsList.length;
page = response.page ?? 1;
limit = response.limit ?? 10;
totalPages = response.totalPages ?? 1;
} else if (Array.isArray(response.data)) {
questsList = response.data;
total = response.total ?? questsList.length;
page = response.page ?? 1;
limit = response.limit ?? 10;
totalPages = response.totalPages ?? 1;
}
} else {
if (Array.isArray(response.quests)) {
questsList = response.quests;
total = response.total ?? questsList.length;
page = response.page ?? 1;
limit = response.limit ?? 10;
totalPages = response.totalPages ?? 1;
} else if (Array.isArray(response.data)) {
questsList = response.data;
total = response.total ?? questsList.length;
page = response.page ?? 1;
limit = response.limit ?? 10;
totalPages = response.totalPages ?? 1;
}
}

return {
quests: questsList.map(deserializeQuest),
total,
page,
limit,
totalPages,
};
}

// ---------------------------------------------------------------------------
// List quests
// ---------------------------------------------------------------------------
Expand All @@ -62,14 +189,16 @@ export async function getQuests(

return cacheManager.getStaleWhileRevalidate(
cacheKey,
() =>
withRetry(() =>
get<PaginatedQuestsResponse>('/quests', {
async () => {
const response = await withRetry(() =>
get<any>('/quests', {
params,
signal: cancelToken?.signal,
timeout,
})
),
);
return deserializePaginatedQuests(response);
},
{
ttl: QUEST_LIST_TTL_MS,
staleTtl: QUEST_LIST_STALE_TTL_MS,
Expand All @@ -92,12 +221,20 @@ export async function getQuestById(
): Promise<QuestResponse> {
return cacheManager.get(
`quest-${id}`,
() =>
withRetry(() =>
get<QuestResponse>(`/quests/${id}`, {
async () => {
const response = await withRetry(() =>
get<any>(`/quests/${id}`, {
signal: cancelToken?.signal,
})
),
);

// Handle both wrapped { data: QuestResponseDto } and unwrapped QuestResponseDto
const rawQuest = response && response.data && !Array.isArray(response.data) && response.data.id
? response.data
: response;

return deserializeQuest(rawQuest);
},
60_000
);
}
Expand All @@ -109,10 +246,14 @@ export async function getQuestById(
export async function createQuest(
payload: CreateQuestRequest
): Promise<QuestResponse> {
const result = await post<QuestResponse>('/quests', payload);
// Invalidate list cache (no simple key, so just clear all quest entries)
const result = await post<any>('/quests', payload);
cacheManager.clear();
return result;

const rawQuest = result && result.data && !Array.isArray(result.data) && result.data.id
? result.data
: result;

return deserializeQuest(rawQuest);
}

// ---------------------------------------------------------------------------
Expand All @@ -123,9 +264,14 @@ export async function updateQuest(
id: string,
payload: UpdateQuestRequest
): Promise<QuestResponse> {
const result = await patch<QuestResponse>(`/quests/${id}`, payload);
const result = await patch<any>(`/quests/${id}`, payload);
cacheManager.invalidate(`quest-${id}`);
return result;

const rawQuest = result && result.data && !Array.isArray(result.data) && result.data.id
? result.data
: result;

return deserializeQuest(rawQuest);
}

// ---------------------------------------------------------------------------
Expand Down
Loading