diff --git a/FrontEnd/my-app/lib/api/quests.serialization.test.ts b/FrontEnd/my-app/lib/api/quests.serialization.test.ts index 5fca503dd..81e51ce64 100644 --- a/FrontEnd/my-app/lib/api/quests.serialization.test.ts +++ b/FrontEnd/my-app/lib/api/quests.serialization.test.ts @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }) ) ); @@ -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() }, + }); }) ); diff --git a/FrontEnd/my-app/lib/api/quests.ts b/FrontEnd/my-app/lib/api/quests.ts index 2f6c64981..172794971 100644 --- a/FrontEnd/my-app/lib/api/quests.ts +++ b/FrontEnd/my-app/lib/api/quests.ts @@ -25,6 +25,7 @@ import type { CreateQuestRequest, UpdateQuestRequest, QuestQueryParams, + QuestStatus, } from '@/lib/types/api.types'; const QUEST_LIST_TTL_MS = 3 * 60 * 1000; @@ -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 // --------------------------------------------------------------------------- @@ -62,14 +189,16 @@ export async function getQuests( return cacheManager.getStaleWhileRevalidate( cacheKey, - () => - withRetry(() => - get('/quests', { + async () => { + const response = await withRetry(() => + get('/quests', { params, signal: cancelToken?.signal, timeout, }) - ), + ); + return deserializePaginatedQuests(response); + }, { ttl: QUEST_LIST_TTL_MS, staleTtl: QUEST_LIST_STALE_TTL_MS, @@ -92,12 +221,20 @@ export async function getQuestById( ): Promise { return cacheManager.get( `quest-${id}`, - () => - withRetry(() => - get(`/quests/${id}`, { + async () => { + const response = await withRetry(() => + get(`/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 ); } @@ -109,10 +246,14 @@ export async function getQuestById( export async function createQuest( payload: CreateQuestRequest ): Promise { - const result = await post('/quests', payload); - // Invalidate list cache (no simple key, so just clear all quest entries) + const result = await post('/quests', payload); cacheManager.clear(); - return result; + + const rawQuest = result && result.data && !Array.isArray(result.data) && result.data.id + ? result.data + : result; + + return deserializeQuest(rawQuest); } // --------------------------------------------------------------------------- @@ -123,9 +264,14 @@ export async function updateQuest( id: string, payload: UpdateQuestRequest ): Promise { - const result = await patch(`/quests/${id}`, payload); + const result = await patch(`/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); } // ---------------------------------------------------------------------------