feat(mongo): connect to mongo

This commit is contained in:
hantsy 2020-06-15 11:33:04 +08:00 committed by Hantsy Bai
parent e37124bfd0
commit 0c985e505f
17 changed files with 1410 additions and 236 deletions

View File

@ -5,6 +5,7 @@ A NestJS RESTful APIs sample project.
## Docs
* [Building RESTful APIs with NestJS](./docs/guide.md)
* [Connecting to MongoDB](./docs/mongo.md)
## Build
@ -44,4 +45,6 @@ $ npm run test:cov
## Reference
* [The official Nestjs documentation](https://docs.nestjs.com/first-steps)
* [The official Nestjs documentation](https://docs.nestjs.com/first-steps)
* [Unit testing NestJS applications with Jest](https://blog.logrocket.com/unit-testing-nestjs-applications-with-jest/)
* [ts-mockito: Mocking library for TypeScript inspired by http://mockito.org/](https://github.com/NagRock/ts-mockito)

19
docker-compose.yml Normal file
View File

@ -0,0 +1,19 @@
version: '3.8' # specify docker-compose version
# Define the services/containers to be run
services:
mongodb:
image: mongo
volumes:
- mongodata:/data/db
ports:
- "27017:27017"
networks:
- backend
volumes:
mongodata:
networks:
backend:

728
docs/mongo.md Normal file
View File

@ -0,0 +1,728 @@
# Connecting to MongoDB
In the last post , we created a RESTful API application for simple CRUD functionalities. In this post, we will enrich it:
* Add Mongo support
* Change dummy data storage to use MongoDB
* Clean the testing codes
Let's go.
## Adding MongooseModule
[MongoDB](https://www.mongodb.com) is a leading document-based NoSQL database. Nestjs has official support for MongoDB via its [Mongoosejs](https://mongoosejs.com/) integration.
Firstly, install the following dependencies.
```bash
npm install --save @nestjs/mongoose mongoose
npm install --save-dev @types/mongoose
```
Declare a `MongooseModule ` in the top-level `AppModule` to activate Mongoose support.
```typescript
//... other imports
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
//...other modules imports
// add MongooseModule
MongooseModule.forRoot('mongodb://localhost/blog')
],
//... providers, controllers
})
export class AppModule {}
```
Mongoose requires a Schema definition to describe every document in MongoDB. Nestjs provides a simple to combine the schema definition and document POJO in the same form.
Rename our former *post.interface.ts* to *post.model.ts*, the *.model* suffix means it is a Mongoose managed `Model`.
```typescript
import { SchemaFactory, Schema, Prop } from '@nestjs/mongoose';
import { Document } from 'mongoose';
@Schema()
export class Post extends Document {
@Prop({ required: true })
title: string;
@Prop({ required: true })
content: string;
@Prop()
createdAt?: Date;
@Prop()
updatedAt?: Date;
}
export const PostSchema = SchemaFactory.createForClass(Post);
```
The annotations `@Schema`, `@Prop` defines the schema definitions on the document class instead of a [mongoose Schema](https://mongoosejs.com/docs/guide.html#definition) function.
Open `PostModule`, declare a `posts` collection for storing `Post` document via importing a `MongooseModule.forFeature`.
```
import { PostSchema } from './post.model';
//other imports
@Module({
imports: [MongooseModule.forFeature([{ name: 'posts', schema: PostSchema }])],
// providers, controllers
})
export class PostModule {}
```
## Refactoring PostService
When a Mongoose schema (eg. `PostSchame`) is mapped to a document collection(eg. `posts`), a Mongoose `Model` is ready for the operations of this collections in MongoDB.
Open *post.service.ts* file.
Change the content to the following:
```typescript
@Injectable()
export class PostService {
constructor(@InjectModel('posts') private postModel: Model<Post>) {}
findAll(keyword?: string, skip = 0, limit = 10): Observable<Post[]> {
if (keyword) {
return from(
this.postModel
.find({ title: { $regex: '.*' + keyword + '.*' } })
.skip(skip)
.limit(limit)
.exec(),
);
} else {
return from(
this.postModel
.find({})
.skip(skip)
.limit(limit)
.exec(),
);
}
}
findById(id: string): Observable<Post> {
return from(this.postModel.findOne({ _id: id }).exec());
}
save(data: CreatePostDto): Observable<Post> {
const createPost = this.postModel.create({ ...data });
return from(createPost);
}
update(id: string, data: UpdatePostDto): Observable<Post> {
return from(this.postModel.findOneAndUpdate({ _id: id }, data).exec());
}
deleteById(id: string): Observable<Post> {
return from(this.postModel.findOneAndDelete({ _id: id }).exec());
}
deleteAll(): Observable<any> {
return from(this.postModel.deleteMany({}).exec());
}
}
```
In the constructor of `PostService` class, use a `@InjectMock('posts')` to bind the `posts` collection to a parameterized Model handler.
The usage of all mongoose related functions can be found in [the official Mongoose docs](https://mongoosejs.com/docs/api/query.html).
As you see, we also add two classes `CreatePostDto` and `UpdatePostDto` instead of the original `Post` for the case of creating and updating posts.
Following the principle [separation of concerns](https://en.wikipedia.org/wiki/Separation_of_concerns), `CreatePostDto` and `UpdatePostDto` are only used for transform data from client, add `readonly` modifier to keep the data *immutable* in the transforming progress.
```typescript
// create-post.dto.ts
export class CreatePostDto {
readonly title: string;
readonly content: string;
}
// update-post.dto.ts
export class UpdatePostDto {
readonly title: string;
readonly content: string;
}
```
## Clean up PostController
Clean the `post.controller.ts` correspondingly.
```typescript
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Get('')
getAllPosts(
@Query('q') keyword?: string,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit?: number,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip?: number,
): Observable<BlogPost[]> {
return this.postService.findAll(keyword, skip, limit);
}
@Get(':id')
getPostById(@Param('id') id: string): Observable<BlogPost> {
return this.postService.findById(id);
}
@Post('')
createPost(@Body() post: CreatePostDto): Observable<BlogPost> {
return this.postService.save(post);
}
@Put(':id')
updatePost(
@Param('id') id: string,
@Body() post: UpdatePostDto,
): Observable<BlogPost> {
return this.postService.update(id, post);
}
@Delete(':id')
deletePostById(@Param('id') id: string): Observable<BlogPost> {
return this.postService.deleteById(id);
}
}
```
> Unluckily, Mongoose APIs has no built-in Rxjs's `Observable` support, if you are stick on Rxjs, you have to use `from` to wrap an existing `Promise` to Rxjs's `Observable`. Check [this topic on stackoverflow to know more details about the difference bwteen Promise and Observable](https://stackoverflow.com/questions/37364973/what-is-the-difference-between-promises-and-observables).
## Run the application
To run the application, a running MongoDB server is required. You can download a copy from [MongoDB](https://www.mongodb.com), and follow the [installation guide](https://docs.mongodb.com/manual/installation/) to install it into your system.
Simply, prepare a *docker-compose.yaml* to run the dependent servers in the development stage.
```yaml
version: '3.8' # specify docker-compose version
# Define the services/containers to be run
services:
mongodb:
image: mongo
volumes:
- mongodata:/data/db
ports:
- "27017:27017"
networks:
- backend
volumes:
mongodata:
networks:
backend:
```
Run the following command to start up a mongo service in a Docker container.
```bash
docker-compose up
```
Execute the following command in the project root folder to start up the application.
```bash
npm run start
```
Now open your terminal and use `curl` to test the endpoints, and make sure it works as expected.
```bash
>curl http://localhost:3000/posts/
[]
```
There is no sample data in the MongoDB. Utilizing with [the lifecycle events](https://docs.nestjs.com/fundamentals/lifecycle-events), it is easy to add some sample data for the test purpose.
```typescript
@Injectable()
export class DataInitializerService implements OnModuleInit, OnModuleDestroy {
private data: CreatePostDto[] = [
{
title: 'Generate a NestJS project',
content: 'content',
},
{
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
title: 'Connect to MongoDB',
content: 'content',
},
];
constructor(private postService: PostService) {}
onModuleInit(): void {
this.data.forEach(d => {
this.postService.save(d).subscribe(saved => console.log(saved));
});
}
onModuleDestroy(): void {
console.log('module is be destroying...');
this.postService
.deleteAll()
.subscribe(del => console.log(`deleted ${del} records.`));
}
}
```
Register it in `PostModule`.
```typescript
//other imports
import { DataInitializerService } from './data-initializer.service';
@Module({
//imports, controllers...
providers: [//other services...
DataInitializerService],
})
export class PostModule {}
```
Run the application again. Now you will see some data when hinting *http://localhost:3000/posts/*.
```bash
>curl http://localhost:3000/posts/
[{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb732f","title":"Create CRUD RESTful APIs","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb7330","title":"Connect to MongoDB","content":"content","__v":0}]
>curl http://localhost:3000/posts/5ee49c3115a4e75254bb732e
{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0}
>curl http://localhost:3000/posts/ -d "{\"title\":\"new post\",\"content\":\"content of my new post\"}" -H "Content-Type:application/json" -X POST
{"_id":"5ee49ca915a4e75254bb7331","title":"new post","content":"content of my new post","__v":0}
>curl http://localhost:3000/posts/5ee49ca915a4e75254bb7331 -d "{\"title\":\"my updated post\",\"content\":\"content of my new post\"}" -H "Content-Type:application/json" -X PUT
{"_id":"5ee49ca915a4e75254bb7331","title":"new post","content":"content of my new post","__v":0}
>curl http://localhost:3000/posts
[{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb732f","title":"Create CRUD RESTful APIs","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb7330","title":"Connect to MongoDB","content":"content","__v":0},{"_id":"5ee49ca915a4e75254bb7331","title":"my updated post","content":"content of my new post","__v":0}]
>curl http://localhost:3000/posts/5ee49ca915a4e75254bb7331 -X DELETE
{"_id":"5ee49ca915a4e75254bb7331","title":"my updated post","content":"content of my new post","__v":0}
>curl http://localhost:3000/posts
[{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb732f","title":"Create CRUD RESTful APIs","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb7330","title":"Connect to MongoDB","content":"content","__v":0}]
```
## Clean the testing codes
When switching to real data storage instead of the dummy array, we face the first issue is how to treat with the Mongo database dependency in our *post.service.spec.ts*.
Jest provides comprehensive mocking features to isolate the dependencies in test cases. Let's have a look at the whole content of the refactored *post.service.spec.ts* file.
```typescript
describe('PostService', () => {
let service: PostService;
let model: Model<Post>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PostService,
{
provide: getModelToken('posts'),
useValue: {
new: jest.fn(),
constructor: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
exec: jest.fn(),
findOneAndUpdate: jest.fn(),
findOneAndDelete: jest.fn(),
},
},
],
}).compile();
service = module.get<PostService>(PostService);
model = module.get<Model<Post>>(getModelToken('posts'));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('getAllPosts should return 3 posts', async () => {
const posts = [
{
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb732f',
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb7330',
title: 'Connect to MongoDB',
content: 'content',
},
];
jest.spyOn(model, 'find').mockReturnValue({
skip: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(posts) as any,
}),
}),
} as any);
const data = await service.findAll().toPromise();
expect(data.length).toBe(3);
});
it('getPostById with existing id should return 1 post', done => {
const found = {
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
};
jest.spyOn(model, 'findOne').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(found) as any,
} as any);
service.findById('1').subscribe({
next: data => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(data.title).toEqual('Generate a NestJS project');
},
error: error => console.log(error),
complete: done(),
});
});
it('should save post', async () => {
const toCreated = {
title: 'test title',
content: 'test content',
};
const toReturned = {
_id: '5ee49c3115a4e75254bb732e',
...toCreated,
};
jest.spyOn(model, 'create').mockResolvedValue(toReturned as Post);
const data = await service.save(toCreated).toPromise();
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(model.create).toBeCalledWith(toCreated);
expect(model.create).toBeCalledTimes(1);
});
it('should update post', done => {
const toUpdated = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
content: 'test content',
};
jest.spyOn(model, 'findOneAndUpdate').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(toUpdated) as any,
} as any);
service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({
next: data => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
},
error: error => console.log(error),
complete: done(),
});
});
it('should delete post', done => {
jest.spyOn(model, 'findOneAndDelete').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce({
deletedCount: 1,
}),
} as any);
service.deleteById('anystring').subscribe({
next: data => expect(data).toBeTruthy,
error: error => console.log(error),
complete: done(),
});
});
});
```
In the above codes,
* Use a custom *Provider* to provide a `PostModel` dependency for `PostService`, the Model is provided in `useValue` which hosted a mocked object instance for PostModel at runtime.
* In every test case, use `jest.spyOn` to assume some mocked behaviors of PostModel happened before the service is executed.
* You can use the `toBeCalledWith` like assertions on mocked object or spied object.
> For me, most of time working as a Java/Spring developers, constructing such a simple Jest based test is not easy, [jmcdo29/testing-nestjs](https://github.com/jmcdo29/testing-nestjs) is very helpful for me to jump into jest testing work.
>
> The jest mock is every different from Mockito in Java. Luckily there is a ts-mockito which port Mockito to the Typescript world, check [this link](https://github.com/NagRock/ts-mockito) for more details .
OK, let's move to *post.controller.spec.ts*.
Similarly, `PostController` depends on `PostService`. To test the functionalities of `PostController`, we should mock it.
Like the method we used in `post.service.spec.ts`, we can mock it in a `Provider`.
```typescript
describe('Post Controller(useValue jest mocking)', () => {
let controller: PostController;
let postService: PostService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useValue: {
findAll: jest
.fn()
.mockImplementation(
(_keyword?: string, _skip?: number, _limit?: number) =>
of<any[]>([
{
_id: 'testid',
title: 'test title',
content: 'test content',
},
]),
),
},
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
postService = module.get<PostService>(PostService);
});
it('should get all posts(useValue: jest mocking)', async () => {
const result = await controller.getAllPosts('test', 10, 0).toPromise();
expect(result[0]._id).toEqual('testid');
expect(postService.findAll).toBeCalled();
expect(postService.findAll).lastCalledWith('test', 0, 10);
});
});
```
Instead of the jest mocking, you can use a dummy implementation directly in the `Provider`.
```typescript
describe('Post Controller(useValue fake object)', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useValue: {
findAll: (_keyword?: string, _skip?: number, _limit?: number) =>
of<any[]>([
{ _id: 'testid', title: 'test title', content: 'test content' },
]),
},
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
});
it('should get all posts(useValue: fake object)', async () => {
const result = await controller.getAllPosts().toPromise();
expect(result[0]._id).toEqual('testid');
});
});
```
Or use fake class to replace the real `PostService` in the tests.
```typescript
class PostServiceFake {
private posts = [
{
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb732f',
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb7330',
title: 'Connect to MongoDB',
content: 'content',
},
];
findAll() {
return of(this.posts);
}
findById(id: string) {
const { title, content } = this.posts[0];
return of({ _id: id, title, content });
}
save(data: CreatePostDto) {
return of({ _id: this.posts[0]._id, ...data });
}
update(id: string, data: UpdatePostDto) {
return of({ _id: id, ...data });
}
deleteById(id: string) {
return of({ ...this.posts[0], _id: id });
}
}
describe('Post Controller', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useClass: PostServiceFake,
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('GET on /posts should return all posts', async () => {
const posts = await controller.getAllPosts().toPromise();
expect(posts.length).toBe(3);
});
it('GET on /posts/1 should return one post ', done => {
controller.getPostById('1').subscribe(data => {
expect(data._id).toEqual('1');
done();
});
});
it('POST on /posts should return all posts', async () => {
const post: CreatePostDto = {
title: 'test title',
content: 'test content',
};
const saved = await controller.createPost(post).toPromise();
expect(saved.title).toEqual('test title');
});
it('PUT on /posts/1 should return all posts', done => {
const post: UpdatePostDto = {
title: 'test title',
content: 'test content',
};
controller.updatePost('1', post).subscribe(data => {
expect(data.title).toEqual('test title');
expect(data.content).toEqual('test content');
done();
});
});
it('DELETE on /posts/1 should return true', done => {
controller.deletePostById('1').subscribe(data => {
expect(data).toBeTruthy();
done();
});
});
});
```
The above codes are more close the ones in the first article, it is simple and easy to understand.
> To ensure the fake PostService has the exact method signature of the real PostService, it is better to use an interface to define the methods if you prefer this apporach.
I have mentioned *ts-mockito*, for me it is easier to boost up a Mockito like test.
```bash
npm install --save-dev ts-mockito
```
A simple mockito based test looks like this.
```typescript
// import facilites from ts-mockito
import { mock, verify, instance, anyString, anyNumber, when } from 'ts-mockito';
describe('Post Controller(using ts-mockito)', () => {
let controller: PostController;
const mockedPostService: PostService = mock(PostService);
beforeEach(async () => {
controller = new PostController(instance(mockedPostService));
});
it('should get all posts(ts-mockito)', async () => {
when(
mockedPostService.findAll(anyString(), anyNumber(), anyNumber()),
).thenReturn(
of([
{ _id: 'testid', title: 'test title', content: 'content' },
]) as Observable<Post[]>,
);
const result = await controller.getAllPosts('', 10, 0).toPromise();
expect(result.length).toEqual(1);
expect(result[0].title).toBe('test title');
verify(
mockedPostService.findAll(anyString(), anyNumber(), anyNumber()),
).once();
});
});
```
Now run the tests again. All tests should pass.
```bash
> npm run test
...
PASS src/app.controller.spec.ts
PASS src/post/post.service.spec.ts (10.307 s)
PASS src/post/post.controller.spec.ts (10.471 s)
Test Suites: 3 passed, 3 total
Tests: 17 passed, 17 total
Snapshots: 0 total
Time: 11.481 s, estimated 12 s
Ran all test suites.
```
In this post, we connected to the real MongoDB instead of the dummy data storage, correspond to the changes , we have refactored all tests, and discuss some approaches to isolate the dependencies in tests. But we have not test all functionalities in a real integrated environment, Nestjs provides e2e testing skeleton, we will discuss it in a future post.

221
package-lock.json generated
View File

@ -1759,6 +1759,11 @@
"uuid": "8.0.0"
}
},
"@nestjs/mongoose": {
"version": "7.0.1",
"resolved": "https://registry.npm.taobao.org/@nestjs/mongoose/download/@nestjs/mongoose-7.0.1.tgz",
"integrity": "sha1-gy7HJiHMkQPAPcKUgYwa50mkg+M="
},
"@nestjs/platform-express": {
"version": "7.1.2",
"resolved": "https://registry.npm.taobao.org/@nestjs/platform-express/download/@nestjs/platform-express-7.1.2.tgz",
@ -1887,6 +1892,15 @@
"@types/node": "*"
}
},
"@types/bson": {
"version": "4.0.2",
"resolved": "https://registry.npm.taobao.org/@types/bson/download/@types/bson-4.0.2.tgz",
"integrity": "sha1-esy4WUL8ObvbdRXU3kN8BPaYEV8=",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npm.taobao.org/@types/color-name/download/@types/color-name-1.1.1.tgz?cache=0&sync_timestamp=1588200011932&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fcolor-name%2Fdownload%2F%40types%2Fcolor-name-1.1.1.tgz",
@ -2005,6 +2019,26 @@
"integrity": "sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=",
"dev": true
},
"@types/mongodb": {
"version": "3.5.20",
"resolved": "https://registry.npm.taobao.org/@types/mongodb/download/@types/mongodb-3.5.20.tgz",
"integrity": "sha1-QGWvxVtiddDdjoMfpCEBhHXVTVY=",
"dev": true,
"requires": {
"@types/bson": "*",
"@types/node": "*"
}
},
"@types/mongoose": {
"version": "5.7.24",
"resolved": "https://registry.npm.taobao.org/@types/mongoose/download/@types/mongoose-5.7.24.tgz",
"integrity": "sha1-WSJJQ1oun+4CyellCv6sSAfU7wY=",
"dev": true,
"requires": {
"@types/mongodb": "*",
"@types/node": "*"
}
},
"@types/node": {
"version": "13.13.9",
"resolved": "https://registry.npm.taobao.org/@types/node/download/@types/node-13.13.9.tgz",
@ -3022,6 +3056,44 @@
"file-uri-to-path": "1.0.0"
}
},
"bl": {
"version": "2.2.0",
"resolved": "https://registry.npm.taobao.org/bl/download/bl-2.2.0.tgz",
"integrity": "sha1-4aV0zfUo5AUwGbuACwQcCsiNpJM=",
"requires": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
},
"dependencies": {
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npm.taobao.org/isarray/download/isarray-1.0.0.tgz?cache=0&sync_timestamp=1562592096220&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fisarray%2Fdownload%2Fisarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
},
"readable-stream": {
"version": "2.3.7",
"resolved": "https://registry.npm.taobao.org/readable-stream/download/readable-stream-2.3.7.tgz?cache=0&sync_timestamp=1581624324274&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Freadable-stream%2Fdownload%2Freadable-stream-2.3.7.tgz",
"integrity": "sha1-Hsoc9xGu+BTAT2IlKjamL2yyO1c=",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npm.taobao.org/string_decoder/download/string_decoder-1.1.1.tgz",
"integrity": "sha1-nPFhG6YmhdcDCunkujQUnDrwP8g=",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npm.taobao.org/bluebird/download/bluebird-3.7.2.tgz",
@ -3224,6 +3296,11 @@
"node-int64": "^0.4.0"
}
},
"bson": {
"version": "1.1.4",
"resolved": "https://registry.npm.taobao.org/bson/download/bson-1.1.4.tgz",
"integrity": "sha1-92hw15nxW4VN/7fuMvCodHl/fok="
},
"buffer": {
"version": "4.9.2",
"resolved": "https://registry.npm.taobao.org/buffer/download/buffer-4.9.2.tgz?cache=0&sync_timestamp=1588706716358&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbuffer%2Fdownload%2Fbuffer-4.9.2.tgz",
@ -4328,6 +4405,11 @@
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
},
"denque": {
"version": "1.4.1",
"resolved": "https://registry.npm.taobao.org/denque/download/denque-1.4.1.tgz",
"integrity": "sha1-Z0T/dkHBSMP4ppwwflEjXB9KN88="
},
"depd": {
"version": "1.1.2",
"resolved": "https://registry.npm.taobao.org/depd/download/depd-1.1.2.tgz",
@ -9431,6 +9513,11 @@
"verror": "1.10.0"
}
},
"kareem": {
"version": "2.3.1",
"resolved": "https://registry.npm.taobao.org/kareem/download/kareem-2.3.1.tgz",
"integrity": "sha1-3vEtnJQQF/q/sA+HOvlenJnhvoc="
},
"kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npm.taobao.org/kind-of/download/kind-of-6.0.3.tgz",
@ -9731,6 +9818,12 @@
}
}
},
"memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npm.taobao.org/memory-pager/download/memory-pager-1.5.0.tgz",
"integrity": "sha1-2HUWVdItOEaCdByXLyw9bfo+ZrU=",
"optional": true
},
"meow": {
"version": "5.0.0",
"resolved": "https://registry.npm.taobao.org/meow/download/meow-5.0.0.tgz",
@ -10199,6 +10292,49 @@
"minimist": "^1.2.5"
}
},
"mongodb": {
"version": "3.5.8",
"resolved": "https://registry.npm.taobao.org/mongodb/download/mongodb-3.5.8.tgz?cache=0&sync_timestamp=1591967946651&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmongodb%2Fdownload%2Fmongodb-3.5.8.tgz",
"integrity": "sha1-NFUIVkSbdF0UWHNzS/kiwS1rnKo=",
"requires": {
"bl": "^2.2.0",
"bson": "^1.1.4",
"denque": "^1.4.1",
"require_optional": "^1.0.1",
"safe-buffer": "^5.1.2",
"saslprep": "^1.0.0"
}
},
"mongoose": {
"version": "5.9.18",
"resolved": "https://registry.npm.taobao.org/mongoose/download/mongoose-5.9.18.tgz",
"integrity": "sha1-0DHtU7HmC6ShSR+1QZBorLHzPQA=",
"requires": {
"bson": "^1.1.4",
"kareem": "2.3.1",
"mongodb": "3.5.8",
"mongoose-legacy-pluralize": "1.0.2",
"mpath": "0.7.0",
"mquery": "3.2.2",
"ms": "2.1.2",
"regexp-clone": "1.0.0",
"safe-buffer": "5.1.2",
"sift": "7.0.1",
"sliced": "1.0.1"
},
"dependencies": {
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npm.taobao.org/ms/download/ms-2.1.2.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.1.2.tgz",
"integrity": "sha1-0J0fNXtEP0kzgqjrPM0YOHKuYAk="
}
}
},
"mongoose-legacy-pluralize": {
"version": "1.0.2",
"resolved": "https://registry.npm.taobao.org/mongoose-legacy-pluralize/download/mongoose-legacy-pluralize-1.0.2.tgz",
"integrity": "sha1-O6n5H6UHtRhtOZ+0CFS/8Y+1Y+Q="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npm.taobao.org/move-concurrently/download/move-concurrently-1.0.1.tgz",
@ -10224,6 +10360,30 @@
}
}
},
"mpath": {
"version": "0.7.0",
"resolved": "https://registry.npm.taobao.org/mpath/download/mpath-0.7.0.tgz",
"integrity": "sha1-IOgQLidrcXCdbgfp+NTQ9kGvv7g="
},
"mquery": {
"version": "3.2.2",
"resolved": "https://registry.npm.taobao.org/mquery/download/mquery-3.2.2.tgz",
"integrity": "sha1-4Tg6OVGFLOI+N/YZqbNQ8fs2ZOc=",
"requires": {
"bluebird": "3.5.1",
"debug": "3.1.0",
"regexp-clone": "^1.0.0",
"safe-buffer": "5.1.2",
"sliced": "1.0.1"
},
"dependencies": {
"bluebird": {
"version": "3.5.1",
"resolved": "https://registry.npm.taobao.org/bluebird/download/bluebird-3.5.1.tgz",
"integrity": "sha1-2VUfnemPH82h5oPRfukaBgLuLrk="
}
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npm.taobao.org/ms/download/ms-2.0.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.0.0.tgz",
@ -11368,6 +11528,11 @@
"safe-regex": "^1.1.0"
}
},
"regexp-clone": {
"version": "1.0.0",
"resolved": "https://registry.npm.taobao.org/regexp-clone/download/regexp-clone-1.0.0.tgz",
"integrity": "sha1-Ii25Z2IydwViYLmSYmNUoEzpv2M="
},
"regexpp": {
"version": "3.1.0",
"resolved": "https://registry.npm.taobao.org/regexpp/download/regexpp-3.1.0.tgz",
@ -11488,6 +11653,22 @@
"integrity": "sha1-0LMp7MfMD2Fkn2IhW+aa9UqomJs=",
"dev": true
},
"require_optional": {
"version": "1.0.1",
"resolved": "https://registry.npm.taobao.org/require_optional/download/require_optional-1.0.1.tgz",
"integrity": "sha1-TPNaQkf2TKPfjC7yCMxJSxyo/C4=",
"requires": {
"resolve-from": "^2.0.0",
"semver": "^5.1.0"
},
"dependencies": {
"resolve-from": {
"version": "2.0.0",
"resolved": "https://registry.npm.taobao.org/resolve-from/download/resolve-from-2.0.0.tgz",
"integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
}
}
},
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npm.taobao.org/resolve/download/resolve-1.17.0.tgz",
@ -11662,6 +11843,15 @@
}
}
},
"saslprep": {
"version": "1.0.3",
"resolved": "https://registry.npm.taobao.org/saslprep/download/saslprep-1.0.3.tgz",
"integrity": "sha1-TAL5RrVs9UKX40e6EJPnrKxM8iY=",
"optional": true,
"requires": {
"sparse-bitfield": "^3.0.3"
}
},
"saxes": {
"version": "5.0.1",
"resolved": "https://registry.npm.taobao.org/saxes/download/saxes-5.0.1.tgz",
@ -11685,8 +11875,7 @@
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npm.taobao.org/semver/download/semver-5.7.1.tgz",
"integrity": "sha1-qVT5Ma66UI0we78Gnv8MAclhFvc=",
"dev": true
"integrity": "sha1-qVT5Ma66UI0we78Gnv8MAclhFvc="
},
"semver-compare": {
"version": "1.0.0",
@ -11842,6 +12031,11 @@
"dev": true,
"optional": true
},
"sift": {
"version": "7.0.1",
"resolved": "https://registry.npm.taobao.org/sift/download/sift-7.0.1.tgz",
"integrity": "sha1-R9YsULFZ0xbxNy+LU/nBDNIaSwg="
},
"signal-exit": {
"version": "3.0.3",
"resolved": "https://registry.npm.taobao.org/signal-exit/download/signal-exit-3.0.3.tgz",
@ -11879,6 +12073,11 @@
}
}
},
"sliced": {
"version": "1.0.1",
"resolved": "https://registry.npm.taobao.org/sliced/download/sliced-1.0.1.tgz",
"integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
},
"snapdragon": {
"version": "0.8.2",
"resolved": "https://registry.npm.taobao.org/snapdragon/download/snapdragon-0.8.2.tgz",
@ -12056,6 +12255,15 @@
"integrity": "sha1-6oBL2UhXQC5pktBaOO8a41qatMQ=",
"dev": true
},
"sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npm.taobao.org/sparse-bitfield/download/sparse-bitfield-3.0.3.tgz",
"integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
"optional": true,
"requires": {
"memory-pager": "^1.0.2"
}
},
"spdx-correct": {
"version": "3.1.1",
"resolved": "https://registry.npm.taobao.org/spdx-correct/download/spdx-correct-3.1.1.tgz?cache=0&sync_timestamp=1590161967473&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fspdx-correct%2Fdownload%2Fspdx-correct-3.1.1.tgz",
@ -12926,6 +13134,15 @@
}
}
},
"ts-mockito": {
"version": "2.6.1",
"resolved": "https://registry.npm.taobao.org/ts-mockito/download/ts-mockito-2.6.1.tgz",
"integrity": "sha1-vJ7iYZAzk05vrRxEVayltazjTnM=",
"dev": true,
"requires": {
"lodash": "^4.17.5"
}
},
"ts-node": {
"version": "8.10.2",
"resolved": "https://registry.npm.taobao.org/ts-node/download/ts-node-8.10.2.tgz",

View File

@ -23,7 +23,9 @@
"dependencies": {
"@nestjs/common": "^7.0.0",
"@nestjs/core": "^7.0.0",
"@nestjs/mongoose": "^7.0.1",
"@nestjs/platform-express": "^7.0.0",
"mongoose": "^5.9.18",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^6.5.4"
@ -36,6 +38,7 @@
"@nestjs/testing": "^7.0.0",
"@types/express": "^4.17.3",
"@types/jest": "25.2.3",
"@types/mongoose": "^5.7.24",
"@types/node": "^13.9.1",
"@types/supertest": "^2.0.8",
"@typescript-eslint/eslint-plugin": "3.0.2",
@ -49,6 +52,7 @@
"supertest": "^4.0.2",
"ts-jest": "26.1.0",
"ts-loader": "^6.2.1",
"ts-mockito": "^2.6.1",
"ts-node": "^8.6.2",
"tsconfig-paths": "^3.9.0",
"typescript": "^3.7.4"

View File

@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PostModule } from './post/post.module';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [PostModule],
imports: [PostModule, MongooseModule.forRoot('mongodb://localhost/blog')],
controllers: [AppController],
providers: [AppService],
})

