The NestJS API serves the public profile through Apollo Sandbox and loads only the relations selected by the query.
196 lines
5.8 KiB
TypeScript
196 lines
5.8 KiB
TypeScript
import { existsSync } from 'node:fs';
|
||
import type { INestApplication } from '@nestjs/common';
|
||
import { Test } from '@nestjs/testing';
|
||
import request from 'supertest';
|
||
import { AppModule } from '../src/app.module.js';
|
||
import { applyMigrations } from '../src/prisma/apply-migrations.js';
|
||
import { PrismaService } from '../src/prisma/prisma.service.js';
|
||
|
||
type GraphqlBody<T> = {
|
||
data?: T;
|
||
errors?: Array<{ message: string }>;
|
||
};
|
||
|
||
function readBody<T>(response: request.Response): GraphqlBody<T> {
|
||
const payload: unknown = response.body;
|
||
if (typeof payload !== 'object' || payload === null) {
|
||
throw new Error('GraphQL response was not an object');
|
||
}
|
||
const record = payload as Record<string, unknown>;
|
||
const errors = Array.isArray(record.errors)
|
||
? record.errors.filter(isErrorItem)
|
||
: undefined;
|
||
return {
|
||
data: record.data as T | undefined,
|
||
errors,
|
||
};
|
||
}
|
||
|
||
function isErrorItem(value: unknown): value is { message: string } {
|
||
return (
|
||
typeof value === 'object' &&
|
||
value !== null &&
|
||
'message' in value &&
|
||
typeof value.message === 'string'
|
||
);
|
||
}
|
||
|
||
function http(app: INestApplication) {
|
||
// The Nest server handle is typed as any.
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||
return request(app.getHttpServer());
|
||
}
|
||
|
||
if (existsSync('.env')) {
|
||
process.loadEnvFile('.env');
|
||
}
|
||
|
||
const EXAMPLE = `
|
||
query {
|
||
profile {
|
||
name
|
||
description
|
||
skills { name }
|
||
experience { company position }
|
||
projects { name }
|
||
}
|
||
}
|
||
`;
|
||
|
||
describe('digital card', () => {
|
||
let app: INestApplication;
|
||
|
||
beforeAll(async () => {
|
||
await applyMigrations();
|
||
const moduleRef = await Test.createTestingModule({
|
||
imports: [AppModule],
|
||
}).compile();
|
||
app = moduleRef.createNestApplication();
|
||
await app.init();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
});
|
||
|
||
it('opens Apollo Sandbox and a health check', async () => {
|
||
const sandbox = await http(app).get('/graphql').set('Accept', 'text/html');
|
||
expect(sandbox.status).toBe(200);
|
||
expect(sandbox.text.toLowerCase()).toMatch(/sandbox|apollo/);
|
||
|
||
const health = await http(app).get('/health');
|
||
expect(health.status).toBe(200);
|
||
expect(health.body).toEqual({ status: 'ok' });
|
||
});
|
||
|
||
it('returns the profile for the assignment query', async () => {
|
||
const prisma = app.get(PrismaService);
|
||
prisma.takeQueries();
|
||
|
||
const response = await http(app).post('/graphql').send({ query: EXAMPLE });
|
||
|
||
expect(response.status).toBe(200);
|
||
const body = readBody<{
|
||
profile: {
|
||
name: string;
|
||
description: string;
|
||
skills: Array<{ name: string }>;
|
||
experience: Array<{ company: string; position: string }>;
|
||
projects: Array<{ name: string }>;
|
||
};
|
||
}>(response);
|
||
expect(body.errors).toBeUndefined();
|
||
|
||
const profile = body.data?.profile;
|
||
expect(profile?.name).toBe('Илья Братских');
|
||
expect(profile?.description.length).toBeGreaterThan(0);
|
||
expect(profile?.skills.map((skill) => skill.name)).toContain('NestJS');
|
||
expect(profile?.experience).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
company: 'АО «Синтелс»',
|
||
position: 'Fullstack Developer & Researcher',
|
||
}),
|
||
]),
|
||
);
|
||
expect(profile?.projects.map((project) => project.name)).toContain(
|
||
'AI-обучение и чат-боты',
|
||
);
|
||
|
||
const statements = prisma.takeQueries().filter(isApplicationSql);
|
||
expect(statements.length).toBeGreaterThan(0);
|
||
expect(statements.length).toBeLessThanOrEqual(6);
|
||
expect(statements.filter((sql) => /Achievement/i.test(sql))).toHaveLength(
|
||
0,
|
||
);
|
||
});
|
||
|
||
it('loads nested achievements without a query per job', async () => {
|
||
const prisma = app.get(PrismaService);
|
||
prisma.takeQueries();
|
||
|
||
const response = await http(app)
|
||
.post('/graphql')
|
||
.send({
|
||
query: `
|
||
query {
|
||
profile {
|
||
experience { company achievements { description } }
|
||
projects { name url skills { name } highlights }
|
||
}
|
||
}
|
||
`,
|
||
});
|
||
|
||
expect(response.status).toBe(200);
|
||
const body = readBody<{
|
||
profile: {
|
||
experience: Array<{
|
||
company: string;
|
||
achievements: Array<{ description: string }>;
|
||
}>;
|
||
projects: Array<{
|
||
name: string;
|
||
url: string | null;
|
||
skills: Array<{ name: string }>;
|
||
}>;
|
||
};
|
||
}>(response);
|
||
expect(body.errors).toBeUndefined();
|
||
const jobs = body.data?.profile.experience ?? [];
|
||
const sintels = jobs.find((job) => job.company.includes('Синтелс'));
|
||
expect(sintels?.achievements).toHaveLength(4);
|
||
|
||
const projects = body.data?.profile.projects ?? [];
|
||
const ai = projects.find((project) => project.name.startsWith('AI-'));
|
||
expect(ai?.url).toBe('https://github.com/KBAHTNET/ai_learning');
|
||
expect(ai?.skills.map((skill) => skill.name)).toEqual(
|
||
expect.arrayContaining(['Python', 'PyTorch']),
|
||
);
|
||
|
||
const statements = prisma.takeQueries().filter(isApplicationSql);
|
||
expect(statements.filter((sql) => /Achievement/i.test(sql))).toHaveLength(
|
||
1,
|
||
);
|
||
expect(statements.length).toBeLessThanOrEqual(8);
|
||
});
|
||
|
||
it('rejects an unknown or malformed slug', async () => {
|
||
const missing = await http(app).post('/graphql').send({
|
||
query: 'query { profile(slug: "nobody") { name } }',
|
||
});
|
||
expect(readBody(missing).errors?.[0]?.message).toContain('не найден');
|
||
|
||
const invalid = await http(app).post('/graphql').send({
|
||
query: 'query { profile(slug: "Bad Slug") { name } }',
|
||
});
|
||
expect(readBody(invalid).errors?.[0]?.message).toContain('Некорректный');
|
||
});
|
||
});
|
||
|
||
function isApplicationSql(sql: string): boolean {
|
||
return /profile|skill|experience|achievement|project|education|award|link/i.test(
|
||
sql,
|
||
);
|
||
}
|