From 6e901f0d5257dcb86760ca75120063546bf4cba9 Mon Sep 17 00:00:00 2001 From: selimyanat Date: Sat, 8 Mar 2025 18:19:02 +0100 Subject: [PATCH 1/3] Use an in-memory repository to save shortened url --- api/package.json | 2 + api/src/app.module.ts | 3 +- .../infrastructure/infrastructure.module.ts | 10 +++++ .../repository/in-memory-url.repository.ts | 17 +++++++ api/src/main.ts | 36 ++++++++++++++- api/src/shorten-url/create-shorten-url.dto.ts | 6 +++ .../shorten-url.controller.spec.ts | 15 +++---- api/src/shorten-url/shorten-url.controller.ts | 13 ++++-- api/src/shorten-url/shorten-url.module.ts | 10 ++++- api/src/shorten-url/shorten-url.repository.ts | 5 +++ api/src/shorten-url/shorten-url.usecase.ts | 45 ++++++++++++++++--- api/yarn.lock | 29 ++++++++++++ webapp/app/api/url-shortener/route.ts | 36 +++++++++------ .../services/url-shorten-service.ts | 4 +- 14 files changed, 192 insertions(+), 39 deletions(-) create mode 100644 api/src/infrastructure/infrastructure.module.ts create mode 100644 api/src/infrastructure/repository/in-memory-url.repository.ts create mode 100644 api/src/shorten-url/create-shorten-url.dto.ts create mode 100644 api/src/shorten-url/shorten-url.repository.ts diff --git a/api/package.json b/api/package.json index bd05534..981a6ec 100644 --- a/api/package.json +++ b/api/package.json @@ -24,6 +24,8 @@ "@nestjs/config": "4.0.0", "@nestjs/core": "10.0.0", "@nestjs/platform-express": "10.0.0", + "class-transformer": "0.5.1", + "class-validator": "0.14.1", "reflect-metadata": "0.2.0", "rxjs": "7.8.1" }, diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 3d05aff..684dfb9 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ShortenUrlModule } from './shorten-url/shorten-url.module'; +import { InfrastructureModule } from './infrastructure/infrastructure.module'; @Module({ - imports: [ConfigModule.forRoot(), ShortenUrlModule], // Load environment variables + imports: [ConfigModule.forRoot(), InfrastructureModule, ShortenUrlModule], // Load environment variables controllers: [], providers: [], }) diff --git a/api/src/infrastructure/infrastructure.module.ts b/api/src/infrastructure/infrastructure.module.ts new file mode 100644 index 0000000..f9e7cc5 --- /dev/null +++ b/api/src/infrastructure/infrastructure.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { InMemoryUrlRepository } from '../infrastructure/repository/in-memory-url.repository'; + +@Module({ + imports: [ConfigModule.forRoot()], // Load environment variables + controllers: [], + providers: [InMemoryUrlRepository], +}) +export class InfrastructureModule {} diff --git a/api/src/infrastructure/repository/in-memory-url.repository.ts b/api/src/infrastructure/repository/in-memory-url.repository.ts new file mode 100644 index 0000000..78b5ce3 --- /dev/null +++ b/api/src/infrastructure/repository/in-memory-url.repository.ts @@ -0,0 +1,17 @@ +import { ShortenUrlRepository } from '../../shorten-url/shorten-url.repository'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class InMemoryUrlRepository implements ShortenUrlRepository { + private urls: Map = new Map(); + + create(url: string, shortenedUrl: string): Promise { + this.urls.set(url, shortenedUrl); + return Promise.resolve(undefined); + } + + findURL(url: string): Promise { + const shortenedUrl = this.urls.get(url); + return Promise.resolve(shortenedUrl); + } +} diff --git a/api/src/main.ts b/api/src/main.ts index 7bde003..df50d33 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,8 +1,42 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { + BadRequestException, + Logger, + ValidationError, + ValidationPipe, +} from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3001); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + exceptionFactory: (validationErrors: ValidationError[] = []) => { + Logger.log('error', validationErrors); + const formatError = (error: ValidationError) => { + if (error.children?.length) { + return { + field: error.property, + errors: error.children.map(formatError), + }; + } + return { + field: error.property, + errors: Object.values(error.constraints ?? {}), + }; + }; + + return new BadRequestException( + validationErrors.map((error) => formatError(error)), + ); + }, + }), + ); + + const port = process.env.PORT || 3000; + await app.listen(port).then(() => Logger.log(`Server started on ${port}`)); } + bootstrap(); diff --git a/api/src/shorten-url/create-shorten-url.dto.ts b/api/src/shorten-url/create-shorten-url.dto.ts new file mode 100644 index 0000000..f241e8c --- /dev/null +++ b/api/src/shorten-url/create-shorten-url.dto.ts @@ -0,0 +1,6 @@ +import { IsString, IsUrl } from 'class-validator'; + +export class CreateShortenUrlDto { + @IsUrl() + url: string; +} diff --git a/api/src/shorten-url/shorten-url.controller.spec.ts b/api/src/shorten-url/shorten-url.controller.spec.ts index f8f851d..f9b67ff 100644 --- a/api/src/shorten-url/shorten-url.controller.spec.ts +++ b/api/src/shorten-url/shorten-url.controller.spec.ts @@ -1,17 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ShortenUrlController } from './shorten-url.controller'; -import { ShortenUrlUsecase } from './shorten-url.usecase'; -import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service'; import { ConfigModule } from '@nestjs/config'; +import { ShortenUrlModule } from './shorten-url.module'; describe('ShortenUrl controller', () => { let underTest: ShortenUrlController; beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ - imports: [await ConfigModule.forRoot()], - controllers: [ShortenUrlController], - providers: [ShortenUrlIdGeneratorService, ShortenUrlUsecase], + imports: [await ConfigModule.forRoot(), ShortenUrlModule], }).compile(); underTest = app.get(ShortenUrlController); @@ -21,13 +18,13 @@ describe('ShortenUrl controller', () => { it('should return a valid Base62 short URL with 10 chars length', async () => { const longUrl = 'https://zapper.xyz/very-long-url/very-long-url/very-long-url'; - const shortenedUrl = await underTest.shortenUrl(longUrl); // Assuming it's async + const response = await underTest.shortenUrl({ url: longUrl }); // Extract the unique ID part of the URL - const urlPattern = /^https:\/\/zapper\.xyz\/([A-Za-z0-9]{10})$/; - const match = shortenedUrl.match(urlPattern); + const urlPattern = /^http:\/\/localhost:3000\/([A-Za-z0-9]{10})$/; + const match = response?.shortenedUrl.match(urlPattern); - expect(shortenedUrl).not.toBeNull(); + expect(response).not.toBeNull(); expect(match).not.toBeNull(); // Ensure it matches the pattern expect(match![1].length).toBe(10); // Validate the ID part has 10 characters }); diff --git a/api/src/shorten-url/shorten-url.controller.ts b/api/src/shorten-url/shorten-url.controller.ts index d0dbe8a..1b557ae 100644 --- a/api/src/shorten-url/shorten-url.controller.ts +++ b/api/src/shorten-url/shorten-url.controller.ts @@ -1,12 +1,19 @@ -import { Controller, Post } from '@nestjs/common'; +import { Body, Controller, Logger, Post } from '@nestjs/common'; import { ShortenUrlUsecase } from './shorten-url.usecase'; +import { CreateShortenUrlDto } from './create-shorten-url.dto'; @Controller('/shorten-url') export class ShortenUrlController { constructor(private readonly shortenUrlUsecase: ShortenUrlUsecase) {} @Post() - shortenUrl(url: string): string { - return this.shortenUrlUsecase.createTinyURL('https://zapper.xyz'); + async shortenUrl( + @Body() request: CreateShortenUrlDto, + ): Promise<{ shortenedUrl: string }> { + Logger.log(`Received url ${request.url} to shorten`); + const shortenedUrl = await this.shortenUrlUsecase.createTinyURL( + request.url, + ); + return { shortenedUrl }; } } diff --git a/api/src/shorten-url/shorten-url.module.ts b/api/src/shorten-url/shorten-url.module.ts index c72c267..4d1c2d7 100644 --- a/api/src/shorten-url/shorten-url.module.ts +++ b/api/src/shorten-url/shorten-url.module.ts @@ -3,10 +3,16 @@ import { ConfigModule } from '@nestjs/config'; import { ShortenUrlController } from './shorten-url.controller'; import { ShortenUrlUsecase } from './shorten-url.usecase'; import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service'; +import { InMemoryUrlRepository } from '../infrastructure/repository/in-memory-url.repository'; +import { InfrastructureModule } from '../infrastructure/infrastructure.module'; @Module({ - imports: [ConfigModule.forRoot()], // Load environment variables + imports: [ConfigModule.forRoot(), InfrastructureModule], // Load environment variables controllers: [ShortenUrlController], - providers: [ShortenUrlIdGeneratorService, ShortenUrlUsecase], + providers: [ + { provide: 'ShortenUrlRepository', useClass: InMemoryUrlRepository }, + ShortenUrlIdGeneratorService, + ShortenUrlUsecase, + ], }) export class ShortenUrlModule {} diff --git a/api/src/shorten-url/shorten-url.repository.ts b/api/src/shorten-url/shorten-url.repository.ts new file mode 100644 index 0000000..3c5814e --- /dev/null +++ b/api/src/shorten-url/shorten-url.repository.ts @@ -0,0 +1,5 @@ +export interface ShortenUrlRepository { + findURL(url: string): Promise; + + create(url: string, shortenedUrl: string): Promise; +} diff --git a/api/src/shorten-url/shorten-url.usecase.ts b/api/src/shorten-url/shorten-url.usecase.ts index 8609c8e..cf82680 100644 --- a/api/src/shorten-url/shorten-url.usecase.ts +++ b/api/src/shorten-url/shorten-url.usecase.ts @@ -1,5 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service'; +import { ShortenUrlRepository } from './shorten-url.repository'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class ShortenUrlUsecase { @@ -8,15 +10,35 @@ export class ShortenUrlUsecase { private static BASE62 = ShortenUrlUsecase.BASE62_CHARACTERS.length; - constructor(private readonly idGenerator: ShortenUrlIdGeneratorService) {} + private readonly shortenedBaseUrl: string; - createTinyURL(originalURL: string): string { + constructor( + private readonly configService: ConfigService, + private readonly idGenerator: ShortenUrlIdGeneratorService, + @Inject('ShortenUrlRepository') + private readonly shortenUrlRepository: ShortenUrlRepository, + ) { + if (!this.configService.get('SHORTENED_BASE_URL')) + throw new Error( + 'SHORTENED_BASE_URL is not defined in the environment variables', + ); + this.shortenedBaseUrl = + this.configService.get('SHORTENED_BASE_URL'); + } + + async createTinyURL(originalURL: string): Promise { + const existingShortenedUrl = await this.shortenUrlRepository.findURL( + originalURL, + ); + + if (existingShortenedUrl) { + return existingShortenedUrl; + } const id = this.idGenerator.generateId(); - // encode the id to base 62 - // return the original URL with the id appended - // write your code here const encodedId = this.encodeBase62(Number(id)); - return originalURL + '/' + encodedId; + const shortenedUrl = this.shortenedBaseUrl + '/' + encodedId; + await this.shortenUrlRepository.create(originalURL, shortenedUrl); + return shortenedUrl; } private encodeBase62(id: number): string { @@ -30,4 +52,13 @@ export class ShortenUrlUsecase { } return encoded; } + + private getBaseUrl(url: string): string { + try { + const parsedUrl = new URL(url); + return `${parsedUrl.protocol}//${parsedUrl.hostname}`; + } catch (error) { + throw new Error(`Invalid URL: ${url}`); + } + } } diff --git a/api/yarn.lock b/api/yarn.lock index 1af3581..8ff1f00 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -1048,6 +1048,11 @@ "@types/methods" "*" "@types/superagent" "*" +"@types/validator@^13.11.8": + version "13.12.2" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" + integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -1733,6 +1738,20 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz#707413784dbb3a72aa11c2f2b042a0bef4004170" integrity sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA== +class-transformer@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" + integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== + +class-validator@0.14.1: + version "0.14.1" + resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.1.tgz#ff2411ed8134e9d76acfeb14872884448be98110" + integrity sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ== + dependencies: + "@types/validator" "^13.11.8" + libphonenumber-js "^1.10.53" + validator "^13.9.0" + cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -3481,6 +3500,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libphonenumber-js@^1.10.53: + version "1.12.5" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.5.tgz#8e6043a67112d4beedb8627b359a613f04d88fba" + integrity sha512-DOjiaVjjSmap12ztyb4QgoFmUe/GbgnEXHu+R7iowk0lzDIjScvPAm8cK9RYTEobbRb0OPlwlZUGTTJPJg13Kw== + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -4814,6 +4838,11 @@ v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^2.0.0" +validator@^13.9.0: + version "13.12.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f" + integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" diff --git a/webapp/app/api/url-shortener/route.ts b/webapp/app/api/url-shortener/route.ts index 952cdf4..3d35aba 100644 --- a/webapp/app/api/url-shortener/route.ts +++ b/webapp/app/api/url-shortener/route.ts @@ -4,35 +4,43 @@ import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { try { const { url } = await request.json(); + + console.log('Sending request body:', request.body); // ✅ Debugging + + if (!url || typeof url !== 'string') { + throw new Error(`Invalid URL format ${url}`); + } + const shortenedUrl = await shortenUrl(url); return NextResponse.json({ shortenedUrl }, { status: 200 }); } catch (error) { - // Handle errors and return a 400 status with an error message console.error('Failed to shorten URL:', error); - return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Invalid request' }, + { status: 400 } + ); } } -// Example function to simulate URL shortening logic +// Function to call the backend API async function shortenUrl(url: string): Promise { try { - const response = await fetch('http://localhost:3001/shorten-url', { - method: 'POST', // Use POST method - headers: { - 'Content-Type': 'application/json', // Inform the server we are sending JSON - }, - body: JSON.stringify({ url }), // Send URL in request body + const requestBody = { url }; + const response = await fetch('http://localhost:3000/shorten-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), }); - console.log(response); - if (!response.ok) { - throw new Error(`Failed to shorten URL: ${response.statusText}`); + const errorResponse = await response.json(); + throw new Error(errorResponse.error || 'Failed to shorten URL'); } - const data = await response.text(); // Assume server returns JSON + const data = await response.json(); // Expected { shortenedUrl: "https://short.ly/abc123" } console.log('URL shortened successfully:', data); - return data; // Adjust this based on the API response structure + + return data; } catch (error) { console.error('Error shortening URL:', error); throw error; diff --git a/webapp/components/url-shortener/services/url-shorten-service.ts b/webapp/components/url-shortener/services/url-shorten-service.ts index 4e013dc..895c48f 100644 --- a/webapp/components/url-shortener/services/url-shorten-service.ts +++ b/webapp/components/url-shortener/services/url-shorten-service.ts @@ -4,7 +4,7 @@ export const shortenURL = async (longUrl: string): Promise => { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ longUrl }), + body: JSON.stringify({ url: longUrl }), }); if (!response.ok) { @@ -13,5 +13,5 @@ export const shortenURL = async (longUrl: string): Promise => { } const data = await response.json(); - return data.shortenedUrl; + return data.shortenedUrl.shortenedUrl; }; From faf5ba8e75d3b470f5ff8acbed9447bacd102dc8 Mon Sep 17 00:00:00 2001 From: selimyanat Date: Sun, 9 Mar 2025 00:00:30 +0100 Subject: [PATCH 2/3] Use an in-memory repository to save shortened url --- webapp/app/api/url-shortener/route.ts | 7 +------ webapp/components/url-shortener/hooks/useURLShortener.ts | 1 - .../url-shortener/services/url-shorten-service.ts | 2 +- webapp/components/url-shortener/ui/URLShortener.test.jsx | 5 ++--- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/webapp/app/api/url-shortener/route.ts b/webapp/app/api/url-shortener/route.ts index 3d35aba..b419887 100644 --- a/webapp/app/api/url-shortener/route.ts +++ b/webapp/app/api/url-shortener/route.ts @@ -4,9 +4,6 @@ import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { try { const { url } = await request.json(); - - console.log('Sending request body:', request.body); // ✅ Debugging - if (!url || typeof url !== 'string') { throw new Error(`Invalid URL format ${url}`); } @@ -37,9 +34,7 @@ async function shortenUrl(url: string): Promise { throw new Error(errorResponse.error || 'Failed to shorten URL'); } - const data = await response.json(); // Expected { shortenedUrl: "https://short.ly/abc123" } - console.log('URL shortened successfully:', data); - + const data = await response.json(); return data; } catch (error) { console.error('Error shortening URL:', error); diff --git a/webapp/components/url-shortener/hooks/useURLShortener.ts b/webapp/components/url-shortener/hooks/useURLShortener.ts index 402e98e..15749cb 100644 --- a/webapp/components/url-shortener/hooks/useURLShortener.ts +++ b/webapp/components/url-shortener/hooks/useURLShortener.ts @@ -1,6 +1,5 @@ import { useState } from 'react'; import { shortenURL } from '../services/url-shorten-service'; -import * as console from 'node:console'; export const useURLShortener = () => { const shorten = async () => { diff --git a/webapp/components/url-shortener/services/url-shorten-service.ts b/webapp/components/url-shortener/services/url-shorten-service.ts index 895c48f..a7b7a83 100644 --- a/webapp/components/url-shortener/services/url-shorten-service.ts +++ b/webapp/components/url-shortener/services/url-shorten-service.ts @@ -13,5 +13,5 @@ export const shortenURL = async (longUrl: string): Promise => { } const data = await response.json(); - return data.shortenedUrl.shortenedUrl; + return data.shortenedUrl; }; diff --git a/webapp/components/url-shortener/ui/URLShortener.test.jsx b/webapp/components/url-shortener/ui/URLShortener.test.jsx index 56089f5..4d1fc85 100644 --- a/webapp/components/url-shortener/ui/URLShortener.test.jsx +++ b/webapp/components/url-shortener/ui/URLShortener.test.jsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect } from '@jest/globals'; import { URLShortener } from './URLShortener'; -import React, { act } from 'react'; +import React from 'react'; global.fetch = jest.fn(); @@ -26,7 +26,7 @@ describe('URLShortenerV2', () => { const shortenedUrl = 'https://short.ly/abc123'; global.fetch = jest.fn().mockResolvedValue({ ok: true, - json: async () => ({ shortenedUrl }), // API response in JSON + json: async () => ({ shortenedUrl: shortenedUrl }), // API response in JSON }); render(); @@ -51,7 +51,6 @@ describe('URLShortenerV2', () => { const shortenedUrlTextBox = screen.getByRole('textbox', { name: /shortened url/i, }); - //expect(shortenedUrlTextBox).toHaveValue('should-be-a-shortened-url'); expect(shortenedUrlTextBox).toHaveValue(shortenedUrl); expect( screen.queryByRole('button', { name: /shorten url/i }) From add5f709e47ebc09de7d3c13acf88bb03bf39c01 Mon Sep 17 00:00:00 2001 From: selimyanat Date: Sun, 9 Mar 2025 00:20:13 +0100 Subject: [PATCH 3/3] Use an in-memory repository to save shortened url --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf29d7b..e7aa89b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: - name: Create .env file run: | echo "MACHINE_ID=1" > .env + echo "PORT=3000" >> .env + echo "SHORTENED_BASE_URL=http://localhost:3000" >> .env working-directory: api # Ensure it is created inside `api/` - name: Run Tests (API)