View File

@ -3,6 +3,9 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// enable shutdown hooks explicitly.
app.enableShutdownHooks();
await app.listen(3000);
}
bootstrap();

View File

@ -0,0 +1,4 @@
export class CreatePostDto {
readonly title: string;
readonly content: string;
}

View File

@ -0,0 +1,43 @@
import { PostService } from './post.service';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { CreatePostDto } from './create-post.dto';
@Injectable()
export class DataInitializerService implements OnModuleInit, OnModuleDestroy {
private data: CreatePostDto[] = [
{
title: 'Generate a NestJS project',
content: 'content',
},
{
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
title: 'Connect to MongoDB',
content: 'content',
},
];
constructor(private postService: PostService) {}
onModuleInit(): void {
this.data.forEach(d => {
this.postService.save(d).subscribe(saved => console.log(saved));
});
}
onModuleDestroy(): void {
console.log('module is be destroying...');
this.postService
.deleteAll()
.subscribe(del => console.log(`deleted ${del} records.`));
}
// onApplicationBootstrap(): void {
// this.data.forEach(d => {
// this.save(d).subscribe(saved => console.log(saved));
// });
// }
// onApplicationShutdown(signal?: string): void {
// console.log(signal);
// this.postModel.deleteMany({}).exec();
// }
}

