mirror of
https://github.com/zen-browser/rices.git
synced 2025-07-07 17:05:40 +02:00
supabase persistence and multiple api fixes
This commit is contained in:
parent
ff50da2afd
commit
37586abe97
17 changed files with 438 additions and 505 deletions
|
@ -2,4 +2,7 @@ GITHUB_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
GITHUB_REPO_OWNER=zen-browser
|
GITHUB_REPO_OWNER=zen-browser
|
||||||
GITHUB_REPO_NAME=rices-store
|
GITHUB_REPO_NAME=rices-store
|
||||||
|
|
||||||
|
SUPABASE_URL=https://xxxxxxxxxxxxx.supabase.co
|
||||||
|
SUPABASE_KEY=XXXXXXXXXXXXXXXXXXX
|
||||||
|
|
||||||
MODERATION_SECRET=superSecret123
|
MODERATION_SECRET=superSecret123
|
||||||
|
|
|
@ -20,8 +20,6 @@
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.0.0",
|
|
||||||
"@nestjs/core": "^10.0.0",
|
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/swagger": "^8.1.0",
|
"@nestjs/swagger": "^8.1.0",
|
||||||
"@octokit/rest": "^18.12.0",
|
"@octokit/rest": "^18.12.0",
|
||||||
|
@ -37,10 +35,13 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.15",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
"@nestjs/throttler": "^6.3.0",
|
"@nestjs/throttler": "^6.3.0",
|
||||||
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
|
|
25
sql/ddl_1.0.0.sql
Normal file
25
sql/ddl_1.0.0.sql
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
-- DROP TABLE IF EXISTS rices;
|
||||||
|
|
||||||
|
CREATE TABLE rices (
|
||||||
|
id UUID NOT NULL, -- Unique identifier
|
||||||
|
slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier
|
||||||
|
name VARCHAR(75) NOT NULL, -- Name of the rice
|
||||||
|
token UUID NOT NULL, -- Unique authorization token
|
||||||
|
visits INTEGER DEFAULT 0 NOT NULL, -- Visit counter, initialized to 0
|
||||||
|
level INTEGER DEFAULT 0 NOT NULL, -- Level: 0 (Public), 1 (Verified)
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(), -- Creation date
|
||||||
|
updated_at TIMESTAMP, -- Last update date
|
||||||
|
PRIMARY KEY (id, slug), -- Composite primary key
|
||||||
|
UNIQUE (slug), -- Ensure slug is unique
|
||||||
|
UNIQUE (name) -- Ensure name is unique
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION increment_visits(slug_param TEXT)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE rices
|
||||||
|
SET visits = visits + 1
|
||||||
|
WHERE slug = slug_param;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
|
@ -146,9 +146,7 @@ export class GitHubService implements OnModuleInit {
|
||||||
branch: this.defaultBranch,
|
branch: this.defaultBranch,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`File ${filePath} created/updated successfully.`);
|
||||||
`File ${filePath} created/updated successfully.`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.status === 409 && attempt < retries) {
|
if (error.status === 409 && attempt < retries) {
|
||||||
|
@ -241,7 +239,9 @@ export class GitHubService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOctokitResponseError(error) && error.status === 404) {
|
if (isOctokitResponseError(error) && error.status === 404) {
|
||||||
this.logger.warn(`The file ${filePath} does not exist in the repository.`);
|
this.logger.warn(
|
||||||
|
`The file ${filePath} does not exist in the repository.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,9 +330,7 @@ export class GitHubService implements OnModuleInit {
|
||||||
`Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`,
|
`Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(
|
this.logger.error(`Error listing files in ${directoryPath}: ${error}`);
|
||||||
`Error listing files in ${directoryPath}: ${error}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -434,9 +432,7 @@ export class GitHubService implements OnModuleInit {
|
||||||
*/
|
*/
|
||||||
private async lockDirectory(directoryPath: string): Promise<void> {
|
private async lockDirectory(directoryPath: string): Promise<void> {
|
||||||
while (this.directoryLocks.get(directoryPath)) {
|
while (this.directoryLocks.get(directoryPath)) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Directory ${directoryPath} is locked. Waiting...`);
|
||||||
`Directory ${directoryPath} is locked. Waiting...`,
|
|
||||||
);
|
|
||||||
await this.delay(100); // Wait 100ms before retrying
|
await this.delay(100); // Wait 100ms before retrying
|
||||||
}
|
}
|
||||||
this.directoryLocks.set(directoryPath, true);
|
this.directoryLocks.set(directoryPath, true);
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateRiceDto {
|
export class CreateRiceDto {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name?: string;
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content!: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateRiceDto {
|
export class UpdateRiceDto {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name?: string;
|
content!: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UploadedFile,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
Headers,
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
@ -23,8 +22,8 @@ import {
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiConsumes,
|
ApiConsumes,
|
||||||
|
ApiBody,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
@ApiTags('rices')
|
@ApiTags('rices')
|
||||||
@Controller('rices')
|
@Controller('rices')
|
||||||
|
@ -34,45 +33,57 @@ export class RicesController {
|
||||||
@ApiOperation({ summary: 'Upload a new Rice' })
|
@ApiOperation({ summary: 'Upload a new Rice' })
|
||||||
@ApiResponse({ status: 201, description: 'Rice successfully created.' })
|
@ApiResponse({ status: 201, description: 'Rice successfully created.' })
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
description: 'Data required to create a rice',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Name of the rice',
|
||||||
|
example: 'My First Rice',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The JSON content to upload',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@Post()
|
@Post()
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
async createRice(@Body() createRiceDto: CreateRiceDto) {
|
||||||
async createRice(
|
return this.ricesService.create(createRiceDto);
|
||||||
@Body() createRiceDto: CreateRiceDto,
|
|
||||||
@UploadedFile() file: Express.Multer.File,
|
|
||||||
) {
|
|
||||||
return this.ricesService.create(createRiceDto, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Get information about a Rice' })
|
@ApiOperation({ summary: 'Get information about a Rice' })
|
||||||
@ApiResponse({ status: 200, description: 'Returns metadata of the Rice.' })
|
@ApiResponse({ status: 200, description: 'Returns metadata of the Rice.' })
|
||||||
@Get(':identifier')
|
@Get(':slug')
|
||||||
async getRice(@Param('identifier') identifier: string) {
|
async getRice(@Param('slug') slug: string) {
|
||||||
return this.ricesService.findOne(identifier);
|
return this.ricesService.findOne(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Update an existing Rice' })
|
@ApiOperation({ summary: 'Update an existing Rice' })
|
||||||
@ApiResponse({ status: 200, description: 'Rice successfully updated.' })
|
@ApiResponse({ status: 200, description: 'Rice successfully updated.' })
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@Put(':identifier')
|
@Put(':slug')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async updateRice(
|
async updateRice(
|
||||||
@Param('identifier') identifier: string,
|
@Param('slug') slug: string,
|
||||||
@Headers('x-rices-token') token: string,
|
@Headers('x-rices-token') token: string,
|
||||||
@Body() updateRiceDto: UpdateRiceDto,
|
@Body() updateRiceDto: UpdateRiceDto,
|
||||||
@UploadedFile() file?: Express.Multer.File,
|
|
||||||
) {
|
) {
|
||||||
return this.ricesService.update(identifier, token, updateRiceDto, file);
|
return this.ricesService.update(slug, token, updateRiceDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Delete an existing Rice' })
|
@ApiOperation({ summary: 'Delete an existing Rice' })
|
||||||
@ApiResponse({ status: 204, description: 'Rice successfully deleted.' })
|
@ApiResponse({ status: 204, description: 'Rice successfully deleted.' })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@Delete(':identifier')
|
@Delete(':slug')
|
||||||
async removeRice(
|
async removeRice(
|
||||||
@Param('identifier') identifier: string,
|
@Param('slug') slug: string,
|
||||||
@Headers('x-rices-token') token: string,
|
@Headers('x-rices-token') token: string,
|
||||||
) {
|
) {
|
||||||
await this.ricesService.remove(identifier, token);
|
await this.ricesService.remove(slug, token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,9 +97,9 @@ export class RicesController {
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 204, description: 'Rice deleted by moderation.' })
|
@ApiResponse({ status: 204, description: 'Rice deleted by moderation.' })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@Delete('moderate/delete/:identifier')
|
@Delete('moderate/delete/:slug')
|
||||||
async removeRiceByModerator(
|
async removeRiceByModerator(
|
||||||
@Param('identifier') identifier: string,
|
@Param('slug') slug: string,
|
||||||
@Headers('x-moderation-secret') moderationSecret: string,
|
@Headers('x-moderation-secret') moderationSecret: string,
|
||||||
) {
|
) {
|
||||||
// Verify the secret
|
// Verify the secret
|
||||||
|
@ -97,7 +108,7 @@ export class RicesController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the service to delete without a token
|
// Call the service to delete without a token
|
||||||
await this.ricesService.moderateRemove(identifier);
|
await this.ricesService.moderateRemove(slug);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,11 @@ import { Module } from '@nestjs/common';
|
||||||
import { RicesService } from './rices.service';
|
import { RicesService } from './rices.service';
|
||||||
import { GitHubModule } from '../github/github.module';
|
import { GitHubModule } from '../github/github.module';
|
||||||
import { RicesController } from './rices.controller';
|
import { RicesController } from './rices.controller';
|
||||||
|
import { SupabaseService } from '../supabase/supabase.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GitHubModule],
|
imports: [GitHubModule],
|
||||||
providers: [RicesService],
|
providers: [RicesService, SupabaseService],
|
||||||
controllers: [RicesController],
|
controllers: [RicesController],
|
||||||
exports: [RicesService],
|
exports: [RicesService],
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,296 +1,172 @@
|
||||||
// src/rices/rices.service.ts
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreateRiceDto } from './dto/create-rice.dto';
|
import { CreateRiceDto } from './dto/create-rice.dto';
|
||||||
import { UpdateRiceDto } from './dto/update-rice.dto';
|
import { UpdateRiceDto } from './dto/update-rice.dto';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { generateSlug } from './utils/slug.util';
|
import { generateSlug } from './utils/slug.util';
|
||||||
import { GitHubService } from '../github/github.service';
|
import { GitHubService } from '../github/github.service';
|
||||||
|
import { SupabaseService } from '../supabase/supabase.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()
|
@Injectable()
|
||||||
export class RicesService {
|
export class RicesService {
|
||||||
constructor(private readonly gitHubService: GitHubService) {}
|
constructor(
|
||||||
|
private readonly gitHubService: GitHubService,
|
||||||
|
private readonly supabaseService: SupabaseService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
async create(createRiceDto: CreateRiceDto) {
|
||||||
* Create a new rice
|
// Check if a rice with the same name already exists
|
||||||
*/
|
const existingRice = await this.supabaseService.getRiceByName(
|
||||||
async create(createRiceDto: CreateRiceDto, file?: Express.Multer.File) {
|
createRiceDto.name,
|
||||||
try {
|
);
|
||||||
// 1. Generate identifier (slug + UUID or just UUID)
|
if (existingRice) {
|
||||||
let identifier: string;
|
throw new ConflictException(
|
||||||
if (createRiceDto.name) {
|
`A rice with the name '${createRiceDto.name}' already exists.`,
|
||||||
// Generate slug from the name
|
);
|
||||||
const slug = generateSlug(createRiceDto.name);
|
}
|
||||||
identifier = `${slug}-${uuidv4()}`;
|
|
||||||
} else {
|
|
||||||
identifier = uuidv4();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Generate token and save metadata
|
const slug = createRiceDto.name
|
||||||
const token = uuidv4();
|
? `${generateSlug(createRiceDto.name)}-${uuidv4()}`
|
||||||
const metadata = {
|
: uuidv4();
|
||||||
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
|
const token = uuidv4();
|
||||||
|
const metadata = {
|
||||||
|
id: uuidv4(),
|
||||||
|
token,
|
||||||
|
name: createRiceDto.name || null,
|
||||||
|
slug: slug,
|
||||||
|
visits: 0,
|
||||||
|
level: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.supabaseService.insertRice(metadata);
|
||||||
|
|
||||||
|
const metadataContent = JSON.stringify(metadata, null, 2);
|
||||||
|
const riceJsonPath = `rices/${slug}/rice.json`;
|
||||||
|
await this.gitHubService.createOrUpdateFile(
|
||||||
|
riceJsonPath,
|
||||||
|
metadataContent,
|
||||||
|
`Add rice ${slug}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createRiceDto.content) {
|
||||||
|
const uploadedFilePath = `rices/${slug}/data.zenrice`;
|
||||||
await this.gitHubService.createOrUpdateFile(
|
await this.gitHubService.createOrUpdateFile(
|
||||||
riceJsonPath,
|
uploadedFilePath,
|
||||||
metadataContent,
|
createRiceDto.content,
|
||||||
`Add rice ${identifier}`,
|
`Add file createRiceDto.content to rice ${slug}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { slug, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async findOne(slug: string) {
|
||||||
* Get rice information by its identifier
|
// Check if the rice exists in the database
|
||||||
*/
|
const rice = await this.supabaseService.getRiceBySlug(slug);
|
||||||
async findOne(identifier: string) {
|
if (!rice) throw new NotFoundException('Rice not found');
|
||||||
try {
|
|
||||||
const riceJsonPath = `rices/${identifier}/data.zenrice`;
|
|
||||||
const fileContent = await this.gitHubService.getFileContent(riceJsonPath);
|
|
||||||
|
|
||||||
if (!fileContent) {
|
// Fetch the file from GitHub
|
||||||
throw new NotFoundException('Rice not found');
|
const filePath = `rices/${slug}/data.zenrice`;
|
||||||
}
|
const fileContent = await this.gitHubService.getFileContent(filePath);
|
||||||
|
|
||||||
return fileContent;
|
if (!fileContent) {
|
||||||
} catch (error) {
|
throw new NotFoundException('Rice file not found in GitHub');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { slug, fileContent };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) {
|
||||||
* Update an existing rice
|
const rice = await this.supabaseService.getRiceBySlug(slug);
|
||||||
*/
|
if (!rice) throw new NotFoundException('Rice not found');
|
||||||
async update(
|
if (rice.token !== token) throw new UnauthorizedException('Invalid token');
|
||||||
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) {
|
const updatedMetadata = {
|
||||||
throw new NotFoundException('Rice not found');
|
...rice,
|
||||||
}
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
const metadata = JSON.parse(metadataContent);
|
await this.supabaseService.updateRice(slug, updatedMetadata);
|
||||||
|
|
||||||
if (metadata.token !== token) {
|
const metadataContent = JSON.stringify(updatedMetadata, null, 2);
|
||||||
throw new UnauthorizedException('Invalid token');
|
const riceJsonPath = `rices/${slug}/rice.json`;
|
||||||
}
|
await this.gitHubService.createOrUpdateFile(
|
||||||
|
riceJsonPath,
|
||||||
|
metadataContent,
|
||||||
|
`Update rice ${slug}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Update metadata
|
if (updateRiceDto.content) {
|
||||||
if (updateRiceDto.name) {
|
const uploadedFilePath = `rices/${slug}/data.zenrice`;
|
||||||
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(
|
await this.gitHubService.createOrUpdateFile(
|
||||||
riceJsonPath,
|
uploadedFilePath,
|
||||||
updatedMetadataContent,
|
updateRiceDto.content,
|
||||||
`Update rice ${identifier}`,
|
`Update file updateRiceDto.content in rice ${slug}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { message: `ok` };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async remove(slug: string, token: string): Promise<void> {
|
||||||
* Delete an existing rice
|
const rice = await this.supabaseService.getRiceBySlug(slug);
|
||||||
*/
|
if (!rice) throw new NotFoundException('Rice not found');
|
||||||
async remove(identifier: string, token: string): Promise<void> {
|
if (rice.token !== token) throw new UnauthorizedException('Invalid token');
|
||||||
try {
|
|
||||||
// 1. Retrieve and validate metadata
|
|
||||||
const riceJsonPath = `rices/${identifier}/rice.json`;
|
|
||||||
const metadataContent =
|
|
||||||
await this.gitHubService.getFileContent(riceJsonPath);
|
|
||||||
|
|
||||||
if (!metadataContent) {
|
await this.supabaseService.deleteRice(slug);
|
||||||
throw new NotFoundException('Rice not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = JSON.parse(metadataContent);
|
const riceJsonPath = `rices/${slug}/rice.json`;
|
||||||
|
await this.gitHubService.deleteFile(riceJsonPath, `Remove rice ${slug}`);
|
||||||
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.
|
* Delete a rice without checking the user's token.
|
||||||
* Exclusive use for moderators with the secret key.
|
* Exclusive use for moderators with the secret key.
|
||||||
*/
|
*/
|
||||||
public async moderateRemove(identifier: string): Promise<void> {
|
public async moderateRemove(slug: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 1. Check if rice.json exists
|
// 1. Check if rice exists in Supabase
|
||||||
const riceJsonPath = `rices/${identifier}/rice.json`;
|
const rice = await this.supabaseService.getRiceBySlug(slug);
|
||||||
const metadataContent =
|
if (!rice) {
|
||||||
await this.gitHubService.getFileContent(riceJsonPath);
|
|
||||||
|
|
||||||
if (!metadataContent) {
|
|
||||||
throw new NotFoundException('Rice not found');
|
throw new NotFoundException('Rice not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delete rice.json from GitHub
|
// 2. Delete metadata from Supabase
|
||||||
|
await this.supabaseService.deleteRice(slug);
|
||||||
|
|
||||||
|
// 3. Delete rice.json from GitHub
|
||||||
|
const riceJsonPath = `rices/${slug}/rice.json`;
|
||||||
await this.gitHubService.deleteFile(
|
await this.gitHubService.deleteFile(
|
||||||
riceJsonPath,
|
riceJsonPath,
|
||||||
`[MODERATION] Remove rice ${identifier}`,
|
`[MODERATION] Remove rice ${slug}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. List and delete uploaded files (if any)
|
// 4. List and delete uploaded files from GitHub (if any)
|
||||||
const uploadedFilesPath = `rices/${identifier}`;
|
const uploadedFilesPath = `rices/${slug}`;
|
||||||
const files =
|
const files =
|
||||||
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
|
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file !== 'rice.json') {
|
if (file !== 'rice.json') {
|
||||||
const filePath = `rices/${identifier}/${file}`;
|
const filePath = `rices/${slug}/${file}`;
|
||||||
await this.gitHubService.deleteFile(
|
await this.gitHubService.deleteFile(
|
||||||
filePath,
|
filePath,
|
||||||
`[MODERATION] Remove file ${file} from rice ${identifier}`,
|
`[MODERATION] Remove file ${file} from rice ${slug}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
console.error('Error removing rice by moderation:', error);
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new Error('Failed to remove rice by moderation');
|
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,25 @@
|
||||||
export function generateSlug(name: string): string {
|
export function generateSlug(name: string): string {
|
||||||
return name
|
// Ensure the input is a string and trim whitespace
|
||||||
|
if (typeof name !== 'string') {
|
||||||
|
throw new Error('Input must be a string');
|
||||||
|
}
|
||||||
|
const sanitizedInput = name.trim();
|
||||||
|
|
||||||
|
// Replace accented characters with their unaccented counterparts
|
||||||
|
const normalized = sanitizedInput
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{Diacritic}/gu, '');
|
||||||
|
|
||||||
|
// Replace any non-alphanumeric characters (excluding '-') with a hyphen
|
||||||
|
const slug = normalized
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-') // Replace invalid characters
|
||||||
.replace(/^-+|-+$/g, '');
|
.replace(/^-+|-+$/g, ''); // Trim leading and trailing hyphens
|
||||||
|
|
||||||
|
// Ensure the slug is not empty
|
||||||
|
if (!slug) {
|
||||||
|
throw new Error('Generated slug is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
135
src/supabase/supabase.service.ts
Normal file
135
src/supabase/supabase.service.ts
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupabaseService {
|
||||||
|
private supabase: SupabaseClient;
|
||||||
|
private supabase_url: string;
|
||||||
|
private supabase_key: string;
|
||||||
|
private readonly logger = new Logger(SupabaseService.name);
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
// Initialize properties in the constructor
|
||||||
|
this.supabase_url = this.configService.get<string>('SUPABASE_URL') || '';
|
||||||
|
this.supabase_key = this.configService.get<string>('SUPABASE_KEY') || '';
|
||||||
|
|
||||||
|
this.supabase = createClient(this.supabase_url, this.supabase_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertRice(metadata: any) {
|
||||||
|
const { error } = await this.supabase.from('rices').insert(metadata);
|
||||||
|
if (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to insert rice: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to insert rice: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRiceById(id: string) {
|
||||||
|
const { data, error } = await this.supabase
|
||||||
|
.from('rices')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch rice with ID ${id}: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to fetch rice: ${error.message}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRiceBySlug(slug: string) {
|
||||||
|
const { data, error } = await this.supabase
|
||||||
|
.from('rices')
|
||||||
|
.select('*')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch rice with slug ${slug}: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to fetch rice: ${error.message}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRiceByName(name: string) {
|
||||||
|
const { data, error } = await this.supabase
|
||||||
|
.from('rices')
|
||||||
|
.select('*')
|
||||||
|
.eq('name', name)
|
||||||
|
.single();
|
||||||
|
if (error && error.code !== 'PGRST116') {
|
||||||
|
// Handle "no rows found" separately
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch rice with name ${name}: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to fetch rice: ${error.message}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRice(slug: string, metadata: any) {
|
||||||
|
const { error } = await this.supabase
|
||||||
|
.from('rices')
|
||||||
|
.update(metadata)
|
||||||
|
.eq('slug', slug);
|
||||||
|
if (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update rice with slug ${slug}: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to update rice: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRice(slug: string) {
|
||||||
|
const { error } = await this.supabase
|
||||||
|
.from('rices')
|
||||||
|
.delete()
|
||||||
|
.eq('slug', slug);
|
||||||
|
if (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to delete rice with slug ${slug}: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to delete rice: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementVisits(slug: string) {
|
||||||
|
const { error } = await this.supabase.rpc('increment_visits', {
|
||||||
|
slug_param: slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to increment visits for rice with slug ${slug}: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to increment visits: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLevel(slug: string, level: number) {
|
||||||
|
const { error } = await this.supabase
|
||||||
|
.from('rices')
|
||||||
|
.update({ level })
|
||||||
|
.eq('slug', slug);
|
||||||
|
if (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update level for rice with slug ${slug}: ${error.message}`,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to update rice level: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,10 +5,12 @@ import { AppModule } from './../src/app.module';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { GitHubService } from '../src/github/github.service';
|
import { GitHubService } from '../src/github/github.service';
|
||||||
|
import { SupabaseService } from '../src/supabase/supabase.service';
|
||||||
|
|
||||||
describe('Rices API E2E', () => {
|
describe('Rices API E2E', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let gitHubService: GitHubService;
|
let gitHubService: GitHubService;
|
||||||
|
let supabaseService: SupabaseService;
|
||||||
const moderationSecret = 'testSecret999';
|
const moderationSecret = 'testSecret999';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
@ -19,6 +21,7 @@ describe('Rices API E2E', () => {
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
gitHubService = moduleFixture.get<GitHubService>(GitHubService);
|
gitHubService = moduleFixture.get<GitHubService>(GitHubService);
|
||||||
|
supabaseService = moduleFixture.get<SupabaseService>(SupabaseService);
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||||
|
@ -31,181 +34,138 @@ describe('Rices API E2E', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// await gitHubService.clearRepository();
|
// Limpiar repositorio y base de datos antes de cada test si es necesario
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /rices - Create new zenrice', async () => {
|
it('POST /rices - Create a new rice entry', async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/rices')
|
.post('/rices')
|
||||||
.field('name', 'My first zenrice')
|
.field('name', 'Test Rice')
|
||||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
expect(response.body).toHaveProperty('identifier');
|
const { slug, token } = response.body;
|
||||||
expect(response.body).toHaveProperty('token');
|
expect(slug).toBeDefined();
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
|
||||||
const { identifier, token } = response.body;
|
const riceInDatabase = await supabaseService.getRiceBySlug(slug);
|
||||||
|
expect(riceInDatabase).not.toBeNull();
|
||||||
|
expect(riceInDatabase.name).toBe('Test Rice');
|
||||||
|
|
||||||
const uploadedFileContent = await gitHubService.getFileContent(
|
const fileInGitHub = await gitHubService.getFileContent(
|
||||||
`rices/${identifier}/data.zenrice`,
|
`rices/${slug}/data.zenrice`,
|
||||||
);
|
);
|
||||||
expect(uploadedFileContent).not.toBeNull();
|
expect(fileInGitHub).toContain('This is an example zenrice file.');
|
||||||
expect(uploadedFileContent).toContain('This is an example zenrice file.');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /rices/:identifier - Download zenrice', async () => {
|
it('GET /rices/:slug - Retrieve a rice entry and increment visits', async () => {
|
||||||
const createResponse = await request(app.getHttpServer())
|
const createResponse = await request(app.getHttpServer())
|
||||||
.post('/rices')
|
.post('/rices')
|
||||||
.field('name', 'My first zenrice')
|
.field('name', 'Test Rice')
|
||||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
const { identifier, token } = createResponse.body;
|
const { slug } = createResponse.body;
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const initialData = await supabaseService.getRiceBySlug(slug);
|
||||||
.get(`/rices/${identifier}`)
|
expect(initialData.visits).toBe(0);
|
||||||
.expect(200);
|
|
||||||
|
await request(app.getHttpServer()).get(`/rices/${slug}`).expect(200);
|
||||||
|
|
||||||
|
const updatedData = await supabaseService.getRiceBySlug(slug);
|
||||||
|
expect(updatedData.visits).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PUT /rices/:identifier - Update zenrice', async () => {
|
it('PUT /rices/:slug - Update a rice entry', async () => {
|
||||||
const createResponse = await request(app.getHttpServer())
|
const createResponse = await request(app.getHttpServer())
|
||||||
.post('/rices')
|
.post('/rices')
|
||||||
.field('name', 'My first zenrice')
|
.field('name', 'Original Rice')
|
||||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
const { identifier, token } = createResponse.body;
|
const { slug, token } = createResponse.body;
|
||||||
|
|
||||||
const updateResponse = await request(app.getHttpServer())
|
const updateResponse = await request(app.getHttpServer())
|
||||||
.put(`/rices/${identifier}`)
|
.put(`/rices/${slug}`)
|
||||||
.set('x-rices-token', token)
|
.set('x-rices-token', token)
|
||||||
.field('name', 'Mi rice renombrado')
|
.field('name', 'Updated Rice')
|
||||||
.attach('file', path.join(__dirname, 'files', 'example_update.zenrice'))
|
.attach('file', path.join(__dirname, 'files', 'example_update.zenrice'))
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(updateResponse.body).toHaveProperty(
|
expect(updateResponse.body.message).toBe(`ok`);
|
||||||
'message',
|
|
||||||
`Rice ${identifier} updated`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const uploadedFileContent = await gitHubService.getFileContent(
|
const updatedData = await supabaseService.getRiceBySlug(slug);
|
||||||
`rices/${identifier}/data.zenrice`,
|
expect(updatedData.name).toBe('Updated Rice');
|
||||||
|
|
||||||
|
const updatedFile = await gitHubService.getFileContent(
|
||||||
|
`rices/${slug}/data.zenrice`,
|
||||||
);
|
);
|
||||||
expect(uploadedFileContent).not.toBeNull();
|
expect(updatedFile).toContain(
|
||||||
expect(uploadedFileContent).toContain(
|
|
||||||
'This is an example zenrice file (modified).',
|
'This is an example zenrice file (modified).',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE /rices/:identifier - Delete zenrice with previous token', async () => {
|
it('DELETE /rices/:slug - Delete a rice entry', async () => {
|
||||||
const createResponse = await request(app.getHttpServer())
|
const createResponse = await request(app.getHttpServer())
|
||||||
.post('/rices')
|
.post('/rices')
|
||||||
.field('name', 'My first zenrice')
|
.field('name', 'Rice to Delete')
|
||||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
const { identifier, token } = createResponse.body;
|
const { slug, token } = createResponse.body;
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.delete(`/rices/${identifier}`)
|
.delete(`/rices/${slug}`)
|
||||||
.set('x-rices-token', token)
|
.set('x-rices-token', token)
|
||||||
.expect(204);
|
.expect(204);
|
||||||
|
|
||||||
const riceJsonContent = await gitHubService.getFileContent(
|
const riceInDatabase = await supabaseService.getRiceBySlug(slug);
|
||||||
`rices/${identifier}/rice.json`,
|
expect(riceInDatabase).toBeNull();
|
||||||
);
|
|
||||||
expect(riceJsonContent).toBeNull();
|
|
||||||
|
|
||||||
const uploadedFileContent = await gitHubService.getFileContent(
|
const fileInGitHub = await gitHubService.getFileContent(
|
||||||
`rices/${identifier}/data.zenrice`,
|
`rices/${slug}/data.zenrice`,
|
||||||
);
|
);
|
||||||
expect(uploadedFileContent).toBeNull();
|
expect(fileInGitHub).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /rices/:identifier - Trying to download deleted zenrice', async () => {
|
it('DELETE /rices/moderate/delete/:slug - Moderation delete with correct secret', async () => {
|
||||||
const createResponse = await request(app.getHttpServer())
|
const createResponse = await request(app.getHttpServer())
|
||||||
.post('/rices')
|
.post('/rices')
|
||||||
.field('name', 'My first zenrice')
|
.field('name', 'Moderation Test Rice')
|
||||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
const { identifier, token } = createResponse.body;
|
const { slug } = createResponse.body;
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.delete(`/rices/${identifier}`)
|
.delete(`/rices/moderate/delete/${slug}`)
|
||||||
.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)
|
.set('x-moderation-secret', moderationSecret)
|
||||||
.expect(204);
|
.expect(204);
|
||||||
|
|
||||||
const riceJsonContent = await gitHubService.getFileContent(
|
const riceInDatabase = await supabaseService.getRiceBySlug(slug);
|
||||||
`rices/${identifier}/rice.json`,
|
expect(riceInDatabase).toBeNull();
|
||||||
);
|
|
||||||
expect(riceJsonContent).toBeNull();
|
|
||||||
|
|
||||||
const uploadedFileContent = await gitHubService.getFileContent(
|
const fileInGitHub = await gitHubService.getFileContent(
|
||||||
`rices/${identifier}/data.zenrice`,
|
`rices/${slug}/data.zenrice`,
|
||||||
);
|
);
|
||||||
expect(uploadedFileContent).toBeNull();
|
expect(fileInGitHub).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE /rices/moderate/delete/:identifier - Delete zenrice for moderation using an incorrect secret', async () => {
|
it('DELETE /rices/moderate/delete/:slug - Moderation delete with incorrect secret', async () => {
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post('/rices')
|
||||||
|
.field('name', 'Moderation Failure Test')
|
||||||
|
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { slug } = createResponse.body;
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.delete(`/rices/moderate/delete/${uuidv4()}`)
|
.delete(`/rices/moderate/delete/${slug}`)
|
||||||
.set('x-moderation-secret', 'claveIncorrecta')
|
.set('x-moderation-secret', 'wrongSecret')
|
||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
|
||||||
|
|
||||||
it('DELETE /rices/moderate/delete/:identifier - Delete non existent zenrice for moderation', async () => {
|
const riceInDatabase = await supabaseService.getRiceBySlug(slug);
|
||||||
await request(app.getHttpServer())
|
expect(riceInDatabase).not.toBeNull();
|
||||||
.delete(`/rices/moderate/delete/${uuidv4()}`)
|
|
||||||
.set('x-moderation-secret', moderationSecret)
|
|
||||||
.expect(404);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
@baseUrl = http://localhost:3000
|
@baseUrl = http://localhost:3000
|
||||||
@moderationSecret = superSecret123
|
|
||||||
@random_identifier = 123e4567-e89b-12d3-a456-426614174000
|
# base64 of following json: ewogICAgImtleSI6ICJ2YWx1ZSIsCiAgICAiZGVzY3JpcHRpb24iOiAiRXhhbXBsZSBjb250ZW50Igp9
|
||||||
|
|
||||||
|
# {
|
||||||
|
# "key": "value",
|
||||||
|
# "description": "Example content"
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
POST {{baseUrl}}/rices
|
POST {{baseUrl}}/rices
|
||||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
Content-Type: application/json
|
||||||
|
|
||||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
{
|
||||||
Content-Disposition: form-data; name="name"
|
"name": "cool-zenrice-aurora2",
|
||||||
|
"content": "ewogICAgImtleSI6ICJ2YWx1ZSIsCiAgICAiZGVzY3JpcHRpb24iOiAiRXhhbXBsZSBjb250ZW50Igp9"
|
||||||
|
}
|
||||||
|
|
||||||
Mi primer zenrice
|
|
||||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
|
||||||
Content-Disposition: form-data; name="file"; filename="example.zenrice"
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
This is an example zenrice file.
|
|
||||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
|
|
@ -1,5 +1,4 @@
|
||||||
@baseUrl = http://localhost:3000
|
@baseUrl = http://localhost:3000
|
||||||
@moderationSecret = superSecret123
|
@previous_slug = cool-zenrice-aurora-84154784-8dc4-4153-8bd5-fd9f55f03b93
|
||||||
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
|
|
||||||
|
|
||||||
GET {{baseUrl}}/rices/{{previous_identifier}}
|
GET {{baseUrl}}/rices/{{previous_slug}}
|
|
@ -1,19 +1,20 @@
|
||||||
@baseUrl = http://localhost:3000
|
@baseUrl = http://localhost:3000
|
||||||
@moderationSecret = superSecret123
|
@previous_slug = cool-zenrice-aurora2-1d1e74b3-8d6d-40ea-bd4f-a6f4ad893f88
|
||||||
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
|
@previous_token = be4545f4-d92b-416c-8b3b-50cc9a49dee9
|
||||||
@previous_token = 806cd360-6c14-44de-92db-f46f328dab5a
|
|
||||||
|
|
||||||
PUT {{baseUrl}}/rices/{{previous_identifier}}
|
# base64 of following json: ewogICAgImtleSI6ICJuZXdfdmFsdWUiLAogICAgImRlc2NyaXB0aW9uIjogIkV4YW1wbGUgY29udGVudCIKfQ
|
||||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
|
||||||
|
# {
|
||||||
|
# "key": "new_value",
|
||||||
|
# "description": "Example content"
|
||||||
|
# }
|
||||||
|
|
||||||
|
PUT {{baseUrl}}/rices/{{previous_slug}}
|
||||||
|
Content-Type: application/json
|
||||||
x-rices-token: {{previous_token}}
|
x-rices-token: {{previous_token}}
|
||||||
|
|
||||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
{
|
||||||
Content-Disposition: form-data; name="name"
|
|
||||||
|
|
||||||
Mi rice renombrado
|
"content": "ewogICAgImtleSI6ICJuZXdfdmFsdWUiLAogICAgImRlc2NyaXB0aW9uIjogIkV4YW1wbGUgY29udGVudCIKfQ"
|
||||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
}
|
||||||
Content-Disposition: form-data; name="file"; filename="newfile.txt"
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
This is an example zenrice file (modified).
|
|
||||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
|
|
@ -1,7 +1,6 @@
|
||||||
@baseUrl = http://localhost:3000
|
@baseUrl = http://localhost:3000
|
||||||
@moderationSecret = superSecret123
|
@previous_slug = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
|
||||||
@previous_identifier = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
|
|
||||||
@previous_token = 6181664b-00e8-4eef-8e23-1f7fa0c64021
|
@previous_token = 6181664b-00e8-4eef-8e23-1f7fa0c64021
|
||||||
|
|
||||||
DELETE {{baseUrl}}/rices/{{previous_identifier}}
|
DELETE {{baseUrl}}/rices/{{previous_slug}}
|
||||||
x-rices-token: {{previous_token}}
|
x-rices-token: {{previous_token}}
|
||||||
|
|
|
@ -1,94 +0,0 @@
|
||||||
### 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}}
|
|
Loading…
Add table
Add a link
Reference in a new issue