Skip to content

Commit 0bc3436

Browse files
committed
feat(backend): deterministic seed API and safer user updates for dev data
- Add SeedDevOptions (faker seed, user count clamp, fixed createdAt upper bound) and stable nbw-seed-* emails; document limits in seed.types. - Extend GET /seed/seed-dev with optional query params; await seeding; guard when NODE_ENV is not development. - Fix socialLinks seeding via a plain object; expand seed controller/service tests. - UserService.update now applies $set from toObject() and strips nested _id to avoid Mongoose circular-reference errors on findByIdAndUpdate.
1 parent 8857558 commit 0bc3436

8 files changed

Lines changed: 439 additions & 73 deletions

File tree

CONTRIBUTING.md

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,37 @@ You'll need the following installed on your machine:
2121
- [Docker](https://www.docker.com/)
2222
- [Docker Compose](https://docs.docker.com/compose/)
2323

24-
We provide a `docker-compose-dev.yml` file that sets up:
24+
We provide [`docker-compose.yml`](docker-compose.yml) at the repository root. It defines:
2525

26-
- A MongoDB instance
27-
- A local mail server (`maildev`)
28-
- An S3-compatible storage (`minio`)
29-
- A MinIO client
26+
- **MongoDB**
27+
- **MailDev** (SMTP + web UI for local email)
28+
- **MinIO** (S3-compatible storage)
29+
- A **MinIO client** job (profile `minio-init`) that creates buckets and CORS—**not** started by a plain `docker compose up`
3030

31-
To start the services, run the following in the root directory:
31+
Start dependencies from the repo root in one of these ways:
32+
33+
**Recommended** (waits for MongoDB and MinIO to be healthy, then runs bucket setup):
34+
35+
```bash
36+
bun run docker:up
37+
```
38+
39+
**Manual** (then create buckets once; uploads will fail with `NoSuchBucket` until you do):
40+
41+
```bash
42+
docker compose up -d
43+
bun run docker:minio-init
44+
```
45+
46+
To tear down volumes and start clean (still runs MinIO init after `up --wait`):
3247

3348
```bash
34-
docker-compose -f docker-compose.yml up -d
49+
bun run docker:reset:fresh
3550
```
3651

37-
> Remove the `-d` flag if you'd like to see container logs in your terminal.
52+
> Drop `-d` on `docker compose up -d` if you prefer logs attached to your terminal.
3853
39-
You can find authentication details in the [`docker-compose.yml`](docker-compose.yml) file.
54+
Ports and default credentials (Mongo, MinIO, MailDev) are in [`docker-compose.yml`](docker-compose.yml). Match the MinIO bucket names and keys in your backend `.env` (see the example block under **Environment Variables** below).
4055

4156
---
4257

apps/backend/.env.development.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ APP_DOMAIN=
2323

2424
RECAPTCHA_KEY=
2525

26+
# MinIO from repo docker-compose: after `docker compose up -d` (or `bun run docker:up`),
27+
# create buckets once with `bun run docker:minio-init` from the monorepo root (see root package.json).
28+
# Example local values (match docker-compose mc bucket names):
29+
# S3_ENDPOINT=http://localhost:9000
30+
# S3_BUCKET_SONGS=noteblockworld-songs
31+
# S3_BUCKET_THUMBS=noteblockworld-thumbs
32+
# S3_KEY=minioadmin
33+
# S3_SECRET=minioadmin
34+
# S3_REGION=us-east-1
2635
S3_ENDPOINT=
2736
S3_BUCKET_SONGS=
2837
S3_BUCKET_THUMBS=
Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,140 @@
1+
import { BadRequestException } from '@nestjs/common';
12
import { Test, TestingModule } from '@nestjs/testing';
23

34
import { SeedController } from './seed.controller';
45
import { SeedService } from './seed.service';
6+
import {
7+
DEFAULT_SEED_DATA_TIME_CAP,
8+
DEFAULT_SEED_FAKER,
9+
SEED_USER_COUNT_MAX,
10+
} from './seed.types';
511

612
describe('SeedController', () => {
713
let controller: SeedController;
14+
let seedService: { seedDev: jest.Mock };
15+
16+
async function createController(nodeEnv: string) {
17+
seedService = { seedDev: jest.fn().mockResolvedValue(undefined) };
818

9-
beforeEach(async () => {
1019
const module: TestingModule = await Test.createTestingModule({
1120
controllers: [SeedController],
1221
providers: [
13-
{
14-
provide: SeedService,
15-
useValue: {},
16-
},
22+
{ provide: SeedService, useValue: seedService },
23+
{ provide: 'NODE_ENV', useValue: nodeEnv },
1724
],
1825
}).compile();
1926

20-
controller = module.get<SeedController>(SeedController);
27+
return module.get<SeedController>(SeedController);
28+
}
29+
30+
beforeEach(async () => {
31+
controller = await createController('development');
32+
});
33+
34+
afterEach(() => {
35+
jest.clearAllMocks();
2136
});
2237

2338
it('should be defined', () => {
2439
expect(controller).toBeDefined();
2540
});
41+
42+
describe('seed', () => {
43+
it('calls seedDev with defaults when query params are omitted', async () => {
44+
const result = await controller.seed(undefined, undefined, undefined);
45+
46+
expect(seedService.seedDev).toHaveBeenCalledTimes(1);
47+
expect(seedService.seedDev).toHaveBeenLastCalledWith({
48+
fakerSeed: DEFAULT_SEED_FAKER,
49+
userCount: 100,
50+
});
51+
expect(result).toMatchObject({
52+
message: 'Seeding complete',
53+
fakerSeed: DEFAULT_SEED_FAKER,
54+
userCount: 100,
55+
createdAtUpper: DEFAULT_SEED_DATA_TIME_CAP.toISOString(),
56+
});
57+
});
58+
59+
it('parses fakerSeed and userCount from query strings', async () => {
60+
await controller.seed('7', '12', undefined);
61+
62+
expect(seedService.seedDev).toHaveBeenLastCalledWith({
63+
fakerSeed: 7,
64+
userCount: 12,
65+
});
66+
});
67+
68+
it('passes createdAtUpper when a valid ISO string is provided', async () => {
69+
const iso = '2024-03-01T15:30:00.000Z';
70+
await controller.seed(undefined, undefined, iso);
71+
72+
expect(seedService.seedDev).toHaveBeenLastCalledWith({
73+
fakerSeed: DEFAULT_SEED_FAKER,
74+
userCount: 100,
75+
createdAtUpper: new Date(iso),
76+
});
77+
});
78+
79+
it('clamps userCount to the configured maximum', async () => {
80+
await controller.seed(
81+
undefined,
82+
String(SEED_USER_COUNT_MAX + 999),
83+
undefined,
84+
);
85+
86+
expect(seedService.seedDev).toHaveBeenLastCalledWith({
87+
fakerSeed: DEFAULT_SEED_FAKER,
88+
userCount: SEED_USER_COUNT_MAX,
89+
});
90+
});
91+
92+
it('clamps userCount to at least 1', async () => {
93+
await controller.seed(undefined, '0', undefined);
94+
95+
expect(seedService.seedDev).toHaveBeenLastCalledWith({
96+
fakerSeed: DEFAULT_SEED_FAKER,
97+
userCount: 1,
98+
});
99+
});
100+
101+
it('throws BadRequest when fakerSeed is not an integer', async () => {
102+
await expect(
103+
controller.seed('not-int', undefined, undefined),
104+
).rejects.toThrow(BadRequestException);
105+
await expect(
106+
controller.seed('not-int', undefined, undefined),
107+
).rejects.toThrow('fakerSeed must be an integer');
108+
expect(seedService.seedDev).not.toHaveBeenCalled();
109+
});
110+
111+
it('throws BadRequest when userCount is not an integer', async () => {
112+
await expect(controller.seed(undefined, 'x', undefined)).rejects.toThrow(
113+
BadRequestException,
114+
);
115+
expect(seedService.seedDev).not.toHaveBeenCalled();
116+
});
117+
118+
it('throws BadRequest when createdAtUpper is not a valid date', async () => {
119+
await expect(
120+
controller.seed(undefined, undefined, 'not-a-date'),
121+
).rejects.toThrow(BadRequestException);
122+
await expect(
123+
controller.seed(undefined, undefined, 'not-a-date'),
124+
).rejects.toThrow('createdAtUpper must be a valid ISO 8601 date string');
125+
expect(seedService.seedDev).not.toHaveBeenCalled();
126+
});
127+
128+
it('throws BadRequest when NODE_ENV is not development', async () => {
129+
const prodController = await createController('production');
130+
131+
await expect(
132+
prodController.seed(undefined, undefined, undefined),
133+
).rejects.toThrow(BadRequestException);
134+
await expect(
135+
prodController.seed(undefined, undefined, undefined),
136+
).rejects.toThrow('Seeding is only allowed in development mode');
137+
expect(seedService.seedDev).not.toHaveBeenCalled();
138+
});
139+
});
26140
});
Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,130 @@
1-
import { Controller, Get } from '@nestjs/common';
2-
import { ApiOperation, ApiTags } from '@nestjs/swagger';
1+
import {
2+
BadRequestException,
3+
Controller,
4+
Get,
5+
Inject,
6+
Query,
7+
} from '@nestjs/common';
8+
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
39

410
import { SeedService } from './seed.service';
11+
import {
12+
DEFAULT_SEED_DATA_TIME_CAP,
13+
DEFAULT_SEED_FAKER,
14+
SEED_USER_COUNT_MAX,
15+
SEED_USER_COUNT_MIN,
16+
type SeedDevOptions,
17+
} from './seed.types';
518

619
@Controller('seed')
720
@ApiTags('seed')
821
export class SeedController {
9-
constructor(private readonly seedService: SeedService) {}
22+
constructor(
23+
private readonly seedService: SeedService,
24+
@Inject('NODE_ENV')
25+
private readonly NODE_ENV: string,
26+
) {}
1027

1128
@Get('seed-dev')
1229
@ApiOperation({
13-
summary: 'Seed the database with development data',
30+
summary:
31+
'Seed the database with development data (deterministic by default)',
32+
description:
33+
'Uses a fixed Faker seed and stable user emails unless overridden. ' +
34+
'Use a fresh database or expect "email already registered" on repeat. ' +
35+
'Song `publicId` values still use random nanoids from the upload pipeline.',
1436
})
15-
async seed() {
16-
this.seedService.seedDev();
37+
@ApiQuery({
38+
name: 'fakerSeed',
39+
required: false,
40+
type: Number,
41+
example: DEFAULT_SEED_FAKER,
42+
})
43+
@ApiQuery({
44+
name: 'userCount',
45+
required: false,
46+
type: Number,
47+
example: 20,
48+
description: `Clamped to ${SEED_USER_COUNT_MIN}${SEED_USER_COUNT_MAX}.`,
49+
})
50+
@ApiQuery({
51+
name: 'createdAtUpper',
52+
required: false,
53+
type: String,
54+
example: DEFAULT_SEED_DATA_TIME_CAP.toISOString(),
55+
description: 'ISO 8601 upper bound for random user/song createdAt.',
56+
})
57+
async seed(
58+
@Query('fakerSeed') fakerSeedRaw?: string,
59+
@Query('userCount') userCountRaw?: string,
60+
@Query('createdAtUpper') createdAtUpperIso?: string,
61+
) {
62+
if (this.NODE_ENV !== 'development') {
63+
throw new BadRequestException(
64+
'Seeding is only allowed in development mode',
65+
);
66+
}
67+
const fakerSeed = parseOptionalIntQuery(
68+
fakerSeedRaw,
69+
DEFAULT_SEED_FAKER,
70+
'fakerSeed',
71+
);
72+
const userCount = parseOptionalIntQuery(
73+
userCountRaw,
74+
100,
75+
'userCount',
76+
SEED_USER_COUNT_MIN,
77+
SEED_USER_COUNT_MAX,
78+
);
79+
80+
const options: SeedDevOptions = { fakerSeed, userCount };
81+
82+
if (createdAtUpperIso !== undefined && createdAtUpperIso !== '') {
83+
const d = new Date(createdAtUpperIso);
84+
if (Number.isNaN(d.getTime())) {
85+
throw new BadRequestException(
86+
'createdAtUpper must be a valid ISO 8601 date string',
87+
);
88+
}
89+
options.createdAtUpper = d;
90+
}
91+
92+
await this.seedService.seedDev(options);
93+
1794
return {
18-
message: 'Seeding in progress',
95+
message: 'Seeding complete',
96+
fakerSeed: options.fakerSeed,
97+
userCount: options.userCount,
98+
createdAtUpper: (
99+
options.createdAtUpper ?? DEFAULT_SEED_DATA_TIME_CAP
100+
).toISOString(),
19101
};
20102
}
21103
}
104+
105+
function parseOptionalIntQuery(
106+
raw: string | undefined,
107+
fallback: number,
108+
name: string,
109+
min?: number,
110+
max?: number,
111+
): number {
112+
if (raw === undefined || raw === '') {
113+
return fallback;
114+
}
115+
116+
const n = Number.parseInt(raw, 10);
117+
if (!Number.isFinite(n)) {
118+
throw new BadRequestException(`${name} must be an integer`);
119+
}
120+
121+
let v = Math.trunc(n);
122+
if (min !== undefined) {
123+
v = Math.max(min, v);
124+
}
125+
if (max !== undefined) {
126+
v = Math.min(max, v);
127+
}
128+
129+
return v;
130+
}

0 commit comments

Comments
 (0)