From e64001d00604bcf587ec5e9a631282f212df450d Mon Sep 17 00:00:00 2001
From: Sebastian Janik <sebastian.janik@deravesoftware.com>
Date: Wed, 22 Jun 2022 10:48:58 +0000
Subject: [PATCH] feat(api): add rest api with podcasts read endpoints

relates to #210
---
 .env.example                                  |   5 +
 .gitlab-ci.yml                                |  12 +
 app/Config/Autoload.php                       |   1 +
 app/Config/Filters.php                        |   2 +
 .../Seeds/FakeSinglePodcastApiSeeder.php      | 141 ++++++++
 modules/Api/Rest/V1/Config/Api.php            |  19 +
 modules/Api/Rest/V1/Config/Routes.php         |  21 ++
 modules/Api/Rest/V1/Config/Services.php       |  20 ++
 .../V1/Controllers/ExceptionController.php    |  19 +
 .../Rest/V1/Controllers/PodcastController.php |  42 +++
 modules/Api/Rest/V1/Core/Exceptions.php       |  32 ++
 modules/Api/Rest/V1/Filters/ApiFilter.php     |  25 ++
 modules/Api/Rest/V1/podcast.json              | 328 ++++++++++++++++++
 phpunit.xml.dist                              |   9 +-
 tests/modules/Api/Rest/V1/PodcastTest.php     | 109 ++++++
 15 files changed, 780 insertions(+), 5 deletions(-)
 create mode 100644 app/Database/Seeds/FakeSinglePodcastApiSeeder.php
 create mode 100644 modules/Api/Rest/V1/Config/Api.php
 create mode 100644 modules/Api/Rest/V1/Config/Routes.php
 create mode 100644 modules/Api/Rest/V1/Config/Services.php
 create mode 100644 modules/Api/Rest/V1/Controllers/ExceptionController.php
 create mode 100644 modules/Api/Rest/V1/Controllers/PodcastController.php
 create mode 100644 modules/Api/Rest/V1/Core/Exceptions.php
 create mode 100644 modules/Api/Rest/V1/Filters/ApiFilter.php
 create mode 100644 modules/Api/Rest/V1/podcast.json
 create mode 100644 tests/modules/Api/Rest/V1/PodcastTest.php

diff --git a/.env.example b/.env.example
index 578e0e0cb3..2844aa83b1 100644
--- a/.env.example
+++ b/.env.example
@@ -41,3 +41,8 @@ cache.handler="file"
 # cache.redis.password=null
 # cache.redis.port=6379
 # cache.redis.database=0
+
+#REST API configuration
+#--------------------------------------------------------------------
+# 0/1 Disabled/Enabled
+REST_API_ENABLED=1
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b344654b40..578045a96d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -65,7 +65,19 @@ lint-js:
 
 tests:
   stage: quality
+  services:
+    - mariadb
+  variables:
+    MYSQL_DATABASE: "tests"
+    MYSQL_ROOT_PASSWORD: "R00Tp4ssW0RD"
+    MYSQL_USER: "tests_user"
+    MYSQL_PASSWORD: "password"
+
   script:
+    - apt-get install -y mariadb-client  libmariadb-dev
+
+    - echo "SHOW DATABASES;" | mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host=mariadb "$MYSQL_DATABASE"
+
     # run phpunit without code coverage
     # TODO: add code coverage
     - vendor/bin/phpunit --no-coverage
diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php
index f68f50debd..8c5f6f0138 100644
--- a/app/Config/Autoload.php
+++ b/app/Config/Autoload.php
@@ -50,6 +50,7 @@ class Autoload extends AutoloadConfig
         'Modules\Install' => ROOTPATH . 'modules/Install/',
         'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/',
         'Modules\WebSub' => ROOTPATH . 'modules/WebSub/',
+        'Modules\Api\Rest\V1' => ROOTPATH . 'modules/Api/Rest/V1',
         'Config' => APPPATH . 'Config/',
         'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
         'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',
diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index f7088509bd..8d893b746d 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -10,6 +10,7 @@ use CodeIgniter\Filters\DebugToolbar;
 use CodeIgniter\Filters\Honeypot;
 use CodeIgniter\Filters\InvalidChars;
 use CodeIgniter\Filters\SecureHeaders;
+use Modules\Api\Rest\V1\Filters\ApiFilter;
 use Modules\Auth\Filters\PermissionFilter;
 use Modules\Fediverse\Filters\AllowCorsFilter;
 use Modules\Fediverse\Filters\FediverseFilter;