View File

@ -1,17 +1,64 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PostController } from './post.controller';
import { PostService } from './post.service';
import { async } from 'rxjs/internal/scheduler/async';
import { Post } from '../../dist/post/post.interface';
import { CreatePostDto } from './create-post.dto';
import { UpdatePostDto } from './update-post.dto';
import { of, Observable } from 'rxjs';
import { mock, verify, instance, anyString, anyNumber, when } from 'ts-mockito';
import { Post } from './post.model';
//jest.mock('./post.service');
class PostServiceFake {
private posts = [
{
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb732f',
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb7330',
title: 'Connect to MongoDB',
content: 'content',
},
];
findAll() {
return of(this.posts);
}
findById(id: string) {
const { title, content } = this.posts[0];
return of({ _id: id, title, content });
}
save(data: CreatePostDto) {
return of({ _id: this.posts[0]._id, ...data });
}
update(id: string, data: UpdatePostDto) {
return of({ _id: id, ...data });
}
deleteById(id: string) {
return of({ ...this.posts[0], _id: id });
}
}
describe('Post Controller', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PostService],
providers: [
{
provide: PostService,
useClass: PostServiceFake,
},
],
controllers: [PostController],
}).compile();
@ -28,40 +75,159 @@ describe('Post Controller', () => {
});
it('GET on /posts/1 should return one post ', done => {
controller.getPostById(1).subscribe(data => {
expect(data.id).toEqual(1);
controller.getPostById('1').subscribe(data => {
expect(data._id).toEqual('1');
done();
});
});
it('POST on /posts should return all posts', async () => {
const post:Post = {id:4, title:'test title', content:'test content'};
const posts = await controller.createPost(post).toPromise();
expect(posts.length).toBe(4);
const post: CreatePostDto = {
title: 'test title',
content: 'test content',
};
const saved = await controller.createPost(post).toPromise();
expect(saved.title).toEqual('test title');
});
it('PUT on /posts/1 should return all posts', done => {
const post:Post = {id:4, title:'test title', content:'test content'};
controller.updatePost(1, post).subscribe(data => {
expect(data.length).toBe(3);
expect(data[0].title).toEqual('test title');
expect(data[0].content).toEqual('test content');
expect(data[0].updatedAt).toBeTruthy();
const post: UpdatePostDto = {
title: 'test title',
content: 'test content',
};
controller.updatePost('1', post).subscribe(data => {
expect(data.title).toEqual('test title');
expect(data.content).toEqual('test content');
done();
});
});
it('DELETE on /posts/1 should return true', done => {
controller.deletePostById(1).subscribe(data => {
controller.deletePostById('1').subscribe(data => {
expect(data).toBeTruthy();
done();
});
});
});
it('DELETE on /posts/1001 should return false', done => {
controller.deletePostById(1001).subscribe(data => {
expect(data).toBeFalsy();
done();
});
describe('Post Controller(useValue fake object)', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useValue: {
findAll: () =>
of<any[]>([
{ _id: 'testid', title: 'title', content: 'test content' },
]),
},
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
});
it('should get all posts', async () => {
const result = await controller.getAllPosts().toPromise();
expect(result[0]._id).toEqual('testid');
});
});
describe('Post Controller(useValue: fake object)', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useValue: {
findAll: (_keyword?: string, _skip?: number, _limit?: number) =>
of<any[]>([
{ _id: 'testid', title: 'test title', content: 'test content' },
]),
},
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
});
it('should get all posts(useValue: fake object)', async () => {
const result = await controller.getAllPosts().toPromise();
expect(result[0]._id).toEqual('testid');
});
});
describe('Post Controller(using ts-mockito)', () => {
let controller: PostController;
const mockedPostService: PostService = mock(PostService);
beforeEach(async () => {
controller = new PostController(instance(mockedPostService));
});
it('should get all posts(ts-mockito)', async () => {
when(
mockedPostService.findAll(anyString(), anyNumber(), anyNumber()),
).thenReturn(
of([
{ _id: 'testid', title: 'test title', content: 'content' },
]) as Observable<Post[]>,
);
const result = await controller.getAllPosts('', 10, 0).toPromise();
expect(result.length).toEqual(1);
expect(result[0].title).toBe('test title');
verify(
mockedPostService.findAll(anyString(), anyNumber(), anyNumber()),
).once();
});
});
describe('Post Controller(useValue: jest mocked object)', () => {
let controller: PostController;
let postService: PostService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useValue: {
findAll: jest
.fn()
.mockImplementation(
(_keyword?: string, _skip?: number, _limit?: number) =>
of<any[]>([
{
_id: 'testid',
title: 'test title',
content: 'test content',
},
]),
),
},
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
postService = module.get<PostService>(PostService);
});
it('should get all posts(useValue: jest mocking)', async () => {
const result = await controller.getAllPosts('test', 10, 0).toPromise();
expect(result[0]._id).toEqual('testid');
expect(postService.findAll).toBeCalled();
expect(postService.findAll).lastCalledWith('test', 0, 10);
});
});

