mirror of
https://github.com/zen-browser/rices.git
synced 2025-07-07 08:55:40 +02:00
first commit
This commit is contained in:
commit
232d8b37d6
36 changed files with 11302 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
GITHUB_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
GITHUB_REPO_OWNER=zen-browser
|
||||||
|
GITHUB_REPO_NAME=rices-store
|
||||||
|
|
||||||
|
MODERATION_SECRET=superSecret123
|
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
};
|
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
57
README.md
Normal file
57
README.md
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Zen Rices API
|
||||||
|
Based on [Nest](https://github.com/nestjs/nest) framework
|
||||||
|
|
||||||
|
## Project setup (install NVM)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ nvm use 22
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
22
jest.config.js
Normal file
22
jest.config.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
// jest.config.js
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest/presets/default-esm',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
|
maxWorkers: 1,
|
||||||
|
rootDir: '.',
|
||||||
|
testRegex: '.e2e-spec.ts$',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(t|j)s$': ['ts-jest', { useESM: true }],
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'/node_modules/(?!(\\@octokit)/)',
|
||||||
|
],
|
||||||
|
globals: {
|
||||||
|
'ts-jest': {
|
||||||
|
tsconfig: 'tsconfig.json',
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
9640
package-lock.json
generated
Normal file
9640
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
64
package.json
Normal file
64
package.json
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"name": "zen-rices-backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/swagger": "^8.1.0",
|
||||||
|
"@octokit/rest": "^18.12.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"reflect-metadata": "^0.2.0",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"simple-git": "^3.27.0",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@nestjs/throttler": "^6.3.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^20.3.1",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.4.3",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.1.3"
|
||||||
|
}
|
||||||
|
}
|
18
src/app.controller.spec.ts
Normal file
18
src/app.controller.spec.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {});
|
||||||
|
});
|
7
src/app.controller.ts
Normal file
7
src/app.controller.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
}
|
31
src/app.module.ts
Normal file
31
src/app.module.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { GitHubModule } from './github/github.module';
|
||||||
|
import { RicesModule } from './rices/rices.module';
|
||||||
|
import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
GitHubModule,
|
||||||
|
RicesModule,
|
||||||
|
],
|
||||||
|
controllers: [],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
4
src/app.service.ts
Normal file
4
src/app.service.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {}
|
21
src/common/filters/throttler-exception.filter.ts
Normal file
21
src/common/filters/throttler-exception.filter.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ThrottlerException } from '@nestjs/throttler';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Catch(ThrottlerException)
|
||||||
|
export class ThrottlerExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: ThrottlerException, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
|
||||||
|
response.status(HttpStatus.TOO_MANY_REQUESTS).json({
|
||||||
|
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
message: 'Too many requests. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
12
src/github/github.module.ts
Normal file
12
src/github/github.module.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
// src/github/github.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GitHubService } from './github.service';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [GitHubService],
|
||||||
|
exports: [GitHubService],
|
||||||
|
})
|
||||||
|
export class GitHubModule {}
|
18
src/github/github.service.spec.ts
Normal file
18
src/github/github.service.spec.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { GitHubService } from './github.service';
|
||||||
|
|
||||||
|
describe('GithubService', () => {
|
||||||
|
let service: GitHubService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [GitHubService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<GitHubService>(GitHubService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
453
src/github/github.service.ts
Normal file
453
src/github/github.service.ts
Normal file
|
@ -0,0 +1,453 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnModuleInit,
|
||||||
|
Logger,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Octokit } from '@octokit/rest';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to verify if the error has a 'status' property of type 'number'
|
||||||
|
* and a 'message' property of type 'string'.
|
||||||
|
*/
|
||||||
|
function isOctokitResponseError(
|
||||||
|
error: any,
|
||||||
|
): error is { status: number; message: string } {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'status' in error &&
|
||||||
|
typeof error.status === 'number' &&
|
||||||
|
'message' in error &&
|
||||||
|
typeof error.message === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GitHubService implements OnModuleInit {
|
||||||
|
private octokit!: Octokit;
|
||||||
|
private readonly logger = new Logger(GitHubService.name);
|
||||||
|
private repoOwner: string;
|
||||||
|
private repoName: string;
|
||||||
|
private defaultBranch: string = 'main'; // Default value
|
||||||
|
private directoryLocks: Map<string, boolean> = new Map();
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
// Initialize properties in the constructor
|
||||||
|
this.repoOwner = this.configService.get<string>('GITHUB_REPO_OWNER') || '';
|
||||||
|
this.repoName = this.configService.get<string>('GITHUB_REPO_NAME') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const token = this.configService.get<string>('GITHUB_TOKEN');
|
||||||
|
if (!token) {
|
||||||
|
this.logger.error(
|
||||||
|
'GITHUB_TOKEN is not defined in the environment variables',
|
||||||
|
);
|
||||||
|
throw new Error('GITHUB_TOKEN is not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.repoOwner || !this.repoName) {
|
||||||
|
this.logger.error(
|
||||||
|
'GITHUB_REPO_OWNER or GITHUB_REPO_NAME is not defined in the environment variables',
|
||||||
|
);
|
||||||
|
throw new Error('GITHUB_REPO_OWNER or GITHUB_REPO_NAME is not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.octokit = new Octokit({
|
||||||
|
auth: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch the default branch of the repository
|
||||||
|
try {
|
||||||
|
const { data: repo } = await this.octokit.repos.get({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
});
|
||||||
|
this.defaultBranch = repo.default_branch;
|
||||||
|
this.logger.log(
|
||||||
|
`Default branch of the repository: ${this.defaultBranch}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
this.logger.error(
|
||||||
|
`Repository ${this.repoOwner}/${this.repoName} not found.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`Error fetching repository information: ${error.message} (Status: ${error.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`Unexpected error fetching repository information: ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a file in the repository.
|
||||||
|
* Ensures that the specified directory exists by creating a .gitkeep file if necessary.
|
||||||
|
* @param filePath Path of the file in the repository.
|
||||||
|
* @param content Content of the file in plain text.
|
||||||
|
* @param commitMessage Commit message.
|
||||||
|
*/
|
||||||
|
async createOrUpdateFile(
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
commitMessage: string,
|
||||||
|
retries = 3,
|
||||||
|
): Promise<void> {
|
||||||
|
const directoryPath = path.dirname(filePath);
|
||||||
|
await this.lockDirectory(directoryPath);
|
||||||
|
try {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
// Get the SHA of the file if it exists
|
||||||
|
let sha: string | undefined;
|
||||||
|
try {
|
||||||
|
const { data: existingFile } = await this.octokit.repos.getContent({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: filePath,
|
||||||
|
ref: this.defaultBranch,
|
||||||
|
});
|
||||||
|
if ('sha' in existingFile) {
|
||||||
|
sha = existingFile.sha;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// File does not exist, proceed to create it
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error checking file ${filePath}: ${error.message} (Status: ${error.status})`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// If the error is 404, the file does not exist and we can proceed to create it
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to create or update the file
|
||||||
|
await this.octokit.repos.createOrUpdateFileContents({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: filePath,
|
||||||
|
message: commitMessage,
|
||||||
|
content: Buffer.from(content, 'utf-8').toString('base64'),
|
||||||
|
sha,
|
||||||
|
branch: this.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`File ${filePath} created/updated successfully.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 409 && attempt < retries) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Conflict creating/updating ${filePath}. Retrying (${attempt}/${retries})...`,
|
||||||
|
);
|
||||||
|
const backoffTime = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s
|
||||||
|
await this.delay(backoffTime);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.logger.error(
|
||||||
|
`Persistent conflict creating/updating ${filePath}: ${error.message}`,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Error creating/updating file ${filePath}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`Error creating/updating file ${filePath}: ${error.message}`,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Error creating/updating file ${filePath}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.unlockDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file from the repository.
|
||||||
|
* @param filePath Path of the file in the repository.
|
||||||
|
* @param commitMessage Commit message.
|
||||||
|
*/
|
||||||
|
async deleteFile(
|
||||||
|
filePath: string,
|
||||||
|
commitMessage: string,
|
||||||
|
retries = 3,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
// Get the file's SHA
|
||||||
|
const { data: existingFile } = await this.octokit.repos.getContent({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: filePath,
|
||||||
|
ref: this.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!('sha' in existingFile)) {
|
||||||
|
throw new Error(`The file ${filePath} does not have a valid SHA`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = existingFile.sha;
|
||||||
|
|
||||||
|
// Attempt to delete the file
|
||||||
|
await this.octokit.repos.deleteFile({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: filePath,
|
||||||
|
message: commitMessage,
|
||||||
|
sha: sha,
|
||||||
|
branch: this.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`File ${filePath} deleted successfully.`);
|
||||||
|
return;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 409 && attempt < retries) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Conflict deleting ${filePath}. Retrying (${attempt}/${retries})...`,
|
||||||
|
);
|
||||||
|
const backoffTime = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s
|
||||||
|
await this.delay(backoffTime);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.logger.error(
|
||||||
|
`Persistent conflict deleting ${filePath}: ${error.message}`,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Error deleting file ${filePath}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOctokitResponseError(error) && error.status === 404) {
|
||||||
|
this.logger.warn(`The file ${filePath} does not exist in the repository.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error deleting file ${filePath}: ${error.message} (Status: ${error.status})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`Error deleting file ${filePath}: ${error}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error deleting file ${filePath}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the content of a file.
|
||||||
|
* @param filePath Path of the file in the repository.
|
||||||
|
* @returns Plain text file content or null if it does not exist.
|
||||||
|
*/
|
||||||
|
async getFileContent(filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { data } = await this.octokit.repos.getContent({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: filePath,
|
||||||
|
ref: this.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('content' in data && data.content) {
|
||||||
|
const buffer = Buffer.from(data.content, 'base64');
|
||||||
|
return buffer.toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error getting content of file ${filePath}: ${error.message} (Status: ${error.status})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`Error getting content of file ${filePath}: ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the files in a specific directory on GitHub.
|
||||||
|
* @param directoryPath Path of the directory in the repository.
|
||||||
|
* @returns Array of file names.
|
||||||
|
*/
|
||||||
|
async listFilesInDirectory(directoryPath: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await this.octokit.repos.getContent({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: directoryPath,
|
||||||
|
ref: this.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((file) => file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isOctokitResponseError(error) && error.status === 404) {
|
||||||
|
this.logger.warn(`The directory ${directoryPath} does not exist.`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`Error listing files in ${directoryPath}: ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all files in the GitHub repository.
|
||||||
|
* Useful for cleaning the state before running tests.
|
||||||
|
*/
|
||||||
|
async clearRepository(): Promise<void> {
|
||||||
|
this.logger.log('Starting GitHub repository cleanup...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await this.listAllFiles();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Do not delete essential files like .gitignore or .gitkeep
|
||||||
|
if (
|
||||||
|
file.path === '.gitignore' ||
|
||||||
|
path.basename(file.path) === '.gitkeep'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.deleteFile(
|
||||||
|
file.path,
|
||||||
|
`Clear repository: Remove ${file.path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('GitHub repository cleaned successfully.');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Error cleaning the repository: ${error.message}`);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Error cleaning the repository: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively lists all files in the GitHub repository.
|
||||||
|
* @returns List of file paths in the repository.
|
||||||
|
*/
|
||||||
|
private async listAllFiles(): Promise<Array<{ path: string }>> {
|
||||||
|
const rootPath = '';
|
||||||
|
const files: Array<{ path: string }> = [];
|
||||||
|
|
||||||
|
async function traverseDirectory(
|
||||||
|
service: GitHubService,
|
||||||
|
currentPath: string,
|
||||||
|
accumulator: Array<{ path: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await service.octokit.repos.getContent({
|
||||||
|
owner: service.repoOwner,
|
||||||
|
repo: service.repoName,
|
||||||
|
path: currentPath,
|
||||||
|
ref: service.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
for (const file of response.data) {
|
||||||
|
if (file.type === 'file') {
|
||||||
|
accumulator.push({ path: file.path });
|
||||||
|
} else if (file.type === 'dir') {
|
||||||
|
await traverseDirectory(service, file.path, accumulator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isOctokitResponseError(error) && error.status === 404) {
|
||||||
|
service.logger.warn(`Directory ${currentPath} does not exist.`);
|
||||||
|
} else {
|
||||||
|
service.logger.error(
|
||||||
|
`Error listing files in ${currentPath}: ${error.message} (Status: ${error.status})`,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Error listing files in ${currentPath}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await traverseDirectory(this, rootPath, files);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Introduces a delay during tests.
|
||||||
|
* @param ms Milliseconds to pause.
|
||||||
|
*/
|
||||||
|
private async delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple directory lock implementation to prevent concurrent operations.
|
||||||
|
* @param directoryPath Path of the directory to lock.
|
||||||
|
*/
|
||||||
|
private async lockDirectory(directoryPath: string): Promise<void> {
|
||||||
|
while (this.directoryLocks.get(directoryPath)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Directory ${directoryPath} is locked. Waiting...`,
|
||||||
|
);
|
||||||
|
await this.delay(100); // Wait 100ms before retrying
|
||||||
|
}
|
||||||
|
this.directoryLocks.set(directoryPath, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocks a directory after completing operations.
|
||||||
|
* @param directoryPath Path of the directory to unlock.
|
||||||
|
*/
|
||||||
|
private unlockDirectory(directoryPath: string): void {
|
||||||
|
this.directoryLocks.set(directoryPath, false);
|
||||||
|
this.logger.log(`Directory ${directoryPath} unlocked.`);
|
||||||
|
}
|
||||||
|
}
|
28
src/main.ts
Normal file
28
src/main.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||||
|
app.useGlobalFilters(new ThrottlerExceptionFilter());
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Rices API')
|
||||||
|
.setDescription('Zen Rices API management (Zen Browser)')
|
||||||
|
.setVersion('1.0')
|
||||||
|
// To manage the API with Swagger, we need to add the bearer token
|
||||||
|
// .addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api', app, document);
|
||||||
|
|
||||||
|
await app.listen(3000);
|
||||||
|
console.log('API running on http://localhost:3000');
|
||||||
|
console.log('Swagger docs on http://localhost:3000/api');
|
||||||
|
}
|
||||||
|
bootstrap();
|
7
src/rices/dto/create-rice.dto.ts
Normal file
7
src/rices/dto/create-rice.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRiceDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
}
|
7
src/rices/dto/update-rice.dto.ts
Normal file
7
src/rices/dto/update-rice.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateRiceDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
}
|
103
src/rices/rices.controller.ts
Normal file
103
src/rices/rices.controller.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { RicesService } from './rices.service';
|
||||||
|
import { CreateRiceDto } from './dto/create-rice.dto';
|
||||||
|
import { UpdateRiceDto } from './dto/update-rice.dto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiConsumes,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
@ApiTags('rices')
|
||||||
|
@Controller('rices')
|
||||||
|
export class RicesController {
|
||||||
|
constructor(private readonly ricesService: RicesService) {}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Upload a new Rice' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Rice successfully created.' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async createRice(
|
||||||
|
@Body() createRiceDto: CreateRiceDto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.ricesService.create(createRiceDto, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Get information about a Rice' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Returns metadata of the Rice.' })
|
||||||
|
@Get(':identifier')
|
||||||
|
async getRice(@Param('identifier') identifier: string) {
|
||||||
|
return this.ricesService.findOne(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Update an existing Rice' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Rice successfully updated.' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@Put(':identifier')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async updateRice(
|
||||||
|
@Param('identifier') identifier: string,
|
||||||
|
@Headers('x-rices-token') token: string,
|
||||||
|
@Body() updateRiceDto: UpdateRiceDto,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.ricesService.update(identifier, token, updateRiceDto, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Delete an existing Rice' })
|
||||||
|
@ApiResponse({ status: 204, description: 'Rice successfully deleted.' })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Delete(':identifier')
|
||||||
|
async removeRice(
|
||||||
|
@Param('identifier') identifier: string,
|
||||||
|
@Headers('x-rices-token') token: string,
|
||||||
|
) {
|
||||||
|
await this.ricesService.remove(identifier, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// NEW ENDPOINT FOR MODERATION DELETION
|
||||||
|
// =========================================
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Forcefully delete a Rice (moderation)',
|
||||||
|
description:
|
||||||
|
'Requires knowledge of a moderation secret to delete the Rice.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 204, description: 'Rice deleted by moderation.' })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Delete('moderate/delete/:identifier')
|
||||||
|
async removeRiceByModerator(
|
||||||
|
@Param('identifier') identifier: string,
|
||||||
|
@Headers('x-moderation-secret') moderationSecret: string,
|
||||||
|
) {
|
||||||
|
// Verify the secret
|
||||||
|
if (moderationSecret !== process.env.MODERATION_SECRET) {
|
||||||
|
throw new UnauthorizedException('Invalid moderation secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the service to delete without a token
|
||||||
|
await this.ricesService.moderateRemove(identifier);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
14
src/rices/rices.module.ts
Normal file
14
src/rices/rices.module.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
// src/rices/rices.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RicesService } from './rices.service';
|
||||||
|
import { GitHubModule } from '../github/github.module';
|
||||||
|
import { RicesController } from './rices.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [GitHubModule],
|
||||||
|
providers: [RicesService],
|
||||||
|
controllers: [RicesController],
|
||||||
|
exports: [RicesService],
|
||||||
|
})
|
||||||
|
export class RicesModule {}
|
296
src/rices/rices.service.ts
Normal file
296
src/rices/rices.service.ts
Normal file
|
@ -0,0 +1,296 @@
|
||||||
|
// src/rices/rices.service.ts
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CreateRiceDto } from './dto/create-rice.dto';
|
||||||
|
import { UpdateRiceDto } from './dto/update-rice.dto';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { generateSlug } from './utils/slug.util';
|
||||||
|
import { GitHubService } from '../github/github.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the provided error has a 'status' property of type 'number'
|
||||||
|
* and a 'message' property of type 'string'.
|
||||||
|
*/
|
||||||
|
function isOctokitResponseError(
|
||||||
|
error: unknown,
|
||||||
|
): error is { status: number; message: string } {
|
||||||
|
return (
|
||||||
|
error !== null &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'status' in error &&
|
||||||
|
typeof (error as any).status === 'number' &&
|
||||||
|
'message' in error &&
|
||||||
|
typeof (error as any).message === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RicesService {
|
||||||
|
constructor(private readonly gitHubService: GitHubService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new rice
|
||||||
|
*/
|
||||||
|
async create(createRiceDto: CreateRiceDto, file?: Express.Multer.File) {
|
||||||
|
try {
|
||||||
|
// 1. Generate identifier (slug + UUID or just UUID)
|
||||||
|
let identifier: string;
|
||||||
|
if (createRiceDto.name) {
|
||||||
|
// Generate slug from the name
|
||||||
|
const slug = generateSlug(createRiceDto.name);
|
||||||
|
identifier = `${slug}-${uuidv4()}`;
|
||||||
|
} else {
|
||||||
|
identifier = uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate token and save metadata
|
||||||
|
const token = uuidv4();
|
||||||
|
const metadata = {
|
||||||
|
id: identifier,
|
||||||
|
token,
|
||||||
|
name: createRiceDto.name || null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const metadataContent = JSON.stringify(metadata, null, 2);
|
||||||
|
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||||
|
|
||||||
|
// 3. Create or update rice.json in GitHub
|
||||||
|
await this.gitHubService.createOrUpdateFile(
|
||||||
|
riceJsonPath,
|
||||||
|
metadataContent,
|
||||||
|
`Add rice ${identifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. If there's a file, upload it to GitHub
|
||||||
|
if (file && file.originalname && file.buffer) {
|
||||||
|
const fileContent = file.buffer.toString('utf-8');
|
||||||
|
const uploadedFilePath = `rices/${identifier}/data.zenrice`;
|
||||||
|
await this.gitHubService.createOrUpdateFile(
|
||||||
|
uploadedFilePath,
|
||||||
|
fileContent,
|
||||||
|
`Add file ${file.originalname} to rice ${identifier}/data.zenrice`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Return identifier and token
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating the rice:', error);
|
||||||
|
throw new Error('Failed to create rice');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rice information by its identifier
|
||||||
|
*/
|
||||||
|
async findOne(identifier: string) {
|
||||||
|
try {
|
||||||
|
const riceJsonPath = `rices/${identifier}/data.zenrice`;
|
||||||
|
const fileContent = await this.gitHubService.getFileContent(riceJsonPath);
|
||||||
|
|
||||||
|
if (!fileContent) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileContent;
|
||||||
|
} catch (error) {
|
||||||
|
if (isOctokitResponseError(error) && error.status === 404) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
console.error('Error getting the rice:', error);
|
||||||
|
throw new Error('Failed to get rice');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing rice
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
identifier: string,
|
||||||
|
token: string,
|
||||||
|
updateRiceDto: UpdateRiceDto,
|
||||||
|
file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 1. Retrieve and validate metadata
|
||||||
|
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||||
|
const metadataContent =
|
||||||
|
await this.gitHubService.getFileContent(riceJsonPath);
|
||||||
|
|
||||||
|
if (!metadataContent) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(metadataContent);
|
||||||
|
|
||||||
|
if (metadata.token !== token) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update metadata
|
||||||
|
if (updateRiceDto.name) {
|
||||||
|
metadata.name = updateRiceDto.name;
|
||||||
|
}
|
||||||
|
metadata.updatedAt = new Date().toISOString();
|
||||||
|
const updatedMetadataContent = JSON.stringify(metadata, null, 2);
|
||||||
|
|
||||||
|
// 3. Update rice.json in GitHub
|
||||||
|
await this.gitHubService.createOrUpdateFile(
|
||||||
|
riceJsonPath,
|
||||||
|
updatedMetadataContent,
|
||||||
|
`Update rice ${identifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. If there's a file, update it in GitHub
|
||||||
|
if (file && file.originalname && file.buffer) {
|
||||||
|
const fileContent = file.buffer.toString('utf-8');
|
||||||
|
const uploadedFilePath = `rices/${identifier}/data.zenrice`;
|
||||||
|
await this.gitHubService.createOrUpdateFile(
|
||||||
|
uploadedFilePath,
|
||||||
|
fileContent,
|
||||||
|
`Update file ${file.originalname} in rice ${identifier}/data.zenrice`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `Rice ${identifier} updated`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
if (error.status === 401 || error.status === 403) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Error updating the rice:', error);
|
||||||
|
throw new Error('Failed to update rice');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an existing rice
|
||||||
|
*/
|
||||||
|
async remove(identifier: string, token: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 1. Retrieve and validate metadata
|
||||||
|
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||||
|
const metadataContent =
|
||||||
|
await this.gitHubService.getFileContent(riceJsonPath);
|
||||||
|
|
||||||
|
if (!metadataContent) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(metadataContent);
|
||||||
|
|
||||||
|
if (metadata.token !== token) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete rice.json from GitHub
|
||||||
|
await this.gitHubService.deleteFile(
|
||||||
|
riceJsonPath,
|
||||||
|
`Remove rice ${identifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. List and delete uploaded files (if any)
|
||||||
|
const uploadedFilesPath = `rices/${identifier}`;
|
||||||
|
const files =
|
||||||
|
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file !== 'rice.json') {
|
||||||
|
const filePath = `rices/${identifier}/${file}`;
|
||||||
|
await this.gitHubService.deleteFile(
|
||||||
|
filePath,
|
||||||
|
`Remove file ${file} from rice ${identifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
if (error.status === 401 || error.status === 403) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Error deleting the rice:', error);
|
||||||
|
throw new Error('Failed to remove rice');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a rice without checking the user's token.
|
||||||
|
* Exclusive use for moderators with the secret key.
|
||||||
|
*/
|
||||||
|
public async moderateRemove(identifier: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 1. Check if rice.json exists
|
||||||
|
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||||
|
const metadataContent =
|
||||||
|
await this.gitHubService.getFileContent(riceJsonPath);
|
||||||
|
|
||||||
|
if (!metadataContent) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete rice.json from GitHub
|
||||||
|
await this.gitHubService.deleteFile(
|
||||||
|
riceJsonPath,
|
||||||
|
`[MODERATION] Remove rice ${identifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. List and delete uploaded files (if any)
|
||||||
|
const uploadedFilesPath = `rices/${identifier}`;
|
||||||
|
const files =
|
||||||
|
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file !== 'rice.json') {
|
||||||
|
const filePath = `rices/${identifier}/${file}`;
|
||||||
|
await this.gitHubService.deleteFile(
|
||||||
|
filePath,
|
||||||
|
`[MODERATION] Remove file ${file} from rice ${identifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isOctokitResponseError(error)) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
throw new NotFoundException('Rice not found');
|
||||||
|
}
|
||||||
|
if (error.status === 401 || error.status === 403) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Error removing rice by moderation:', error);
|
||||||
|
throw new Error('Failed to remove rice by moderation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in a specific directory in GitHub
|
||||||
|
*/
|
||||||
|
private async listFilesInDirectory(pathInRepo: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return await this.gitHubService.listFilesInDirectory(pathInRepo);
|
||||||
|
} catch (error) {
|
||||||
|
if (isOctokitResponseError(error) && error.status === 404) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
console.error(`Error listing files in ${pathInRepo}:`, error);
|
||||||
|
throw new Error('Failed to list files in directory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
src/rices/utils/slug.util.ts
Normal file
6
src/rices/utils/slug.util.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export function generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
211
test/app.e2e-spec.ts
Normal file
211
test/app.e2e-spec.ts
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { GitHubService } from '../src/github/github.service';
|
||||||
|
|
||||||
|
describe('Rices API E2E', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let gitHubService: GitHubService;
|
||||||
|
const moderationSecret = 'testSecret999';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
require('dotenv').config({ path: '.env.test.local' });
|
||||||
|
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
gitHubService = moduleFixture.get<GitHubService>(GitHubService);
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// await gitHubService.clearRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /rices - Create new zenrice', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'My first zenrice')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('identifier');
|
||||||
|
expect(response.body).toHaveProperty('token');
|
||||||
|
|
||||||
|
const { identifier, token } = response.body;
|
||||||
|
|
||||||
|
const uploadedFileContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/data.zenrice`,
|
||||||
|
);
|
||||||
|
expect(uploadedFileContent).not.toBeNull();
|
||||||
|
expect(uploadedFileContent).toContain('This is an example zenrice file.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /rices/:identifier - Download zenrice', async () => {
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'My first zenrice')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { identifier, token } = createResponse.body;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/rices/${identifier}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /rices/:identifier - Update zenrice', async () => {
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'My first zenrice')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { identifier, token } = createResponse.body;
|
||||||
|
|
||||||
|
const updateResponse = await request(app.getHttpServer())
|
||||||
|
.put(`/rices/${identifier}`)
|
||||||
|
.set('x-rices-token', token)
|
||||||
|
.field('name', 'Mi rice renombrado')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example_update.zenrice'))
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(updateResponse.body).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
`Rice ${identifier} updated`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedFileContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/data.zenrice`,
|
||||||
|
);
|
||||||
|
expect(uploadedFileContent).not.toBeNull();
|
||||||
|
expect(uploadedFileContent).toContain(
|
||||||
|
'This is an example zenrice file (modified).',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /rices/:identifier - Delete zenrice with previous token', async () => {
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'My first zenrice')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { identifier, token } = createResponse.body;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/rices/${identifier}`)
|
||||||
|
.set('x-rices-token', token)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const riceJsonContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/rice.json`,
|
||||||
|
);
|
||||||
|
expect(riceJsonContent).toBeNull();
|
||||||
|
|
||||||
|
const uploadedFileContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/data.zenrice`,
|
||||||
|
);
|
||||||
|
expect(uploadedFileContent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /rices/:identifier - Trying to download deleted zenrice', async () => {
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'My first zenrice')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { identifier, token } = createResponse.body;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/rices/${identifier}`)
|
||||||
|
.set('x-rices-token', token)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
await request(app.getHttpServer()).get(`/rices/${identifier}`).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /rices - New zenrice for moderation test', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'Rice for moderation')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('identifier');
|
||||||
|
expect(response.body).toHaveProperty('token');
|
||||||
|
|
||||||
|
const { identifier, token } = response.body;
|
||||||
|
|
||||||
|
const riceJsonContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/rice.json`,
|
||||||
|
);
|
||||||
|
expect(riceJsonContent).not.toBeNull();
|
||||||
|
|
||||||
|
const riceData = JSON.parse(riceJsonContent!);
|
||||||
|
expect(riceData).toMatchObject({
|
||||||
|
id: identifier,
|
||||||
|
token,
|
||||||
|
name: 'Rice for moderation',
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedFileContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/data.zenrice`,
|
||||||
|
);
|
||||||
|
expect(uploadedFileContent).not.toBeNull();
|
||||||
|
expect(uploadedFileContent).toContain('This is an example zenrice file.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /rices/moderate/delete/:identifier - Delete zenrice for moderation using a correct secret', async () => {
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'Rice for moderation')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { identifier, token } = createResponse.body;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/rices/moderate/delete/${identifier}`)
|
||||||
|
.set('x-moderation-secret', moderationSecret)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const riceJsonContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/rice.json`,
|
||||||
|
);
|
||||||
|
expect(riceJsonContent).toBeNull();
|
||||||
|
|
||||||
|
const uploadedFileContent = await gitHubService.getFileContent(
|
||||||
|
`rices/${identifier}/data.zenrice`,
|
||||||
|
);
|
||||||
|
expect(uploadedFileContent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /rices/moderate/delete/:identifier - Delete zenrice for moderation using an incorrect secret', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/rices/moderate/delete/${uuidv4()}`)
|
||||||
|
.set('x-moderation-secret', 'claveIncorrecta')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /rices/moderate/delete/:identifier - Delete non existent zenrice for moderation', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/rices/moderate/delete/${uuidv4()}`)
|
||||||
|
.set('x-moderation-secret', moderationSecret)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
1
test/files/example.zenrice
Normal file
1
test/files/example.zenrice
Normal file
|
@ -0,0 +1 @@
|
||||||
|
This is an example zenrice file.
|
1
test/files/example_update.zenrice
Normal file
1
test/files/example_update.zenrice
Normal file
|
@ -0,0 +1 @@
|
||||||
|
This is an example zenrice file (modified).
|
13
test/jest-e2e.json
Normal file
13
test/jest-e2e.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "../",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.ts$": "ts-jest"
|
||||||
|
},
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/test/setup.ts"],
|
||||||
|
"coverageDirectory": "./coverage",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"maxWorkers": 1,
|
||||||
|
"testTimeout": 30000
|
||||||
|
}
|
18
test/restclient/01_create_rice.http
Normal file
18
test/restclient/01_create_rice.http
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
@baseUrl = http://localhost:3000
|
||||||
|
@moderationSecret = superSecret123
|
||||||
|
@random_identifier = 123e4567-e89b-12d3-a456-426614174000
|
||||||
|
|
||||||
|
|
||||||
|
POST {{baseUrl}}/rices
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="name"
|
||||||
|
|
||||||
|
Mi primer zenrice
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="example.zenrice"
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
This is an example zenrice file.
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
5
test/restclient/02_download_rice.http
Normal file
5
test/restclient/02_download_rice.http
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
@baseUrl = http://localhost:3000
|
||||||
|
@moderationSecret = superSecret123
|
||||||
|
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
|
||||||
|
|
||||||
|
GET {{baseUrl}}/rices/{{previous_identifier}}
|
19
test/restclient/03_update_rice.http
Normal file
19
test/restclient/03_update_rice.http
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
@baseUrl = http://localhost:3000
|
||||||
|
@moderationSecret = superSecret123
|
||||||
|
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
|
||||||
|
@previous_token = 806cd360-6c14-44de-92db-f46f328dab5a
|
||||||
|
|
||||||
|
PUT {{baseUrl}}/rices/{{previous_identifier}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
x-rices-token: {{previous_token}}
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="name"
|
||||||
|
|
||||||
|
Mi rice renombrado
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="newfile.txt"
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
This is an example zenrice file (modified).
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
7
test/restclient/04_delete_rice.http
Normal file
7
test/restclient/04_delete_rice.http
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
@baseUrl = http://localhost:3000
|
||||||
|
@moderationSecret = superSecret123
|
||||||
|
@previous_identifier = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
|
||||||
|
@previous_token = 6181664b-00e8-4eef-8e23-1f7fa0c64021
|
||||||
|
|
||||||
|
DELETE {{baseUrl}}/rices/{{previous_identifier}}
|
||||||
|
x-rices-token: {{previous_token}}
|
94
test/restclient/requests_full.http
Normal file
94
test/restclient/requests_full.http
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
### Variables Iniciales
|
||||||
|
@baseUrl = http://localhost:3000
|
||||||
|
@moderationSecret = superSecret123
|
||||||
|
@random_identifier = 123e4567-e89b-12d3-a456-426614174000 # Give a valid UUID
|
||||||
|
|
||||||
|
@identifier = YOUR_IDENTIFIER_HERE
|
||||||
|
@token = YOUR_TOKEN_HERE
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### 1. Crear un Nuevo Pack
|
||||||
|
POST {{baseUrl}}/rices
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="name"
|
||||||
|
|
||||||
|
Mi primer rice
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="example.txt"
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
Contenido de mi archivo.
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
### **Instrucciones Después de Ejecutar:**
|
||||||
|
1. Ejecuta esta solicitud.
|
||||||
|
2. En la respuesta, copia los valores de `identifier` y `token`.
|
||||||
|
3. Reemplaza `YOUR_IDENTIFIER_HERE` y `YOUR_TOKEN_HERE` en las variables al inicio del archivo con los valores copiados.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET {{baseUrl}}/rices/{{identifier}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
PUT {{baseUrl}}/rices/{{identifier}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
x-rices-token: {{token}}
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="name"
|
||||||
|
|
||||||
|
Mi rice renombrado
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="newfile.txt"
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
Contenido de mi nuevo archivo.
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### 4. Eliminar el Pack Usando el Token
|
||||||
|
DELETE {{baseUrl}}/rices/{{identifier}}
|
||||||
|
x-rices-token: {{token}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### 5. Intentar Obtener el Pack Eliminado (Debe Retornar 404)
|
||||||
|
GET {{baseUrl}}/rices/{{identifier}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### 6. Crear un Nuevo Pack para Moderación
|
||||||
|
POST {{baseUrl}}/rices
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="name"
|
||||||
|
|
||||||
|
Pack para moderación
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="example.txt"
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
Contenido de mi archivo.
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### 7. Eliminar el Pack por Moderación con el Secreto Correcto
|
||||||
|
DELETE {{baseUrl}}/rices/moderate/delete/{{identifier}}
|
||||||
|
x-moderation-secret: {{moderationSecret}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
DELETE {{baseUrl}}/rices/moderate/delete/{{random_identifier}}
|
||||||
|
x-moderation-secret: claveIncorrecta
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
DELETE {{baseUrl}}/rices/moderate/delete/{{random_identifier}}
|
||||||
|
x-moderation-secret: {{moderationSecret}}
|
5
test/setup.ts
Normal file
5
test/setup.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
// test/setup.ts
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '.env.test.local') });
|
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue