Skip to content
Merged
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
85 changes: 85 additions & 0 deletions server/src/services/appwrite.service.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
99 changes: 98 additions & 1 deletion server/src/services/freefire.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import prisma from '../config/database';
import { appwriteService } from './appwrite.service';
import { AppError } from '../utils/errors';

export interface FreeFireProfileInput {
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Comment on lines +126 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use an Appwrite-valid ID for Free Fire discovery documents

For the normal User.id values generated by this schema (36-character UUIDs), syncTeammateDiscovery derives the Appwrite document ID as freefire_${userId}, which is 45 characters. Appwrite custom document IDs are capped at 36 characters, so both the update and create attempts fail and are swallowed by the best-effort sync; with Appwrite configured, this new Free Fire read model will therefore never be populated.

Useful? React with 👍 / 👎.

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;
}

/**
Expand All @@ -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,
Comment on lines +163 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh cached teammates after reputation updates

Once Appwrite has any matching document, this early return skips both the PostgreSQL query and the lazy synchronization loop. rateTeammate updates FreeFireProfile.reputationScore only in PostgreSQL, so a rating submitted after a candidate has been cached leaves that candidate's cached reputation—and therefore its compatibility score and trust reason—stale indefinitely until that candidate updates their profile again.

Useful? React with 👍 / 👎.

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 },
Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions server/src/services/game-sync.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -61,6 +62,17 @@ export class GameSyncService {
},
});

appwriteService.syncGameProfile({
userId,
game: 'STEAM',
Comment on lines +66 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use an Appwrite-valid ID for Steam profile documents

The game-profile sync derives its Appwrite document ID as ${userId}_${game}. Since production userIds are 36-character UUIDs, the STEAM suffix produces a 42-character ID, exceeding Appwrite's 36-character custom-ID limit. The rejected update/create calls are caught by the sync service, so every Steam sync silently misses the Appwrite read model.

Useful? React with 👍 / 👎.

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 };
}
Expand Down
Loading