View File

@ -1,35 +1,54 @@
import { Controller, Query, Get, Param, Post, Body, HttpStatus, Put, Delete, ParseIntPipe } from '@nestjs/common';
import {
Controller,
Query,
Get,
Param,
Post,
Body,
Put,
Delete,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { PostService } from './post.service';
import { take, toArray } from 'rxjs/operators';
import { Post as BlogPost } from './post.interface';
import { Post as BlogPost } from './post.model';
import { UpdatePostDto } from './update-post.dto';
import { CreatePostDto } from './create-post.dto';
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Get('')
getAllPosts(@Query('q') keyword?: string): Observable<BlogPost[]> {
return this.postService.findAll(keyword).pipe(take(10), toArray());
getAllPosts(
@Query('q') keyword?: string,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit?: number,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip?: number,
): Observable<BlogPost[]> {
return this.postService.findAll(keyword, skip, limit);
}
@Get(':id')
getPostById(@Param('id', ParseIntPipe) id: number): Observable<BlogPost> {
getPostById(@Param('id') id: string): Observable<BlogPost> {
return this.postService.findById(id);
}
@Post('')
createPost(@Body() post: BlogPost):Observable<BlogPost[]> {
return this.postService.save(post).pipe(toArray());
createPost(@Body() post: CreatePostDto): Observable<BlogPost> {
return this.postService.save(post);
}
@Put(':id')
updatePost(@Param('id', ParseIntPipe) id: number, @Body() post: BlogPost): Observable<BlogPost[]> {
return this.postService.update(id, post).pipe(toArray());
updatePost(
@Param('id') id: string,
@Body() post: UpdatePostDto,
): Observable<BlogPost> {
return this.postService.update(id, post);
}
@Delete(':id')
deletePostById(@Param('id', ParseIntPipe) id: number): Observable<boolean> {
deletePostById(@Param('id') id: string): Observable<BlogPost> {
return this.postService.deleteById(id);
}
}

View File

@ -1,7 +0,0 @@
export interface Post {
id?: number;
title:string;
content: string;
createdAt?: Date,
updatedAt?: Date
}

18
src/post/post.model.ts Normal file
View File

@ -0,0 +1,18 @@
import { SchemaFactory, Schema, Prop } from '@nestjs/mongoose';
import { Document } from 'mongoose';
@Schema()
export class Post extends Document {
@Prop({ required: true })
title: string;
@Prop({ required: true })
content: string;
@Prop()
createdAt?: Date;
@Prop()
updatedAt?: Date;
}
export const PostSchema = SchemaFactory.createForClass(Post);

View File

@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { PostController } from './post.controller';
import { PostService } from './post.service';
import { MongooseModule } from '@nestjs/mongoose';
import { PostSchema } from './post.model';
import { DataInitializerService } from './data-initializer.service';
@Module({
imports: [MongooseModule.forFeature([{ name: 'posts', schema: PostSchema }])],
controllers: [PostController],
providers: [PostService]
providers: [PostService, DataInitializerService],
})
export class PostModule {}

View File

@ -1,49 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PostService } from './post.service';
import { toArray, take, throttleTime } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { Post } from './post.model';
import { Model } from 'mongoose';
describe('PostService', () => {
let service: PostService;
let model: Model<Post>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PostService],
providers: [
PostService,
{
provide: getModelToken('posts'),
useValue: {
new: jest.fn(),
constructor: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
exec: jest.fn(),
findOneAndUpdate: jest.fn(),
findOneAndDelete: jest.fn(),
},
},
],
}).compile();
service = module.get<PostService>(PostService);
model = module.get<Model<Post>>(getModelToken('posts'));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('getAllPosts should return 3 posts', done => {
service
.findAll()
.pipe(take(3), toArray())
.subscribe({
next: data => expect(data.length).toBe(3),
error: error => console.log(error),
complete: done(),
});
});
it('getAllPosts should return 3 posts', async () => {
const posts = [
{
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb732f',
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb7330',
title: 'Connect to MongoDB',
content: 'content',
},
];
jest.spyOn(model, 'find').mockReturnValue({
skip: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(posts) as any,
}),
}),
} as any);
it('getAllPosts with keyword should return 1 post', done => {
service
.findAll('Generate')
.pipe(take(3), toArray())
.subscribe({
next: data => expect(data.length).toBe(1),
error: error => console.log(error),
complete: done(),
});
const data = await service.findAll().toPromise();
expect(data.length).toBe(3);
});
it('getPostById with existing id should return 1 post', done => {
service.findById(1).subscribe({
const found = {
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
};
jest.spyOn(model, 'findOne').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(found) as any,
} as any);
service.findById('1').subscribe({
next: data => {
expect(data.id).toBe(1);
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(data.title).toEqual('Generate a NestJS project');
},
error: error => console.log(error),
@ -51,125 +89,56 @@ describe('PostService', () => {
});
});
it('getPostById with none existing id should return empty', done => {
service
.findById(10001)
.pipe(toArray())
.subscribe({
next: data => expect(data.length).toBe(0),
error: error => console.log(error),
complete: done(),
});
it('should save post', async () => {
const toCreated = {
title: 'test title',
content: 'test content',
};
const toReturned = {
_id: '5ee49c3115a4e75254bb732e',
...toCreated,
};
jest.spyOn(model, 'create').mockResolvedValue(toReturned as Post);
const data = await service.save(toCreated).toPromise();
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(model.create).toBeCalledWith(toCreated);
expect(model.create).toBeCalledTimes(1);
});
it('save should increase the length of posts', done => {
service
.save({
id: 4,
title: 'test title',
content: 'test content',
})
.pipe(take(4), toArray())
.subscribe({
next: data => {
expect(data.length).toBe(4);
expect(data[3].createdAt).toBeTruthy();
},
error: error => console.log(error),
complete: done(),
});
it('should update post', done => {
const toUpdated = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
content: 'test content',
};
jest.spyOn(model, 'findOneAndUpdate').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(toUpdated) as any,
} as any);
service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({
next: data => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
},
error: error => console.log(error),
complete: done(),
});
});
it('update should change the content of post', done => {
service
.update(1, {
id: 1,
title: 'test title',
content: 'test content',
createdAt: new Date(),
})
.pipe(take(4), toArray())
.subscribe({
next: data => {
expect(data.length).toBe(3);
expect(data[0].title).toBe('test title');
expect(data[0].content).toBe('test content');
expect(data[0].updatedAt).not.toBeNull();
},
error: error => console.log(error),
complete: done(),
});
});
it('should delete post', done => {
jest.spyOn(model, 'findOneAndDelete').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce({
deletedCount: 1,
}),
} as any);
it('deleteById with existing id should return true', done => {
service.deleteById(1).subscribe({
service.deleteById('anystring').subscribe({
next: data => expect(data).toBeTruthy,
error: error => console.log(error),
complete: done(),
});
});
it('deleteById with none existing id should return false', done => {
service.deleteById(10001).subscribe({
next: data => expect(data).toBeFalsy,
error: error => console.log(error),
complete: done(),
});
});
});
// see: https://stackoverflow.com/questions/62208107/how-test-a-rxjs-empty-operater-using-jest
// https://rxjs-dev.firebaseapp.com/guide/testing/marble-testing
describe('PostService(test for empty())', () => {
let service: PostService;
let testScheduler: TestScheduler;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PostService],
}).compile();
service = module.get<PostService>(PostService);
});
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
// e.g. using chai.
expect(actual).toEqual(expected);
});
});
// This test will actually run *synchronously*
it('test complete for empty()', () => {
testScheduler.run(helpers => {
const { cold, expectObservable, expectSubscriptions } = helpers;
// const e1 = cold('-a--b--c---|');
// const subs = '^----------!';
// const expected = '-a-----c---|';
// expectObservable(e1.pipe(throttleTime(3, testScheduler))).toBe(expected);
// expectSubscriptions(e1.subscriptions).toBe(subs);
expectObservable(service.findById(10001)).toBe('|');
});
});
it('test complete for empty():getPostById with none existing id', done => {
let called = false;
service.findById(10001).subscribe({
next: data => {
console.log(data);
called = true;
},
error: error => {
console.log(error);
called = true;
},
complete: () => {
console.log("calling complete");
expect(called).toBeFalsy();
done();
},
});
});
});

