Compare commits
13 Commits
108f021bd8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 13744eaaaa | |||
| 494d05c755 | |||
| 14823ed769 | |||
| d3e9dc61a5 | |||
| 2e9e438baa | |||
| 7b7f2ac8e7 | |||
| 075afa1063 | |||
| 7106791372 | |||
| 6dcb6bf4b5 | |||
| f83b07863d | |||
| e68c8d1f19 | |||
| 2504c3756d | |||
| 1059d4ae17 |
15
backend/jest.config.ts
Normal file
15
backend/jest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
|
||||
export default config;
|
||||
3492
backend/package-lock.json
generated
3492
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,17 @@
|
||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||
"migration:generate": "npm run typeorm -- migration:generate src/migrations/$npm_config_name -d src/config/data-source.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/config/data-source.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/config/data-source.ts"
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/config/data-source.ts",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.17",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.17",
|
||||
"@nestjs/platform-express": "^11.1.17",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
@@ -30,9 +34,13 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"jest": "^30.3.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.2"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { getDatabaseConfig } from './config/database.config';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
@@ -11,6 +13,7 @@ import { ListModule } from './list/list.module';
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot([{ ttl: 60000, limit: 60 }]),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: getDatabaseConfig,
|
||||
@@ -21,5 +24,8 @@ import { ListModule } from './list/list.module';
|
||||
WorkModule,
|
||||
ListModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
153
backend/src/auth/auth.guard.spec.ts
Normal file
153
backend/src/auth/auth.guard.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const SUPEROAUTH_URL = 'http://oauth.test';
|
||||
|
||||
const fakeUser = {
|
||||
id: 42,
|
||||
nickname: 'tetard',
|
||||
email: 'tetard@test.com',
|
||||
};
|
||||
|
||||
const oauthResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
user: fakeUser,
|
||||
linkedProviders: [{ avatar: 'https://img.test/avatar.png' }],
|
||||
},
|
||||
};
|
||||
|
||||
function makeContext(token?: string): ExecutionContext {
|
||||
const request: any = {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
cookies: {},
|
||||
};
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function makeGuard(): AuthGuard {
|
||||
const configService = {
|
||||
get: jest.fn().mockReturnValue(SUPEROAUTH_URL),
|
||||
} as unknown as ConfigService;
|
||||
return new AuthGuard(configService);
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
let fetchSpy: jest.SpiedFunction<typeof global.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => oauthResponse,
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls SuperOAuth when token is not cached', async () => {
|
||||
const guard = makeGuard();
|
||||
const ctx = makeContext('fresh-token');
|
||||
|
||||
await guard.canActivate(ctx);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${SUPEROAUTH_URL}/api/v1/user/profile`,
|
||||
{ headers: { Authorization: 'Bearer fresh-token' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns cached user without calling SuperOAuth on second call', async () => {
|
||||
const guard = makeGuard();
|
||||
|
||||
// First call — populates cache
|
||||
await guard.canActivate(makeContext('cached-token'));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — should hit cache
|
||||
const ctx2 = makeContext('cached-token');
|
||||
await guard.canActivate(ctx2);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // still 1 — no new fetch
|
||||
|
||||
const req = ctx2.switchToHttp().getRequest() as any;
|
||||
expect(req.user).toEqual({
|
||||
id: 42,
|
||||
nickname: 'tetard',
|
||||
avatar: 'https://img.test/avatar.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls SuperOAuth again when cached entry has expired', async () => {
|
||||
const guard = makeGuard();
|
||||
|
||||
// First call
|
||||
await guard.canActivate(makeContext('expiring-token'));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Fast-forward time past TTL (5 min + 1 ms)
|
||||
const realNow = Date.now;
|
||||
Date.now = jest.fn().mockReturnValue(realNow() + 5 * 60 * 1000 + 1);
|
||||
|
||||
try {
|
||||
await guard.canActivate(makeContext('expiring-token'));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2); // re-fetched
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when no token is provided', async () => {
|
||||
const guard = makeGuard();
|
||||
await expect(guard.canActivate(makeContext())).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when SuperOAuth rejects the token', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
const guard = makeGuard();
|
||||
await expect(guard.canActivate(makeContext('bad-token'))).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cache a failed introspection', async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
const guard = makeGuard();
|
||||
|
||||
// First call fails
|
||||
await expect(guard.canActivate(makeContext('retry-token'))).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
|
||||
// Restore successful response
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => oauthResponse,
|
||||
} as Response);
|
||||
|
||||
// Second call should hit SuperOAuth again (not cached failure)
|
||||
await guard.canActivate(makeContext('retry-token'));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,34 @@ import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
interface CacheEntry {
|
||||
user: any;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TOKEN_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
@@ -18,7 +39,7 @@ export class AuthGuard implements CanActivate {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const userInfo = await this.introspect(token);
|
||||
const userInfo = await this.resolveUser(token);
|
||||
if (!userInfo) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
@@ -27,6 +48,31 @@ export class AuthGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async resolveUser(token: string): Promise<any> {
|
||||
const cached = this.cache.get(token);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
// Lazy cleanup: remove this expired entry
|
||||
if (cached) {
|
||||
this.cache.delete(token);
|
||||
}
|
||||
|
||||
const user = await this.introspect(token);
|
||||
if (user) {
|
||||
if (this.cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest) this.cache.delete(oldest);
|
||||
}
|
||||
this.cache.set(token, {
|
||||
user,
|
||||
expiresAt: Date.now() + TOKEN_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private extractToken(request: any): string | null {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
|
||||
4
backend/src/auth/public.decorator.ts
Normal file
4
backend/src/auth/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
11
backend/src/list/dto/add-to-list.dto.ts
Normal file
11
backend/src/list/dto/add-to-list.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsEnum, IsInt, Min } from 'class-validator';
|
||||
import { ListStatus } from '../user-work.entity';
|
||||
|
||||
export class AddToListDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
anilistId: number;
|
||||
|
||||
@IsEnum(ListStatus)
|
||||
status: ListStatus;
|
||||
}
|
||||
168
backend/src/list/dto/dto-validation.spec.ts
Normal file
168
backend/src/list/dto/dto-validation.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { AddToListDto } from './add-to-list.dto';
|
||||
import { UpdateProgressDto } from './update-progress.dto';
|
||||
import { UpdateStatusDto } from './update-status.dto';
|
||||
import { SetScoreDto } from './set-score.dto';
|
||||
import { ListStatus } from '../user-work.entity';
|
||||
|
||||
// Helper: transform plain object to class instance and validate
|
||||
async function check<T extends object>(
|
||||
cls: new () => T,
|
||||
plain: Record<string, any>,
|
||||
): Promise<string[]> {
|
||||
const instance = plainToInstance(cls, plain);
|
||||
const errors = await validate(instance);
|
||||
return errors.flatMap((e) => Object.values(e.constraints ?? {}));
|
||||
}
|
||||
|
||||
// ─── AddToListDto ─────────────────────────────────────────────
|
||||
|
||||
describe('AddToListDto', () => {
|
||||
it('should accept valid input', async () => {
|
||||
const errors = await check(AddToListDto, {
|
||||
anilistId: 12345,
|
||||
status: ListStatus.WATCHING,
|
||||
});
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject missing anilistId', async () => {
|
||||
const errors = await check(AddToListDto, {
|
||||
status: ListStatus.WATCHING,
|
||||
});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject non-integer anilistId', async () => {
|
||||
const errors = await check(AddToListDto, {
|
||||
anilistId: 'abc',
|
||||
status: ListStatus.WATCHING,
|
||||
});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject anilistId < 1', async () => {
|
||||
const errors = await check(AddToListDto, {
|
||||
anilistId: 0,
|
||||
status: ListStatus.WATCHING,
|
||||
});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject missing status', async () => {
|
||||
const errors = await check(AddToListDto, {
|
||||
anilistId: 12345,
|
||||
});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject invalid status', async () => {
|
||||
const errors = await check(AddToListDto, {
|
||||
anilistId: 12345,
|
||||
status: 'binge_watching',
|
||||
});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should accept all valid ListStatus values', async () => {
|
||||
for (const status of Object.values(ListStatus)) {
|
||||
const errors = await check(AddToListDto, { anilistId: 1, status });
|
||||
expect(errors).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UpdateProgressDto ───────────────────────────────────────
|
||||
|
||||
describe('UpdateProgressDto', () => {
|
||||
it('should accept valid progress', async () => {
|
||||
const errors = await check(UpdateProgressDto, { progress: 5 });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept progress = 0', async () => {
|
||||
const errors = await check(UpdateProgressDto, { progress: 0 });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject negative progress', async () => {
|
||||
const errors = await check(UpdateProgressDto, { progress: -1 });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject non-integer progress', async () => {
|
||||
const errors = await check(UpdateProgressDto, { progress: 3.5 });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject missing progress', async () => {
|
||||
const errors = await check(UpdateProgressDto, {});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject string progress', async () => {
|
||||
const errors = await check(UpdateProgressDto, { progress: 'five' });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UpdateStatusDto ─────────────────────────────────────────
|
||||
|
||||
describe('UpdateStatusDto', () => {
|
||||
it('should accept valid status', async () => {
|
||||
const errors = await check(UpdateStatusDto, {
|
||||
status: ListStatus.COMPLETED,
|
||||
});
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject missing status', async () => {
|
||||
const errors = await check(UpdateStatusDto, {});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject invalid status string', async () => {
|
||||
const errors = await check(UpdateStatusDto, { status: 'finished' });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SetScoreDto ─────────────────────────────────────────────
|
||||
|
||||
describe('SetScoreDto', () => {
|
||||
it('should accept valid score', async () => {
|
||||
const errors = await check(SetScoreDto, { score: 8.5 });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept score = 0', async () => {
|
||||
const errors = await check(SetScoreDto, { score: 0 });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept score = 10', async () => {
|
||||
const errors = await check(SetScoreDto, { score: 10 });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject score > 10', async () => {
|
||||
const errors = await check(SetScoreDto, { score: 11 });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject negative score', async () => {
|
||||
const errors = await check(SetScoreDto, { score: -1 });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject missing score', async () => {
|
||||
const errors = await check(SetScoreDto, {});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reject string score', async () => {
|
||||
const errors = await check(SetScoreDto, { score: 'great' });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
8
backend/src/list/dto/set-score.dto.ts
Normal file
8
backend/src/list/dto/set-score.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsNumber, Min, Max } from 'class-validator';
|
||||
|
||||
export class SetScoreDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(10)
|
||||
score: number;
|
||||
}
|
||||
7
backend/src/list/dto/update-progress.dto.ts
Normal file
7
backend/src/list/dto/update-progress.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class UpdateProgressDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
progress: number;
|
||||
}
|
||||
7
backend/src/list/dto/update-status.dto.ts
Normal file
7
backend/src/list/dto/update-status.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ListStatus } from '../user-work.entity';
|
||||
|
||||
export class UpdateStatusDto {
|
||||
@IsEnum(ListStatus)
|
||||
status: ListStatus;
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import { ListService } from './list.service';
|
||||
import { ListStatus } from './user-work.entity';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AddToListDto } from './dto/add-to-list.dto';
|
||||
import { UpdateProgressDto } from './dto/update-progress.dto';
|
||||
import { UpdateStatusDto } from './dto/update-status.dto';
|
||||
import { SetScoreDto } from './dto/set-score.dto';
|
||||
|
||||
@Controller('api/list')
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -37,7 +41,7 @@ export class ListController {
|
||||
@Post()
|
||||
async addToList(
|
||||
@Req() req: any,
|
||||
@Body() body: { anilistId: number; status: ListStatus },
|
||||
@Body() body: AddToListDto,
|
||||
) {
|
||||
const user = await this.userService.findOrCreate({
|
||||
id: req.user.id,
|
||||
@@ -51,7 +55,7 @@ export class ListController {
|
||||
async updateProgress(
|
||||
@Req() req: any,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { progress: number },
|
||||
@Body() body: UpdateProgressDto,
|
||||
) {
|
||||
const user = await this.userService.findOrCreate({
|
||||
id: req.user.id,
|
||||
@@ -65,7 +69,7 @@ export class ListController {
|
||||
async updateStatus(
|
||||
@Req() req: any,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { status: ListStatus },
|
||||
@Body() body: UpdateStatusDto,
|
||||
) {
|
||||
const user = await this.userService.findOrCreate({
|
||||
id: req.user.id,
|
||||
@@ -79,7 +83,7 @@ export class ListController {
|
||||
async setScore(
|
||||
@Req() req: any,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { score: number },
|
||||
@Body() body: SetScoreDto,
|
||||
) {
|
||||
const user = await this.userService.findOrCreate({
|
||||
id: req.user.id,
|
||||
|
||||
320
backend/src/list/list.service.spec.ts
Normal file
320
backend/src/list/list.service.spec.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ListService } from './list.service';
|
||||
import { UserWork, ListStatus } from './user-work.entity';
|
||||
import { WorkService } from '../work/work.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { Work, WorkType, WorkStatus } from '../work/work.entity';
|
||||
|
||||
// XP constants mirrored from list.service.ts
|
||||
const XP_ADD_WORK = 10;
|
||||
const XP_PROGRESS = 5;
|
||||
const XP_COMPLETE = 100;
|
||||
const XP_SCORE = 15;
|
||||
|
||||
describe('ListService', () => {
|
||||
let service: ListService;
|
||||
let userWorkRepo: jest.Mocked<Repository<UserWork>>;
|
||||
let workService: { findOrCreateFromAniList: jest.Mock };
|
||||
let userService: { addXp: jest.Mock };
|
||||
|
||||
const mockWork = {
|
||||
id: 1,
|
||||
anilistId: 12345,
|
||||
type: WorkType.ANIME,
|
||||
titleRomaji: 'Test Anime',
|
||||
titleEnglish: 'Test Anime EN',
|
||||
titleNative: null,
|
||||
posterUrl: null,
|
||||
synopsis: null,
|
||||
totalEpisodes: 12,
|
||||
totalChapters: null,
|
||||
status: WorkStatus.FINISHED,
|
||||
genres: ['Action'],
|
||||
userWorks: [],
|
||||
cachedAt: new Date(),
|
||||
} as unknown as Work;
|
||||
|
||||
const makeUserWork = (overrides: Partial<Record<string, any>> = {}): UserWork => ({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
workId: 1,
|
||||
user: null,
|
||||
work: mockWork,
|
||||
status: ListStatus.WATCHING,
|
||||
progress: 0,
|
||||
score: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as unknown as UserWork);
|
||||
|
||||
beforeEach(async () => {
|
||||
const repoMock = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
workService = { findOrCreateFromAniList: jest.fn() };
|
||||
userService = { addXp: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ListService,
|
||||
{ provide: getRepositoryToken(UserWork), useValue: repoMock },
|
||||
{ provide: WorkService, useValue: workService },
|
||||
{ provide: UserService, useValue: userService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ListService>(ListService);
|
||||
userWorkRepo = module.get(getRepositoryToken(UserWork));
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
// ─── addToList ─────────────────────────────────────────────
|
||||
|
||||
describe('addToList', () => {
|
||||
it('should create a new entry and award XP_ADD_WORK', async () => {
|
||||
workService.findOrCreateFromAniList.mockResolvedValue(mockWork);
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
const created = makeUserWork();
|
||||
userWorkRepo.create.mockReturnValue(created);
|
||||
userWorkRepo.save.mockResolvedValue(created);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.addToList(1, 12345, ListStatus.WATCHING);
|
||||
|
||||
expect(result).toEqual(created);
|
||||
expect(workService.findOrCreateFromAniList).toHaveBeenCalledWith(12345);
|
||||
expect(userWorkRepo.create).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
workId: 1,
|
||||
status: ListStatus.WATCHING,
|
||||
progress: 0,
|
||||
});
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_ADD_WORK);
|
||||
});
|
||||
|
||||
it('should update status if entry already exists (no duplicate XP)', async () => {
|
||||
workService.findOrCreateFromAniList.mockResolvedValue(mockWork);
|
||||
const existing = makeUserWork({ status: ListStatus.PLAN_TO });
|
||||
userWorkRepo.findOne.mockResolvedValue(existing);
|
||||
userWorkRepo.save.mockResolvedValue({ ...existing, status: ListStatus.WATCHING });
|
||||
|
||||
await service.addToList(1, 12345, ListStatus.WATCHING);
|
||||
|
||||
expect(userWorkRepo.create).not.toHaveBeenCalled();
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProgress ────────────────────────────────────────
|
||||
|
||||
describe('updateProgress', () => {
|
||||
it('should update progress and award delta XP', async () => {
|
||||
const uw = makeUserWork({ progress: 3 });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockResolvedValue({ ...uw, progress: 5 });
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateProgress(1, 1, 5);
|
||||
|
||||
// delta = 5 - 3 = 2 => 2 * XP_PROGRESS
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, 2 * XP_PROGRESS);
|
||||
});
|
||||
|
||||
it('should auto-complete when progress >= totalEpisodes', async () => {
|
||||
const uw = makeUserWork({ progress: 11, status: ListStatus.WATCHING });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.updateProgress(1, 1, 12);
|
||||
|
||||
expect(result.status).toBe(ListStatus.COMPLETED);
|
||||
expect(result.completedAt).toBeInstanceOf(Date);
|
||||
// Should receive XP_COMPLETE + delta XP_PROGRESS
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_COMPLETE);
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, 1 * XP_PROGRESS);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.updateProgress(1, 999, 5)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should not award progress XP when progress does not increase', async () => {
|
||||
const uw = makeUserWork({ progress: 5 });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockResolvedValue({ ...uw, progress: 3 });
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateProgress(1, 1, 3);
|
||||
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateStatus ──────────────────────────────────────────
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should change status and award XP_COMPLETE on completion', async () => {
|
||||
const uw = makeUserWork({ status: ListStatus.WATCHING });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.updateStatus(1, 1, ListStatus.COMPLETED);
|
||||
|
||||
expect(result.status).toBe(ListStatus.COMPLETED);
|
||||
expect(result.completedAt).toBeInstanceOf(Date);
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_COMPLETE);
|
||||
});
|
||||
|
||||
it('should change status without XP for non-completion', async () => {
|
||||
const uw = makeUserWork({ status: ListStatus.WATCHING });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
|
||||
await service.updateStatus(1, 1, ListStatus.PAUSED);
|
||||
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.updateStatus(1, 999, ListStatus.COMPLETED)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ANTI-REGRESSION : DOUBLE XP COMPLETION ───────────────
|
||||
|
||||
describe('ANTI-REGRESSION: double XP completion', () => {
|
||||
it('should NOT award XP_COMPLETE twice when updateProgress auto-completes then updateStatus(COMPLETED) is called', async () => {
|
||||
// --- Step 1: updateProgress triggers auto-completion ---
|
||||
const uw = makeUserWork({
|
||||
progress: 11,
|
||||
status: ListStatus.WATCHING,
|
||||
});
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateProgress(1, 1, 12);
|
||||
|
||||
// After auto-completion: status is now COMPLETED
|
||||
expect(uw.status).toBe(ListStatus.COMPLETED);
|
||||
const xpCallsAfterProgress = userService.addXp.mock.calls.filter(
|
||||
([, amount]) => amount === XP_COMPLETE,
|
||||
);
|
||||
expect(xpCallsAfterProgress).toHaveLength(1);
|
||||
|
||||
// --- Step 2: user explicitly calls updateStatus(COMPLETED) ---
|
||||
// The entry is already COMPLETED from step 1
|
||||
// BUG: updateStatus does NOT check if already COMPLETED — it awards XP again
|
||||
userService.addXp.mockClear();
|
||||
|
||||
// findOne now returns the already-completed entry
|
||||
const completedUw = makeUserWork({
|
||||
progress: 12,
|
||||
status: ListStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
userWorkRepo.findOne.mockResolvedValue(completedUw);
|
||||
|
||||
await service.updateStatus(1, 1, ListStatus.COMPLETED);
|
||||
|
||||
// FIX MERGED: updateStatus now guards against already-completed status.
|
||||
// addXp should NOT be called a second time.
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setScore ──────────────────────────────────────────────
|
||||
|
||||
describe('setScore', () => {
|
||||
it('should set score and award XP_SCORE on first scoring', async () => {
|
||||
const uw = makeUserWork({ score: null });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.setScore(1, 1, 8.5);
|
||||
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_SCORE);
|
||||
});
|
||||
|
||||
it('should NOT award XP_SCORE when updating an existing score', async () => {
|
||||
const uw = makeUserWork({ score: 7.0 });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
|
||||
await service.setScore(1, 1, 9.0);
|
||||
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.setScore(1, 999, 8)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── removeFromList ────────────────────────────────────────
|
||||
|
||||
describe('removeFromList', () => {
|
||||
it('should remove the entry', async () => {
|
||||
const uw = makeUserWork();
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.remove.mockResolvedValue(uw);
|
||||
|
||||
await service.removeFromList(1, 1);
|
||||
|
||||
expect(userWorkRepo.remove).toHaveBeenCalledWith(uw);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.removeFromList(1, 999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getUserList ───────────────────────────────────────────
|
||||
|
||||
describe('getUserList', () => {
|
||||
it('should return all entries for a user', async () => {
|
||||
const entries = [makeUserWork(), makeUserWork({ id: 2 })];
|
||||
userWorkRepo.find.mockResolvedValue(entries);
|
||||
|
||||
const result = await service.getUserList(1);
|
||||
|
||||
expect(userWorkRepo.find).toHaveBeenCalledWith({
|
||||
where: { userId: 1 },
|
||||
relations: ['work'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter by status when provided', async () => {
|
||||
userWorkRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.getUserList(1, ListStatus.COMPLETED);
|
||||
|
||||
expect(userWorkRepo.find).toHaveBeenCalledWith({
|
||||
where: { userId: 1, status: ListStatus.COMPLETED },
|
||||
relations: ['work'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -97,9 +97,10 @@ export class ListService {
|
||||
});
|
||||
if (!uw) throw new NotFoundException('Entry not found');
|
||||
|
||||
const wasAlreadyCompleted = uw.status === ListStatus.COMPLETED;
|
||||
uw.status = status;
|
||||
if (status === ListStatus.COMPLETED) {
|
||||
uw.completedAt = new Date();
|
||||
if (status === ListStatus.COMPLETED && !wasAlreadyCompleted) {
|
||||
uw.completedAt = uw.completedAt || new Date();
|
||||
await this.userService.addXp(userId, XP_COMPLETE);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/user')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AuthGuard)
|
||||
async me(@Req() req: any) {
|
||||
const user = await this.userService.findOrCreate({
|
||||
id: req.user.id,
|
||||
|
||||
196
backend/src/user/user.service.spec.ts
Normal file
196
backend/src/user/user.service.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { UserService } from './user.service';
|
||||
import { User } from './user.entity';
|
||||
|
||||
const mockRepo = () => ({
|
||||
findOne: jest.fn(),
|
||||
findOneByOrFail: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
});
|
||||
|
||||
type MockRepository = ReturnType<typeof mockRepo>;
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
let repo: MockRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserService,
|
||||
{ provide: getRepositoryToken(User), useFactory: mockRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
repo = module.get(getRepositoryToken(User));
|
||||
});
|
||||
|
||||
// --- calculateLevel (private, accessed via bracket notation) ---
|
||||
|
||||
describe('calculateLevel', () => {
|
||||
it('returns 1 for 0 XP', () => {
|
||||
expect(service['calculateLevel'](0)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for 100 XP (below first threshold)', () => {
|
||||
expect(service['calculateLevel'](100)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for 499 XP (just below threshold)', () => {
|
||||
expect(service['calculateLevel'](499)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2 for exactly 500 XP', () => {
|
||||
expect(service['calculateLevel'](500)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 2 for 999 XP', () => {
|
||||
expect(service['calculateLevel'](999)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 3 for 1000 XP', () => {
|
||||
expect(service['calculateLevel'](1000)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 11 for 5000 XP', () => {
|
||||
expect(service['calculateLevel'](5000)).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
// --- addXp ---
|
||||
|
||||
describe('addXp', () => {
|
||||
it('adds XP and recalculates level', async () => {
|
||||
const user = { id: 1, xp: 200, level: 1 } as User;
|
||||
repo.findOneByOrFail.mockResolvedValue(user);
|
||||
repo.save.mockImplementation((u) => Promise.resolve(u));
|
||||
|
||||
const result = await service.addXp(1, 300);
|
||||
|
||||
expect(result.xp).toBe(500);
|
||||
expect(result.level).toBe(2);
|
||||
expect(repo.save).toHaveBeenCalledWith(user);
|
||||
});
|
||||
|
||||
it('keeps level 1 when XP stays below 500', async () => {
|
||||
const user = { id: 1, xp: 50, level: 1 } as User;
|
||||
repo.findOneByOrFail.mockResolvedValue(user);
|
||||
repo.save.mockImplementation((u) => Promise.resolve(u));
|
||||
|
||||
const result = await service.addXp(1, 100);
|
||||
|
||||
expect(result.xp).toBe(150);
|
||||
expect(result.level).toBe(1);
|
||||
});
|
||||
|
||||
it('jumps multiple levels on large XP gain', async () => {
|
||||
const user = { id: 1, xp: 0, level: 1 } as User;
|
||||
repo.findOneByOrFail.mockResolvedValue(user);
|
||||
repo.save.mockImplementation((u) => Promise.resolve(u));
|
||||
|
||||
const result = await service.addXp(1, 1500);
|
||||
|
||||
expect(result.xp).toBe(1500);
|
||||
expect(result.level).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// --- findOrCreate ---
|
||||
|
||||
describe('findOrCreate', () => {
|
||||
const profile = { id: 'oauth-42', nickname: 'tetard', avatar: 'https://img/a.png' };
|
||||
|
||||
it('returns existing user when found and unchanged', async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
superOauthId: 'oauth-42',
|
||||
username: 'tetard',
|
||||
avatarUrl: 'https://img/a.png',
|
||||
} as User;
|
||||
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
|
||||
const result = await service.findOrCreate(profile);
|
||||
|
||||
expect(result).toBe(existing);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a new user when none exists', async () => {
|
||||
const created = {
|
||||
id: 1,
|
||||
superOauthId: 'oauth-42',
|
||||
username: 'tetard',
|
||||
avatarUrl: 'https://img/a.png',
|
||||
} as User;
|
||||
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
repo.create.mockReturnValue(created);
|
||||
repo.save.mockResolvedValue(created);
|
||||
|
||||
const result = await service.findOrCreate(profile);
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith({
|
||||
superOauthId: 'oauth-42',
|
||||
username: 'tetard',
|
||||
avatarUrl: 'https://img/a.png',
|
||||
});
|
||||
expect(repo.save).toHaveBeenCalledWith(created);
|
||||
expect(result).toBe(created);
|
||||
});
|
||||
|
||||
it('updates username when it changed upstream', async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
superOauthId: 'oauth-42',
|
||||
username: 'old-name',
|
||||
avatarUrl: 'https://img/a.png',
|
||||
} as User;
|
||||
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((u) => Promise.resolve(u));
|
||||
|
||||
const result = await service.findOrCreate(profile);
|
||||
|
||||
expect(result.username).toBe('tetard');
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates avatar when it changed upstream', async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
superOauthId: 'oauth-42',
|
||||
username: 'tetard',
|
||||
avatarUrl: 'https://img/old.png',
|
||||
} as User;
|
||||
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((u) => Promise.resolve(u));
|
||||
|
||||
const result = await service.findOrCreate({ ...profile, avatar: 'https://img/new.png' });
|
||||
|
||||
expect(result.avatarUrl).toBe('https://img/new.png');
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps existing avatar when upstream avatar is undefined', async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
superOauthId: 'oauth-42',
|
||||
username: 'tetard',
|
||||
avatarUrl: 'https://img/a.png',
|
||||
} as User;
|
||||
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((u) => Promise.resolve(u));
|
||||
|
||||
const result = await service.findOrCreate({ id: 'oauth-42', nickname: 'new-name' });
|
||||
|
||||
expect(result.avatarUrl).toBe('https://img/a.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
300
backend/src/work/anilist.service.spec.ts
Normal file
300
backend/src/work/anilist.service.spec.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/// <reference types="jest" />
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AniListService, AniListMedia } from './anilist.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ANIME_FIXTURE: AniListMedia = {
|
||||
id: 21,
|
||||
type: 'ANIME',
|
||||
title: { romaji: 'One Punch Man', english: 'One-Punch Man', native: 'ワンパンマン' },
|
||||
coverImage: { large: 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21-YCDoj1EkAxFn.jpg' },
|
||||
description: 'Saitama has a rather peculiar hobby...',
|
||||
episodes: 12,
|
||||
chapters: null,
|
||||
status: 'FINISHED',
|
||||
genres: ['Action', 'Comedy', 'Sci-Fi'],
|
||||
};
|
||||
|
||||
const MANGA_FIXTURE: AniListMedia = {
|
||||
id: 30013,
|
||||
type: 'MANGA',
|
||||
title: { romaji: 'One Piece', english: 'One Piece', native: 'ONE PIECE' },
|
||||
coverImage: { large: 'https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30013.jpg' },
|
||||
description: 'Gold Roger, the King of the Pirates...',
|
||||
episodes: null,
|
||||
chapters: 1120,
|
||||
status: 'RELEASING',
|
||||
genres: ['Action', 'Adventure', 'Comedy'],
|
||||
};
|
||||
|
||||
function makeSearchResponse(media: AniListMedia[], total = media.length, hasNextPage = false) {
|
||||
return {
|
||||
data: {
|
||||
Page: {
|
||||
media,
|
||||
pageInfo: { total, hasNextPage },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeGetByIdResponse(media: AniListMedia) {
|
||||
return { data: { Media: media } };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockFetchOk(body: unknown): jest.Mock {
|
||||
return jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(body),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
function mockFetchError(status: number, body = ''): jest.Mock {
|
||||
return jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status,
|
||||
text: () => Promise.resolve(body),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AniListService', () => {
|
||||
let service: AniListService;
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeAll(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AniListService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string, fallback: string) =>
|
||||
key === 'ANILIST_API_URL' ? 'https://graphql.anilist.co' : fallback,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AniListService>(AniListService);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// search()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('search()', () => {
|
||||
it('should return parsed anime results', async () => {
|
||||
const payload = makeSearchResponse([ANIME_FIXTURE], 1, false);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
const result = await service.search('One Punch', 'ANIME');
|
||||
|
||||
expect(result.media).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.hasNextPage).toBe(false);
|
||||
|
||||
const media = result.media[0];
|
||||
expect(media.id).toBe(21);
|
||||
expect(media.type).toBe('ANIME');
|
||||
expect(media.title.romaji).toBe('One Punch Man');
|
||||
expect(media.title.english).toBe('One-Punch Man');
|
||||
expect(media.title.native).toBe('ワンパンマン');
|
||||
expect(media.episodes).toBe(12);
|
||||
expect(media.chapters).toBeNull();
|
||||
expect(media.coverImage?.large).toContain('anilist');
|
||||
expect(media.genres).toEqual(['Action', 'Comedy', 'Sci-Fi']);
|
||||
});
|
||||
|
||||
it('should return parsed manga results', async () => {
|
||||
const payload = makeSearchResponse([MANGA_FIXTURE], 42, true);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
const result = await service.search('One Piece', 'MANGA');
|
||||
|
||||
expect(result.media).toHaveLength(1);
|
||||
expect(result.total).toBe(42);
|
||||
expect(result.hasNextPage).toBe(true);
|
||||
|
||||
const media = result.media[0];
|
||||
expect(media.type).toBe('MANGA');
|
||||
expect(media.chapters).toBe(1120);
|
||||
expect(media.episodes).toBeNull();
|
||||
});
|
||||
|
||||
it('should search without type filter', async () => {
|
||||
const payload = makeSearchResponse([ANIME_FIXTURE, MANGA_FIXTURE], 2);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
const result = await service.search('One');
|
||||
|
||||
expect(result.media).toHaveLength(2);
|
||||
|
||||
// verify the variables sent to the API don't include type
|
||||
const fetchMock = globalThis.fetch as jest.Mock;
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.variables).not.toHaveProperty('type');
|
||||
expect(body.variables.search).toBe('One');
|
||||
});
|
||||
|
||||
it('should forward page and perPage parameters', async () => {
|
||||
const payload = makeSearchResponse([], 0);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
await service.search('test', 'ANIME', 3, 10);
|
||||
|
||||
const fetchMock = globalThis.fetch as jest.Mock;
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.variables.page).toBe(3);
|
||||
expect(body.variables.perPage).toBe(10);
|
||||
});
|
||||
|
||||
it('should POST to the configured API URL', async () => {
|
||||
const payload = makeSearchResponse([]);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
await service.search('test');
|
||||
|
||||
const fetchMock = globalThis.fetch as jest.Mock;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://graphql.anilist.co',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getById()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('getById()', () => {
|
||||
it('should return a single media by id', async () => {
|
||||
const payload = makeGetByIdResponse(ANIME_FIXTURE);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
const media = await service.getById(21);
|
||||
|
||||
expect(media).not.toBeNull();
|
||||
expect(media!.id).toBe(21);
|
||||
expect(media!.title.romaji).toBe('One Punch Man');
|
||||
});
|
||||
|
||||
it('should return null when API errors', async () => {
|
||||
globalThis.fetch = mockFetchError(404, 'Not Found');
|
||||
|
||||
const media = await service.getById(999999);
|
||||
|
||||
expect(media).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when fetch rejects (network error)', async () => {
|
||||
globalThis.fetch = jest.fn().mockRejectedValue(new Error('network down'));
|
||||
|
||||
const media = await service.getById(1);
|
||||
|
||||
expect(media).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw on HTTP 500', async () => {
|
||||
globalThis.fetch = mockFetchError(500, 'Internal Server Error');
|
||||
|
||||
await expect(service.search('test')).rejects.toThrow('AniList API error: 500');
|
||||
});
|
||||
|
||||
it('should throw on HTTP 429 rate limit', async () => {
|
||||
globalThis.fetch = mockFetchError(429, 'Too Many Requests');
|
||||
|
||||
await expect(service.search('test')).rejects.toThrow('AniList API error: 429');
|
||||
});
|
||||
|
||||
it('should throw on HTTP 404', async () => {
|
||||
globalThis.fetch = mockFetchError(404, 'Not Found');
|
||||
|
||||
await expect(service.search('test')).rejects.toThrow('AniList API error: 404');
|
||||
});
|
||||
|
||||
it('should propagate network errors from fetch', async () => {
|
||||
globalThis.fetch = jest.fn().mockRejectedValue(new TypeError('fetch failed'));
|
||||
|
||||
await expect(service.search('test')).rejects.toThrow('fetch failed');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Response field parsing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('field parsing', () => {
|
||||
it('should handle null optional fields', async () => {
|
||||
const sparse: AniListMedia = {
|
||||
id: 100,
|
||||
type: 'ANIME',
|
||||
title: { romaji: 'Unknown', english: null, native: null },
|
||||
coverImage: null,
|
||||
description: null,
|
||||
episodes: null,
|
||||
chapters: null,
|
||||
status: null,
|
||||
genres: [],
|
||||
};
|
||||
const payload = makeSearchResponse([sparse]);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
const result = await service.search('unknown');
|
||||
const media = result.media[0];
|
||||
|
||||
expect(media.title.english).toBeNull();
|
||||
expect(media.title.native).toBeNull();
|
||||
expect(media.coverImage).toBeNull();
|
||||
expect(media.description).toBeNull();
|
||||
expect(media.episodes).toBeNull();
|
||||
expect(media.chapters).toBeNull();
|
||||
expect(media.status).toBeNull();
|
||||
expect(media.genres).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve all genres', async () => {
|
||||
const payload = makeSearchResponse([ANIME_FIXTURE]);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
const result = await service.search('opm');
|
||||
expect(result.media[0].genres).toEqual(['Action', 'Comedy', 'Sci-Fi']);
|
||||
});
|
||||
|
||||
it('should preserve description text', async () => {
|
||||
const payload = makeSearchResponse([MANGA_FIXTURE]);
|
||||
globalThis.fetch = mockFetchOk(payload);
|
||||
|
||||
const result = await service.search('op');
|
||||
expect(result.media[0].description).toBe('Gold Roger, the King of the Pirates...');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { WorkService } from './work.service';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('api/works')
|
||||
export class WorkController {
|
||||
constructor(private readonly workService: WorkService) {}
|
||||
|
||||
@Public()
|
||||
@Throttle([{ ttl: 60000, limit: 20 }])
|
||||
@Get('search')
|
||||
async search(
|
||||
@Query('q') query: string,
|
||||
|
||||
216
backend/src/work/work.service.spec.ts
Normal file
216
backend/src/work/work.service.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/// <reference types="jest" />
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { WorkService } from './work.service';
|
||||
import { Work, WorkType, WorkStatus } from './work.entity';
|
||||
import { AniListService, AniListMedia } from './anilist.service';
|
||||
|
||||
const mockRepository = () => ({
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
});
|
||||
|
||||
const mockAniListService = () => ({
|
||||
search: jest.fn(),
|
||||
getById: jest.fn(),
|
||||
});
|
||||
|
||||
const buildMedia = (overrides: Partial<AniListMedia> = {}): AniListMedia => ({
|
||||
id: 1,
|
||||
type: 'ANIME',
|
||||
title: { romaji: 'Shingeki no Kyojin', english: 'Attack on Titan', native: '進撃の巨人' },
|
||||
coverImage: { large: 'https://img.anilist.co/cover.jpg' },
|
||||
description: 'Humanity fights titans.',
|
||||
episodes: 25,
|
||||
chapters: null,
|
||||
status: 'FINISHED',
|
||||
genres: ['Action', 'Drama'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('WorkService', () => {
|
||||
let service: WorkService;
|
||||
let workRepo: jest.Mocked<Pick<Repository<Work>, 'findOne' | 'create' | 'save'>>;
|
||||
let anilist: jest.Mocked<Pick<AniListService, 'search' | 'getById'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkService,
|
||||
{ provide: getRepositoryToken(Work), useFactory: mockRepository },
|
||||
{ provide: AniListService, useFactory: mockAniListService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(WorkService);
|
||||
workRepo = module.get(getRepositoryToken(Work));
|
||||
anilist = module.get(AniListService);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// findOrCreateFromAniList — existing in DB
|
||||
// ------------------------------------------------------------------
|
||||
describe('findOrCreateFromAniList (existing)', () => {
|
||||
it('should return the existing work without calling AniList', async () => {
|
||||
const existingWork = { id: 42, anilistId: 1, titleRomaji: 'SNK' } as Work;
|
||||
workRepo.findOne.mockResolvedValue(existingWork);
|
||||
|
||||
const result = await service.findOrCreateFromAniList(1);
|
||||
|
||||
expect(result).toBe(existingWork);
|
||||
expect(workRepo.findOne).toHaveBeenCalledWith({ where: { anilistId: 1 } });
|
||||
expect(anilist.getById).not.toHaveBeenCalled();
|
||||
expect(workRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// findOrCreateFromAniList — fetch + create
|
||||
// ------------------------------------------------------------------
|
||||
describe('findOrCreateFromAniList (create)', () => {
|
||||
it('should fetch from AniList and persist a new ANIME work', async () => {
|
||||
workRepo.findOne.mockResolvedValue(null);
|
||||
const media = buildMedia();
|
||||
anilist.getById.mockResolvedValue(media);
|
||||
|
||||
const created = { id: 1, anilistId: media.id } as Work;
|
||||
workRepo.create.mockReturnValue(created);
|
||||
workRepo.save.mockResolvedValue(created);
|
||||
|
||||
const result = await service.findOrCreateFromAniList(media.id);
|
||||
|
||||
expect(anilist.getById).toHaveBeenCalledWith(media.id);
|
||||
expect(workRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anilistId: media.id,
|
||||
type: WorkType.ANIME,
|
||||
titleRomaji: 'Shingeki no Kyojin',
|
||||
titleEnglish: 'Attack on Titan',
|
||||
titleNative: '進撃の巨人',
|
||||
posterUrl: 'https://img.anilist.co/cover.jpg',
|
||||
synopsis: 'Humanity fights titans.',
|
||||
totalEpisodes: 25,
|
||||
totalChapters: undefined,
|
||||
status: WorkStatus.FINISHED,
|
||||
genres: ['Action', 'Drama'],
|
||||
}),
|
||||
);
|
||||
expect(workRepo.save).toHaveBeenCalledWith(created);
|
||||
expect(result).toBe(created);
|
||||
});
|
||||
|
||||
it('should map MANGA type correctly', async () => {
|
||||
workRepo.findOne.mockResolvedValue(null);
|
||||
const media = buildMedia({ type: 'MANGA', episodes: null, chapters: 139 });
|
||||
anilist.getById.mockResolvedValue(media);
|
||||
workRepo.create.mockReturnValue({} as Work);
|
||||
workRepo.save.mockResolvedValue({} as Work);
|
||||
|
||||
await service.findOrCreateFromAniList(media.id);
|
||||
|
||||
expect(workRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: WorkType.MANGA,
|
||||
totalEpisodes: undefined,
|
||||
totalChapters: 139,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when AniList returns null', async () => {
|
||||
workRepo.findOne.mockResolvedValue(null);
|
||||
anilist.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOrCreateFromAniList(999)).rejects.toThrow(
|
||||
'AniList media 999 not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// mapStatus — all branches
|
||||
// ------------------------------------------------------------------
|
||||
describe('mapStatus (via findOrCreateFromAniList)', () => {
|
||||
// Helper: trigger findOrCreate and capture the status passed to create()
|
||||
const captureStatus = async (anilistStatus: string | null) => {
|
||||
workRepo.findOne.mockResolvedValue(null);
|
||||
anilist.getById.mockResolvedValue(buildMedia({ status: anilistStatus }));
|
||||
workRepo.create.mockReturnValue({} as Work);
|
||||
workRepo.save.mockResolvedValue({} as Work);
|
||||
|
||||
await service.findOrCreateFromAniList(1);
|
||||
|
||||
const arg = workRepo.create.mock.calls[0][0] as Partial<Work>;
|
||||
return arg.status;
|
||||
};
|
||||
|
||||
it.each([
|
||||
['RELEASING', WorkStatus.RELEASING],
|
||||
['FINISHED', WorkStatus.FINISHED],
|
||||
['NOT_YET_RELEASED', WorkStatus.NOT_YET_RELEASED],
|
||||
['CANCELLED', WorkStatus.CANCELLED],
|
||||
['HIATUS', WorkStatus.HIATUS],
|
||||
])('should map "%s" to WorkStatus.%s', async (input: string, expected: WorkStatus) => {
|
||||
expect(await captureStatus(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return undefined for null status', async () => {
|
||||
expect(await captureStatus(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for unknown status', async () => {
|
||||
expect(await captureStatus('UNKNOWN_VALUE')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Optional fields — null handling
|
||||
// ------------------------------------------------------------------
|
||||
describe('optional fields handling', () => {
|
||||
it('should set optional fields to undefined when AniList returns null', async () => {
|
||||
workRepo.findOne.mockResolvedValue(null);
|
||||
const media = buildMedia({
|
||||
title: { romaji: 'Test', english: null, native: null },
|
||||
coverImage: null,
|
||||
description: null,
|
||||
episodes: null,
|
||||
chapters: null,
|
||||
status: null,
|
||||
});
|
||||
anilist.getById.mockResolvedValue(media);
|
||||
workRepo.create.mockReturnValue({} as Work);
|
||||
workRepo.save.mockResolvedValue({} as Work);
|
||||
|
||||
await service.findOrCreateFromAniList(media.id);
|
||||
|
||||
expect(workRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titleEnglish: undefined,
|
||||
titleNative: undefined,
|
||||
posterUrl: undefined,
|
||||
synopsis: undefined,
|
||||
totalEpisodes: undefined,
|
||||
totalChapters: undefined,
|
||||
status: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// search — delegates to AniListService
|
||||
// ------------------------------------------------------------------
|
||||
describe('search', () => {
|
||||
it('should delegate to anilist.search with correct args', async () => {
|
||||
const expected = { media: [], total: 0, hasNextPage: false };
|
||||
anilist.search.mockResolvedValue(expected);
|
||||
|
||||
const result = await service.search('naruto', 'ANIME', 2);
|
||||
|
||||
expect(anilist.search).toHaveBeenCalledWith('naruto', 'ANIME', 2);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user