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
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { meRoutes } from './routes/me';
import { meExportRoutes } from './routes/me-export';
import { plannedRoutesPlugin } from './routes/planned';
import { routeRatingsPlugin } from './routes/ratings';
import { routeReportsRoutes } from './routes/route-reports';
import { routeSheetRoutes } from './routes/route-sheet';
import { searchRoutesPlugin } from './routes/search';
import { supportRoutes } from './routes/support';
Expand Down Expand Up @@ -173,6 +174,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) {
await app.register(goRoutes, { prefix: '/v1' });
await app.register(searchRoutesPlugin, { prefix: '/v1' });
await app.register(routeRatingsPlugin, { prefix: '/v1' });
await app.register(routeReportsRoutes, { prefix: '/v1' });
await app.register(routeSheetRoutes, {
prefix: '/v1',
directions:
Expand Down
57 changes: 57 additions & 0 deletions apps/api/src/reports/submit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
onlineRouteStatuses,
type RouteReportBody,
type RouteReportReceipt,
type UserRouteReportReason,
} from '@widoo/shared';
import { eq } from 'drizzle-orm';
import type { Db } from '../db/client';
import { routes } from '../db/schema';
import { httpError } from '../errors';
import type { User } from '../users/repository';
import { createRouteReport } from './repository';

/**
* Writes the report a reader makes of an online route (wiki API › Lieux, D-056). 404 for a route
* that does not exist or is not online (a draft, a rejected or a withdrawn one: the answer never
* tells them apart), 403 for the route of the caller. The rule of the 7 days, the check of the
* step and the lock against two reports at once are those of `createRouteReport`: 409 with
* `details.reportedAt`, and 422 for a step that is not of the route.
*/
export async function submitRouteReport(
db: Db,
user: User,
routeId: string,
body: RouteReportBody,
now: Date = new Date(),
): Promise<RouteReportReceipt> {
const [route] = await db
.select({ status: routes.status, authorId: routes.authorId })
.from(routes)
.where(eq(routes.id, routeId));
if (!route || !(onlineRouteStatuses as readonly string[]).includes(route.status)) {
throw httpError(404, 'Route not found');
}
if (route.authorId === user.id) {
throw httpError(403, 'A route cannot be reported by its author');
}
const created = await db.transaction((tx) =>
createRouteReport(
tx,
{
routeId,
userId: user.id,
reason: body.reason satisfies UserRouteReportReason,
stepId: body.stepId,
comment: body.comment,
},
now,
),
);
return {
id: created.id,
reason: body.reason,
status: 'open',
reportedAt: created.createdAt.toISOString(),
};
}
226 changes: 226 additions & 0 deletions apps/api/src/routes/route-reports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import {
ApiError,
RouteDetail,
RouteReportReceipt,
type UserRouteReportReason,
} from '@widoo/shared';
import { and, eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../app';
import { TestTokenVerifier } from '../auth/test-verifier';
import { routeReports, steps } from '../db/schema';
import { uuidv7 } from '../db/uuid';
import { plannedFixtures } from '../planned/test-fixtures';
import { testConfig } from '../test-config';

// On copies of the demo route, one per case: shared database, parallel files. Fictitious accounts
// are deleted afterwards with their reports.
const verifier = new TestTokenVerifier();
let app: Awaited<ReturnType<typeof buildApp>>;
const fixtures = plannedFixtures(() => app, verifier);
beforeAll(async () => {
app = await buildApp(testConfig(), { tokenVerifier: verifier, directions: null });
});
afterAll(async () => {
await fixtures.cleanUp();
await app.close();
});

type Headers = Record<string, string>;

/** A signed-in account and its id. */
async function account() {
const headers = await fixtures.signIn();
const me = await app.inject({ url: '/v1/me', headers });
return { headers, id: me.json<{ id: string }>().id };
}

const report = (routeId: string, headers: Headers, body: object) =>
app.inject({ method: 'POST', url: `/v1/routes/${routeId}/reports`, headers, payload: body });
const firstStepOf = async (routeId: string) => {
const [step] = await app.db.select().from(steps).where(eq(steps.routeId, routeId)).limit(1);
if (!step) throw new Error('Step missing');
return step.id;
};
const reportsOf = (routeId: string, userId: string) =>
app.db
.select()
.from(routeReports)
.where(and(eq(routeReports.routeId, routeId), eq(routeReports.userId, userId)));

describe('POST /v1/routes/:id/reports', () => {
it('writes an open report and answers 201 with it', async () => {
const reader = await account();
const routeId = await fixtures.copyDemoRoute();

const response = await report(routeId, reader.headers, { reason: 'inappropriate' });

expect(response.statusCode).toBe(201);
const receipt = RouteReportReceipt.parse(response.json());
expect(receipt).toMatchObject({ reason: 'inappropriate', status: 'open' });
expect(response.headers['cache-control']).toBe('no-store');
const [row] = await reportsOf(routeId, reader.id);
expect(row).toMatchObject({
id: receipt.id,
reason: 'inappropriate',
status: 'open',
stepId: null,
comment: null,
});
expect(Object.keys(receipt).sort()).toEqual(['id', 'reason', 'reportedAt', 'status']);
});

it('keeps the step and the comment of « place_changed », and the comment of « other »', async () => {
const reader = await account();
const routeId = await fixtures.copyDemoRoute();
const stepId = await firstStepOf(routeId);

const changed = await report(routeId, reader.headers, {
reason: 'place_changed',
stepId,
comment: 'Fermé depuis lundi',
});
expect(changed.statusCode).toBe(201);
const other = await report(await fixtures.copyDemoRoute(), reader.headers, {
reason: 'other',
comment: 'Une autre raison',
});
expect(other.statusCode).toBe(201);

const [row] = await reportsOf(routeId, reader.id);
expect(row).toMatchObject({ stepId, comment: 'Fermé depuis lundi' });
});

it('answers 409 with the time of the first report, inside 7 days, and shows it on the sheet', async () => {
const reader = await account();
const routeId = await fixtures.copyDemoRoute();
const first = RouteReportReceipt.parse(
(await report(routeId, reader.headers, { reason: 'hidden_ad' })).json(),
);

const again = await report(routeId, reader.headers, { reason: 'false_info' });

expect(again.statusCode).toBe(409);
expect(ApiError.parse(again.json()).details).toEqual({ reportedAt: first.reportedAt });
expect(await reportsOf(routeId, reader.id)).toHaveLength(1);
const sheet = RouteDetail.parse(
(await app.inject({ url: `/v1/routes/${routeId}`, headers: reader.headers })).json(),
);
expect(sheet.myReport).toEqual({ reportedAt: first.reportedAt, status: 'open' });
});

it('lets the reader report again once the team decided, and another reader report the route', async () => {
const reader = await account();
const other = await account();
const routeId = await fixtures.copyDemoRoute();
await report(routeId, reader.headers, { reason: 'copied_content' });
await app.db
.update(routeReports)
.set({ status: 'rejected', reviewedAt: new Date() })
.where(and(eq(routeReports.routeId, routeId), eq(routeReports.userId, reader.id)));

expect((await report(routeId, reader.headers, { reason: 'copied_content' })).statusCode).toBe(
201,
);
expect((await report(routeId, other.headers, { reason: 'copied_content' })).statusCode).toBe(
201,
);
});

it('answers 403 on the own route of the reader', async () => {
const author = await account();
const routeId = await fixtures.copyDemoRoute({ authorId: author.id });

const response = await report(routeId, author.headers, { reason: 'inappropriate' });

expect(response.statusCode).toBe(403);
expect(await reportsOf(routeId, author.id)).toHaveLength(0);
});

it('answers 404 for a route that is not online, withdrawn or unknown, and 401 without a token', async () => {
const reader = await account();
const draft = await fixtures.copyDemoRoute({ status: 'draft' });
const unpublished = await fixtures.copyDemoRoute({ status: 'unpublished' });
const online = await fixtures.copyDemoRoute({ status: 'needs_fix' });

expect(
(await report(draft, reader.headers, { reason: 'other', comment: 'x' })).statusCode,
).toBe(404);
expect(
(await report(unpublished, reader.headers, { reason: 'other', comment: 'x' })).statusCode,
).toBe(404);
expect(
(await report(uuidv7(), reader.headers, { reason: 'other', comment: 'x' })).statusCode,
).toBe(404);
// A route to correct stays online: it can be reported.
expect((await report(online, reader.headers, { reason: 'inappropriate' })).statusCode).toBe(
201,
);
expect(
(
await app.inject({
method: 'POST',
url: `/v1/routes/${online}/reports`,
payload: { reason: 'inappropriate' },
})
).statusCode,
).toBe(401);
});

it('answers 422 for a body that breaks the rules of the reasons, and writes nothing', async () => {
const reader = await account();
const routeId = await fixtures.copyDemoRoute();
const otherRoute = await fixtures.copyDemoRoute();
const stepOfOther = await firstStepOf(otherRoute);
const stepId = await firstStepOf(routeId);
const bodies: object[] = [
{ reason: 'place_changed' },
{ reason: 'place_changed', stepId: stepOfOther },
{ reason: 'other' },
{ reason: 'other', comment: 'x'.repeat(81) },
{ reason: 'inappropriate', stepId },
{ reason: 'inappropriate', comment: 'Un mot' },
{ reason: 'low_rating' },
{ reason: 'inappropriate', unknown: true },
{},
];

for (const body of bodies) {
const response = await report(routeId, reader.headers, body);
expect(response.statusCode, JSON.stringify(body)).toBe(422);
}
expect(await reportsOf(routeId, reader.id)).toHaveLength(0);
expect(
(await report('not-an-id', reader.headers, { reason: 'inappropriate' })).statusCode,
).toBe(400);
});

it('writes one report when the same report is sent twice at once', async () => {
const reader = await account();
const routeId = await fixtures.copyDemoRoute();

const answers = await Promise.all(
Array.from({ length: 4 }, () => report(routeId, reader.headers, { reason: 'inappropriate' })),
);

expect(answers.map((response) => response.statusCode).sort()).toEqual([201, 409, 409, 409]);
expect(await reportsOf(routeId, reader.id)).toHaveLength(1);
});
});

const everyReason: UserRouteReportReason[] = [
'inappropriate',
'dangerous_place',
'hidden_ad',
'copied_content',
'false_info',
];

describe('the reasons that take no step and no comment', () => {
it.each(everyReason)('takes « %s » alone', async (reason) => {
const reader = await account();
const routeId = await fixtures.copyDemoRoute();

expect((await report(routeId, reader.headers, { reason })).statusCode).toBe(201);
});
});
36 changes: 36 additions & 0 deletions apps/api/src/routes/route-reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { RouteReportBody, RouteReportReceipt } from '@widoo/shared';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { submitRouteReport } from '../reports/submit';
import { currentUser } from './me';

/**
* `POST /routes/:id/reports` (wiki API › Lieux, E-05 « Signaler ce parcours », D-056): a signed-in
* reader reports an online route; one report by user and by route over 7 sliding days. Never
* cached.
*/
export const routeReportsRoutes: FastifyPluginAsyncZod = async (app) => {
app.post(
'/routes/:id/reports',
{
onRequest: app.requireAuth,
onSend: async (_request, reply) => {
reply.header('cache-control', 'no-store');
},
schema: {
tags: ['discovery'],
summary: 'Report an online route: a place that changed, an abuse, a wrong information',
security: [{ firebaseIdToken: [] }],
params: z.strictObject({ id: z.uuid() }),
body: RouteReportBody,
response: { 201: RouteReportReceipt },
},
},
async (request, reply) =>
reply
.status(201)
.send(
await submitRouteReport(app.db, currentUser(request), request.params.id, request.body),
),
);
};
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export * from './schemas/rating';
export * from './schemas/recommendation';
export * from './schemas/route';
export * from './schemas/route-detail';
export * from './schemas/route-report';
export * from './schemas/route-editor';
export * from './schemas/search';
export * from './schemas/support';
53 changes: 53 additions & 0 deletions packages/shared/src/schemas/route-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { RouteReportBody } from './route-report';

const stepId = '0198c1de-0000-7000-8000-000000000001';

describe('RouteReportBody', () => {
it('takes a reason that takes nothing else', () => {
for (const reason of [
'inappropriate',
'dangerous_place',
'hidden_ad',
'copied_content',
'false_info',
]) {
expect(RouteReportBody.safeParse({ reason }).success).toBe(true);
}
});

it('asks the step of the place for « place_changed », and a comment for « other »', () => {
expect(RouteReportBody.safeParse({ reason: 'place_changed' }).success).toBe(false);
expect(RouteReportBody.safeParse({ reason: 'place_changed', stepId }).success).toBe(true);
expect(
RouteReportBody.safeParse({ reason: 'place_changed', stepId, comment: 'Fermé depuis lundi' })
.success,
).toBe(true);
expect(RouteReportBody.safeParse({ reason: 'other' }).success).toBe(false);
expect(RouteReportBody.safeParse({ reason: 'other', comment: 'Autre chose' }).success).toBe(
true,
);
});

it('refuses a step or a comment on a reason that takes none', () => {
expect(RouteReportBody.safeParse({ reason: 'hidden_ad', stepId }).success).toBe(false);
expect(RouteReportBody.safeParse({ reason: 'hidden_ad', comment: 'Une pub' }).success).toBe(
false,
);
expect(RouteReportBody.safeParse({ reason: 'other', stepId, comment: 'Autre' }).success).toBe(
false,
);
});

it('refuses the reason of the system, markup, a long comment and an unknown key', () => {
expect(RouteReportBody.safeParse({ reason: 'low_rating' }).success).toBe(false);
expect(RouteReportBody.safeParse({ reason: 'other', comment: '<b>x</b>' }).success).toBe(false);
expect(RouteReportBody.safeParse({ reason: 'other', comment: 'x'.repeat(81) }).success).toBe(
false,
);
expect(RouteReportBody.safeParse({ reason: 'other', comment: 'x'.repeat(80) }).success).toBe(
true,
);
expect(RouteReportBody.safeParse({ reason: 'inappropriate', extra: 1 }).success).toBe(false);
});
});
Loading
Loading