View File

@ -1,74 +1,53 @@
import { Injectable } from '@nestjs/common';
import { Post } from './post.interface';
import { of, from, Observable, empty, EMPTY } from 'rxjs';
import { Post } from './post.model';
import { from, Observable } from 'rxjs';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { CreatePostDto } from './create-post.dto';
import { UpdatePostDto } from './update-post.dto';
@Injectable()
export class PostService {
private posts: Post[] = [
{
id: 1,
title: 'Generate a NestJS project',
content: 'content',
createdAt: new Date(),
},
{
id: 2,
title: 'Create CRUD RESTful APIs',
content: 'content',
createdAt: new Date(),
},
{
id: 3,
title: 'Connect to MongoDB',
content: 'content',
createdAt: new Date(),
},
];
constructor(@InjectModel('posts') private postModel: Model<Post>) {}
findAll(keyword?: string): Observable<Post> {
findAll(keyword?: string, skip = 0, limit = 10): Observable<Post[]> {
if (keyword) {
return from(this.posts.filter(post => post.title.indexOf(keyword) >= 0));
return from(
this.postModel
.find({ title: { $regex: '.*' + keyword + '.*' } })
.skip(skip)
.limit(limit)
.exec(),
);
} else {
return from(
this.postModel
.find({})
.skip(skip)
.limit(limit)
.exec(),
);
}
return from(this.posts);
}
findById(id: number): Observable<Post> {
const found = this.posts.find(post => post.id === id);
if (found) {
return of(found);
}
return EMPTY;
findById(id: string): Observable<Post> {
return from(this.postModel.findOne({ _id: id }).exec());
}
save(data: Post): Observable<Post> {
const post = { ...data, id: this.posts.length + 1, createdAt: new Date() };
this.posts = [...this.posts, post];
return from(this.posts);
save(data: CreatePostDto): Observable<Post> {
const createPost = this.postModel.create({ ...data });
return from(createPost);
}
update(id: number, data: Post): Observable<Post> {
this.posts = this.posts.map(post => {
if (id === post.id) {
post.title = data.title;
post.content = data.content;
post.updatedAt = new Date();
}
return post;
});
return from(this.posts);
update(id: string, data: UpdatePostDto): Observable<Post> {
return from(this.postModel.findOneAndUpdate({ _id: id }, data).exec());
}
deleteById(id: number): Observable<boolean> {
const idx: number = this.posts.findIndex(post => post.id === id);
if (idx >= 0) {
// this.posts.splice(idx, 1);
this.posts = [
...this.posts.slice(0, idx),
...this.posts.slice(idx + 1),
];
return of(true);
}
return of(false);
deleteById(id: string): Observable<Post> {
return from(this.postModel.findOneAndDelete({ _id: id }).exec());
}
deleteAll(): Observable<any> {
return from(this.postModel.deleteMany({}).exec());
}
}

View File

@ -0,0 +1,4 @@
export class UpdatePostDto {
readonly title: string;
readonly content: string;
}