feat: add trip and trip settings with optimistic locking
This commit is contained in:
@@ -5,9 +5,16 @@ import { AppService } from './app.service';
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { VersionModule } from './version/version.module';
|
import { VersionModule } from './version/version.module';
|
||||||
import { UsersApiModule } from './users/users.module';
|
import { UsersApiModule } from './users/users.module';
|
||||||
|
import { TripsApiModule } from './trips/trips.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigurationModule, HealthModule, VersionModule, UsersApiModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
HealthModule,
|
||||||
|
VersionModule,
|
||||||
|
UsersApiModule,
|
||||||
|
TripsApiModule,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
})
|
})
|
||||||
|
|||||||
73
backend/apps/api/src/trips/trips.controller.spec.ts
Normal file
73
backend/apps/api/src/trips/trips.controller.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { TripsController } from './trips.controller';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
|
||||||
|
describe('TripsController', () => {
|
||||||
|
const currentUser: AuthenticatedUser = {
|
||||||
|
id: 'u1',
|
||||||
|
externalSubjectId: 'sub-1',
|
||||||
|
displayName: 'Alex',
|
||||||
|
email: 'a@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('GET /trips lists trips for the current user', async () => {
|
||||||
|
const tripsService = {
|
||||||
|
listTripsForUser: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 't1', name: 'Slovenia 2027' }]),
|
||||||
|
};
|
||||||
|
const controller = new TripsController(tripsService as never);
|
||||||
|
|
||||||
|
await expect(controller.list(currentUser)).resolves.toEqual([
|
||||||
|
{ id: 't1', name: 'Slovenia 2027' },
|
||||||
|
]);
|
||||||
|
expect(tripsService.listTripsForUser).toHaveBeenCalledWith('u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /trips creates a trip owned by the current user', async () => {
|
||||||
|
const created = {
|
||||||
|
id: 't1',
|
||||||
|
name: 'Slovenia 2027',
|
||||||
|
ownerId: 'u1',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
const tripsService = { createTrip: jest.fn().mockResolvedValue(created) };
|
||||||
|
const controller = new TripsController(tripsService as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.create(currentUser, { name: 'Slovenia 2027' }),
|
||||||
|
).resolves.toEqual(created);
|
||||||
|
expect(tripsService.createTrip).toHaveBeenCalledWith('u1', {
|
||||||
|
name: 'Slovenia 2027',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /trips/:tripId returns a single trip', async () => {
|
||||||
|
const trip = { id: 't1', name: 'Slovenia 2027' };
|
||||||
|
const tripsService = { getTrip: jest.fn().mockResolvedValue(trip) };
|
||||||
|
const controller = new TripsController(tripsService as never);
|
||||||
|
|
||||||
|
await expect(controller.getOne('t1')).resolves.toEqual(trip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /trips/:tripId forwards the update dto including version', async () => {
|
||||||
|
const updated = { id: 't1', name: 'New name', version: 2 };
|
||||||
|
const tripsService = { updateTrip: jest.fn().mockResolvedValue(updated) };
|
||||||
|
const controller = new TripsController(tripsService as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.update('t1', { name: 'New name', version: 1 }),
|
||||||
|
).resolves.toEqual(updated);
|
||||||
|
expect(tripsService.updateTrip).toHaveBeenCalledWith('t1', {
|
||||||
|
name: 'New name',
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /trips/:tripId deletes the trip', async () => {
|
||||||
|
const tripsService = { deleteTrip: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const controller = new TripsController(tripsService as never);
|
||||||
|
|
||||||
|
await controller.remove('t1');
|
||||||
|
expect(tripsService.deleteTrip).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
});
|
||||||
56
backend/apps/api/src/trips/trips.controller.ts
Normal file
56
backend/apps/api/src/trips/trips.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
import { TripsService } from '../../../../libs/trips/src';
|
||||||
|
import type {
|
||||||
|
CreateTripDto,
|
||||||
|
Trip,
|
||||||
|
UpdateTripDto,
|
||||||
|
} from '../../../../libs/trips/src';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
|
||||||
|
@Controller('trips')
|
||||||
|
@UseGuards(OidcAuthGuard)
|
||||||
|
export class TripsController {
|
||||||
|
constructor(private readonly tripsService: TripsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() currentUser: AuthenticatedUser): Promise<Trip[]> {
|
||||||
|
return this.tripsService.listTripsForUser(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentUser() currentUser: AuthenticatedUser,
|
||||||
|
@Body() dto: CreateTripDto,
|
||||||
|
): Promise<Trip> {
|
||||||
|
return this.tripsService.createTrip(currentUser.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':tripId')
|
||||||
|
getOne(@Param('tripId') tripId: string): Promise<Trip> {
|
||||||
|
return this.tripsService.getTrip(tripId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':tripId')
|
||||||
|
update(
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@Body() dto: UpdateTripDto,
|
||||||
|
): Promise<Trip> {
|
||||||
|
return this.tripsService.updateTrip(tripId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':tripId')
|
||||||
|
remove(@Param('tripId') tripId: string): Promise<void> {
|
||||||
|
return this.tripsService.deleteTrip(tripId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/apps/api/src/trips/trips.module.ts
Normal file
10
backend/apps/api/src/trips/trips.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../../../../libs/auth/src';
|
||||||
|
import { TripsLibModule } from '../../../../libs/trips/src';
|
||||||
|
import { TripsController } from './trips.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, TripsLibModule],
|
||||||
|
controllers: [TripsController],
|
||||||
|
})
|
||||||
|
export class TripsApiModule {}
|
||||||
4
backend/libs/trips/src/index.ts
Normal file
4
backend/libs/trips/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './trip.types';
|
||||||
|
export * from './trips.service';
|
||||||
|
export * from './trip-settings.service';
|
||||||
|
export * from './trips.module';
|
||||||
67
backend/libs/trips/src/trip-settings.repository.ts
Normal file
67
backend/libs/trips/src/trip-settings.repository.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
import { KYSELY_DB } from '../../database/src';
|
||||||
|
import type { Database } from '../../database/src';
|
||||||
|
import type {
|
||||||
|
ResearchDepth,
|
||||||
|
TripPlanningStyle,
|
||||||
|
TripSettings,
|
||||||
|
UpdateTripSettingsDto,
|
||||||
|
} from './trip.types';
|
||||||
|
|
||||||
|
function toTripSettings(row: {
|
||||||
|
trip_id: string;
|
||||||
|
web_research_enabled: boolean;
|
||||||
|
periodic_agent_review_enabled: boolean;
|
||||||
|
notification_email_enabled: boolean;
|
||||||
|
notification_push_enabled: boolean;
|
||||||
|
default_research_depth: string | null;
|
||||||
|
default_planning_style: string | null;
|
||||||
|
}): TripSettings {
|
||||||
|
return {
|
||||||
|
tripId: row.trip_id,
|
||||||
|
webResearchEnabled: row.web_research_enabled,
|
||||||
|
periodicAgentReviewEnabled: row.periodic_agent_review_enabled,
|
||||||
|
notificationEmailEnabled: row.notification_email_enabled,
|
||||||
|
notificationPushEnabled: row.notification_push_enabled,
|
||||||
|
defaultResearchDepth: row.default_research_depth as ResearchDepth | null,
|
||||||
|
defaultPlanningStyle:
|
||||||
|
row.default_planning_style as TripPlanningStyle | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TripSettingsRepository {
|
||||||
|
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
|
||||||
|
|
||||||
|
async findByTripId(tripId: string): Promise<TripSettings | undefined> {
|
||||||
|
const row = await this.db
|
||||||
|
.selectFrom('trip_settings')
|
||||||
|
.selectAll()
|
||||||
|
.where('trip_id', '=', tripId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return row ? toTripSettings(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async replace(
|
||||||
|
tripId: string,
|
||||||
|
dto: UpdateTripSettingsDto,
|
||||||
|
): Promise<TripSettings> {
|
||||||
|
const row = await this.db
|
||||||
|
.updateTable('trip_settings')
|
||||||
|
.set({
|
||||||
|
web_research_enabled: dto.webResearchEnabled,
|
||||||
|
periodic_agent_review_enabled: dto.periodicAgentReviewEnabled,
|
||||||
|
notification_email_enabled: dto.notificationEmailEnabled,
|
||||||
|
notification_push_enabled: dto.notificationPushEnabled,
|
||||||
|
default_research_depth: dto.defaultResearchDepth,
|
||||||
|
default_planning_style: dto.defaultPlanningStyle,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where('trip_id', '=', tripId)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return toTripSettings(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/libs/trips/src/trip-settings.service.ts
Normal file
21
backend/libs/trips/src/trip-settings.service.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { TripSettingsRepository } from './trip-settings.repository';
|
||||||
|
import type { TripSettings, UpdateTripSettingsDto } from './trip.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TripSettingsService {
|
||||||
|
constructor(private readonly repository: TripSettingsRepository) {}
|
||||||
|
|
||||||
|
async getSettings(tripId: string): Promise<TripSettings> {
|
||||||
|
const settings = await this.repository.findByTripId(tripId);
|
||||||
|
if (!settings) throw new NotFoundException('Trip settings not found');
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceSettings(
|
||||||
|
tripId: string,
|
||||||
|
dto: UpdateTripSettingsDto,
|
||||||
|
): Promise<TripSettings> {
|
||||||
|
return this.repository.replace(tripId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/libs/trips/src/trip.types.ts
Normal file
74
backend/libs/trips/src/trip.types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export type TripStatus =
|
||||||
|
| 'DRAFT'
|
||||||
|
| 'PLANNING'
|
||||||
|
| 'BOOKING'
|
||||||
|
| 'UPCOMING'
|
||||||
|
| 'ACTIVE'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'ARCHIVED';
|
||||||
|
|
||||||
|
export interface Trip {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
ownerId: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
status: TripStatus;
|
||||||
|
planningStage: string | null;
|
||||||
|
currency: string;
|
||||||
|
version: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTripDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTripDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
status?: TripStatus;
|
||||||
|
planningStage?: string | null;
|
||||||
|
currency?: string;
|
||||||
|
/** The caller's last-known version; required so stale concurrent writes are rejected. */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TripPlanningStyle = 'RELAXED' | 'BALANCED' | 'PACKED';
|
||||||
|
export type ResearchDepth = 'MINIMAL' | 'STANDARD' | 'THOROUGH';
|
||||||
|
|
||||||
|
export interface TripSettings {
|
||||||
|
tripId: string;
|
||||||
|
webResearchEnabled: boolean;
|
||||||
|
periodicAgentReviewEnabled: boolean;
|
||||||
|
notificationEmailEnabled: boolean;
|
||||||
|
notificationPushEnabled: boolean;
|
||||||
|
defaultResearchDepth: ResearchDepth | null;
|
||||||
|
defaultPlanningStyle: TripPlanningStyle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTripSettingsDto {
|
||||||
|
webResearchEnabled: boolean;
|
||||||
|
periodicAgentReviewEnabled: boolean;
|
||||||
|
notificationEmailEnabled: boolean;
|
||||||
|
notificationPushEnabled: boolean;
|
||||||
|
defaultResearchDepth: ResearchDepth | null;
|
||||||
|
defaultPlanningStyle: TripPlanningStyle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
|
||||||
|
webResearchEnabled: false,
|
||||||
|
periodicAgentReviewEnabled: false,
|
||||||
|
notificationEmailEnabled: true,
|
||||||
|
notificationPushEnabled: true,
|
||||||
|
defaultResearchDepth: null,
|
||||||
|
defaultPlanningStyle: null,
|
||||||
|
};
|
||||||
18
backend/libs/trips/src/trips.module.ts
Normal file
18
backend/libs/trips/src/trips.module.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseModule } from '../../database/src';
|
||||||
|
import { TripsRepository } from './trips.repository';
|
||||||
|
import { TripsService } from './trips.service';
|
||||||
|
import { TripSettingsRepository } from './trip-settings.repository';
|
||||||
|
import { TripSettingsService } from './trip-settings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
providers: [
|
||||||
|
TripsRepository,
|
||||||
|
TripsService,
|
||||||
|
TripSettingsRepository,
|
||||||
|
TripSettingsService,
|
||||||
|
],
|
||||||
|
exports: [TripsService, TripSettingsService],
|
||||||
|
})
|
||||||
|
export class TripsLibModule {}
|
||||||
141
backend/libs/trips/src/trips.repository.ts
Normal file
141
backend/libs/trips/src/trips.repository.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
import { KYSELY_DB } from '../../database/src';
|
||||||
|
import type { Database } from '../../database/src';
|
||||||
|
import { DEFAULT_TRIP_SETTINGS } from './trip.types';
|
||||||
|
import type { CreateTripDto, Trip, TripStatus } from './trip.types';
|
||||||
|
|
||||||
|
function toTrip(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
owner_id: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
status: string;
|
||||||
|
planning_stage: string | null;
|
||||||
|
currency: string;
|
||||||
|
version: number;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}): Trip {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
startDate: row.start_date,
|
||||||
|
endDate: row.end_date,
|
||||||
|
status: row.status as TripStatus,
|
||||||
|
planningStage: row.planning_stage,
|
||||||
|
currency: row.currency,
|
||||||
|
version: row.version,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripUpdateFields {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
status: string;
|
||||||
|
planning_stage: string | null;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TripsRepository {
|
||||||
|
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
|
||||||
|
|
||||||
|
async createTrip(ownerId: string, dto: CreateTripDto): Promise<Trip> {
|
||||||
|
return this.db.transaction().execute(async (trx) => {
|
||||||
|
const trip = await trx
|
||||||
|
.insertInto('trips')
|
||||||
|
.values({
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
owner_id: ownerId,
|
||||||
|
start_date: dto.startDate ?? null,
|
||||||
|
end_date: dto.endDate ?? null,
|
||||||
|
status: 'DRAFT',
|
||||||
|
currency: dto.currency ?? 'EUR',
|
||||||
|
})
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto('trip_settings')
|
||||||
|
.values({
|
||||||
|
trip_id: trip.id,
|
||||||
|
web_research_enabled: DEFAULT_TRIP_SETTINGS.webResearchEnabled,
|
||||||
|
periodic_agent_review_enabled:
|
||||||
|
DEFAULT_TRIP_SETTINGS.periodicAgentReviewEnabled,
|
||||||
|
notification_email_enabled:
|
||||||
|
DEFAULT_TRIP_SETTINGS.notificationEmailEnabled,
|
||||||
|
notification_push_enabled:
|
||||||
|
DEFAULT_TRIP_SETTINGS.notificationPushEnabled,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto('trip_members')
|
||||||
|
.values({
|
||||||
|
trip_id: trip.id,
|
||||||
|
user_id: ownerId,
|
||||||
|
role: 'OWNER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
joined_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return toTrip(trip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(tripId: string): Promise<Trip | undefined> {
|
||||||
|
const row = await this.db
|
||||||
|
.selectFrom('trips')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', tripId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return row ? toTrip(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(userId: string): Promise<Trip[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('trips')
|
||||||
|
.innerJoin('trip_members', 'trip_members.trip_id', 'trips.id')
|
||||||
|
.selectAll('trips')
|
||||||
|
.where('trip_members.user_id', '=', userId)
|
||||||
|
.where('trip_members.status', '=', 'ACTIVE')
|
||||||
|
.execute();
|
||||||
|
return rows.map(toTrip);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWithVersionCheck(
|
||||||
|
tripId: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
patch: Partial<TripUpdateFields>,
|
||||||
|
): Promise<Trip | undefined> {
|
||||||
|
const row = await this.db
|
||||||
|
.updateTable('trips')
|
||||||
|
.set({
|
||||||
|
...patch,
|
||||||
|
version: sql`version + 1`,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where('id', '=', tripId)
|
||||||
|
.where('version', '=', expectedVersion)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return row ? toTrip(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(tripId: string): Promise<void> {
|
||||||
|
await this.db.deleteFrom('trips').where('id', '=', tripId).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/libs/trips/src/trips.service.spec.ts
Normal file
33
backend/libs/trips/src/trips.service.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { TripsService } from './trips.service';
|
||||||
|
|
||||||
|
describe('TripsService.updateTrip', () => {
|
||||||
|
it('throws ConflictException when the repository update matches no row', async () => {
|
||||||
|
const repo = {
|
||||||
|
updateWithVersionCheck: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new TripsService(repo as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateTrip('trip-1', { name: 'New name', version: 1 }),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
|
||||||
|
expect(repo.updateWithVersionCheck).toHaveBeenCalledWith(
|
||||||
|
'trip-1',
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ name: 'New name' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the updated trip when the version matches', async () => {
|
||||||
|
const updated = { id: 'trip-1', name: 'New name', version: 2 };
|
||||||
|
const repo = {
|
||||||
|
updateWithVersionCheck: jest.fn().mockResolvedValue(updated),
|
||||||
|
};
|
||||||
|
const service = new TripsService(repo as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateTrip('trip-1', { name: 'New name', version: 1 }),
|
||||||
|
).resolves.toEqual(updated);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
backend/libs/trips/src/trips.service.ts
Normal file
55
backend/libs/trips/src/trips.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { TripsRepository } from './trips.repository';
|
||||||
|
import type { TripUpdateFields } from './trips.repository';
|
||||||
|
import type { CreateTripDto, Trip, UpdateTripDto } from './trip.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TripsService {
|
||||||
|
constructor(private readonly tripsRepository: TripsRepository) {}
|
||||||
|
|
||||||
|
createTrip(ownerId: string, dto: CreateTripDto): Promise<Trip> {
|
||||||
|
return this.tripsRepository.createTrip(ownerId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
listTripsForUser(userId: string): Promise<Trip[]> {
|
||||||
|
return this.tripsRepository.listForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrip(tripId: string): Promise<Trip> {
|
||||||
|
const trip = await this.tripsRepository.findById(tripId);
|
||||||
|
if (!trip) throw new NotFoundException('Trip not found');
|
||||||
|
return trip;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTrip(tripId: string, dto: UpdateTripDto): Promise<Trip> {
|
||||||
|
const patch: Partial<TripUpdateFields> = {};
|
||||||
|
if (dto.name !== undefined) patch.name = dto.name;
|
||||||
|
if (dto.description !== undefined) patch.description = dto.description;
|
||||||
|
if (dto.startDate !== undefined) patch.start_date = dto.startDate;
|
||||||
|
if (dto.endDate !== undefined) patch.end_date = dto.endDate;
|
||||||
|
if (dto.status !== undefined) patch.status = dto.status;
|
||||||
|
if (dto.planningStage !== undefined)
|
||||||
|
patch.planning_stage = dto.planningStage;
|
||||||
|
if (dto.currency !== undefined) patch.currency = dto.currency;
|
||||||
|
|
||||||
|
const updated = await this.tripsRepository.updateWithVersionCheck(
|
||||||
|
tripId,
|
||||||
|
dto.version,
|
||||||
|
patch,
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Trip was modified by someone else. Reload and retry.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTrip(tripId: string): Promise<void> {
|
||||||
|
await this.tripsRepository.delete(tripId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { Kysely, PostgresDialect } from 'kysely';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import type { Database } from '../../database/src';
|
||||||
|
import { TripsRepository } from '../src/trips.repository';
|
||||||
|
import { TripsService } from '../src/trips.service';
|
||||||
|
import { UsersRepository } from '../../users/src/users.repository';
|
||||||
|
|
||||||
|
describe('Trip optimistic locking (integration)', () => {
|
||||||
|
let db: Kysely<Database>;
|
||||||
|
let tripsService: TripsService;
|
||||||
|
let ownerId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = new Kysely<Database>({
|
||||||
|
dialect: new PostgresDialect({
|
||||||
|
pool: new Pool({ connectionString: process.env.DATABASE_URL }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const usersRepository = new UsersRepository(db);
|
||||||
|
const owner = await usersRepository.upsertByExternalSubjectId(
|
||||||
|
'optimistic-locking-test-sub',
|
||||||
|
{
|
||||||
|
email: 'owner@example.test',
|
||||||
|
displayName: 'Owner',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ownerId = owner.id;
|
||||||
|
tripsService = new TripsService(new TripsRepository(db));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a second update that used a stale version', async () => {
|
||||||
|
const trip = await tripsService.createTrip(ownerId, {
|
||||||
|
name: 'Slovenia 2027',
|
||||||
|
});
|
||||||
|
expect(trip.version).toBe(1);
|
||||||
|
|
||||||
|
const firstUpdate = await tripsService.updateTrip(trip.id, {
|
||||||
|
name: 'Slovenia 2027 (v2)',
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
expect(firstUpdate.version).toBe(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tripsService.updateTrip(trip.id, {
|
||||||
|
name: 'Conflicting concurrent edit',
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user