diff --git a/server/src/services/appwrite.service.test.ts b/server/src/services/appwrite.service.test.ts index eeab265..962230b 100644 --- a/server/src/services/appwrite.service.test.ts +++ b/server/src/services/appwrite.service.test.ts @@ -1,4 +1,5 @@ import { AppwriteService, appwriteService } from './appwrite.service'; +import * as appwriteConfigModule from '../config/appwrite'; import { isAppwriteConfigured } from '../config/appwrite'; describe('AppwriteService Integration & Fallback', () => { @@ -88,4 +89,88 @@ describe('AppwriteService Integration & Fallback', () => { expect(typeof success).toBe('boolean'); }); + + describe('Appwrite mock database operations & failure handling', () => { + let mockGetDocument: jest.Mock; + let mockListDocuments: jest.Mock; + let mockCreateDocument: jest.Mock; + let mockUpdateDocument: jest.Mock; + + beforeEach(() => { + mockGetDocument = jest.fn(); + mockListDocuments = jest.fn(); + mockCreateDocument = jest.fn(); + mockUpdateDocument = jest.fn(); + + const mockDatabases = { + getDocument: mockGetDocument, + listDocuments: mockListDocuments, + createDocument: mockCreateDocument, + updateDocument: mockUpdateDocument, + }; + + jest.spyOn(appwriteConfigModule, 'getAppwriteDatabases').mockReturnValue(mockDatabases as any); + jest.spyOn(appwriteConfigModule, 'isAppwriteConfigured').mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should retrieve public profile document and parse JSON languages array', async () => { + mockGetDocument.mockResolvedValueOnce({ + userId: 'u123', + username: 'pro_gamer', + displayName: 'Pro Gamer', + avatar: 'https://example.com/avatar.png', + rank: 'HEROIC', + winRate: 75.0, + kd: 4.5, + totalMatches: 250, + gamerScore: 1800, + verified: true, + languages: JSON.stringify(['ENGLISH', 'SPANISH']), + updatedAt: '2025-01-01T00:00:00.000Z', + }); + + const profile = await service.getPublicProfile('u123'); + expect(profile).toBeDefined(); + expect(profile?.userId).toBe('u123'); + expect(profile?.languages).toEqual(['ENGLISH', 'SPANISH']); + }); + + it('should return null safely if getPublicProfile encounters a database error', async () => { + mockGetDocument.mockRejectedValueOnce(new Error('Document not found or network error')); + const profile = await service.getPublicProfile('u123'); + expect(profile).toBeNull(); + }); + + it('should update document or create document if update fails during syncPublicProfile', async () => { + mockUpdateDocument.mockRejectedValueOnce(new Error('Document not found')); + mockCreateDocument.mockResolvedValueOnce({ $id: 'u123' }); + + const success = await service.syncPublicProfile({ + userId: 'u123', + username: 'pro_gamer', + updatedAt: new Date().toISOString(), + }); + + expect(success).toBe(true); + expect(mockUpdateDocument).toHaveBeenCalledTimes(1); + expect(mockCreateDocument).toHaveBeenCalledTimes(1); + }); + + it('should return false if both update and create fail during syncPublicProfile', async () => { + mockUpdateDocument.mockRejectedValueOnce(new Error('Update failed')); + mockCreateDocument.mockRejectedValueOnce(new Error('Create failed')); + + const success = await service.syncPublicProfile({ + userId: 'u123', + username: 'pro_gamer', + updatedAt: new Date().toISOString(), + }); + + expect(success).toBe(false); + }); + }); }); diff --git a/server/src/services/freefire.service.ts b/server/src/services/freefire.service.ts index ef400cc..0bf4936 100644 --- a/server/src/services/freefire.service.ts +++ b/server/src/services/freefire.service.ts @@ -1,4 +1,5 @@ import prisma from '../config/database'; +import { appwriteService } from './appwrite.service'; import { AppError } from '../utils/errors'; export interface FreeFireProfileInput { @@ -77,7 +78,7 @@ export class FreeFireService { * Upsert Free Fire profile information */ async updateProfile(userId: string, data: FreeFireProfileInput) { - return (prisma as any).freeFireProfile.upsert({ + const profile = await (prisma as any).freeFireProfile.upsert({ where: { userId }, create: { userId, @@ -104,7 +105,40 @@ export class FreeFireService { teammatePreference: data.teammatePreference, isVerified: false, // Ensure users cannot self-set verified }, + include: { + user: { + select: { + profile: { + select: { + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }, + }, }); + + // Write-through sync to Appwrite teammate discovery read model + appwriteService.syncTeammateDiscovery({ + id: `freefire_${userId}`, + userId, + gameId: 'freefire', + rank: profile.rank || 'HEROIC', + playstyle: profile.playstyle || 'BALANCED', + language: profile.language || 'ENGLISH', + micPreference: profile.micPreference, + availability: profile.availability || 'EVENING', + reputationScore: profile.reputationScore || 5.0, + isVerified: false, + username: profile.user?.profile?.username || 'Gamer', + displayName: profile.user?.profile?.displayName || profile.user?.profile?.username || 'Gamer', + avatar: profile.user?.profile?.avatar || null, + updatedAt: new Date().toISOString(), + }).catch(err => console.warn('[Appwrite] Teammate discovery sync error for Free Fire:', err?.message)); + + return profile; } /** @@ -113,6 +147,49 @@ export class FreeFireService { async discoverTeammates(currentUserId: string, filters: TeammateFilterQuery) { const currentUserProfile = await this.getProfile(currentUserId); + // 1. Appwrite Read Model Attempt + const cachedCandidates = await appwriteService.getTeammateCandidates('freefire', { + rank: filters.rank, + playstyle: filters.playstyle, + language: filters.language, + availability: filters.availability, + limit: filters.limit || 20, + }); + + if (cachedCandidates && cachedCandidates.length > 0) { + // Filter out current user if returned in Appwrite results + const filteredCached = cachedCandidates.filter(c => c.userId !== currentUserId); + if (filteredCached.length > 0) { + return filteredCached.map(candidate => { + const matchDetails = this.calculateCompatibility(currentUserProfile, candidate); + return { + profile: { + userId: candidate.userId, + rank: candidate.rank, + playstyle: candidate.playstyle, + language: candidate.language, + micPreference: candidate.micPreference, + availability: candidate.availability, + reputationScore: candidate.reputationScore, + isVerified: false, + user: { + id: candidate.userId, + profile: { + username: candidate.username, + displayName: candidate.displayName, + avatar: candidate.avatar, + }, + }, + }, + compatibility: matchDetails.score, + reasons: matchDetails.reasons, + isSelfReported: true, + }; + }).sort((a: any, b: any) => b.compatibility - a.compatibility); + } + } + + // 2. Primary Source Fallback (Prisma PostgreSQL) const candidates = await (prisma as any).freeFireProfile.findMany({ where: { userId: { not: currentUserId }, @@ -138,6 +215,26 @@ export class FreeFireService { }, }); + // Asynchronously populate Appwrite cache + for (const c of candidates) { + appwriteService.syncTeammateDiscovery({ + id: `freefire_${c.userId}`, + userId: c.userId, + gameId: 'freefire', + rank: c.rank, + playstyle: c.playstyle, + language: c.language, + micPreference: c.micPreference, + availability: c.availability, + reputationScore: c.reputationScore, + isVerified: false, + username: c.user?.profile?.username || 'Gamer', + displayName: c.user?.profile?.displayName || c.user?.profile?.username || 'Gamer', + avatar: c.user?.profile?.avatar || null, + updatedAt: new Date().toISOString(), + }).catch(err => console.warn('[Appwrite] Lazy teammate sync warning:', err?.message)); + } + return candidates.map((candidate: any) => { const matchDetails = this.calculateCompatibility(currentUserProfile, candidate); return { diff --git a/server/src/services/game-sync.service.ts b/server/src/services/game-sync.service.ts index a6ef1d5..2e83dd9 100644 --- a/server/src/services/game-sync.service.ts +++ b/server/src/services/game-sync.service.ts @@ -1,6 +1,7 @@ import prisma from '../config/database'; import { io } from '../index'; import { steamService } from './steam.service'; +import { appwriteService } from './appwrite.service'; import { AppError } from '../utils/errors'; export interface SyncResult { @@ -61,6 +62,17 @@ export class GameSyncService { }, }); + appwriteService.syncGameProfile({ + userId, + game: 'STEAM', + inGameUid: profile.steamId, + inGameName: profile.username, + level: profile.level, + verified: true, + syncStatus: 'SUCCESS', + updatedAt: new Date().toISOString(), + }).catch(err => console.warn('[Appwrite] Game profile sync warning on Steam sync:', err?.message)); + this.broadcastUpdate(userId, 'STEAM', updated); return { success: true, platform: 'STEAM', gameAccount: updated }; }