diff --git a/README.md b/README.md index 2dacb966..8ea3e2d0 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ - [W.I.P. API Migration](#wip-api-migration) - [Prerequisites](#prerequisites) - [Setup](#setup) + - [Supabase (local)](#supabase-local) - [Environments and Variables](#environments-and-variables) - - [Auth0](#auth0) + - [Auth0 (deprecated)](#auth0-deprecated) - [Vercel](#vercel) - [Vercel Environment Variables](#vercel-environment-variables) - [API Routes](#api-routes) @@ -15,6 +16,11 @@ - [Migrations and Types](#migrations-and-types) - [Deployment](#deployment) - [Radolan Harvester](#radolan-harvester) + - [API Routes](#api-routes-1) + - [API Authorization](#api-authorization-1) + - [Supabase](#supabase-1) + - [Auth0 (deprecated)](#auth0-deprecated-1) + - [Tests](#tests-1) - [Contributors ✨](#contributors-) - [Credits](#credits) @@ -28,7 +34,7 @@ Built with Typescript connects to Supabase and (still) Auth0.com, runs on vercel ![](./docs/wip.png) -We are in the process of migrating the API fully to supabase. These docs are not up to date yet. +We are in the process of migrating the API fully to supabase. These docs might have some missing information. ## Prerequisites @@ -36,10 +42,12 @@ We are in the process of migrating the API fully to supabase. These docs are not - [Supabase](https://supabase.com) Account - Supabase CLI install with brew `brew install supabase/tap/supabase` - [Docker](https://www.docker.com/) Dependency for Supabase -- [Auth0.com](https://auth0.com) Account +- (deprecated) [Auth0.com](https://auth0.com) Account ## Setup +### Supabase (local) + ```bash git clone git@github.com:technologiestiftung/giessdenkiez-de-postgres-api.git cd ./giessdenkiez-de-postgres-api @@ -74,9 +82,9 @@ In the example code above the Postgres database Postgrest API are run locally. Y Again. Be a smart developer, read https://12factor.net/config, https://github.com/motdotla/dotenv#should-i-have-multiple-env-files and never ever touch production with your local code! -### Auth0 +### Auth0 (deprecated) -**!Hint: We are working on replacing Auth0 with Supabase. This is not yet implemented.** +**!Hint: We still support using Auth0 in this API but will eventually remove it. Using Supabase is preferred.** Setup your auth0.com account and create a new API. Get your `jwksUri`, `issuer`, `audience`, `client_id` and `client_secret` values and add them to the `.env` file as well. The values for `client_id` and `client_secret` are only needed if you want to run local integration tests and use tools like rest-client, Postman, Insomnia or Paw to obtain a token. This is explained later in this document. @@ -109,8 +117,6 @@ To let these variables take effect you need to deploy your application once more vercel --prod ``` - - ## API Routes There are 3 main routes `/get`, `/post` and `/delete`. @@ -223,6 +229,99 @@ INSERT INTO "public"."radolan_harvester" ("id", "collection_date", "start_date", This process is actually a little blackbox we need to solve. +## API Routes + +There are 3 main routes `/get`, `/post` and `/delete`. + +On the `/get` route all actions are controlled by passing URL params. On the `/post` and `/delete` route you will have to work with additional POST bodies. For example to fetch a specific tree run the following command. + +```bash +curl --request GET \ + --url 'http://localhost:3000/get/byid&id=_123456789' \ + +``` + +You can see all the available routes in the [docs/api.http](./docs/api.http) file with all their needed `URLSearchParams` and JSON bodies or by inspecting the JSON Schema that is returned when you do a request to the `/get`, `/post` or `/delete` route. + +Currently we have these routes (for routes that still use auth0 remove the v3 prefix) + +| `/v3/get` | `/v3/post` | `/v3/delete` | +| -------------------- | ---------- | ------------ | +| `/byid` | `/adopt` | `/unadopt` | +| `/treesbyids` | `/water` | `/unwater` | +| `/adopted` | | | +| `/istreeadopted` | | | +| `/wateredandadopted` | | | +| `/lastwatered` | | | +| `/wateredbyuser` | | | + +### API Authorization + +#### Supabase + +Some of the requests need a authorized user. You can create a new user using email password via the Supabase API. + +```bash +curl --request POST \ + --url http://localhost:54321/auth/v1/signup \ + --header 'apikey: ' \ + --header 'content-type: application/json' \ + --data '{"email": "someone@email.com","password": "1234567890"}' +``` + +This will give you in development already an aceess token. In production you will need to confirm your email address first. + +A login can be done like this: + +```bash +curl --request POST \ + --url 'http://localhost:54321/auth/v1/token?grant_type=password' \ + --header 'apikey: ' \ + --header 'content-type: application/json' \ + --data '{"email": "someone@email.com","password": "1234567890"}' +``` + +See the [docs/api.http](./docs/api.http) file for more examples or take a look into the API documentation in your local supabase instance under http://localhost:54323/project/default/api?page=users + +#### Auth0 (deprecated) + +Some of the request will need an authorization header. You can obtain a token by making a request to your auth0 token issuer. + +```bash +curl --request POST \ + --url https://your-tenant.eu.auth0.com/oauth/token \ + --header 'content-type: application/json' \ + --data '{"client_id": "","client_secret": "","audience": "","grant_type": "client_credentials"}' +# fill in the fields +``` + +This will respond with an `access_token`. Use it to make authenticated requests. + +```bash +curl --request POST \ + --url http://localhost:3000/post \ + --header 'authorization: Bearer ' \ + --header 'content-type: application/json' \ + --data '{"queryType":"adopt","tree_id":"_01","uuid": "auth0|123"}' +``` + +Take a look into [docs/api.http](./docs/api.http). The requests in this file can be run with the VSCode extension [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client). + +## Tests + +Locally you will need supabase running and a `.env` file with the right values in it. + +```bash +cd giessdenkiez-de-postgres-api +supabase start +# Once the backaned is up and running, run the tests +# Make sure to you habe your .env file setup right +# with all the values from `supabase status` and your API from Auth0.com +npm test +``` + +On CI the Supabase is started automagically. See [.github/workflows/tests.yml](.github/workflows/tests.yml) you still need an API on Auth0.com + ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): @@ -277,5 +376,3 @@ This project follows the [all-contributors](https://github.com/all-contributors/ [gdk-supabase]: https://github.com/technologiestiftung/giessdenkiez-de-supabase/ [supabase]: https://supabase.com/ - - diff --git a/__test-utils/postgres.ts b/__test-utils/postgres.ts index 0c306bc4..fedd3b3b 100644 --- a/__test-utils/postgres.ts +++ b/__test-utils/postgres.ts @@ -19,12 +19,24 @@ export async function truncateTreesAdopted() { export async function createWateredTrees() { const sql = postgres(url); await sql` - INSERT INTO trees_watered (uuid, tree_id, amount, timestamp) - VALUES - ('test', '_2100294b1f', 1, '2023-01-01 00:00:00'), - ('test', '_2100294b1f', 1, '2023-01-01 00:00:00'), - ('test', '_2100186c08', 1, '2023-01-01 00:00:00'), - ('test', '_2100186c08', 1, '2023-01-01 00:00:00'); + INSERT INTO trees_watered (uuid, amount, timestamp, username, tree_id) + SELECT + md5(random()::text), + random() * 10, + NOW() - (random() * INTERVAL '7 days'), + md5(random()::text), + id + FROM + trees + ORDER BY + random() + LIMIT 10; `; sql.end(); } + +export async function deleteSupabaseUser(email: string): Promise { + const sql = postgres(url); + await sql`DELETE FROM auth.users WHERE email = ${email}`; + sql.end(); +} diff --git a/__test-utils/req-test-token.ts b/__test-utils/req-test-token.ts index f8c768d0..dc918b32 100644 --- a/__test-utils/req-test-token.ts +++ b/__test-utils/req-test-token.ts @@ -2,8 +2,9 @@ const issuer = process.env.issuer || ""; const client_id = process.env.client_id || ""; const client_secret = process.env.client_secret || ""; const audience = process.env.audience || ""; - -export async function requestTestToken() { +const SUPABASE_URL = process.env.SUPABASE_URL || "http://localhost:54321"; +const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || ""; +export async function requestAuth0TestToken() { const response = await fetch(`${issuer}oauth/token`, { method: "POST", headers: { @@ -23,3 +24,55 @@ export async function requestTestToken() { const json = await response.json(); return json.access_token; } + +export async function requestSupabaseTestToken( + email: string, + password: string +) { + const response = await fetch( + `${SUPABASE_URL}/auth/v1/token?grant_type=password`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: SUPABASE_ANON_KEY, + }, + body: JSON.stringify({ + email, + password, + }), + } + ); + if (!response.ok) { + const json = await response.text(); + throw new Error(`Could not get test token, ${json}`); + } + const json = (await response.json()) as { + access_token: string; + user: { id: string }; + }; + return json.access_token; +} + +export async function createSupabaseUser(email: string, password: string) { + const response = await fetch(`${SUPABASE_URL}/auth/v1/signup`, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: SUPABASE_ANON_KEY, + }, + body: JSON.stringify({ + email, + password, + }), + }); + if (!response.ok) { + const json = await response.text(); + throw new Error(`Could not create test user, ${json}`); + } + const json = (await response.json()) as { + access_token: string; + user: { id: string }; + }; + return json.access_token; +} diff --git a/__tests__/__snapshots__/get-routes.test.ts.snap b/__tests__/__snapshots__/get-routes.test.ts.snap index 79c51c0d..2ecaecb5 100644 --- a/__tests__/__snapshots__/get-routes.test.ts.snap +++ b/__tests__/__snapshots__/get-routes.test.ts.snap @@ -1537,7 +1537,6 @@ exports[`GET routes snapshot tests default responses Should return 200 on treesb "total": 2, }, "url": "/?type=treesbyids&tree_ids=_2100294b1f%2C_210028b9c8", - "version": "2.0.0", } `; @@ -1553,7 +1552,6 @@ exports[`GET routes snapshot tests default responses should return 200 on adopte "total": 0, }, "url": "/?type=adopted&uuid=auth0%7Cabc", - "version": "2.0.0", } `; @@ -1563,7 +1561,6 @@ exports[`GET routes snapshot tests default responses should return 200 on istree "error": null, "name": "@technologiestiftung/giessdenkiez-de-postgres-api", "url": "/?type=istreeadopted&id=_210028b9c8&uuid=auth0%7Cabc", - "version": "2.0.0", } `; @@ -1579,7 +1576,6 @@ exports[`GET routes snapshot tests default responses should return 200 on lastwa "total": 0, }, "url": "/?type=lastwatered&id=_210028b9c8", - "version": "2.0.0", } `; @@ -2352,7 +2348,6 @@ exports[`GET routes snapshot tests default responses should return 200 on tree b "error": null, "name": "@technologiestiftung/giessdenkiez-de-postgres-api", "url": "/?type=byid&id=_2100294b1f", - "version": "2.0.0", } `; @@ -2368,7 +2363,6 @@ exports[`GET routes snapshot tests default responses should return 200 on watere "total": 0, }, "url": "/?type=wateredandadopted", - "version": "2.0.0", } `; @@ -2384,7 +2378,6 @@ exports[`GET routes snapshot tests default responses should return 200 on watere "total": 0, }, "url": "/?type=wateredbyuser&uuid=auth0%7Cabc", - "version": "2.0.0", } `; @@ -2393,3 +2386,2390 @@ exports[`GET routes snapshot tests default responses should return 404 on invali "error": "invalid route invalid", } `; + +exports[`GET v3 routes snapshot tests default responses Should return 200 on treesbyid route 1`] = ` +{ + "data": [ + { + "adopted": null, + "artbot": "Fraxinus ornus", + "artdtsch": "Blumen-Esche", + "baumhoehe": "0", + "bezirk": "Pankow", + "caretaker": null, + "eigentuemer": "Land Berlin", + "gattung": "FRAXINUS", + "gattungdeutsch": "ESCHE", + "geom": { + "coordinates": [ + 13.50326, + 52.64844, + ], + "crs": { + "properties": { + "name": "EPSG:4326", + }, + "type": "name", + }, + "type": "Point", + }, + "gmlid": "00008100:0028b9c8", + "hausnr": null, + "id": "_210028b9c8", + "kennzeich": "00885", + "kronedurch": "0", + "lat": "13.50326", + "lng": "52.64844", + "pflanzjahr": 2019, + "radolan_days": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 5, + 6, + 10, + 29, + 37, + 43, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 16, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "radolan_sum": 205, + "stammumfg": "0", + "standalter": "3", + "standortnr": "5", + "strname": null, + "type": null, + "watered": null, + "zusatz": null, + }, + { + "adopted": null, + "artbot": "Carpinus betulus", + "artdtsch": "Hainbuche", + "baumhoehe": "7", + "bezirk": "Pankow", + "caretaker": null, + "eigentuemer": "Land Berlin", + "gattung": "CARPINUS", + "gattungdeutsch": "HAINBUCHE", + "geom": { + "coordinates": [ + 13.50295, + 52.64778, + ], + "crs": { + "properties": { + "name": "EPSG:4326", + }, + "type": "name", + }, + "type": "Point", + }, + "gmlid": "00008100:00294b1f", + "hausnr": null, + "id": "_2100294b1f", + "kennzeich": "41374", + "kronedurch": "2", + "lat": "13.50295", + "lng": "52.64778", + "pflanzjahr": 2019, + "radolan_days": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 5, + 6, + 10, + 29, + 37, + 43, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 16, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "radolan_sum": 205, + "stammumfg": "31", + "standalter": "3", + "standortnr": "74/2", + "strname": "Hörstenweg", + "type": null, + "watered": null, + "zusatz": null, + }, + ], + "error": null, + "links": {}, + "name": "@technologiestiftung/giessdenkiez-de-postgres-api", + "range": { + "end": 1, + "start": 0, + "total": 2, + }, + "url": "/?type=treesbyids&tree_ids=_2100294b1f%2C_210028b9c8", +} +`; + +exports[`GET v3 routes snapshot tests default responses should return 200 on adopted route authenticated 1`] = ` +{ + "data": [], + "error": null, + "links": {}, + "name": "@technologiestiftung/giessdenkiez-de-postgres-api", + "range": { + "end": -1, + "start": -1, + "total": 0, + }, + "url": "/?type=adopted&uuid=auth0%7Cabc", +} +`; + +exports[`GET v3 routes snapshot tests default responses should return 200 on istreeadopted route authenticated 1`] = ` +{ + "data": false, + "error": null, + "name": "@technologiestiftung/giessdenkiez-de-postgres-api", + "url": "/?type=istreeadopted&id=_210028b9c8&uuid=auth0%7Cabc", +} +`; + +exports[`GET v3 routes snapshot tests default responses should return 200 on lastwatered route 1`] = ` +{ + "data": [], + "error": null, + "links": {}, + "name": "@technologiestiftung/giessdenkiez-de-postgres-api", + "range": { + "end": -1, + "start": -1, + "total": 0, + }, + "url": "/?type=lastwatered&id=_210028b9c8", +} +`; + +exports[`GET v3 routes snapshot tests default responses should return 200 on tree by id route 1`] = ` +{ + "data": [ + { + "adopted": null, + "artbot": "Carpinus betulus", + "artdtsch": "Hainbuche", + "baumhoehe": "7", + "bezirk": "Pankow", + "caretaker": null, + "eigentuemer": "Land Berlin", + "gattung": "CARPINUS", + "gattungdeutsch": "HAINBUCHE", + "geom": { + "coordinates": [ + 13.50295, + 52.64778, + ], + "crs": { + "properties": { + "name": "EPSG:4326", + }, + "type": "name", + }, + "type": "Point", + }, + "gmlid": "00008100:00294b1f", + "hausnr": null, + "id": "_2100294b1f", + "kennzeich": "41374", + "kronedurch": "2", + "lat": "13.50295", + "lng": "52.64778", + "pflanzjahr": 2019, + "radolan_days": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 5, + 6, + 10, + 29, + 37, + 43, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 16, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "radolan_sum": 205, + "stammumfg": "31", + "standalter": "3", + "standortnr": "74/2", + "strname": "Hörstenweg", + "type": null, + "watered": null, + "zusatz": null, + }, + ], + "error": null, + "name": "@technologiestiftung/giessdenkiez-de-postgres-api", + "url": "/?type=byid&id=_2100294b1f", +} +`; + +exports[`GET v3 routes snapshot tests default responses should return 200 on wateredandadopted route 1`] = ` +{ + "data": [], + "error": null, + "links": {}, + "name": "@technologiestiftung/giessdenkiez-de-postgres-api", + "range": { + "end": -1, + "start": -1, + "total": 0, + }, + "url": "/?type=wateredandadopted", +} +`; + +exports[`GET v3 routes snapshot tests default responses should return 200 on wateredbyuser route authenticated 1`] = ` +{ + "data": [], + "error": null, + "links": {}, + "name": "@technologiestiftung/giessdenkiez-de-postgres-api", + "range": { + "end": -1, + "start": -1, + "total": 0, + }, + "url": "/?type=wateredbyuser&uuid=auth0%7Cabc", +} +`; + +exports[`GET v3 routes snapshot tests default responses should return 404 on invalid route 1`] = ` +{ + "error": "invalid route invalid", +} +`; diff --git a/__tests__/__snapshots__/index.test.ts.snap b/__tests__/__snapshots__/index.test.ts.snap index 28ae249a..e574da90 100644 --- a/__tests__/__snapshots__/index.test.ts.snap +++ b/__tests__/__snapshots__/index.test.ts.snap @@ -281,7 +281,6 @@ exports[`GET/POST/DELETE routes index should list all routes on /delete 1`] = ` }, }, ], - "version": "2.0.0", } `; @@ -566,7 +565,6 @@ exports[`GET/POST/DELETE routes index should list all routes on /get 1`] = ` }, }, ], - "version": "2.0.0", } `; @@ -851,6 +849,5 @@ exports[`GET/POST/DELETE routes index should list all routes on /post 1`] = ` }, }, ], - "version": "2.0.0", } `; diff --git a/__tests__/__snapshots__/post-routes-v3.test.ts.snap b/__tests__/__snapshots__/post-routes-v3.test.ts.snap new file mode 100644 index 00000000..5c1e9642 --- /dev/null +++ b/__tests__/__snapshots__/post-routes-v3.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`posting data should return 201 on water route invalid body missing uuid 1`] = ` +{ + "amount": Any, + "id": Any, + "time": null, + "timestamp": Any, + "tree_id": Any, + "username": Any, + "uuid": Any, +} +`; diff --git a/__tests__/__snapshots__/route-listing.test.ts.snap b/__tests__/__snapshots__/route-listing.test.ts.snap index 40b585f6..7ea83c49 100644 --- a/__tests__/__snapshots__/route-listing.test.ts.snap +++ b/__tests__/__snapshots__/route-listing.test.ts.snap @@ -119,167 +119,3 @@ exports[`route listing should list all POST routes 1`] = ` }, } `; - -exports[`route listing should list all the GET routes 1`] = ` -{ - "method": "GET", - "routes": { - "adopted": { - "schema": { - "additionalProperties": false, - "properties": { - "limit": { - "type": "string", - }, - "offset": { - "type": "string", - }, - "type": { - "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", - "type": "string", - }, - "uuid": { - "type": "string", - }, - }, - "required": [ - "uuid", - ], - "type": "object", - }, - "url": "get/adopted", - }, - "byid": { - "schema": { - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - }, - "type": { - "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", - "type": "string", - }, - }, - "required": [ - "id", - ], - "type": "object", - }, - "url": "get/byid", - }, - "istreeadopted": { - "schema": { - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - }, - "type": { - "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", - "type": "string", - }, - "uuid": { - "type": "string", - }, - }, - "required": [ - "uuid", - "id", - ], - "type": "object", - }, - "url": "get/istreeadopted", - }, - "lastwatered": { - "schema": { - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - }, - "limit": { - "type": "string", - }, - "offset": { - "type": "string", - }, - "type": { - "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", - "type": "string", - }, - }, - "required": [ - "id", - ], - "type": "object", - }, - "url": "get/lastwatered", - }, - "treesbyids": { - "schema": { - "additionalProperties": false, - "properties": { - "limit": { - "type": "string", - }, - "offset": { - "type": "string", - }, - "tree_ids": { - "type": "string", - }, - "type": { - "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", - "type": "string", - }, - }, - "required": [ - "tree_ids", - ], - "type": "object", - }, - "url": "get/treesbyids", - }, - "wateredandadopted": { - "schema": { - "additionalProperties": false, - "properties": { - "limit": { - "type": "string", - }, - "offset": { - "type": "string", - }, - "type": { - "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", - "type": "string", - }, - }, - "required": [], - "type": "object", - }, - "url": "get/wateredandadopted", - }, - "wateredbyuser": { - "schema": { - "additionalProperties": false, - "properties": { - "type": { - "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", - "type": "string", - }, - "uuid": { - "type": "string", - }, - }, - "required": [ - "uuid", - ], - "type": "object", - }, - "url": "get/wateredbyuser", - }, - }, -} -`; diff --git a/__tests__/check-if-v3.test.ts b/__tests__/check-if-v3.test.ts new file mode 100644 index 00000000..fc78eed8 --- /dev/null +++ b/__tests__/check-if-v3.test.ts @@ -0,0 +1,22 @@ +import { urlContainsV3 } from "../_utils/check-if-v3"; +describe("urlContainsV3", () => { + test('returns true if the URL contains the word "v3"', () => { + const url = "https://example.com/api/v3/users"; + expect(urlContainsV3(url)).toBe(true); + }); + + test('returns false if the URL does not contain the word "v3"', () => { + const url = "https://example.com/api/v2/users"; + expect(urlContainsV3(url)).toBe(false); + }); + + test('returns true if the URL contains the word "v3" multiple times', () => { + const url = "https://example.com/api/v3/v3/users"; + expect(urlContainsV3(url)).toBe(true); + }); + + test("returns false if the URL is an empty string", () => { + const url = ""; + expect(urlContainsV3(url)).toBe(false); + }); +}); diff --git a/__tests__/delete-routes.test.ts b/__tests__/delete-routes.test.ts index a78759b9..6735cd21 100644 --- a/__tests__/delete-routes.test.ts +++ b/__tests__/delete-routes.test.ts @@ -3,7 +3,7 @@ import each from "jest-each"; import fetch from "cross-fetch"; import handler from "../api/delete/[type]"; import { createTestServer } from "../__test-utils/create-test-server"; -import { requestTestToken } from "../__test-utils/req-test-token"; +import { requestAuth0TestToken } from "../__test-utils/req-test-token"; describe("deleting data", () => { test("should return 200 on options route", async () => { @@ -31,7 +31,7 @@ describe("deleting data", () => { method: "DELETE", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${await requestTestToken()}`, + Authorization: `Bearer ${await requestAuth0TestToken()}`, }, body: JSON.stringify({ uuid: "test", tree_id: "test", watering_id: 123 }), }); @@ -167,7 +167,7 @@ each([ method: "DELETE", headers: { ...(auth === true && { - Authorization: `Bearer ${await requestTestToken()}`, + Authorization: `Bearer ${await requestAuth0TestToken()}`, "Content-Type": "application/json", }), }, diff --git a/__tests__/delete-v3.test.ts b/__tests__/delete-v3.test.ts new file mode 100644 index 00000000..00d8a0d6 --- /dev/null +++ b/__tests__/delete-v3.test.ts @@ -0,0 +1,181 @@ +// import path from "node:path"; +import { test, describe, expect } from "@jest/globals"; +import { faker } from "@faker-js/faker"; + +import { supabase } from "../_utils/supabase"; +import { Database } from "../_types/database"; +import { + deleteSupabaseUser, + truncateTreesAdopted, + truncateTreesWaterd, +} from "../__test-utils/postgres"; +import { createTestServer } from "../__test-utils/create-test-server"; +import v3DeleteHandler from "../api/v3/delete/[type]"; +import { + createSupabaseUser, + requestSupabaseTestToken, +} from "../__test-utils/req-test-token"; +// const envs = config({ path: path.resolve(process.cwd(), ".env") }); +process.env.NODE_ENV = "test"; +const email = "deleter@example.com"; +const password = "1234567890@"; +describe("api/v3/delete/[type]", () => { + beforeAll(async () => { + await createSupabaseUser(email, password); + }); + afterAll(async () => { + await deleteSupabaseUser(email); + }); + + test("should make a request to delete/unwater and fail unauthorized", async () => { + const { server, url } = await createTestServer( + { type: "unwater" }, + v3DeleteHandler + ); + const response = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + server.close(); + expect(response.status).toBe(401); + }); + test("should make a request to api/delete/unwater and fail due to missing body", async () => { + const { server, url } = await createTestServer( + { type: "unwater" }, + v3DeleteHandler + ); + const token = await requestSupabaseTestToken(email, password); + const response = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + server.close(); + expect(response.status).toBe(400); + }); + + test("should make a request to api/delete/unwater and fail due to wrong body", async () => { + const { server, url } = await createTestServer( + { type: "unwater" }, + v3DeleteHandler + ); + const token = await requestSupabaseTestToken(email, password); + const response = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({}), + }); + server.close(); + expect(response.status).toBe(400); + }); + + test("should make request to delete/unwater and succeed", async () => { + await truncateTreesWaterd(); + + const uuid = faker.internet.userName(); + const timestamp = new Date().toISOString().slice(0, 19).replace("T", " "); + const amount = 1; + + // get a tree_id + const { data: treeData, error: treeError } = await supabase + .from("trees") + .select("id") + .limit(1); + expect(treeError).toBe(null); + expect(treeData).not.toBe(null); + if (treeData === null) throw new Error("treeData is null"); + const tree_id = treeData[0].id; + + // insert watering into trees_waterd and get watering id + const { data: waterData, error: waterError } = await supabase + .from("trees_watered") + .insert({ + tree_id, + uuid, + amount, + timestamp, + time: timestamp, + username: uuid, + }) + .select("id"); + expect(waterError).toBe(null); + expect(waterData).not.toBe(null); + if (waterData === null) throw new Error("waterData is null"); + + const watering_id = waterData[0].id; + const { server, url } = await createTestServer( + { type: "unwater" }, + v3DeleteHandler + ); + const token = await requestSupabaseTestToken(email, password); + const response = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + tree_id, + uuid, + watering_id, + }), + }); + server.close(); + expect(response.status).toBe(204); + }); + + test("should make request to delete/unadopt and succeed", async () => { + await truncateTreesAdopted(); + const uuid = faker.internet.userName(); + + // get a tree_id + const { data: treeData, error: treeError } = await supabase + .from("trees") + .select("id") + .limit(1); + + expect(treeError).toBe(null); + expect(treeData).not.toBe(null); + if (treeData === null) throw new Error("treeData is null"); + const tree_id = treeData[0].id; + + // insert adoption into trees_adopted and + // get adoption id + const { data: adoptData, error: adoptError } = await supabase + .from("trees_adopted") + .insert({ + tree_id, + uuid, + }) + .select(); + expect(adoptError).toBe(null); + expect(adoptData).not.toBe(null); + if (adoptData === null) throw new Error("adoptData is null"); + + const { server, url } = await createTestServer( + { type: "unadopt" }, + v3DeleteHandler + ); + const token = await requestSupabaseTestToken(email, password); + const response = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + tree_id, + uuid, + }), + }); + server.close(); + expect(response.status).toBe(204); + }); +}); diff --git a/__tests__/delete.test.ts b/__tests__/delete.test.ts index fa2d8348..5b8f2dee 100644 --- a/__tests__/delete.test.ts +++ b/__tests__/delete.test.ts @@ -2,7 +2,7 @@ import { test, describe, expect } from "@jest/globals"; import { faker } from "@faker-js/faker"; -import { requestTestToken } from "../__test-utils/req-test-token"; +import { requestAuth0TestToken } from "../__test-utils/req-test-token"; import { supabase } from "../_utils/supabase"; import { Database } from "../_types/database"; import { @@ -10,7 +10,7 @@ import { truncateTreesWaterd, } from "../__test-utils/postgres"; import { createTestServer } from "../__test-utils/create-test-server"; -import deleteHandler from "../api/delete/[type]"; +import v2DeleteHandler from "../api/delete/[type]"; // const envs = config({ path: path.resolve(process.cwd(), ".env") }); process.env.NODE_ENV = "test"; @@ -18,7 +18,7 @@ describe("api/delete/[type]", () => { test("should make a request to delete/unwater and fail unauthorized", async () => { const { server, url } = await createTestServer( { type: "unwater" }, - deleteHandler + v2DeleteHandler ); const response = await fetch(url, { method: "DELETE", @@ -32,9 +32,9 @@ describe("api/delete/[type]", () => { test("should make a request to api/delete/unwater and fail due to missing body", async () => { const { server, url } = await createTestServer( { type: "unwater" }, - deleteHandler + v2DeleteHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const response = await fetch(url, { method: "DELETE", headers: { @@ -49,9 +49,9 @@ describe("api/delete/[type]", () => { test("should make a request to api/delete/unwater and fail due to wrong body", async () => { const { server, url } = await createTestServer( { type: "unwater" }, - deleteHandler + v2DeleteHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const response = await fetch(url, { method: "DELETE", headers: { @@ -100,9 +100,9 @@ describe("api/delete/[type]", () => { const watering_id = waterData[0].id; const { server, url } = await createTestServer( { type: "unwater" }, - deleteHandler + v2DeleteHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const response = await fetch(url, { method: "DELETE", headers: { @@ -149,9 +149,9 @@ describe("api/delete/[type]", () => { const { server, url } = await createTestServer( { type: "unadopt" }, - deleteHandler + v2DeleteHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const response = await fetch(url, { method: "DELETE", headers: { diff --git a/__tests__/get-routes.test.ts b/__tests__/get-routes.test.ts index cc675cfc..6fc38336 100644 --- a/__tests__/get-routes.test.ts +++ b/__tests__/get-routes.test.ts @@ -1,15 +1,20 @@ import each from "jest-each"; import fetch from "cross-fetch"; import { test, describe, expect } from "@jest/globals"; -import handler from "../api/get/[type]"; +import v2handler from "../api/get/[type]"; +import v3handler from "../api/v3/get/[type]"; import { createTestServer } from "../__test-utils/create-test-server"; import { + deleteSupabaseUser, truncateTreesAdopted, truncateTreesWaterd, } from "../__test-utils/postgres"; -import { requestTestToken } from "../__test-utils/req-test-token"; +import { + createSupabaseUser, + requestAuth0TestToken, + requestSupabaseTestToken, +} from "../__test-utils/req-test-token"; // byid ✓ - // treesbyids ✓ // wateredandadopted ✓ // lastwatered ✓ @@ -22,10 +27,10 @@ import { requestTestToken } from "../__test-utils/req-test-token"; describe("GET routes snapshot tests default responses", () => { test("should return 200 on wateredbyuser route authenticated", async () => { await truncateTreesWaterd(); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const { server, url } = await createTestServer( { type: "wateredbyuser", uuid: "auth0|abc" }, - handler + v2handler ); // console.log(url); const response = await fetch(url, { @@ -41,10 +46,10 @@ describe("GET routes snapshot tests default responses", () => { }); test("should return 200 on istreeadopted route authenticated", async () => { await truncateTreesWaterd(); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const { server, url } = await createTestServer( { type: "istreeadopted", id: "_210028b9c8", uuid: "auth0|abc" }, - handler + v2handler ); const response = await fetch(url, { headers: { @@ -59,10 +64,10 @@ describe("GET routes snapshot tests default responses", () => { test("should return 200 on adopted route authenticated", async () => { await truncateTreesWaterd(); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const { server, url } = await createTestServer( { type: "adopted", uuid: "auth0|abc" }, - handler + v2handler ); const response = await fetch(url, { headers: { @@ -79,7 +84,7 @@ describe("GET routes snapshot tests default responses", () => { const { server, url } = await createTestServer( { type: "lastwatered", id: "_210028b9c8" }, - handler + v2handler ); const response = await fetch(url); server.close(); @@ -92,7 +97,7 @@ describe("GET routes snapshot tests default responses", () => { await truncateTreesAdopted(); const { server, url } = await createTestServer( { type: "wateredandadopted" }, - handler + v2handler ); const response = await fetch(url); server.close(); @@ -104,7 +109,7 @@ describe("GET routes snapshot tests default responses", () => { test("Should return 200 on treesbyid route", async () => { const { server, url } = await createTestServer( { type: "treesbyids", tree_ids: "_2100294b1f,_210028b9c8" }, - handler + v2handler ); const response = await fetch(`${url}`); server.close(); @@ -116,7 +121,7 @@ describe("GET routes snapshot tests default responses", () => { test("should return 200 on tree by id route", async () => { const { server, url } = await createTestServer( { type: "byid", id: "_2100294b1f" }, - handler + v2handler ); const response = await fetch(`${url}`); server.close(); @@ -128,7 +133,7 @@ describe("GET routes snapshot tests default responses", () => { test("should return 404 on invalid route", async () => { const { server, url } = await createTestServer( { type: "invalid" }, - handler + v2handler ); const response = await fetch(`${url}`); server.close(); @@ -174,16 +179,203 @@ each([ needsAuth?: boolean ) => { test(`should return ${statusCode} on route "${type}" ${description}`, async () => { - // const token = await requestTestToken(); + // const token = await requestAuth0TestToken(); + + const { server, url } = await createTestServer( + { type, ...overrides }, + v2handler + ); + const response = await fetch(`${url}`, { + headers: { + ...(needsAuth === true && { + Authorization: `Bearer ${await requestAuth0TestToken()}`, + }), + "Content-Type": "application/json", + }, + }); + server.close(); + expect(response.status).toBe(statusCode); + }); + } +); + +// ██ ██ ██████ +// ██ ██ ██ +// ██ ██ █████ +// ██ ██ ██ +// ████ ██████ + +describe("GET v3 routes snapshot tests default responses", () => { + const email = "foo@example.com"; + const password = "1234567890@"; + beforeAll(async () => { + await createSupabaseUser(email, password); + }); + afterAll(async () => { + await deleteSupabaseUser(email); + }); + test("should return 200 on wateredbyuser route authenticated", async () => { + await truncateTreesWaterd(); + const token = await requestSupabaseTestToken(email, password); + const { server, url } = await createTestServer( + { type: "wateredbyuser", uuid: "auth0|abc" }, + v3handler + ); + // console.log(url); + const response = await fetch(url, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + server.close(); + const json = await response.json(); + expect(response.status).toBe(200); + expect(json).toMatchSnapshot(); + }); + test("should return 200 on istreeadopted route authenticated", async () => { + await truncateTreesWaterd(); + const token = await requestSupabaseTestToken(email, password); + const { server, url } = await createTestServer( + { type: "istreeadopted", id: "_210028b9c8", uuid: "auth0|abc" }, + v3handler + ); + const response = await fetch(url, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + server.close(); + const json = await response.json(); + expect(response.status).toBe(200); + expect(json).toMatchSnapshot(); + }); + + test("should return 200 on adopted route authenticated", async () => { + await truncateTreesWaterd(); + const token = await requestSupabaseTestToken(email, password); + const { server, url } = await createTestServer( + { type: "adopted", uuid: "auth0|abc" }, + v3handler + ); + const response = await fetch(url, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + server.close(); + const json = await response.json(); + expect(response.status).toBe(200); + expect(json).toMatchSnapshot(); + }); + test("should return 200 on lastwatered route", async () => { + await truncateTreesWaterd(); + + const { server, url } = await createTestServer( + { type: "lastwatered", id: "_210028b9c8" }, + v3handler + ); + const response = await fetch(url); + server.close(); + const json = await response.json(); + expect(response.status).toBe(200); + expect(json).toMatchSnapshot(); + }); + test("should return 200 on wateredandadopted route", async () => { + await truncateTreesWaterd(); + await truncateTreesAdopted(); + const { server, url } = await createTestServer( + { type: "wateredandadopted" }, + v3handler + ); + const response = await fetch(url); + server.close(); + const json = await response.json(); + expect(response.status).toBe(200); + expect(json).toMatchSnapshot(); + }); + + test("Should return 200 on treesbyid route", async () => { + const { server, url } = await createTestServer( + { type: "treesbyids", tree_ids: "_2100294b1f,_210028b9c8" }, + v3handler + ); + const response = await fetch(`${url}`); + server.close(); + const json = await response.json(); + expect(response.status).toBe(200); + expect(json).toMatchSnapshot(); + }); + + test("should return 200 on tree by id route", async () => { + const { server, url } = await createTestServer( + { type: "byid", id: "_2100294b1f" }, + v3handler + ); + const response = await fetch(`${url}`); + server.close(); + const json = await response.json(); + expect(response.status).toBe(200); + expect(json).toMatchSnapshot(); + }); + + test("should return 404 on invalid route", async () => { + const { server, url } = await createTestServer( + { type: "invalid" }, + v3handler + ); + const response = await fetch(`${url}`); + server.close(); + expect(response.status).toBe(404); + expect(response.statusText).toBe("Not Found"); + expect(await response.json()).toMatchSnapshot(); + }); +}); +each([ + [401, "wateredbyuser", { uuid: "123" }, "due to not being authorized"], + [400, "wateredbyuser", {}, "due to uuid missing", true], + + [400, "istreeadopted", {}, "due to uuid missing", true], + [400, "istreeadopted", { uuid: "abc" }, "due to id missing", true], + [ + 401, + "istreeadopted", + { uuid: "abc", id: "_21000c10a9" }, + "due to not being authorized", + ], + + [400, "adopted", {}, "due to not uuid missing"], + [401, "adopted", { uuid: "123" }, "due to not being authorized"], + + [400, "byid", {}, "due to missing id serachParam"], + [400, "treesbyids", {}, "due to tree_ids missing"], + + [400, "lastwatered", {}, "due to id missing"], + [ + 400, + "treesbyids", + {}, + "due to missing tree_ids list serachParam (_2100294b1f,_210028b9c8)", + ], +]).describe( + "error tests for GET routes", + ( + statusCode: number, + type: string, + overrides: Record, + description: string, + needsAuth?: boolean + ) => { + test(`should return ${statusCode} on route "${type}" ${description}`, async () => { + // const token = await requestAuth0TestToken(); const { server, url } = await createTestServer( { type, ...overrides }, - handler + v3handler ); const response = await fetch(`${url}`, { headers: { ...(needsAuth === true && { - Authorization: `Bearer ${await requestTestToken()}`, + Authorization: `Bearer ${await requestAuth0TestToken()}`, }), "Content-Type": "application/json", }, diff --git a/__tests__/post-routes-v3.test.ts b/__tests__/post-routes-v3.test.ts new file mode 100644 index 00000000..2f3d7aab --- /dev/null +++ b/__tests__/post-routes-v3.test.ts @@ -0,0 +1,263 @@ +import { test, describe, expect } from "@jest/globals"; +import each from "jest-each"; +import fetch from "cross-fetch"; +import handler from "../api/v3/post/[type]"; +import { createTestServer } from "../__test-utils/create-test-server"; +import { + createSupabaseUser, + requestSupabaseTestToken, +} from "../__test-utils/req-test-token"; +import { + truncateTreesWaterd, + truncateTreesAdopted, + deleteSupabaseUser, +} from "../__test-utils/postgres"; + +// adopt +// water +describe("posting data", () => { + const email = "poster@example.com"; + const password = "1234567890@"; + beforeAll(async () => { + await createSupabaseUser(email, password); + }); + afterAll(async () => { + await deleteSupabaseUser(email); + }); + test("should return 200 on options route", async () => { + const { server, url } = await createTestServer({ type: "water" }, handler); + const response = await fetch(url, { + method: "OPTIONS", + }); + server.close(); + expect(response.status).toBe(200); + }); + test("should return 201 on water route invalid body missing uuid", async () => { + await truncateTreesWaterd(); + const { server, url } = await createTestServer({ type: "water" }, handler); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${await requestSupabaseTestToken( + email, + password + )}`, + + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tree_id: "_2100186a5c", + uuid: "123", + username: "test", + timestamp: "2023-01-01T00:00:00", + amount: 200, + }), + }); + server.close(); + const json = await response.json(); + expect(response.status).toBe(201); + json.data.forEach((data: unknown) => { + expect(data).toMatchSnapshot({ + uuid: expect.any(String), + tree_id: expect.any(String), + username: expect.any(String), + timestamp: expect.any(String), + amount: expect.any(Number), + id: expect.any(Number), + }); + }); + }); +}); + +each([ + { + statusCode: 401, + type: "water", + description: "due to not authorized", + auth: false, + overrides: {}, + }, + { + statusCode: 400, + type: ["water"], + description: "due to type not being a string", + auth: true, + overrides: {}, + }, + { + statusCode: 400, + type: "water", + description: "due to invalid body missing uuid", + auth: true, + overrides: {}, + }, + { + statusCode: 400, + type: "water", + description: "due to invalid body missing tree_id", + auth: true, + overrides: {}, + body: { uuid: "123" }, + }, + { + statusCode: 201, + type: "water", + description: "(all valid)", + auth: true, + overrides: {}, + body: { + uuid: "123", + tree_id: "_2100186a5c", + username: "foo", + timestamp: "2023-01-01T00:00:00", + amount: 200, + }, + }, + { + statusCode: 500, + type: "water", + description: "fail due to invalid tree id", + auth: true, + overrides: {}, + body: { + uuid: "123", + tree_id: "123", + username: "foo", + timestamp: "2023-01-01T00:00:00", + amount: 200, + }, + }, + { + statusCode: 400, + type: "water", + description: "due to invalid body missing amount", + auth: true, + overrides: {}, + body: { + uuid: "123", + tree_id: "_2100186a5c", + username: "foo", + timestamp: "2023-01-01T00:00:00", + }, + }, + { + statusCode: 400, + type: "water", + description: "due to invalid body missing timestamp", + auth: true, + overrides: {}, + body: { uuid: "123", tree_id: "_2100186a5c", username: "foo" }, + }, + { + statusCode: 400, + type: "water", + description: "due to invalid body missing username", + auth: true, + overrides: {}, + body: { uuid: "123", tree_id: "_2100186a5c" }, + }, + { + statusCode: 401, + type: "adopt", + description: "due to not authorized", + auth: false, + overrides: {}, + }, + { + statusCode: 400, + type: ["adopt"], + description: "due to type not being a string", + auth: true, + overrides: {}, + }, + { + statusCode: 400, + type: "foo", + description: "due to type being a invalid query type", + auth: true, + overrides: {}, + }, + { + statusCode: 400, + type: "adopt", + description: "due to invalid body missing uuid", + auth: true, + overrides: {}, + }, + { + statusCode: 400, + type: "adopt", + description: "due to invalid body missing tree_id", + auth: true, + overrides: {}, + body: { uuid: "123" }, + }, + { + statusCode: 500, + type: "adopt", + description: "due to invalid tree_id", + auth: true, + overrides: {}, + body: { uuid: "123", tree_id: "123" }, + }, + { + statusCode: 201, + type: "adopt", + description: "(all valid)", + auth: true, + overrides: {}, + body: { uuid: "123", tree_id: "_2100186a5c" }, + }, +]).describe( + "error tests for POST routes", + ({ + statusCode, + type, + description, + overrides, + auth, + body, + }: { + statusCode: number; + type: string; + description: string; + overrides: Record; + auth?: boolean; + body?: Record; + }) => { + const email = "poster2@example.com"; + const password = "1234567890@"; + beforeAll(async () => { + await createSupabaseUser(email, password); + }); + afterAll(async () => { + await deleteSupabaseUser(email); + }); + test(`should return ${statusCode} on route "${type} ${description}"`, async () => { + const { server, url } = await createTestServer( + { type, ...overrides }, + handler + ); + const response = await fetch(url, { + method: "POST", + headers: { + ...(auth === true && { + Authorization: `Bearer ${await requestSupabaseTestToken( + email, + password + )}`, + }), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + server.close(); + // for debugging it is useful to log the results of the request + // const json = await response.json(); + // console.log(json); + expect(response.status).toBe(statusCode); + await truncateTreesWaterd(); + await truncateTreesAdopted(); + }); + } +); diff --git a/__tests__/post-routes.test.ts b/__tests__/post-routes.test.ts index 55cbe743..1cf60dd4 100644 --- a/__tests__/post-routes.test.ts +++ b/__tests__/post-routes.test.ts @@ -3,7 +3,7 @@ import each from "jest-each"; import fetch from "cross-fetch"; import handler from "../api/post/[type]"; import { createTestServer } from "../__test-utils/create-test-server"; -import { requestTestToken } from "../__test-utils/req-test-token"; +import { requestAuth0TestToken } from "../__test-utils/req-test-token"; import { truncateTreesWaterd, truncateTreesAdopted, @@ -26,7 +26,7 @@ describe("posting data", () => { const response = await fetch(url, { method: "POST", headers: { - Authorization: `Bearer ${await requestTestToken()}`, + Authorization: `Bearer ${await requestAuth0TestToken()}`, "Content-Type": "application/json", }, @@ -219,7 +219,7 @@ each([ method: "POST", headers: { ...(auth === true && { - Authorization: `Bearer ${await requestTestToken()}`, + Authorization: `Bearer ${await requestAuth0TestToken()}`, }), "Content-Type": "application/json", }, diff --git a/__tests__/post.test.ts b/__tests__/post.test.ts index 6c724051..2b1d3f4f 100644 --- a/__tests__/post.test.ts +++ b/__tests__/post.test.ts @@ -1,7 +1,7 @@ import { test, describe, expect } from "@jest/globals"; import postHandler from "../api/post/[type]"; -import { requestTestToken } from "../__test-utils/req-test-token"; +import { requestAuth0TestToken } from "../__test-utils/req-test-token"; import { supabase } from "../_utils/supabase"; import { truncateTreesAdopted, @@ -42,7 +42,7 @@ describe("api/post/[type]", () => { { type: "watered" }, postHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const response = await fetch(url, { method: "POST", headers: { @@ -60,7 +60,7 @@ describe("api/post/[type]", () => { { type: "water" }, postHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const response = await fetch(url, { method: "POST", headers: { @@ -77,7 +77,7 @@ describe("api/post/[type]", () => { { type: "adopt" }, postHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const response = await fetch(url, { method: "POST", headers: { @@ -96,7 +96,7 @@ describe("api/post/[type]", () => { { type: "water" }, postHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const { data: trees, error } = await supabase.from("trees").select("*"); if (error) { throw error; @@ -134,7 +134,7 @@ describe("api/post/[type]", () => { { type: "adopt" }, postHandler ); - const token = await requestTestToken(); + const token = await requestAuth0TestToken(); const { data: trees, error: treeError } = await supabase .from("trees") .select("id") diff --git a/__tests__/route-listing.test.ts b/__tests__/route-listing.test.ts index 064b5f59..9efdfd93 100644 --- a/__tests__/route-listing.test.ts +++ b/__tests__/route-listing.test.ts @@ -8,7 +8,7 @@ describe("route listing", () => { const params = paramsToObject("uuid=1234&limit=10&offset=0"); const [valid, _validationErrors] = validate( params, - getRoutesList.routes.wateredandadopted.schema + getRoutesList.routes.lastwatered.schema ); expect(valid).toBe(false); @@ -22,6 +22,168 @@ describe("route listing", () => { }); test("should list all the GET routes", async () => { - expect(getRoutesList).toMatchSnapshot(); + expect(getRoutesList).toMatchInlineSnapshot(` + { + "method": "GET", + "routes": { + "adopted": { + "schema": { + "additionalProperties": false, + "properties": { + "limit": { + "type": "string", + }, + "offset": { + "type": "string", + }, + "type": { + "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", + "type": "string", + }, + "uuid": { + "type": "string", + }, + }, + "required": [ + "uuid", + ], + "type": "object", + }, + "url": "get/adopted", + }, + "byid": { + "schema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + }, + "type": { + "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", + "type": "string", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + "url": "get/byid", + }, + "istreeadopted": { + "schema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + }, + "type": { + "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", + "type": "string", + }, + "uuid": { + "type": "string", + }, + }, + "required": [ + "uuid", + "id", + ], + "type": "object", + }, + "url": "get/istreeadopted", + }, + "lastwatered": { + "schema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + }, + "limit": { + "type": "string", + }, + "offset": { + "type": "string", + }, + "type": { + "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", + "type": "string", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + "url": "get/lastwatered", + }, + "treesbyids": { + "schema": { + "additionalProperties": false, + "properties": { + "limit": { + "type": "string", + }, + "offset": { + "type": "string", + }, + "tree_ids": { + "type": "string", + }, + "type": { + "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", + "type": "string", + }, + }, + "required": [ + "tree_ids", + ], + "type": "object", + }, + "url": "get/treesbyids", + }, + "wateredandadopted": { + "schema": { + "additionalProperties": false, + "properties": { + "limit": { + "type": "string", + }, + "offset": { + "type": "string", + }, + "type": { + "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", + "type": "string", + }, + }, + "required": [], + "type": "object", + }, + "url": "get/wateredandadopted", + }, + "wateredbyuser": { + "schema": { + "additionalProperties": false, + "properties": { + "type": { + "description": "The type property is atomaticaly added by dynamic vercel api routes. You should not add it yourself", + "type": "string", + }, + "uuid": { + "type": "string", + }, + }, + "required": [ + "uuid", + ], + "type": "object", + }, + "url": "get/wateredbyuser", + }, + }, + } + `); }); }); diff --git a/__tests__/verify-supabase-token.test.ts b/__tests__/verify-supabase-token.test.ts new file mode 100644 index 00000000..12abe9f8 --- /dev/null +++ b/__tests__/verify-supabase-token.test.ts @@ -0,0 +1,84 @@ +// FIXME: Mocking is a code smell. Get a token from the local dev server and use that instead. +import { verifySupabaseToken } from "../_utils/verify-supabase-token"; +import { VercelRequest } from "@vercel/node"; +import { supabase } from "../_utils/supabase"; +import { GDKAuthError } from "../_utils/errors"; +import { AuthError, User } from "@supabase/supabase-js"; +jest.mock("../_utils/supabase", () => ({ + supabase: { + auth: { + getUser: jest.fn(), + }, + }, +})); + +describe("verifySupabaseToken", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should return an error when authorization header is missing", async () => { + const request = { + headers: {}, + } as VercelRequest; + + const { data, error } = await verifySupabaseToken(request); + + expect(data).toBeNull(); + expect(error).toBeInstanceOf(GDKAuthError); + expect(error?.message).toBe("not authorized"); + }); + + test("should return an error when access_token is missing", async () => { + const request = { + headers: { + authorization: "Bearer ", + }, + } as VercelRequest; + + const { data, error } = await verifySupabaseToken(request); + + expect(data).toBeNull(); + expect(error).toBeInstanceOf(GDKAuthError); + expect(error?.message).toBe("not authorized"); + }); + test("should return an error if the access token is invalid", async () => { + const request = { + headers: { authorization: "Bearer invalid" }, + } as VercelRequest; + const getUserMock = supabase.auth.getUser as jest.MockedFunction< + typeof supabase.auth.getUser + >; + getUserMock.mockResolvedValueOnce({ + error: new AuthError("Invalid token"), + data: { user: null }, + }); + const result = await verifySupabaseToken(request); + expect(getUserMock).toHaveBeenCalledWith("invalid"); + expect(result).toEqual({ + data: null, + error: new AuthError("Invalid token"), + }); + }); + + test("should return the user data if the access token is valid", async () => { + const request = { + headers: { authorization: "Bearer valid" }, + } as VercelRequest; + const getUserMock = supabase.auth.getUser as jest.MockedFunction< + typeof supabase.auth.getUser + >; + const userData = { + user: { id: "123", email: "test@example.com" }, + } as { + user: User; + }; + getUserMock.mockResolvedValueOnce({ data: userData, error: null }); + const result = await verifySupabaseToken(request); + expect(getUserMock).toHaveBeenCalledWith("valid"); + expect(result).toEqual({ + data: userData.user, + error: null, + }); + }); +}); diff --git a/_requests/delete/unadopt.ts b/_requests/delete/unadopt.ts new file mode 100644 index 00000000..15ef58bc --- /dev/null +++ b/_requests/delete/unadopt.ts @@ -0,0 +1,28 @@ +import { User } from "@supabase/supabase-js"; +import { VercelRequest, VercelResponse } from "@vercel/node"; +import { urlContainsV3 } from "../../_utils/check-if-v3"; +import { supabase } from "../../_utils/supabase"; + +export default async function handler( + request: VercelRequest, + response: VercelResponse, + user?: User +) { + let { uuid } = request.body; + const { tree_id } = request.body; + if (!request.url) { + return response.status(500).json({ error: "no url in request" }); + } + if (urlContainsV3(request.url)) { + uuid = user?.id || uuid; + } + const { error } = await supabase + .from("trees_adopted") + .delete() + .eq("tree_id", tree_id) + .eq("uuid", uuid); + if (error) { + return response.status(500).json({ error }); + } + return response.status(204).json({ message: `unadopted tree ${tree_id}` }); +} diff --git a/_requests/delete/unwater.ts b/_requests/delete/unwater.ts new file mode 100644 index 00000000..0893859a --- /dev/null +++ b/_requests/delete/unwater.ts @@ -0,0 +1,29 @@ +import { User } from "@supabase/supabase-js"; +import { VercelRequest, VercelResponse } from "@vercel/node"; +import { urlContainsV3 } from "../../_utils/check-if-v3"; +import { supabase } from "../../_utils/supabase"; + +export default async function ( + request: VercelRequest, + response: VercelResponse, + user?: User +) { + let { uuid } = request.body; + const { tree_id, watering_id } = request.body; + if (!request.url) { + return response.status(500).json({ error: "no url in request" }); + } + if (urlContainsV3(request.url)) { + uuid = user?.id || uuid; + } + const { error } = await supabase + .from("trees_watered") + .delete() + .eq("tree_id", tree_id) + .eq("uuid", uuid) + .eq("id", watering_id); + if (error) { + return response.status(500).json({ error }); + } + return response.status(204).json({ message: `unwatered tree ${tree_id} ` }); +} diff --git a/api/get/_requests/adopted.ts b/_requests/get/adopted.ts similarity index 58% rename from api/get/_requests/adopted.ts rename to _requests/get/adopted.ts index 19e2803b..86dd4811 100644 --- a/api/get/_requests/adopted.ts +++ b/_requests/get/adopted.ts @@ -1,33 +1,37 @@ import { VercelRequest, VercelResponse } from "@vercel/node"; -import { checkDataError } from "../../../_utils/data-error-response"; +import { checkDataError } from "../../_utils/data-error-response"; import { checkLimitAndOffset, getLimitAndOffeset, -} from "../../../_utils/limit-and-offset"; -import { getRange } from "../../../_utils/parse-content-range"; -import { setupResponseData } from "../../../_utils/setup-response"; -import { supabase } from "../../../_utils/supabase"; -import { verifyRequest } from "../../../_utils/verify"; -import { getEnvs } from "../../../_utils/envs"; -import { checkRangeError } from "../../../_utils/range-error-response"; -import { createLinks } from "../../../_utils/create-links"; +} from "../../_utils/limit-and-offset"; +import { getRange } from "../../_utils/parse-content-range"; +import { setupResponseData } from "../../_utils/setup-response"; +import { supabase } from "../../_utils/supabase"; +import { getEnvs } from "../../_utils/envs"; +import { checkRangeError } from "../../_utils/range-error-response"; +import { createLinks } from "../../_utils/create-links"; +import { User } from "@supabase/supabase-js"; +import { urlContainsV3 } from "../../_utils/check-if-v3"; const { SUPABASE_URL } = getEnvs(); export default async function handler( request: VercelRequest, - response: VercelResponse + response: VercelResponse, + user?: User ) { - const authorized = await verifyRequest(request); - if (!authorized) { - return response.status(401).json({ error: "unauthorized" }); - } - checkLimitAndOffset(request, response); const { limit, offset } = getLimitAndOffeset(request.query); - const { uuid } = <{ uuid: string }>request.query; + let { uuid } = <{ uuid: string }>request.query; const { range, error: rangeError } = await getRange( `${SUPABASE_URL}/rest/v1/trees_adopted?uuid=eq.${uuid}` ); checkRangeError(response, rangeError, range); + if (!request.url) { + return response.status(500).json({ error: "no url in request" }); + } + if (urlContainsV3(request.url)) { + uuid = user?.id || uuid; + } + const { data, error } = await supabase .from("trees_adopted") .select("tree_id,uuid") diff --git a/api/get/_requests/byid.ts b/_requests/get/byid.ts similarity index 73% rename from api/get/_requests/byid.ts rename to _requests/get/byid.ts index b4e82b23..c7dfbd7b 100644 --- a/api/get/_requests/byid.ts +++ b/_requests/get/byid.ts @@ -1,8 +1,8 @@ // FIXME: Request could be done from the frontend import { VercelRequest, VercelResponse } from "@vercel/node"; -import { supabase } from "../../../_utils/supabase"; -import { setupResponseData } from "../../../_utils/setup-response"; -import { checkDataError } from "../../../_utils/data-error-response"; +import { supabase } from "../../_utils/supabase"; +import { setupResponseData } from "../../_utils/setup-response"; +import { checkDataError } from "../../_utils/data-error-response"; export default async function handler( request: VercelRequest, diff --git a/api/get/_requests/istreeadopted.ts b/_requests/get/istreeadopted.ts similarity index 51% rename from api/get/_requests/istreeadopted.ts rename to _requests/get/istreeadopted.ts index c2793458..6796342b 100644 --- a/api/get/_requests/istreeadopted.ts +++ b/_requests/get/istreeadopted.ts @@ -1,17 +1,22 @@ +import { User } from "@supabase/supabase-js"; import { VercelRequest, VercelResponse } from "@vercel/node"; -import { setupResponseData } from "../../../_utils/setup-response"; -import { supabase } from "../../../_utils/supabase"; -import { verifyRequest } from "../../../_utils/verify"; +import { urlContainsV3 } from "../../_utils/check-if-v3"; +import { setupResponseData } from "../../_utils/setup-response"; +import { supabase } from "../../_utils/supabase"; export default async function handler( request: VercelRequest, - response: VercelResponse + response: VercelResponse, + user?: User ) { - const authorized = await verifyRequest(request); - if (!authorized) { - return response.status(401).json({ error: "unauthorized" }); + const { id } = <{ uuid: string; id: string }>request.query; + let { uuid } = <{ uuid: string; id: string }>request.query; + if (!request.url) { + return response.status(500).json({ error: "no url in request" }); + } + if (urlContainsV3(request.url)) { + uuid = user?.id || uuid; } - const { uuid, id } = <{ uuid: string; id: string }>request.query; const { data, error } = await supabase .from("trees_adopted") diff --git a/api/get/_requests/lastwatered.ts b/_requests/get/lastwatered.ts similarity index 73% rename from api/get/_requests/lastwatered.ts rename to _requests/get/lastwatered.ts index 31c41ccf..6ceb509e 100644 --- a/api/get/_requests/lastwatered.ts +++ b/_requests/get/lastwatered.ts @@ -3,14 +3,14 @@ import { VercelRequest, VercelResponse } from "@vercel/node"; import { checkLimitAndOffset, getLimitAndOffeset, -} from "../../../_utils/limit-and-offset"; -import { getRange } from "../../../_utils/parse-content-range"; -import { setupResponseData } from "../../../_utils/setup-response"; -import { supabase } from "../../../_utils/supabase"; -import { getEnvs } from "../../../_utils/envs"; -import { checkRangeError } from "../../../_utils/range-error-response"; -import { checkDataError } from "../../../_utils/data-error-response"; -import { createLinks } from "../../../_utils/create-links"; +} from "../../_utils/limit-and-offset"; +import { getRange } from "../../_utils/parse-content-range"; +import { setupResponseData } from "../../_utils/setup-response"; +import { supabase } from "../../_utils/supabase"; +import { getEnvs } from "../../_utils/envs"; +import { checkRangeError } from "../../_utils/range-error-response"; +import { checkDataError } from "../../_utils/data-error-response"; +import { createLinks } from "../../_utils/create-links"; const { SUPABASE_URL } = getEnvs(); export default async function handler( diff --git a/api/get/_requests/treesbyids.ts b/_requests/get/treesbyids.ts similarity index 72% rename from api/get/_requests/treesbyids.ts rename to _requests/get/treesbyids.ts index eb26e56a..841e9694 100644 --- a/api/get/_requests/treesbyids.ts +++ b/_requests/get/treesbyids.ts @@ -1,17 +1,17 @@ // FIXME: Request could be done from the frontend import { VercelRequest, VercelResponse } from "@vercel/node"; -import { createLinks } from "../../../_utils/create-links"; -import { getEnvs } from "../../../_utils/envs"; +import { createLinks } from "../../_utils/create-links"; +import { getEnvs } from "../../_utils/envs"; import { checkLimitAndOffset, getLimitAndOffeset, -} from "../../../_utils/limit-and-offset"; -import { getRange } from "../../../_utils/parse-content-range"; -import { checkDataError } from "../../../_utils/data-error-response"; +} from "../../_utils/limit-and-offset"; +import { getRange } from "../../_utils/parse-content-range"; +import { checkDataError } from "../../_utils/data-error-response"; -import { checkRangeError } from "../../../_utils/range-error-response"; -import { setupResponseData } from "../../../_utils/setup-response"; -import { supabase } from "../../../_utils/supabase"; +import { checkRangeError } from "../../_utils/range-error-response"; +import { setupResponseData } from "../../_utils/setup-response"; +import { supabase } from "../../_utils/supabase"; const { SUPABASE_URL } = getEnvs(); export default async function handler( diff --git a/api/get/_requests/wateredandadopted.ts b/_requests/get/wateredandadopted.ts similarity index 70% rename from api/get/_requests/wateredandadopted.ts rename to _requests/get/wateredandadopted.ts index 6faafdaa..3aecda52 100644 --- a/api/get/_requests/wateredandadopted.ts +++ b/_requests/get/wateredandadopted.ts @@ -1,16 +1,16 @@ // // FIXME: Request could be done from the frontend import { VercelRequest, VercelResponse } from "@vercel/node"; -import { createLinks } from "../../../_utils/create-links"; -import { checkDataError } from "../../../_utils/data-error-response"; -import { getEnvs } from "../../../_utils/envs"; +import { createLinks } from "../../_utils/create-links"; +import { checkDataError } from "../../_utils/data-error-response"; +import { getEnvs } from "../../_utils/envs"; import { checkLimitAndOffset, getLimitAndOffeset, -} from "../../../_utils/limit-and-offset"; -import { getRange } from "../../../_utils/parse-content-range"; -import { checkRangeError } from "../../../_utils/range-error-response"; -import { setupResponseData } from "../../../_utils/setup-response"; -import { supabase } from "../../../_utils/supabase"; +} from "../../_utils/limit-and-offset"; +import { getRange } from "../../_utils/parse-content-range"; +import { checkRangeError } from "../../_utils/range-error-response"; +import { setupResponseData } from "../../_utils/setup-response"; +import { supabase } from "../../_utils/supabase"; const { SUPABASE_URL } = getEnvs(); export default async function handler( diff --git a/api/get/_requests/wateredbyuser.ts b/_requests/get/wateredbyuser.ts similarity index 56% rename from api/get/_requests/wateredbyuser.ts rename to _requests/get/wateredbyuser.ts index 4bef5b57..cbfa9986 100644 --- a/api/get/_requests/wateredbyuser.ts +++ b/_requests/get/wateredbyuser.ts @@ -1,33 +1,36 @@ import { VercelRequest, VercelResponse } from "@vercel/node"; -import { createLinks } from "../../../_utils/create-links"; -import { checkDataError } from "../../../_utils/data-error-response"; +import { createLinks } from "../../_utils/create-links"; +import { checkDataError } from "../../_utils/data-error-response"; import { checkLimitAndOffset, getLimitAndOffeset, -} from "../../../_utils/limit-and-offset"; -import { getRange } from "../../../_utils/parse-content-range"; -import { checkRangeError } from "../../../_utils/range-error-response"; -import { setupResponseData } from "../../../_utils/setup-response"; -import { supabase } from "../../../_utils/supabase"; -import { verifyRequest } from "../../../_utils/verify"; -import { getEnvs } from "../../../_utils/envs"; +} from "../../_utils/limit-and-offset"; +import { getRange } from "../../_utils/parse-content-range"; +import { checkRangeError } from "../../_utils/range-error-response"; +import { setupResponseData } from "../../_utils/setup-response"; +import { supabase } from "../../_utils/supabase"; +import { getEnvs } from "../../_utils/envs"; +import { User } from "@supabase/supabase-js"; +import { urlContainsV3 } from "../../_utils/check-if-v3"; const { SUPABASE_URL } = getEnvs(); export default async function handler( request: VercelRequest, - response: VercelResponse + response: VercelResponse, + user?: User ) { - const authorized = await verifyRequest(request); - if (!authorized) { - return response.status(401).json({ error: "unauthorized" }); - } checkLimitAndOffset(request, response); const { limit, offset } = getLimitAndOffeset(request.query); - const { uuid } = <{ uuid: string }>request.query; + let { uuid } = <{ uuid: string }>request.query; const { range, error: rangeError } = await getRange( `${SUPABASE_URL}/rest/v1/trees_watered?uuid=eq.${uuid}` ); checkRangeError(response, rangeError, range); - + if (!request.url) { + return response.status(500).json({ error: "no url in request" }); + } + if (urlContainsV3(request.url)) { + uuid = user?.id || uuid; + } const { data, error } = await supabase .from("trees_watered") .select("*") diff --git a/_requests/post/adopt.ts b/_requests/post/adopt.ts new file mode 100644 index 00000000..bdebe97f --- /dev/null +++ b/_requests/post/adopt.ts @@ -0,0 +1,34 @@ +import { User } from "@supabase/supabase-js"; +import { VercelRequest, VercelResponse } from "@vercel/node"; +import { urlContainsV3 } from "../../_utils/check-if-v3"; +import { supabase } from "../../_utils/supabase"; + +export default async function handler( + request: VercelRequest, + response: VercelResponse, + user?: User +) { + const { tree_id } = request.body; + let { uuid } = request.body; + if (!request.url) { + return response.status(500).json({ error: "no url in request" }); + } + if (urlContainsV3(request.url)) { + uuid = user?.id || uuid; + } + const { data, error } = await supabase + .from("trees_adopted") + .upsert( + { + tree_id, + uuid, + }, + + { onConflict: "uuid,tree_id" } + ) + .select(); + if (error) { + return response.status(500).json({ error }); + } + return response.status(201).json({ message: "adopted", data }); +} diff --git a/_requests/post/water.ts b/_requests/post/water.ts new file mode 100644 index 00000000..5cea11c0 --- /dev/null +++ b/_requests/post/water.ts @@ -0,0 +1,54 @@ +import { User } from "@supabase/supabase-js"; +import { VercelRequest, VercelResponse } from "@vercel/node"; +import { Database } from "../../_types/database"; +import { urlContainsV3 } from "../../_utils/check-if-v3"; +import { checkDataError } from "../../_utils/data-error-response"; +import { supabase } from "../../_utils/supabase"; +type TreesWatered = Database["public"]["Tables"]["trees_watered"]["Insert"]; + +export default async function handler( + request: VercelRequest, + response: VercelResponse, + user?: User +) { + const body = request.body as TreesWatered; + const { tree_id, timestamp, amount } = body; + let { uuid, username } = request.body; + if (!request.url) { + return response.status(500).json({ error: "no url in request" }); + } + + if (urlContainsV3(request.url)) { + uuid = user?.id || uuid; + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", uuid); + checkDataError({ + data, + error, + response, + errorMessage: "no user profile found", + }); + + type UserProfiles = NonNullable; + username = (data as UserProfiles)[0].username || username; + } + + const { data, error } = await supabase + .from("trees_watered") + .insert({ + // TODO: [GDK-220] Remove time from db schema trees_watered it is a legacy value not used anymore + // https://github.com/technologiestiftung/giessdenkiez-de-postgres-api/issues/160 + tree_id, + username, + timestamp, + uuid, + amount, + }) + .select(); + if (error) { + return response.status(500).json({ error }); + } + return response.status(201).json({ message: "watered", data }); +} diff --git a/_utils/check-if-v3.ts b/_utils/check-if-v3.ts new file mode 100644 index 00000000..3575591d --- /dev/null +++ b/_utils/check-if-v3.ts @@ -0,0 +1,3 @@ +export function urlContainsV3(url: string): boolean { + return url.includes("v3"); +} diff --git a/_utils/errors.ts b/_utils/errors.ts new file mode 100644 index 00000000..f1fd331b --- /dev/null +++ b/_utils/errors.ts @@ -0,0 +1,6 @@ +export class GDKAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "GDKAuthError"; + } +} diff --git a/_utils/setup-response.ts b/_utils/setup-response.ts index da8d5e2d..be5a94e5 100644 --- a/_utils/setup-response.ts +++ b/_utils/setup-response.ts @@ -8,7 +8,7 @@ const pkg = getPackage(); // } export function setupResponseData(overrides?: T) { return { - version: pkg.version, + // version: pkg.version, name: pkg.name, // bugs: pkg.bugs?.url, // home: pkg.homepage, diff --git a/_utils/validation.ts b/_utils/validation.ts index f95c0f7a..74384295 100644 --- a/_utils/validation.ts +++ b/_utils/validation.ts @@ -62,41 +62,6 @@ export const wateredandadoptedSchemata: AjvSchema = { additionalProperties: false, }; -export const allSchema: AjvSchema = { - type: "object", - properties: { - type, - limit: { type: "string" }, - offset: { type: "string" }, - }, - required: ["limit", "offset"], - additionalProperties: false, -}; - -export const countbyageSchema: AjvSchema = { - type: "object", - properties: { - type, - start: { type: "string" }, - end: { type: "string" }, - }, - required: ["start", "end"], - additionalProperties: false, -}; - -export const byageSchema: AjvSchema = { - type: "object", - properties: { - type, - start: { type: "string" }, - end: { type: "string" }, - limit: { type: "string" }, - offset: { type: "string" }, - }, - required: ["start", "end"], - additionalProperties: false, -}; - export const lastwateredSchema: AjvSchema = { type: "object", properties: { @@ -146,9 +111,6 @@ export const getSchemas: Record = { byid: byidSchema, treesbyids: treesbyidsSchema, wateredandadopted: wateredandadoptedSchemata, - all: allSchema, - countbyage: countbyageSchema, - byage: byageSchema, lastwatered: lastwateredSchema, adopted: adoptedSchema, istreeadopted: istreeadoptedSchema, diff --git a/_utils/verify.ts b/_utils/verify-auth0.ts similarity index 85% rename from _utils/verify.ts rename to _utils/verify-auth0.ts index 49d68df7..eb997247 100644 --- a/_utils/verify.ts +++ b/_utils/verify-auth0.ts @@ -1,7 +1,7 @@ import { VercelRequest } from "@vercel/node"; import { options, verifyAuth0Token } from "./verify-token"; -export async function verifyRequest(request: VercelRequest) { +export async function verifyAuth0Request(request: VercelRequest) { const { authorization } = request.headers; if (!authorization) { return false; diff --git a/_utils/verify-supabase-token.ts b/_utils/verify-supabase-token.ts new file mode 100644 index 00000000..74aa8c36 --- /dev/null +++ b/_utils/verify-supabase-token.ts @@ -0,0 +1,24 @@ +// based on this thread "Verify access token on node.js" +// https://github.com/supabase/supabase/issues/491 +import { VercelRequest } from "@vercel/node"; +import { GDKAuthError } from "./errors"; +import { supabase } from "./supabase"; + +export async function verifySupabaseToken(request: VercelRequest) { + const { authorization } = request.headers; + + if (!authorization) { + return { data: null, error: new GDKAuthError("not authorized") }; + } + + const access_token = authorization.split("Bearer ").pop(); + if (!access_token) { + return { data: null, error: new GDKAuthError("not authorized") }; + } + const { data, error } = await supabase.auth.getUser(access_token); + + if (error) { + return { data: null, error }; + } + return { data: data.user, error }; +} diff --git a/api/delete/[type].ts b/api/delete/[type].ts index 4a12a31f..0a7bf191 100644 --- a/api/delete/[type].ts +++ b/api/delete/[type].ts @@ -1,9 +1,10 @@ import { VercelRequest, VercelResponse } from "@vercel/node"; -import { verifyRequest } from "../../_utils/verify"; +import { verifyAuth0Request } from "../../_utils/verify-auth0"; import setHeaders from "../../_utils/set-headers"; -import { supabase } from "../../_utils/supabase"; import { deleteSchemas, validate } from "../../_utils/validation"; +import unadoptHandler from "../../_requests/delete/unadopt"; +import unwaterHandler from "../../_requests/delete/unwater"; export const queryTypes = ["unadopt", "unwater"]; // const schemas: Record = { // unadopt: unadoptSchema, @@ -19,7 +20,7 @@ export default async function deleteHandler( if (request.method === "OPTIONS") { return response.status(200).end(); } - const authorized = await verifyRequest(request); + const authorized = await verifyAuth0Request(request); if (!authorized) { return response.status(401).json({ error: "unauthorized" }); } @@ -48,35 +49,10 @@ export default async function deleteHandler( return response.status(400).json({ error: "invalid query type" }); } case "unadopt": { - const { tree_id, uuid } = request.body; - const { error } = await supabase - .from("trees_adopted") - .delete() - .eq("tree_id", tree_id) - .eq("uuid", uuid); - if (error) { - return response.status(500).json({ error }); - } - return response - .status(204) - .json({ message: `unadopted tree ${tree_id}` }); + return await unadoptHandler(request, response); } case "unwater": { - // FIXME: [GDK-221] API (with supabase) Find out why delete/unwater route does not work - - const { tree_id, uuid, watering_id } = request.body; - const { error } = await supabase - .from("trees_watered") - .delete() - .eq("tree_id", tree_id) - .eq("uuid", uuid) - .eq("id", watering_id); - if (error) { - return response.status(500).json({ error }); - } - return response - .status(204) - .json({ message: `unwatered tree ${tree_id} ` }); + return await unwaterHandler(request, response); } } } diff --git a/api/get/[type].ts b/api/get/[type].ts index 62b4dd89..128b21a8 100644 --- a/api/get/[type].ts +++ b/api/get/[type].ts @@ -3,13 +3,14 @@ import setHeaders from "../../_utils/set-headers"; import { queryTypes as queryTypesList } from "../../_utils/routes-listing"; import { getSchemas, paramsToObject, validate } from "../../_utils/validation"; -import byidHandler from "./_requests/byid"; -import treesbyidsHandler from "./_requests/treesbyids"; -import wateredandadoptedHandler from "./_requests/wateredandadopted"; -import lastwateredHandler from "./_requests/lastwatered"; -import adoptedHandler from "./_requests/adopted"; -import istreeadoptedHandler from "./_requests/istreeadopted"; -import wateredbyuserHandler from "./_requests/wateredbyuser"; +import byidHandler from "../../_requests/get/byid"; +import treesbyidsHandler from "../../_requests/get/treesbyids"; +import wateredandadoptedHandler from "../../_requests/get/wateredandadopted"; +import lastwateredHandler from "../../_requests/get/lastwatered"; +import adoptedHandler from "../../_requests/get/adopted"; +import istreeadoptedHandler from "../../_requests/get/istreeadopted"; +import wateredbyuserHandler from "../../_requests/get/wateredbyuser"; +import { verifyAuth0Request } from "../../_utils/verify-auth0"; export const method = "GET"; const queryTypes = Object.keys(queryTypesList[method]); @@ -58,19 +59,32 @@ export default async function handler( case "wateredandadopted": { return await wateredandadoptedHandler(request, response); } + case "lastwatered": { return await lastwateredHandler(request, response); } // All requests below this line are only available for authenticated users // -------------------------------------------------------------------- case "adopted": { + const authorized = await verifyAuth0Request(request); + if (!authorized) { + return response.status(401).json({ error: "unauthorized" }); + } return await adoptedHandler(request, response); } case "istreeadopted": { + const authorized = await verifyAuth0Request(request); + if (!authorized) { + return response.status(401).json({ error: "unauthorized" }); + } return await istreeadoptedHandler(request, response); } case "wateredbyuser": { + const authorized = await verifyAuth0Request(request); + if (!authorized) { + return response.status(401).json({ error: "unauthorized" }); + } return await wateredbyuserHandler(request, response); } } diff --git a/api/post/[type].ts b/api/post/[type].ts index 938298c3..30200d7d 100644 --- a/api/post/[type].ts +++ b/api/post/[type].ts @@ -1,23 +1,16 @@ import { VercelRequest, VercelResponse } from "@vercel/node"; import setHeaders from "../../_utils/set-headers"; -import { supabase } from "../../_utils/supabase"; import { postSchemas, validate } from "../../_utils/validation"; -import { Database } from "../../_types/database"; -import { verifyRequest } from "../../_utils/verify"; import { queryTypes as queryTypesList } from "../../_utils/routes-listing"; +import { verifyAuth0Request } from "../../_utils/verify-auth0"; +import adoptHandler from "../../_requests/post/adopt"; +import waterHandler from "../../_requests/post/water"; const queryTypes = Object.keys(queryTypesList["POST"]); // api/[name].ts -> /api/lee // req.query.name -> "lee" -// const schemas: Record = { -// adopt: adoptSchema, -// water: waterSchema, -// }; - -// type TreesAdopted = Database["public"]["Tables"]["trees_adopted"]["Insert"]; -type TreesWatered = Database["public"]["Tables"]["trees_watered"]["Insert"]; export default async function postHandler( request: VercelRequest, response: VercelResponse @@ -26,8 +19,21 @@ export default async function postHandler( if (request.method === "OPTIONS") { return response.status(200).end(); } - const authorized = await verifyRequest(request); - if (!authorized) { + + // const { data: userData, error } = await verifySupabaseToken(request); + // if (error) { + // console.error("error from supabase auth", error); + // return response.status(401).json({ error: "unauthorized" }); + // } + // if (!userData) { + // console.error("no user data from supabase auth"); + // return response.status(401).json({ error: "unauthorized" }); + // } + /** + * We will remove auth0 but for now we can auth with both + */ + const auth0RequestValid = await verifyAuth0Request(request); + if (!auth0RequestValid) { return response.status(401).json({ error: "unauthorized" }); } const { type } = request.query; @@ -56,43 +62,10 @@ export default async function postHandler( // https://github.com/technologiestiftung/giessdenkiez-de-postgres-api/issues/159 case "adopt": { - const { tree_id, uuid } = request.body; - const { data, error } = await supabase - .from("trees_adopted") - .upsert( - { - tree_id, - uuid, - }, - - { onConflict: "uuid,tree_id" } - ) - .select(); - if (error) { - // console.error(error); - return response.status(500).json({ error }); - } - return response.status(201).json({ message: "adopted", data }); + return await adoptHandler(request, response); } case "water": { - const body = request.body as TreesWatered; - const { tree_id, username, timestamp, uuid, amount } = body; - const { data, error } = await supabase - .from("trees_watered") - .insert({ - // TODO: [GDK-220] Remove time from db schema trees_watered it is a legacy value not used anymore - // https://github.com/technologiestiftung/giessdenkiez-de-postgres-api/issues/160 - tree_id, - username, - timestamp, - uuid, - amount, - }) - .select(); - if (error) { - return response.status(500).json({ error }); - } - return response.status(201).json({ message: "watered", data }); + return await waterHandler(request, response); } } } diff --git a/api/v3/delete/[type].ts b/api/v3/delete/[type].ts new file mode 100644 index 00000000..142f2355 --- /dev/null +++ b/api/v3/delete/[type].ts @@ -0,0 +1,62 @@ +import { VercelRequest, VercelResponse } from "@vercel/node"; +import setHeaders from "../../../_utils/set-headers"; +import { deleteSchemas, validate } from "../../../_utils/validation"; + +import unadoptHandler from "../../../_requests/delete/unadopt"; +import unwaterHandler from "../../../_requests/delete/unwater"; +import { verifySupabaseToken } from "../../../_utils/verify-supabase-token"; +export const queryTypes = ["unadopt", "unwater"]; +// const schemas: Record = { +// unadopt: unadoptSchema, +// unwater: unwaterSchema, +// }; +// api/[name].ts -> /api/lee +// req.query.name -> "lee" +export default async function deleteHandler( + request: VercelRequest, + response: VercelResponse +) { + setHeaders(response, "DELETE"); + if (request.method === "OPTIONS") { + return response.status(200).end(); + } + const { data: userData, error } = await verifySupabaseToken(request); + if (error) { + return response.status(401).json({ error: "unauthorized" }); + } + if (!userData) { + return response.status(401).json({ error: "unauthorized" }); + } + + const { type } = request.query; + if (Array.isArray(type)) { + return response.status(400).json({ error: "type needs to be a string" }); + } + if (!queryTypes.includes(type)) { + return response.status(400).json({ error: "invalid query type" }); + } + const [isBodyValid, validationErrors] = validate( + request.body, + deleteSchemas[type] + ); + if (!isBodyValid) { + return response + .status(400) + .json({ error: `invalid body: ${JSON.stringify(validationErrors)}` }); + } + + switch (type) { + default: { + // this is here to be sure there is no fall through case, + // but we actually already checked for the type above. + // So this is actually unreachable + return response.status(400).json({ error: "invalid query type" }); + } + case "unadopt": { + return await unadoptHandler(request, response, userData); + } + case "unwater": { + return await unwaterHandler(request, response, userData); + } + } +} diff --git a/api/v3/get/[type].ts b/api/v3/get/[type].ts new file mode 100644 index 00000000..9cf90c8f --- /dev/null +++ b/api/v3/get/[type].ts @@ -0,0 +1,95 @@ +import { VercelRequest, VercelResponse } from "@vercel/node"; +import setHeaders from "../../../_utils/set-headers"; +import { queryTypes as queryTypesList } from "../../../_utils/routes-listing"; +import { + getSchemas, + paramsToObject, + validate, +} from "../../../_utils/validation"; + +import byidHandler from "../../../_requests/get/byid"; +import treesbyidsHandler from "../../../_requests/get/treesbyids"; +import wateredandadoptedHandler from "../../../_requests/get/wateredandadopted"; +import lastwateredHandler from "../../../_requests/get/lastwatered"; +import adoptedHandler from "../../../_requests/get/adopted"; +import istreeadoptedHandler from "../../../_requests/get/istreeadopted"; +import wateredbyuserHandler from "../../../_requests/get/wateredbyuser"; +import { verifySupabaseToken } from "../../../_utils/verify-supabase-token"; + +export const method = "GET"; +const queryTypes = Object.keys(queryTypesList[method]); + +// api/[type].ts -> /api/lee +// req.query.type -> "lee" +export default async function handler( + request: VercelRequest, + response: VercelResponse +): Promise { + setHeaders(response, method); + if (request.method === "OPTIONS") { + return response.status(200).end(); + } + const { type } = request.query; + if (Array.isArray(type)) { + return response.status(400).json({ error: `${type} needs to be a string` }); + } + if (!queryTypes.includes(type)) { + return response.status(404).json({ error: `invalid route ${type}` }); + } + if (!request.url) { + return response.status(500).json({ error: "request url not available" }); + } + const params = paramsToObject( + request.url + .replace(`/v3/${method.toLowerCase()}/${type}`, "") + /* FIXME: this is to fix tests not production since the handler does not know the full route in tests */ + .replace(`/v3/?type=${type}`, "") + .replace(`/?type=${type}`, "") + ); + const [paramsAreValid, validationError] = validate(params, getSchemas[type]); + if (!paramsAreValid) { + return response + .status(400) + .json({ error: `invalid params: ${JSON.stringify(validationError)}` }); + } + + switch (type) { + default: { + return response.status(400).json({ error: "invalid query type" }); + } + case "byid": { + return await byidHandler(request, response); + } + case "treesbyids": { + return await treesbyidsHandler(request, response); + } + case "wateredandadopted": { + return await wateredandadoptedHandler(request, response); + } + case "lastwatered": { + return await lastwateredHandler(request, response); + } + // All requests below this line are only available for authenticated users + // -------------------------------------------------------------------- + case "adopted": + case "istreeadopted": + case "wateredbyuser": { + const { data: userData, error } = await verifySupabaseToken(request); + if (error) { + return response.status(401).json({ error: "unauthorized" }); + } + if (!userData) { + return response.status(401).json({ error: "unauthorized" }); + } + if (type === "adopted") { + return await adoptedHandler(request, response, userData); + } else if (type === "istreeadopted") { + return await istreeadoptedHandler(request, response, userData); + } else if (type === "wateredbyuser") { + return await wateredbyuserHandler(request, response, userData); + } else { + return response.status(400).json({ error: "invalid query type" }); + } + } + } +} diff --git a/api/v3/get/index.ts b/api/v3/get/index.ts new file mode 100644 index 00000000..2a482380 --- /dev/null +++ b/api/v3/get/index.ts @@ -0,0 +1,20 @@ +import { VercelRequest, VercelResponse } from "@vercel/node"; +import setHeaders from "../../../_utils/set-headers"; +import { setupResponseData } from "../../../_utils/setup-response"; +import { routes } from "../../../_utils/routes-listing"; + +export default async function handler( + _request: VercelRequest, + response: VercelResponse +) { + setHeaders(response, "GET"); + try { + return response + .status(200) + .json(setupResponseData({ message: "its working", routes })); + } catch (error) { + return response + .status(500) + .json(setupResponseData({ error: "its not working", routes })); + } +} diff --git a/api/v3/index.ts b/api/v3/index.ts new file mode 100644 index 00000000..14fb4aa5 --- /dev/null +++ b/api/v3/index.ts @@ -0,0 +1,20 @@ +import { VercelRequest, VercelResponse } from "@vercel/node"; +import { routes } from "../../_utils/routes-listing"; +import setHeaders from "../../_utils/set-headers"; +import { setupResponseData } from "../../_utils/setup-response"; + +export default async function handler( + _request: VercelRequest, + response: VercelResponse +) { + setHeaders(response, "GET"); + try { + return response + .status(200) + .json(setupResponseData({ message: "its working", routes })); + } catch (error) { + return response + .status(500) + .json(setupResponseData({ error: "its not working", routes })); + } +} diff --git a/api/v3/post/[type].ts b/api/v3/post/[type].ts new file mode 100644 index 00000000..28729a3b --- /dev/null +++ b/api/v3/post/[type].ts @@ -0,0 +1,63 @@ +import { VercelRequest, VercelResponse } from "@vercel/node"; +import setHeaders from "../../../_utils/set-headers"; +import { postSchemas, validate } from "../../../_utils/validation"; +import { queryTypes as queryTypesList } from "../../../_utils/routes-listing"; +import adoptHandler from "../../../_requests/post/adopt"; +import waterHandler from "../../../_requests/post/water"; +import { verifySupabaseToken } from "../../../_utils/verify-supabase-token"; + +const queryTypes = Object.keys(queryTypesList["POST"]); + +// api/[name].ts -> /api/lee +// req.query.name -> "lee" + +export default async function postHandler( + request: VercelRequest, + response: VercelResponse +) { + setHeaders(response, "POST"); + if (request.method === "OPTIONS") { + return response.status(200).end(); + } + + const { data: userData, error } = await verifySupabaseToken(request); + if (error) { + return response.status(401).json({ error: "unauthorized" }); + } + if (!userData) { + return response.status(401).json({ error: "unauthorized" }); + } + + const { type } = request.query; + if (Array.isArray(type)) { + return response.status(400).json({ error: "type needs to be a string" }); + } + if (!queryTypes.includes(type)) { + return response.status(400).json({ error: "invalid query type" }); + } + const [isBodyValid, validationErrors] = validate( + request.body, + postSchemas[type] + ); + if (!isBodyValid) { + return response + .status(400) + .json({ error: `invalid body: ${JSON.stringify(validationErrors)}` }); + } + switch (type) { + default: { + // Since we safegaurd agains invalid types, + // we can safely assume that the type is valid. + // Should not be a fall through case. + return response.status(400).json({ error: "invalid query type" }); + } + // https://github.com/technologiestiftung/giessdenkiez-de-postgres-api/issues/159 + + case "adopt": { + return await adoptHandler(request, response, userData); + } + case "water": { + return await waterHandler(request, response, userData); + } + } +} diff --git a/docs/api.http b/docs/api.http index 0d3eafea..baef8fe2 100644 --- a/docs/api.http +++ b/docs/api.http @@ -4,7 +4,7 @@ # -------------------------------------------------- @protocol = http @host = localhost -@port = 3000 +@port = 8080 @API_HOST = {{protocol}}://{{host}}:{{port}} @@ -13,6 +13,15 @@ @USER_ID = auth0|abc @USER_NAME = foo + +#SUPABASE VARS + +@SUPABASE_USER_EMAIL = someone@email.com +@SUPABASE_USER_PASSWORD = 1234567890 +@SUPABASE_USER_UUID = db640d6c-1ac9-4a4d-accc-0adacbf6d9ad +@SUPABASE_USER_NAME = someone +@SUPABASE_USER_ACCESS_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjc5NDE4Mzc1LCJzdWIiOiI2NmE2ZWNjZS1hZDNhLTRkODctOTIzZS02OTFhMzFhYTMyNjMiLCJlbWFpbCI6ImZhYmlhbm1vcm9uemlyZmFzQHByb3Rvbm1haWwuY2giLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7fSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY3OTQxNDc3NX1dLCJzZXNzaW9uX2lkIjoiOWYxYWM4OWItYTU4NC00Mjg5LWIxYWItY2IxNTBmYWZlMmVkIn0.QKf89g4ASbyC4txyHBWh7A_-nQyL93uY694S1Wm8TIY + # @API_HOST = https://giessdenkiez-de-postgres-api-git-dev-technologiestiftung1.vercel.app # These needs a .env in the root of the project @@ -40,6 +49,9 @@ ### Healthcheck GET {{API_HOST}} + +### v3 +GET {{API_HOST}}/v3 ### GET tree by its id byid ✓ GET {{API_HOST}}/get/byid&id={{TREE_ID}} @@ -87,6 +99,106 @@ GET {{API_HOST}}/get/byage&start=1800&end=2023&limit=10000&offset=0 GET {{API_HOST}}/get/countbyage&start=1800&end=2023 +######################## +# +# SUPABASE AUTH +# +######################### + + + + +### Signup + +POST {{SUPABASE_URL}}/auth/v1/signup +apikey: {{SUPABASE_ANON_KEY}} +Content-Type: application/json + +{ + "email": "{{SUPABASE_USER_EMAIL}}", + "password": "{{SUPABASE_USER_PASSWORD}}" +} + +### Login + + +POST {{SUPABASE_URL}}/auth/v1/token?grant_type=password +apikey: {{SUPABASE_ANON_KEY}} +Content-Type: application/json + +{ + "email": "{{SUPABASE_USER_EMAIL}}", + "password": "{{SUPABASE_USER_PASSWORD}}" +} + + +### Login with magic link +# look ont oinbucket of the email +# http://localhost:54324 +POST {{SUPABASE_URL}}/auth/v1/magiclink +apikey: {{SUPABASE_ANON_KEY}} +Content-Type: application/json + +{ + "email": "{{SUPABASE_USER_EMAIL}}" +} + +### Get user JSON + +GET {{SUPABASE_URL}}/auth/v1/user +apikey: {{SUPABASE_ANON_KEY}} +Content-Type: application/json +Authorization: Bearer {{SUPABASE_USER_ACCESS_TOKEN}} + + +### Password recovery +# in local developement look into the inbuckt of the email +# http://localhost:54324 +# This will send you to http://localhost:3000 ??? +# with a token und the recovery url Param + +POST {{SUPABASE_URL}}/auth/v1/recover +apikey: {{SUPABASE_ANON_KEY}} +Content-Type: application/json + +{ + "email": "{{SUPABASE_USER_EMAIL}}" +} + + +### Update the user data password and/or email +# data is optional + +PUT {{SUPABASE_URL}}/auth/v1/user +apikey: {{SUPABASE_ANON_KEY}} +Authorization: Bearer {{SUPABASE_USER_ACCESS_TOKEN}} +Content-Type: application/json + +{ + "email": "{{SUPABASE_USER_EMAIL}}", + "password": "{{SUPABASE_USER_PASSWORD}}", + "data": { + "key": "value" + } +} + + +### Logout + +POST {{SUPABASE_URL}}/auth/v1/logout +apikey {{SUPABASE_ANON_KEY}} +Authorization: Bearer {{SUPABASE_USER_ACCESS_TOKEN}} + + + + + +######################## +# +# SUPABASE AUTH END +# +######################### + ##### ####### ####### @@ -128,6 +240,9 @@ Content-Type: application/json + + + ##### ####### ####### # # # # # # # @@ -185,7 +300,7 @@ Authorization: Bearer {{token}} ### POST water a tree POST {{API_HOST}}/post/water -Authorization: Bearer {{token}} +Authorization: Bearer {{SUPABASE_USER_ACCESS_TOKEN}} Content-Type: application/json { @@ -247,7 +362,6 @@ Content-Type: application/json } - # ██████ ██ ██ ██████ ███████ # ██ ██ ██ ██ ██ ██ ██ # ██████ ██ ██ ██████ █████ @@ -298,3 +412,11 @@ apikey: {{SUPABASE_ANON_KEY}} Range-Unit: items Prefer: count=exact + +### DELETE an account + +POST {{SUPABASE_URL}}/rest/v1/rpc/remove_account +apikey: {{SUPABASE_ANON_KEY}} +Authorization: Bearer {{SUPABASE_USER_ACCESS_TOKEN}} +Content-Type: application/json + diff --git a/package.json b/package.json index 61252208..f0274ad7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "scripts": { "test": "jest", - "vercel:dev": "vercel dev", + "vercel:dev": "vercel dev --listen 8080", "lint": "eslint ./**/*.ts ", "format": "prettier ./**/*.ts --write", "generate:types": "npx just generate-types" diff --git a/supabase/migrations/20230321152350_username_change.sql b/supabase/migrations/20230321152350_username_change.sql new file mode 100644 index 00000000..face08f3 --- /dev/null +++ b/supabase/migrations/20230321152350_username_change.sql @@ -0,0 +1,22 @@ +SET check_function_bodies = OFF; + +CREATE OR REPLACE FUNCTION public.update_username_on_trees_watered () + RETURNS TRIGGER + LANGUAGE plpgsql + AS $function$ +BEGIN + UPDATE + trees_watered + SET + username = NEW.username + WHERE + uuid = OLD.id::text; + RETURN NEW; +END; +$function$; + +CREATE TRIGGER update_username_on_trees_watered_trigger + AFTER INSERT OR UPDATE ON public.profiles + FOR EACH ROW + EXECUTE FUNCTION update_username_on_trees_watered (); + diff --git a/supabase/migrations/20230321153935_delete_user_data.sql b/supabase/migrations/20230321153935_delete_user_data.sql new file mode 100644 index 00000000..70203323 --- /dev/null +++ b/supabase/migrations/20230321153935_delete_user_data.sql @@ -0,0 +1,29 @@ +SET check_function_bodies = OFF; + +CREATE OR REPLACE FUNCTION public.delete_user () + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + AS $function$ +DECLARE + row_count int; +BEGIN + DELETE FROM public.profiles p + WHERE p.id = OLD.id; + IF found THEN + GET DIAGNOSTICS row_count = ROW_COUNT; + RAISE NOTICE 'DELETEd % row(s) FROM profiles', row_count; + END IF; + UPDATE + trees_watered + SET + uuid = NULL, + username = NULL + WHERE + uuid = OLD.id::text; + DELETE FROM trees_adopted ta + WHERE ta.uuid = OLD.id::text; + RETURN OLD; +END; +$function$; + diff --git a/supabase/migrations/20230321154859_constrain_profiles.sql b/supabase/migrations/20230321154859_constrain_profiles.sql new file mode 100644 index 00000000..6d207257 --- /dev/null +++ b/supabase/migrations/20230321154859_constrain_profiles.sql @@ -0,0 +1,5 @@ +ALTER TABLE "public"."profiles" + ADD CONSTRAINT "fk_users_profiles" FOREIGN KEY (id) REFERENCES auth.users (id) ON DELETE CASCADE NOT valid; + +ALTER TABLE "public"."profiles" validate CONSTRAINT "fk_users_profiles"; + diff --git a/supabase/migrations/20230321161426_remove_account.sql b/supabase/migrations/20230321161426_remove_account.sql new file mode 100644 index 00000000..ef8bda3c --- /dev/null +++ b/supabase/migrations/20230321161426_remove_account.sql @@ -0,0 +1,12 @@ +SET check_function_bodies = OFF; + +CREATE OR REPLACE FUNCTION public.remove_account () + RETURNS void + LANGUAGE sql + SECURITY DEFINER + AS $function$ + DELETE FROM auth.users + WHERE id = auth.uid (); + +$function$; + diff --git a/supabase/migrations/20230322102054_rls_for_users.sql b/supabase/migrations/20230322102054_rls_for_users.sql new file mode 100644 index 00000000..df311d5f --- /dev/null +++ b/supabase/migrations/20230322102054_rls_for_users.sql @@ -0,0 +1,13 @@ +CREATE POLICY "Enable delete for users based on uuid" ON "public"."trees_adopted" AS permissive + FOR DELETE TO authenticated + USING (((auth.uid ())::text = uuid)); + +CREATE POLICY "Enable delete for users based on user_id" ON "public"."trees_watered" AS permissive + FOR DELETE TO authenticated + USING (((auth.uid ())::text = uuid)); + +CREATE POLICY "Enable update for users based on uuid" ON "public"."trees_watered" AS permissive + FOR UPDATE TO authenticated + USING (((auth.uid ())::text = uuid)) + WITH CHECK (((auth.uid ())::text = uuid)); + diff --git a/tsconfig.json b/tsconfig.json index 3cae854a..960a8d27 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "include": [ "api/**/*.ts", "_utils/**/*.ts", + "_requests/**/*.ts", "__tests__/**/*.ts", "__test-utils__/**/*.ts", "_types/**/*.ts"