@@ -34,6 +35,7 @@ class Filters extends BaseConfig
         'permission' => PermissionFilter::class,
         'fediverse' => FediverseFilter::class,
         'allow-cors' => AllowCorsFilter::class,
+        'rest-api' => ApiFilter::class,
     ];
 
     /**
diff --git a/app/Database/Seeds/FakeSinglePodcastApiSeeder.php b/app/Database/Seeds/FakeSinglePodcastApiSeeder.php
new file mode 100644
index 0000000000..b2bd6a3d36
--- /dev/null
+++ b/app/Database/Seeds/FakeSinglePodcastApiSeeder.php
@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Database\Seeds;
+
+use CodeIgniter\Database\Seeder;
+
+class FakeSinglePodcastApiSeeder extends Seeder
+{
+    /**
+     * @return array<mixed>
+     */
+    public static function cover(): array
+    {
+        return [
+            'id' => 1,
+            'file_path' => 'podcasts/Handle/cover.jpg',
+            'file_size' => 400000,
+            'file_mimetype' => 'image/jpeg',
+            'file_metadata' => '{"FILE":{"FileName":"cover.jpg","FileDateTime":1654861723,"FileSize":468541,"FileType":2,"MimeType":"image\/jpeg","SectionsFound":"COMMENT"},"COMPUTED":{"html":"width=\"1400\" height=\"1400\"","Height":1400,"Width":1400,"IsColor":1},"COMMENT":["CREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 90\n"],"sizes":{"tiny":{"width":40,"height":40,"mimetype":"image\/webp","extension":"webp"},"thumbnail":{"width":150,"height":150,"mimetype":"image\/webp","extension":"webp"},"medium":{"width":320,"height":320,"mimetype":"image\/webp","extension":"webp"},"large":{"width":1024,"height":1024,"mimetype":"image\/webp","extension":"webp"},"feed":{"width":1400,"height":1400},"id3":{"width":500,"height":500},"og":{"width":1200,"height":1200},"federation":{"width":400,"height":400},"webmanifest192":{"width":192,"height":192,"mimetype":"image\/png","extension":"png"},"webmanifest512":{"width":512,"height":512,"mimetype":"image\/png","extension":"png"}}}',
+            'type' => 'image',
+            'description' => null,
+            'language_code' => null,
+            'uploaded_by' => 1,
+            'updated_by' => 1,
+            'uploaded_at' => '2022-06-13 8:00:00',
+            'updated_at' => '2022-06-13 8:00:00',
+        ];
+    }
+
+    /**
+     * @return array<mixed>
+     */
+    public static function banner(): array
+    {
+        return [
+            'id' => 2,
+            'file_path' => 'podcasts/Handle/banner.jpg',
+            'file_size' => 400000,
+            'file_mimetype' => 'image/jpeg',
+            'file_metadata' => '{"FILE":{"FileName":"banner.jpg","FileDateTime":1654861724,"FileSize":98209,"FileType":2,"MimeType":"image\/jpeg","SectionsFound":""},"COMPUTED":{"html":"width=\"1500\" height=\"500\"","Height":500,"Width":1500,"IsColor":1},"sizes":{"small":{"width":320,"height":128,"mimetype":"image\/webp","extension":"webp"},"medium":{"width":960,"height":320,"mimetype":"image\/webp","extension":"webp"},"federation":{"width":1500,"height":500}}}',
+            'type' => 'image',
+            'description' => null,
+            'language_code' => null,
+            'uploaded_by' => 1,
+            'updated_by' => 1,
+            'uploaded_at' => '2022-06-13 8:00:00',
+            'updated_at' => '2022-06-13 8:00:00',
+        ];
+    }
+
+    /**
+     * @return array<mixed>
+     */
+    public static function actor(): array
+    {
+        return [
+            'id' => 1,
+            'uri' => getenv('app_baseURL') . '@Handle',
+            'username' => 'Handle',
+            'domain' => getenv('app_baseURL'),
+            'private_key' => 'private_key',
+            'public_key' => 'public_key',
+            'display_name' => 'Title',
+            'summary' => '<p>description</p>',
+            'avatar_image_url' => getenv('app_baseURL') . 'media/podcasts/Handle',
+            'avatar_image_mimetype' => 'image/webp',
+            'cover_image_url' => null,
+            'cover_image_mimetype' => null,
+            'inbox_url' => getenv('app_baseURL') . '@Handle/inbox',
+            'outbox_url' => getenv('app_baseURL') . '@Handle/outbox',
+            'followers_url' => getenv('app_baseURL') . '@Handle/followers',
+            'followers_count' => 0,
+            'posts_count' => 0,
+            'is_blocked' => 0,
+            'created_at' => '2022-06-13 8:00:00',
+            'updated_at' => '2022-06-13 8:00:00',
+        ];
+    }
+
+    /**
+     * @return array<mixed>
+     */
+    public static function podcast(): array
+    {
+        return [
+            'id' => 1,
+            'guid' => '0d341200-0234-5de7-99a6-a7d02bea4ce2',
+            'actor_id' => 1,
+            'handle' => 'Handle',
+            'title' => 'Title',
+            'description_markdown' => 'description',
+            'description_html' => '<p>description</p>',
+            'cover_id' => 1,
+            'banner_id' => 2,
+            'language_code' => 'en',
+            'category_id' => 1,
+            'parental_advisory' => null,
+            'owner_name' => 'Owner',
+            'owner_email' => 'Owner@gmail.com',
+            'publisher' => '',
+            'type' => 'episodic',
+            'copyright' => '',
+            'episode_description_footer_markdown' => null,
+            'episode_description_footer_html' => null,
+            'is_blocked' => 0,
+            'is_completed' => 0,
+            'is_locked' => 1,
+            'imported_feed_url' => null,
+            'new_feed_url' => null,
+            'payment_pointer' => null,
+            'location_name' => null,
+            'location_geo' => null,
+            'location_osm' => null,
+            'custom_rss' => null,
+            'is_published_on_hubs' => 0,
+            'partner_id' => null,
+            'partner_link_url' => null,
+            'partner_image_url' => null,
+            'created_by' => 1,
+            'updated_by' => 1,
+            'created_at' => '2022-06-13 8:00:00',
+            'updated_at' => '2022-06-13 8:00:00',
+        ];
+    }
+
+    public function run(): void
+    {
+        $this->call(AppSeeder::class);
+        $this->call(TestSeeder::class);
+        $this->db->table('media')
+            ->insert(self::cover());
+        $this->db->table('media')
+            ->insert(self::banner());
+        $this->db->table('fediverse_actors')
+            ->insert(self::actor());
+        $this->db->table('podcasts')
+            ->insert(self::podcast());
+    }
+}
diff --git a/modules/Api/Rest/V1/Config/Api.php b/modules/Api/Rest/V1/Config/Api.php
new file mode 100644
index 0000000000..849732e05c
--- /dev/null
+++ b/modules/Api/Rest/V1/Config/Api.php
@@ -0,0 +1,19 @@
+<?php
+
+
+declare(strict_types=1);
+
+namespace Modules\Api\Rest\V1\Config;
+
+use CodeIgniter\Config\BaseConfig;
+
+class Api extends BaseConfig
+{
+    /**
+     * --------------------------------------------------------------------------
+     * Rest API gateway
+     * --------------------------------------------------------------------------
+     * Defines a base route for all API pages
+     */
+    public string $gateway = 'api/rest/v1/';
+}
diff --git a/modules/Api/Rest/V1/Config/Routes.php b/modules/Api/Rest/V1/Config/Routes.php
new file mode 100644
index 0000000000..f40467e03f
--- /dev/null
+++ b/modules/Api/Rest/V1/Config/Routes.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Api\Rest\V1\Config;
+
+$routes = service('routes');
+
+$routes->group(
+    config('Api')
+        ->gateway . 'podcasts',
+    [
+        'namespace' => 'Modules\Api\Rest\V1\Controllers',
+        'filter' => 'rest-api',
+    ],
+    function ($routes): void {
+        $routes->get('/', 'PodcastController::list');
+        $routes->get('(:num)', 'PodcastController::view/$1');
+        $routes->get('(:any)', 'ExceptionController::notFound');
+    }
+);
diff --git a/modules/Api/Rest/V1/Config/Services.php b/modules/Api/Rest/V1/Config/Services.php
new file mode 100644
index 0000000000..c4a3d72c67
--- /dev/null
+++ b/modules/Api/Rest/V1/Config/Services.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Api\Rest\V1\Config;
+
+use CodeIgniter\Config\BaseService;
+use Modules\Api\Rest\V1\Core\Exceptions;
+
+class Services extends BaseService
+{
+    public static function restApiExceptions(bool $getShared = true)
+    {
+        if ($getShared) {
+            return static::getSharedInstance('restApiExceptions');
+        }
+
+        return new Exceptions(config('Exceptions'), static::request(), static::response());
+    }
+}
diff --git a/modules/Api/Rest/V1/Controllers/ExceptionController.php b/modules/Api/Rest/V1/Controllers/ExceptionController.php
new file mode 100644
index 0000000000..50dd786333
--- /dev/null
+++ b/modules/Api/Rest/V1/Controllers/ExceptionController.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Api\Rest\V1\Controllers;
+
+use CodeIgniter\API\ResponseTrait;
+use CodeIgniter\Controller;
+use CodeIgniter\HTTP\Response;
+
+class ExceptionController extends Controller
+{
+    use ResponseTrait;
+
+    public function notFound(): Response
+    {
+        return $this->failNotFound('Podcast not found');
+    }
+}
diff --git a/modules/Api/Rest/V1/Controllers/PodcastController.php b/modules/Api/Rest/V1/Controllers/PodcastController.php
new file mode 100644
index 0000000000..abfab63898
--- /dev/null
+++ b/modules/Api/Rest/V1/Controllers/PodcastController.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Api\Rest\V1\Controllers;
+
+use App\Entities\Podcast;
+use App\Models\PodcastModel;
+use CodeIgniter\API\ResponseTrait;
+use CodeIgniter\Controller;
+use CodeIgniter\HTTP\Response;
+use Modules\Api\Rest\V1\Config\Services;
+
+class PodcastController extends Controller
+{
+    use ResponseTrait;
+
+    public function __construct()
+    {
+        Services::restApiExceptions()->initialize();
+    }
+
+    public function list(): Response
+    {
+        $data = (new PodcastModel())->findAll();
+        array_map(function ($podcast): void {
+            $podcast->feed_url = $podcast->getFeedUrl();
+        }, $data);
+        return $this->respond($data);
+    }
+
+    public function view(int $id): Response
+    {
+        $data = (new PodcastModel())->getPodcastById($id);
+        if (! $data instanceof Podcast) {
+            return $this->failNotFound('Podcast not found');
+        }
+
+        $data->feed_url = $data->getFeedUrl();
+        return $this->respond($data);
+    }
+}
diff --git a/modules/Api/Rest/V1/Core/Exceptions.php b/modules/Api/Rest/V1/Core/Exceptions.php
new file mode 100644
index 0000000000..4dae50e580
--- /dev/null
+++ b/modules/Api/Rest/V1/Core/Exceptions.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Api\Rest\V1\Core;
+
+use Throwable;
+
+class Exceptions extends \CodeIgniter\Debug\Exceptions
+{
+    protected function render(Throwable $exception, int $statusCode): void
+    {
+        header('Content-Type: application/json');
+        $data = [
+            'status' => $statusCode,
+            'error' => $statusCode,
+            'messages' => [
+                'error' => 'Unexpected error',
+            ],
+        ];
+        if (ENVIRONMENT === 'development') {
+            $data['messages'] = array_merge($data['messages'], [
+                'message' => $exception->getMessage(),
+                'file' => $exception->getFile(),
+                'line' => $exception->getLine(),
+                'trace' => $exception->getTrace(),
+            ]);
+        }
+
+        echo json_encode($data);
+    }
+}
diff --git a/modules/Api/Rest/V1/Filters/ApiFilter.php b/modules/Api/Rest/V1/Filters/ApiFilter.php
new file mode 100644
index 0000000000..efd23f3533
--- /dev/null
+++ b/modules/Api/Rest/V1/Filters/ApiFilter.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Api\Rest\V1\Filters;
+
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\Filters\FilterInterface;
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+
+class ApiFilter implements FilterInterface
+{
+    public function before(RequestInterface $request, $arguments = null): void
+    {
+        if (! getenv('REST_API_ENABLED')) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+    }
+
+    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
+    {
+        // Do something here
+    }
+}
diff --git a/modules/Api/Rest/V1/podcast.json b/modules/Api/Rest/V1/podcast.json
new file mode 100644
index 0000000000..4fe37fdeac
--- /dev/null
+++ b/modules/Api/Rest/V1/podcast.json
@@ -0,0 +1,328 @@
+{
+  "openapi": "3.0.0",
+  "info": {
+    "version": "1.0.0",
+    "title": "Castopod podcasts"
+  },
+  "paths": {
+    "/api/rest/v1/podcasts": {
+      "get": {
+        "summary": "List all podcasts",
+        "responses": {
+          "200": {
+            "description": "Object of podcasts",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Podcasts"
+                }
+              }
+            }
+          },
+          "default": {
+            "description": "unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/api/rest/v1/podcasts/{id}": {
+      "parameters": [
+        {
+          "name": "id",
+          "in": "path",
+          "required": true,
+          "description": "The id of the podcast to retrieve",
+          "schema": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          }
+        }
+      ],
+      "get": {
+        "summary": "Info for a specific podcast",
+        "responses": {
+          "200": {
+            "description": "Expected response to a valid request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Podcast"
+                }
+              }
+            }
+          },
+          "default": {
+            "description": "unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "Podcast": {
+        "type": "object",
+        "required": [
+          "id",
+          "guid",
+          "actor_id",
+          "handle",
+          "title",
+          "description_markdown",
+          "description_html",
+          "cover_id",
+          "language_code",
+          "category_id",
+          "owner_name",
+          "owner_email",
+          "type",
+          "is_blocked",
+          "is_completed",
+          "is_locked",
+          "is_published_on_hubs",
+          "created_by",
+          "updated_by",
+          "created_at",
+          "updated_at",
+          "feed_url"
+        ],
+        "properties": {
+          "id": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          },
+          "guid": {
+            "type": "string",
+            "maxLength": 36
+          },
+          "actor_id": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          },
+          "handle": {
+            "type": "string",
+            "maxLength": 32
+          },
+          "title": {
+            "type": "string",
+            "maxLength": 128
+          },
+          "description_markdown": {
+            "type": "string"
+          },
+          "description_html": {
+            "type": "string"
+          },
+          "cover_id": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          },
+          "banner_id": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          },
+          "language_code": {
+            "type": "string",
+            "maxLength": 2
+          },
+          "category_id": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1
+          },
+          "parental_advisory": {
+            "type": "string",
+            "enum": ["clean", "explicit"]
+          },
+          "owner_name": {
+            "type": "string",
+            "maxLength": 128
+          },
+          "owner_email": {
+            "type": "string",
+            "maxLength": 255
+          },
+          "publisher": {
+            "type": "string",
+            "maxLength": 128
+          },
+          "type": {
+            "type": "string",
+            "enum": ["episodic", "serial"]
+          },
+          "copyright": {
+            "type": "string",
+            "maxLength": 128
+          },
+          "episode_description_footer_markdown": {
+            "type": "string"
+          },
+          "episode_description_footer_html": {
+            "type": "string"
+          },
+          "is_blocked": {
+            "type": "integer",
+            "format": "int32",
+            "enum": [0, 1],
+            "minLength": 1
+          },
+          "is_completed": {
+            "type": "integer",
+            "format": "int32",
+            "enum": [0, 1],
+            "minLength": 1
+          },
+          "is_locked": {
+            "type": "integer",
+            "format": "int32",
+            "enum": [0, 1],
+            "minLength": 1
+          },
+          "imported_feed_url": {
+            "type": "string",
+            "maxLength": 512
+          },
+          "new_feed_url": {
+            "type": "string",
+            "maxLength": 512
+          },
+          "payment_pointer": {
+            "type": "string",
+            "maxLength": 128
+          },
+          "location_name": {
+            "type": "string",
+            "maxLength": 128
+          },
+          "location_geo": {
+            "type": "string",
+            "maxLength": 32
+          },
+          "location_osm": {
+            "type": "string",
+            "maxLength": 12
+          },
+          "custom_rss": {
+            "type": "string"
+          },
+          "is_published_on_hubs": {
+            "type": "integer",
+            "format": "int32",
+            "enum": [0, 1],
+            "minLength": 1
+          },
+          "partner_id": {
+            "type": "string",
+            "maxLength": 32
+          },
+          "partner_link_url": {
+            "type": "string",
+            "maxLength": 512
+          },
+          "partner_image_url": {
+            "type": "string",
+            "maxLength": 512
+          },
+          "created_by": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          },
+          "updated_by": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          },
+          "created_at": {
+            "type": "object",
+            "properties": {
+              "date": {
+                "type": "string",
+                "format": "date-time"
+              },
+              "timezone_type": {
+                "type": "integer",
+                "format": "int32"
+              },
+              "timezone": {
+                "type": "string"
+              }
+            }
+          },
+          "updated_at": {
+            "type": "object",
+            "properties": {
+              "date": {
+                "type": "string",
+                "format": "date-time"
+              },
+              "timezone_type": {
+                "type": "integer",
+                "format": "int32"
+              },
+              "timezone": {
+                "type": "string"
+              }
+            }
+          },
+          "feed_url": {
+            "type": "string"
+          }
+        }
+      },
+      "Podcasts": {
+        "type": "array",
+        "items": {
+          "$ref": "#/components/schemas/Podcast"
+        }
+      },
+      "Error": {
+        "type": "object",
+        "properties": {
+          "status": {
+            "type": "integer",
+            "format": "int32"
+          },
+          "error": {
+            "type": "integer",
+            "format": "int32"
+          },
+          "messages": {
+            "type": "object",
+            "properties": {
+              "error": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index c05e50146f..c6b5aa99f2 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -45,13 +45,12 @@
 		<!-- Directory containing the front controller (index.php) -->
 		<const name="PUBLICPATH" value="./public/"/>
 		<!-- Database configuration -->
-		<!-- Uncomment to provide your own database for testing
-		<env name="database.tests.hostname" value="localhost"/>
+		<env name="database.tests.hostname" value="mariadb"/>
 		<env name="database.tests.database" value="tests"/>
 		<env name="database.tests.username" value="tests_user"/>
-		<env name="database.tests.password" value=""/>
+		<env name="database.tests.password" value="password"/>
 		<env name="database.tests.DBDriver" value="MySQLi"/>
 		<env name="database.tests.DBPrefix" value="tests_"/>
-		-->
+		<env name="REST_API_ENABLED" value="1"/>
 	</php>
-</phpunit>
\ No newline at end of file
+</phpunit>
diff --git a/tests/modules/Api/Rest/V1/PodcastTest.php b/tests/modules/Api/Rest/V1/PodcastTest.php
new file mode 100644
index 0000000000..56a2f9c4ff
--- /dev/null
+++ b/tests/modules/Api/Rest/V1/PodcastTest.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace modules\Api\Rest\V1;
+
+use App\Database\Seeds\FakeSinglePodcastApiSeeder;
+use CodeIgniter\Test\CIUnitTestCase;
+use CodeIgniter\Test\DatabaseTestTrait;
+use CodeIgniter\Test\FeatureTestTrait;
+
+class PodcastTest extends CIUnitTestCase
+{
+    use FeatureTestTrait;
+    use DatabaseTestTrait;
+
+    /**
+     * @var bool
+     */
+    protected $migrate = true;
+
+    /**
+     * @var bool
+     */
+    protected $migrateOnce = false;
+
+    /**
+     * @var string|null
+     */
+    protected $namespace;
+
+    /**
+     * @var string
+     */
+    protected $seed = 'FakeSinglePodcastApiSeeder';
+
+    /**
+     * @var string
+     */
+    protected $basePath = 'app/Database';
+
+    /**
+     * @var array<mixed>
+     */
+    private array $podcast = [];
+
+    private string $podcastApiUrl;
+
+    /**
+     * @param array<mixed> $data
+     */
+    public function __construct(?string $name = null, array $data = [], $dataName = '')
+    {
+        parent::__construct($name, $data, $dataName);
+        $this->podcast = FakeSinglePodcastApiSeeder::podcast();
+        $this->podcast['created_at'] = [];
+        $this->podcast['updated_at'] = [];
+        $this->podcastApiUrl = config('Api')
+            ->gateway;
+    }
+
+    public function testList(): void
+    {
+        $result = $this->call('get', $this->podcastApiUrl . 'podcasts');
+        $result->assertStatus(200);
+        $result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
+        $result->assertJSONFragment([
+            0 => $this->podcast,
+        ]);
+    }
+
+    public function testView(): void
+    {
+        $result = $this->call('get', $this->podcastApiUrl . 'podcasts/1');
+        $result->assertStatus(200);
+        $result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
+        $result->assertJSONFragment($this->podcast);
+    }
+
+    public function testViewNotFound(): void
+    {
+        $result = $this->call('get', $this->podcastApiUrl . 'podcasts/2');
+        $result->assertStatus(404);
+        $result->assertJSONExact(
+            [
+                'status' => 404,
+                'error' => 404,
+                'messages' => [
+                    'error' => 'Podcast not found',
+                ],
+            ]
+        );
+        $result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
+    }
+
+    /*
+    * Refreshing database to fetch empty array of podcasts
+    */
+    public function testListEmpty(): void
+    {
+        $this->regressDatabase();
+        $this->migrateDatabase();
+        $result = $this->call('get', $this->podcastApiUrl . 'podcasts');
+        $result->assertStatus(200);
+        $result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
+        $result->assertJSONExact([]);
+        $this->seed($this->seed);
+    }
+}
-- 
GitLab