supabase persistence and multiple api fixes

This commit is contained in:
oscargonzalezmoreno@gmail.com 2024-12-26 18:06:42 +01:00
parent ff50da2afd
commit 37586abe97
17 changed files with 438 additions and 505 deletions

View file

@ -2,4 +2,7 @@ GITHUB_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
GITHUB_REPO_OWNER=zen-browser
GITHUB_REPO_NAME=rices-store
SUPABASE_URL=https://xxxxxxxxxxxxx.supabase.co
SUPABASE_KEY=XXXXXXXXXXXXXXXXXXX
MODERATION_SECRET=superSecret123

View file

@ -20,8 +20,6 @@
"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",
@ -37,10 +35,13 @@
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@nestjs/throttler": "^6.3.0",
"@supabase/supabase-js": "^2.47.10",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.3.1",

25
sql/ddl_1.0.0.sql Normal file
View 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;

View file

@ -146,9 +146,7 @@ export class GitHubService implements OnModuleInit {
branch: this.defaultBranch,
});
this.logger.log(
`File ${filePath} created/updated successfully.`,
);
this.logger.log(`File ${filePath} created/updated successfully.`);
return;
} catch (error: any) {
if (error.status === 409 && attempt < retries) {
@ -241,7 +239,9 @@ export class GitHubService implements OnModuleInit {
}
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;
}
@ -330,9 +330,7 @@ export class GitHubService implements OnModuleInit {
`Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`,
);
} else {
this.logger.error(
`Error listing files in ${directoryPath}: ${error}`,
);
this.logger.error(`Error listing files in ${directoryPath}: ${error}`);
}
throw error;
}
@ -434,9 +432,7 @@ export class GitHubService implements OnModuleInit {
*/
private async lockDirectory(directoryPath: string): Promise<void> {
while (this.directoryLocks.get(directoryPath)) {
this.logger.warn(
`Directory ${directoryPath} is locked. Waiting...`,
);
this.logger.warn(`Directory ${directoryPath} is locked. Waiting...`);
await this.delay(100); // Wait 100ms before retrying
}
this.directoryLocks.set(directoryPath, true);

View file

@ -1,7 +1,9 @@
import { IsOptional, IsString } from 'class-validator';
import { IsString } from 'class-validator';
export class CreateRiceDto {
@IsOptional()
@IsString()
name?: string;
name!: string;
@IsString()
content!: string;
}

View file

@ -1,7 +1,6 @@
import { IsOptional, IsString } from 'class-validator';
import { IsString } from 'class-validator';
export class UpdateRiceDto {
@IsOptional()
@IsString()
name?: string;
content!: string;
}

View file

@ -6,7 +6,6 @@ import {
Delete,
Param,
Body,
UploadedFile,
UseInterceptors,
Headers,
HttpCode,
@ -23,8 +22,8 @@ import {
ApiOperation,
ApiResponse,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
@ApiTags('rices')
@Controller('rices')
@ -34,45 +33,57 @@ export class RicesController {
@ApiOperation({ summary: 'Upload a new Rice' })
@ApiResponse({ status: 201, description: 'Rice successfully created.' })
@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()
@UseInterceptors(FileInterceptor('file'))
async createRice(
@Body() createRiceDto: CreateRiceDto,
@UploadedFile() file: Express.Multer.File,
) {
return this.ricesService.create(createRiceDto, file);
async createRice(@Body() createRiceDto: CreateRiceDto) {
return this.ricesService.create(createRiceDto);
}
@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);
@Get(':slug')
async getRice(@Param('slug') slug: string) {
return this.ricesService.findOne(slug);
}
@ApiOperation({ summary: 'Update an existing Rice' })
@ApiResponse({ status: 200, description: 'Rice successfully updated.' })
@ApiConsumes('multipart/form-data')
@Put(':identifier')
@Put(':slug')
@UseInterceptors(FileInterceptor('file'))
async updateRice(
@Param('identifier') identifier: string,
@Param('slug') slug: string,
@Headers('x-rices-token') token: string,
@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' })
@ApiResponse({ status: 204, description: 'Rice successfully deleted.' })
@HttpCode(HttpStatus.NO_CONTENT)
@Delete(':identifier')
@Delete(':slug')
async removeRice(
@Param('identifier') identifier: string,
@Param('slug') slug: string,
@Headers('x-rices-token') token: string,
) {
await this.ricesService.remove(identifier, token);
await this.ricesService.remove(slug, token);
return;
}
@ -86,9 +97,9 @@ export class RicesController {
})
@ApiResponse({ status: 204, description: 'Rice deleted by moderation.' })
@HttpCode(HttpStatus.NO_CONTENT)
@Delete('moderate/delete/:identifier')
@Delete('moderate/delete/:slug')
async removeRiceByModerator(
@Param('identifier') identifier: string,
@Param('slug') slug: string,
@Headers('x-moderation-secret') moderationSecret: string,
) {
// Verify the secret
@ -97,7 +108,7 @@ export class RicesController {
}
// Call the service to delete without a token
await this.ricesService.moderateRemove(identifier);
await this.ricesService.moderateRemove(slug);
return;
}
}

View file

@ -4,10 +4,11 @@ import { Module } from '@nestjs/common';
import { RicesService } from './rices.service';
import { GitHubModule } from '../github/github.module';
import { RicesController } from './rices.controller';
import { SupabaseService } from '../supabase/supabase.service';
@Module({
imports: [GitHubModule],
providers: [RicesService],
providers: [RicesService, SupabaseService],
controllers: [RicesController],
exports: [RicesService],
})

View file

@ -1,296 +1,172 @@
// src/rices/rices.service.ts
import {
Injectable,
NotFoundException,
UnauthorizedException,
ConflictException,
} 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'
);
}
import { SupabaseService } from '../supabase/supabase.service';
@Injectable()
export class RicesService {
constructor(private readonly gitHubService: GitHubService) {}
constructor(
private readonly gitHubService: GitHubService,
private readonly supabaseService: SupabaseService,
) {}
/**
* 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();
}
async create(createRiceDto: CreateRiceDto) {
// Check if a rice with the same name already exists
const existingRice = await this.supabaseService.getRiceByName(
createRiceDto.name,
);
if (existingRice) {
throw new ConflictException(
`A rice with the name '${createRiceDto.name}' already exists.`,
);
}
// 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`;
const slug = createRiceDto.name
? `${generateSlug(createRiceDto.name)}-${uuidv4()}`
: uuidv4();
// 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(
riceJsonPath,
metadataContent,
`Add rice ${identifier}`,
uploadedFilePath,
createRiceDto.content,
`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 };
}
/**
* Get rice information by its identifier
*/
async findOne(identifier: string) {
try {
const riceJsonPath = `rices/${identifier}/data.zenrice`;
const fileContent = await this.gitHubService.getFileContent(riceJsonPath);
async findOne(slug: string) {
// Check if the rice exists in the database
const rice = await this.supabaseService.getRiceBySlug(slug);
if (!rice) throw new NotFoundException('Rice not found');
if (!fileContent) {
throw new NotFoundException('Rice not found');
}
// Fetch the file from GitHub
const filePath = `rices/${slug}/data.zenrice`;
const fileContent = await this.gitHubService.getFileContent(filePath);
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');
if (!fileContent) {
throw new NotFoundException('Rice file not found in GitHub');
}
return { slug, fileContent };
}
/**
* 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);
async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) {
const rice = await this.supabaseService.getRiceBySlug(slug);
if (!rice) throw new NotFoundException('Rice not found');
if (rice.token !== token) throw new UnauthorizedException('Invalid token');
if (!metadataContent) {
throw new NotFoundException('Rice not found');
}
const updatedMetadata = {
...rice,
updated_at: new Date().toISOString(),
};
const metadata = JSON.parse(metadataContent);
await this.supabaseService.updateRice(slug, updatedMetadata);
if (metadata.token !== token) {
throw new UnauthorizedException('Invalid token');
}
const metadataContent = JSON.stringify(updatedMetadata, null, 2);
const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.createOrUpdateFile(
riceJsonPath,
metadataContent,
`Update rice ${slug}`,
);
// 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
if (updateRiceDto.content) {
const uploadedFilePath = `rices/${slug}/data.zenrice`;
await this.gitHubService.createOrUpdateFile(
riceJsonPath,
updatedMetadataContent,
`Update rice ${identifier}`,
uploadedFilePath,
updateRiceDto.content,
`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` };
}
/**
* 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);
async remove(slug: string, token: string): Promise<void> {
const rice = await this.supabaseService.getRiceBySlug(slug);
if (!rice) throw new NotFoundException('Rice not found');
if (rice.token !== token) throw new UnauthorizedException('Invalid token');
if (!metadataContent) {
throw new NotFoundException('Rice not found');
}
await this.supabaseService.deleteRice(slug);
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');
}
const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.deleteFile(riceJsonPath, `Remove rice ${slug}`);
}
/**
* Delete a rice without checking the user's token.
* Exclusive use for moderators with the secret key.
*/
public async moderateRemove(identifier: string): Promise<void> {
public async moderateRemove(slug: 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) {
// 1. Check if rice exists in Supabase
const rice = await this.supabaseService.getRiceBySlug(slug);
if (!rice) {
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(
riceJsonPath,
`[MODERATION] Remove rice ${identifier}`,
`[MODERATION] Remove rice ${slug}`,
);
// 3. List and delete uploaded files (if any)
const uploadedFilesPath = `rices/${identifier}`;
// 4. List and delete uploaded files from GitHub (if any)
const uploadedFilesPath = `rices/${slug}`;
const files =
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
for (const file of files) {
if (file !== 'rice.json') {
const filePath = `rices/${identifier}/${file}`;
const filePath = `rices/${slug}/${file}`;
await this.gitHubService.deleteFile(
filePath,
`[MODERATION] Remove file ${file} from rice ${identifier}`,
`[MODERATION] Remove file ${file} from rice ${slug}`,
);
}
}
} 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);
if (error instanceof NotFoundException) {
throw 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');
}
}
}

View file

@ -1,6 +1,25 @@
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()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
.replace(/[^a-z0-9]+/g, '-') // Replace invalid characters
.replace(/^-+|-+$/g, ''); // Trim leading and trailing hyphens
// Ensure the slug is not empty
if (!slug) {
throw new Error('Generated slug is empty');
}
return slug;
}

View 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}`);
}
}
}

View file

@ -5,10 +5,12 @@ import { AppModule } from './../src/app.module';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { GitHubService } from '../src/github/github.service';
import { SupabaseService } from '../src/supabase/supabase.service';
describe('Rices API E2E', () => {
let app: INestApplication;
let gitHubService: GitHubService;
let supabaseService: SupabaseService;
const moderationSecret = 'testSecret999';
beforeAll(async () => {
@ -19,6 +21,7 @@ describe('Rices API E2E', () => {
}).compile();
gitHubService = moduleFixture.get<GitHubService>(GitHubService);
supabaseService = moduleFixture.get<SupabaseService>(SupabaseService);
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true }));
@ -31,181 +34,138 @@ describe('Rices API E2E', () => {
});
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())
.post('/rices')
.field('name', 'My first zenrice')
.field('name', 'Test Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
expect(response.body).toHaveProperty('identifier');
expect(response.body).toHaveProperty('token');
const { slug, token } = response.body;
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(
`rices/${identifier}/data.zenrice`,
const fileInGitHub = await gitHubService.getFileContent(
`rices/${slug}/data.zenrice`,
);
expect(uploadedFileContent).not.toBeNull();
expect(uploadedFileContent).toContain('This is an example zenrice file.');
expect(fileInGitHub).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())
.post('/rices')
.field('name', 'My first zenrice')
.field('name', 'Test Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
const { identifier, token } = createResponse.body;
const { slug } = createResponse.body;
const response = await request(app.getHttpServer())
.get(`/rices/${identifier}`)
.expect(200);
const initialData = await supabaseService.getRiceBySlug(slug);
expect(initialData.visits).toBe(0);
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())
.post('/rices')
.field('name', 'My first zenrice')
.field('name', 'Original Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
const { identifier, token } = createResponse.body;
const { slug, token } = createResponse.body;
const updateResponse = await request(app.getHttpServer())
.put(`/rices/${identifier}`)
.put(`/rices/${slug}`)
.set('x-rices-token', token)
.field('name', 'Mi rice renombrado')
.field('name', 'Updated Rice')
.attach('file', path.join(__dirname, 'files', 'example_update.zenrice'))
.expect(200);
expect(updateResponse.body).toHaveProperty(
'message',
`Rice ${identifier} updated`,
);
expect(updateResponse.body.message).toBe(`ok`);
const uploadedFileContent = await gitHubService.getFileContent(
`rices/${identifier}/data.zenrice`,
const updatedData = await supabaseService.getRiceBySlug(slug);
expect(updatedData.name).toBe('Updated Rice');
const updatedFile = await gitHubService.getFileContent(
`rices/${slug}/data.zenrice`,
);
expect(uploadedFileContent).not.toBeNull();
expect(uploadedFileContent).toContain(
expect(updatedFile).toContain(
'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())
.post('/rices')
.field('name', 'My first zenrice')
.field('name', 'Rice to Delete')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
const { identifier, token } = createResponse.body;
const { slug, token } = createResponse.body;
await request(app.getHttpServer())
.delete(`/rices/${identifier}`)
.delete(`/rices/${slug}`)
.set('x-rices-token', token)
.expect(204);
const riceJsonContent = await gitHubService.getFileContent(
`rices/${identifier}/rice.json`,
);
expect(riceJsonContent).toBeNull();
const riceInDatabase = await supabaseService.getRiceBySlug(slug);
expect(riceInDatabase).toBeNull();
const uploadedFileContent = await gitHubService.getFileContent(
`rices/${identifier}/data.zenrice`,
const fileInGitHub = await gitHubService.getFileContent(
`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())
.post('/rices')
.field('name', 'My first zenrice')
.field('name', 'Moderation Test Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
const { identifier, token } = createResponse.body;
const { slug } = 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}`)
.delete(`/rices/moderate/delete/${slug}`)
.set('x-moderation-secret', moderationSecret)
.expect(204);
const riceJsonContent = await gitHubService.getFileContent(
`rices/${identifier}/rice.json`,
);
expect(riceJsonContent).toBeNull();
const riceInDatabase = await supabaseService.getRiceBySlug(slug);
expect(riceInDatabase).toBeNull();
const uploadedFileContent = await gitHubService.getFileContent(
`rices/${identifier}/data.zenrice`,
const fileInGitHub = await gitHubService.getFileContent(
`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())
.delete(`/rices/moderate/delete/${uuidv4()}`)
.set('x-moderation-secret', 'claveIncorrecta')
.delete(`/rices/moderate/delete/${slug}`)
.set('x-moderation-secret', 'wrongSecret')
.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);
const riceInDatabase = await supabaseService.getRiceBySlug(slug);
expect(riceInDatabase).not.toBeNull();
});
});

View file

@ -1,18 +1,18 @@
@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
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--

View file

@ -1,5 +1,4 @@
@baseUrl = http://localhost:3000
@moderationSecret = superSecret123
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
@previous_slug = cool-zenrice-aurora-84154784-8dc4-4153-8bd5-fd9f55f03b93
GET {{baseUrl}}/rices/{{previous_identifier}}
GET {{baseUrl}}/rices/{{previous_slug}}

View file

@ -1,19 +1,20 @@
@baseUrl = http://localhost:3000
@moderationSecret = superSecret123
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
@previous_token = 806cd360-6c14-44de-92db-f46f328dab5a
@previous_slug = cool-zenrice-aurora2-1d1e74b3-8d6d-40ea-bd4f-a6f4ad893f88
@previous_token = be4545f4-d92b-416c-8b3b-50cc9a49dee9
PUT {{baseUrl}}/rices/{{previous_identifier}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
# base64 of following json: ewogICAgImtleSI6ICJuZXdfdmFsdWUiLAogICAgImRlc2NyaXB0aW9uIjogIkV4YW1wbGUgY29udGVudCIKfQ
# {
# "key": "new_value",
# "description": "Example content"
# }
PUT {{baseUrl}}/rices/{{previous_slug}}
Content-Type: application/json
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
"content": "ewogICAgImtleSI6ICJuZXdfdmFsdWUiLAogICAgImRlc2NyaXB0aW9uIjogIkV4YW1wbGUgY29udGVudCIKfQ"
}
This is an example zenrice file (modified).
------WebKitFormBoundary7MA4YWxkTrZu0gW--

View file

@ -1,7 +1,6 @@
@baseUrl = http://localhost:3000
@moderationSecret = superSecret123
@previous_identifier = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
@previous_slug = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
@previous_token = 6181664b-00e8-4eef-8e23-1f7fa0c64021
DELETE {{baseUrl}}/rices/{{previous_identifier}}
DELETE {{baseUrl}}/rices/{{previous_slug}}
x-rices-token: {{previous_token}}

View file

@ -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}}