diff --git a/README.md b/README.md index 02234d9..8fd341f 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,6 @@ MailSettings__SenderEmail= MailSettings__UserName= MailSettings__Password= PUBLIC_HOST= -PORT= # Used for docker-compose -ASPNETCORE_URLS_PORT= ASPNETCORE_URLS= ``` @@ -97,7 +95,7 @@ ASPNETCORE_URLS= See the [docker-compose.yaml here](./web/docker-compose.yaml). ```bash -PORT= ASPNETCORE_URLS_PORT= docker compose up +docker compose up -f docker-compose.yaml ``` #### Using `docker build` and `docker run` @@ -107,7 +105,7 @@ PORT= ASPNETCORE_URLS_PORT= docker docker build -t rss-bookmarkr -f ./Dockerfile . # Run -docker run --env-file ./.env -p : rss-bookmarkr +docker run --env-file ./.env rss-bookmarkr ## or docker run \ -e ConnectionStrings__RssDb="POSTGRES CONNECTION STRING" \ @@ -119,6 +117,24 @@ docker run \ -e MailSettings__Password= \ -e PUBLIC_HOST= \ -e ASPNETCORE_URLS="" \ --p : \ rss-bookmarkr ``` + +## Testing + +Prepare the app in isolated Docker environment. + +```bash +make test_e2e_setup +``` +Wait until the app's container healthy, then run the migration. + +```bash +make test_e2e_migration +``` + +Cleanup the app's container + +```bash +make test_e2e_teardown +``` \ No newline at end of file diff --git a/web/.env.example b/web/.env.example index f000631..c920960 100644 --- a/web/.env.example +++ b/web/.env.example @@ -13,7 +13,6 @@ MailSettings__Password=YOUR_PASWORD PUBLIC_HOST=YOUR_PUBLIC_HOST # PORT where app run -PORT=8080 ASPNETCORE_URLS_PORT=8080 ASPNETCORE_URLS=http://[::]:${ASPNETCORE_URLS_PORT} diff --git a/web/Makefile b/web/Makefile index 7fce534..e43ae36 100644 --- a/web/Makefile +++ b/web/Makefile @@ -38,5 +38,15 @@ publish_project: publish_client publish_server copy_client copy_email test_e2e_setup: docker compose -p rss-bookmarkr -f docker-compose.e2e.yaml up --build -d +test_e2e_migration_backup: + mv migrondi.json migrondi.bak.json ;\ + mv migrondi.e2e.json migrondi.json + +test_e2e_migration_restore: + mv migrondi.json migrondi.e2e.json ;\ + mv migrondi.bak.json migrondi.json + +test_e2e_migration: test_e2e_migration_backup migrate_up test_e2e_migration_restore + test_e2e_teardown: docker compose -p rss-bookmarkr -f docker-compose.e2e.yaml down diff --git a/web/docker-compose.registry.yaml b/web/docker-compose.registry.yaml index 450116f..66ffbcf 100644 --- a/web/docker-compose.registry.yaml +++ b/web/docker-compose.registry.yaml @@ -3,7 +3,6 @@ services: image: localhost:5000/rss-bookmarkr container_name: rss-bookmarkr restart: unless-stopped - ports: - - "${PORT}:${ASPNETCORE_URLS_PORT}" + network_mode: "host" env_file: - .env \ No newline at end of file diff --git a/web/docker-compose.yaml b/web/docker-compose.yaml index cf2b176..741fd98 100644 --- a/web/docker-compose.yaml +++ b/web/docker-compose.yaml @@ -3,7 +3,6 @@ services: build: . container_name: rss-bookmarkr restart: unless-stopped - ports: - - "${PORT}:${ASPNETCORE_URLS_PORT}" + network_mode: "host" env_file: - .env \ No newline at end of file diff --git a/web/src/Client/Client.fs b/web/src/Client/Client.fs index c7df362..bc96b99 100644 --- a/web/src/Client/Client.fs +++ b/web/src/Client/Client.fs @@ -1,4 +1,4 @@ -module App +module App open System open Feliz @@ -54,15 +54,27 @@ module RPC = async { return! store.loginOrRegister loginForm } let saveRSSUrlssss (userId: string, rssUrls: string array) : unit Async = - async { do! store.saveRSSUrls (userId, rssUrls) } + async { + do! + store.saveRSSUrls ( + { SaveRSSUrlReq.Urls = rssUrls + SaveRSSUrlReq.UserId = userId } + ) + } let initLogin (sessionId: string) : LoginResponse Async = - async { return! store.initLogin sessionId } + async { return! store.initLogin { InitLoginReq.SessionId = sessionId } } let subscribe (userId: string, email: string) : unit Async = - async { do! store.subscribe (userId, email) } - - let unsubscribe (email: string) : unit Async = async { do! store.unsubscribe email } + async { + do! + store.subscribe + { SubscribeReq.Email = email + SubscribeReq.UserId = userId } + } + + let unsubscribe (email: string) : unit Async = + async { do! store.unsubscribe { UnsubscribeReq.Email = email } } module Component = let renderError (error: string option) : Fable.React.ReactElement = diff --git a/web/src/Server/Server.fs b/web/src/Server/Server.fs index 308ea1b..40411f9 100644 --- a/web/src/Server/Server.fs +++ b/web/src/Server/Server.fs @@ -22,6 +22,16 @@ open Shared let rssDbConnectionStringKey = "RssDb" +/// Ref: https://github.com/giraffe-fsharp/Giraffe/issues/323#issuecomment-777622090 +let tryBindJson<'T> (parsingErrorHandler: string -> HttpHandler) (successHandler: 'T -> HttpHandler) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> + task { + try + let! model = ctx.BindJsonAsync<'T>() + return! successHandler model next ctx + with (ex: exn) -> + return! parsingErrorHandler ex.Message next ctx + } /// A full background service using a dedicated type. /// Ref: https://github.com/CompositionalIT/background-services @@ -53,10 +63,37 @@ type HttpContext with | v -> v [] -type RSSQueryString = { Url: string array } +type RSSQueryString = + { Url: string array } + + override this.ToString() = + sprintf "Url: %s" (String.concat ", " this.Url) + + member _.HasErrors() = None + + interface IModelValidation with + member this.Validate() = + match this.HasErrors() with + | Some msg -> Error(RequestErrors.badRequest (text msg)) + | None -> Ok this [] -type UnsubscribeQueryString = { Email: string } +type UnsubscribeQueryString = + { Email: string } + + override this.ToString() = sprintf "Email: %s" this.Email + + member this.HasErrors() = + if this.Email.Length <= 0 then + Some "Email is required." + else + None + + interface IModelValidation with + member this.Validate() = + match this.HasErrors() with + | Some msg -> Error(RequestErrors.badRequest (text msg)) + | None -> Ok this type User = { Id: string @@ -627,27 +664,30 @@ module Handler = return loginResponse } - let saveRSSUrls (connectionString: string) (userId: string, urls: string array) : unit Async = + let saveRSSUrls (connectionString: string) (saveRSSUrlReq: SaveRSSUrlReq) : unit Async = async { - let existingUrls = (DataAccess.getRSSUrls connectionString userId) |> List.toArray + let existingUrls = + (DataAccess.getRSSUrls connectionString saveRSSUrlReq.UserId) |> List.toArray let newUrls = - urls |> Array.filter (fun url -> not <| Array.contains url existingUrls) + saveRSSUrlReq.Urls + |> Array.filter (fun url -> not <| Array.contains url existingUrls) let deletedUrls = - existingUrls |> Array.filter (fun url -> not <| Array.contains url urls) + existingUrls + |> Array.filter (fun url -> not <| Array.contains url saveRSSUrlReq.Urls) if newUrls.Length <> 0 then - DataAccess.insertUrls connectionString userId newUrls + DataAccess.insertUrls connectionString saveRSSUrlReq.UserId newUrls if deletedUrls.Length <> 0 then - DataAccess.deleteUrls connectionString userId deletedUrls + DataAccess.deleteUrls connectionString saveRSSUrlReq.UserId deletedUrls } - let initLogin (connectionString: string) (sessionId: string) : LoginResponse Async = + let initLogin (connectionString: string) (initLoginReq: InitLoginReq) : LoginResponse Async = async { return - sessionId + initLoginReq.SessionId |> DataAccess.getUserSession connectionString |> (function | None -> @@ -657,43 +697,78 @@ module Handler = let loginResult = { LoginResult.UserId = user.Id LoginResult.RssUrls = (DataAccess.getRSSUrls connectionString user.Id) |> List.toArray - LoginResult.SessionId = sessionId + LoginResult.SessionId = initLoginReq.SessionId LoginResult.Email = user.Email } Success loginResult) } - let subscribe (connectionString: string) (userId: string, email: string) : unit Async = + let subscribe (connectionString: string) (subscribeReq: SubscribeReq) : unit Async = async { - (DataAccess.getRSSUrls connectionString userId) + (DataAccess.getRSSUrls connectionString subscribeReq.UserId) |> List.map (fun (rssURL: string) -> { RSSHistory.Url = rssURL RSSHistory.LatestUpdated = DateTime.Now }) - |> (DataAccess.setUserEmail connectionString (userId, email)) + |> (DataAccess.setUserEmail connectionString (subscribeReq.UserId, subscribeReq.Email)) |> ignore } - let unsubscribe (connectionString: string) (email: string) : unit Async = - async { (DataAccess.unsetUserEmail connectionString email) |> ignore } + let unsubscribe (connectionString: string) (unsubscribeReq: UnsubscribeReq) : unit Async = + async { (DataAccess.unsetUserEmail connectionString unsubscribeReq.Email) |> ignore } module ApiHandler = - let rssListAction (next: HttpFunc) (ctx: HttpContext) = - match ctx.TryBindQueryString() with - | Error(err: string) -> RequestErrors.BAD_REQUEST err next ctx - | Ok(rssQueryString: RSSQueryString) -> + let rssListAction (rssQueryString: RSSQueryString) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> task { let! (rssList: RSS seq) = rssQueryString.Url |> Handler.getRSSList return! json rssList next ctx } + let loginOrRegisterAction (loginForm: LoginForm) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> + task { + let! (loginResponse: LoginResponse) = (Handler.loginOrRegister ctx.RssDbConnectionString loginForm) + return! json loginResponse next ctx + } + + let saveRSSUrlsAction (saveRSSUrlReq: SaveRSSUrlReq) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> + task { + do! (Handler.saveRSSUrls ctx.RssDbConnectionString saveRSSUrlReq) + return! Successful.OK "ok" next ctx + } + + let initLoginAction (initLoginReq: InitLoginReq) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> + task { + let! (loginResponse: LoginResponse) = (Handler.initLogin ctx.RssDbConnectionString initLoginReq) + return! json loginResponse next ctx + } + + let subscribeAction (subscribeReq: SubscribeReq) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> + task { + do! (Handler.subscribe ctx.RssDbConnectionString subscribeReq) + return! Successful.OK "ok" next ctx + } + + let unsubscribeAction (unsubscribeReq: UnsubscribeReq) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> + task { + do! (Handler.unsubscribe ctx.RssDbConnectionString unsubscribeReq) + return! Successful.OK "ok" next ctx + } + module ViewHandler = - let unsubsribePageAction (next: HttpFunc) (ctx: HttpContext) = - match ctx.TryBindQueryString() with - | Error(err: string) -> RequestErrors.BAD_REQUEST err next ctx - | Ok(unsubscribeQueryString: UnsubscribeQueryString) -> + let unsubsribePageAction (unsubscribeQueryString: UnsubscribeQueryString) : HttpHandler = + fun (next: HttpFunc) (ctx: HttpContext) -> task { - do! Handler.unsubscribe ctx.RssDbConnectionString unsubscribeQueryString.Email + do! + Handler.unsubscribe + ctx.RssDbConnectionString + { UnsubscribeReq.Email = unsubscribeQueryString.Email } + return! htmlView Views.unsubsribePage next ctx } @@ -712,13 +787,39 @@ let webApp = |> Remoting.buildHttpHandler module Router = - let apiRouter = router { get "/rss" ApiHandler.rssListAction } + let apiRouter = + router { + get "/rss" (tryBindQuery RequestErrors.BAD_REQUEST None (ApiHandler.rssListAction)) + + post + "/login" + (tryBindJson RequestErrors.BAD_REQUEST (validateModel ApiHandler.loginOrRegisterAction)) + + post + "/save-urls" + (tryBindJson RequestErrors.BAD_REQUEST (validateModel ApiHandler.saveRSSUrlsAction)) + + post + "/init-login" + (tryBindJson RequestErrors.BAD_REQUEST (validateModel ApiHandler.initLoginAction)) + + post + "/subscribe" + (tryBindJson RequestErrors.BAD_REQUEST (validateModel ApiHandler.subscribeAction)) + + post + "/unsubscribe" + (tryBindJson RequestErrors.BAD_REQUEST (validateModel ApiHandler.unsubscribeAction)) + } let defaultView = router { forward "" webApp forward "/api" apiRouter - get "/unsubscribe" ViewHandler.unsubsribePageAction + + get + "/unsubscribe" + (tryBindQuery RequestErrors.BAD_REQUEST None (ViewHandler.unsubsribePageAction)) } let app = diff --git a/web/src/Shared/Shared.fs b/web/src/Shared/Shared.fs index 525e102..aaedd45 100644 --- a/web/src/Shared/Shared.fs +++ b/web/src/Shared/Shared.fs @@ -1,6 +1,7 @@ namespace Shared open System +open Giraffe [] type RSS = @@ -17,14 +18,116 @@ type RSS = sprintf $"{uri.Scheme}://{uri.Host}" [] -type LoginForm = { Username: string; Password: string } +[] +type LoginForm = + { Username: string + Password: string } + + override this.ToString() = + sprintf "Username: %s, Password: %s" this.Username this.Password + + member this.HasErrors() = + if this.Username.Length <= 0 then + Some "Username is required." + else if this.Password.Length <= 0 then + Some "Password is required." + else + None + + interface IModelValidation with + member this.Validate() = + match this.HasErrors() with + | Some msg -> Error(RequestErrors.badRequest (text msg)) + | None -> Ok this + +[] +[] +type SaveRSSUrlReq = + { UserId: string + Urls: string array } + + override this.ToString() = + sprintf "UserId: %s, Urls: %s" this.UserId (String.concat ", " this.Urls) + + member this.HasErrors() = + if this.UserId.Length <= 0 then + Some "UserId is required." + else + None + + interface IModelValidation with + member this.Validate() = + match this.HasErrors() with + | Some msg -> Error(RequestErrors.badRequest (text msg)) + | None -> Ok this + +[] +[] +type InitLoginReq = + { SessionId: string } + + override this.ToString() = sprintf "SessionId: %s" this.SessionId + + member this.HasErrors() = + if this.SessionId.Length <= 0 then + Some "UserId is required." + else + None + + interface IModelValidation with + member this.Validate() = + match this.HasErrors() with + | Some msg -> Error(RequestErrors.badRequest (text msg)) + | None -> Ok this + +[] +[] +type SubscribeReq = + { UserId: string + Email: string } + + override this.ToString() = + sprintf "UserId: %s, Email: %s" this.UserId this.Email + + member this.HasErrors() = + if this.UserId.Length <= 0 then + Some "UserId is required." + else if this.Email.Length <= 0 then + Some "Email is required." + else + None + + interface IModelValidation with + member this.Validate() = + match this.HasErrors() with + | Some msg -> Error(RequestErrors.badRequest (text msg)) + | None -> Ok this + +[] +[] +type UnsubscribeReq = + { Email: string } + + override this.ToString() = sprintf "Email: %s" this.Email + + member this.HasErrors() = + if this.Email.Length <= 0 then + Some "Email is required." + else + None + + interface IModelValidation with + member this.Validate() = + match this.HasErrors() with + | Some msg -> Error(RequestErrors.badRequest (text msg)) + | None -> Ok this [] type LoginResult = { UserId: string RssUrls: string array SessionId: string - Email: string } + Email: string option } [] type LoginError = { Message: string } @@ -36,10 +139,10 @@ type LoginResponse = type IRPCStore = { getRSSList: string array -> RSS seq Async loginOrRegister: LoginForm -> LoginResponse Async - saveRSSUrls: (string * string array) -> unit Async - initLogin: string -> LoginResponse Async - subscribe: (string * string) -> unit Async - unsubscribe: string -> unit Async } + saveRSSUrls: SaveRSSUrlReq -> unit Async + initLogin: InitLoginReq -> LoginResponse Async + subscribe: SubscribeReq -> unit Async + unsubscribe: UnsubscribeReq -> unit Async } module Route = let routeBuilder (typeName: string) (methodName: string) = diff --git a/web/src/Shared/Shared.fsproj b/web/src/Shared/Shared.fsproj index ce48422..82a4b67 100644 --- a/web/src/Shared/Shared.fsproj +++ b/web/src/Shared/Shared.fsproj @@ -1,12 +1,11 @@ - - - - Exe - net8.0 - - - - - - - + + + + Exe + net8.0 + + + + + + \ No newline at end of file diff --git a/web/src/Shared/paket.references b/web/src/Shared/paket.references new file mode 100644 index 0000000..a442a3b --- /dev/null +++ b/web/src/Shared/paket.references @@ -0,0 +1 @@ +Giraffe \ No newline at end of file diff --git a/web/tests/rssbokmarkr-page.ts b/web/tests/rssbokmarkr-page.ts index e5f17e7..c64a80e 100644 --- a/web/tests/rssbokmarkr-page.ts +++ b/web/tests/rssbokmarkr-page.ts @@ -99,7 +99,9 @@ export class RSSBokmarkrPage { async saveURLs(expectedURLs: string[]) { await this.assertSaveRSSUrls({ - expectedPostData: [[this.loginResonse?.["UserId"], expectedURLs]], + expectedPostData: [ + { UserId: this.loginResonse?.["UserId"], Urls: expectedURLs }, + ], trigger: async () => { await this.saveURLsButton.click(); }, @@ -111,7 +113,9 @@ export class RSSBokmarkrPage { await this.subscribeEmailField.fill(email); await this.assertSubscribe({ - expectedPostData: [[this.loginResonse?.["UserId"], email]], + expectedPostData: [ + { UserId: this.loginResonse?.["UserId"], Email: email }, + ], trigger: async () => { await this.subscribeFormButton.click(); }, @@ -120,7 +124,7 @@ export class RSSBokmarkrPage { async unsubscribe(email: string) { await this.assertUnsubscribe({ - expectedPostData: [email], + expectedPostData: [{ Email: email }], trigger: async () => { await this.unsubscribeButton.click(); }, @@ -154,7 +158,7 @@ export class RSSBokmarkrPage { async assertRSSList(option: { trigger: () => Promise; - expectedPostData: unknown; + expectedPostData: [string[]]; }) { return await this.assertAPICall({ ...option, @@ -164,7 +168,7 @@ export class RSSBokmarkrPage { async assertSaveRSSUrls(option: { trigger: () => Promise; - expectedPostData: unknown; + expectedPostData: [{ UserId: string; Urls: string[] }]; }) { return await this.assertAPICall({ ...option, @@ -174,7 +178,7 @@ export class RSSBokmarkrPage { async assertSubscribe(option: { trigger: () => Promise; - expectedPostData: unknown; + expectedPostData: [{ UserId: string; Email: string }]; }) { return await this.assertAPICall({ ...option, @@ -184,7 +188,7 @@ export class RSSBokmarkrPage { async assertUnsubscribe(option: { trigger: () => Promise; - expectedPostData: unknown; + expectedPostData: [{ Email: string }]; }) { return await this.assertAPICall({ ...option, @@ -194,7 +198,7 @@ export class RSSBokmarkrPage { async assertLoginOrRegister(option: { trigger: () => Promise; - expectedPostData: unknown; + expectedPostData: [{ Username: string; Password: string }]; }) { return await this.assertAPICall({ ...option, diff --git a/web/tests/rssbokmarkr.spec.ts b/web/tests/rssbokmarkr.spec.ts index a84246a..d414816 100644 --- a/web/tests/rssbokmarkr.spec.ts +++ b/web/tests/rssbokmarkr.spec.ts @@ -3,8 +3,18 @@ import { RSSBokmarkrPage } from "./rssbokmarkr-page"; const pageURL = process.env.PAGE_URL || "http://localhost:8080"; -const username = "testingusername"; -const password = "testingpassword"; +const generateRandomString = () => { + return (Math.random() + 1).toString(36).substring(2); +}; + +const generateUsername = () => { + return `${generateRandomString()}username`; +}; + +const generatePassword = () => { + return `${generateRandomString()}password`; +}; + const subscribeEmail = "test@test.com"; const rssURLS = [ @@ -94,7 +104,7 @@ test.describe("user authentication", () => { const rssBookmarkrPage = new RSSBokmarkrPage(pageURL, page); await rssBookmarkrPage.goto(); - await rssBookmarkrPage.login(username, password); + await rssBookmarkrPage.login(generateUsername(), generatePassword()); await rssBookmarkrPage.logout(); }); }); @@ -103,7 +113,7 @@ test.describe("authorized user", () => { test("save urls and delete the url", async ({ page }) => { const rssBookmarkrPage = new RSSBokmarkrPage(pageURL, page); await rssBookmarkrPage.goto(); - await rssBookmarkrPage.login(username, password); + await rssBookmarkrPage.login(generateUsername(), generatePassword()); await rssBookmarkrPage.addURL(overreactedURL); await rssBookmarkrPage.saveURLs([overreactedURL]); @@ -115,7 +125,7 @@ test.describe("authorized user", () => { test("subscribe and unsubscribe", async ({ page }) => { const rssBookmarkrPage = new RSSBokmarkrPage(pageURL, page); await rssBookmarkrPage.goto(); - await rssBookmarkrPage.login(username, password); + await rssBookmarkrPage.login(generateUsername(), generatePassword()); await rssBookmarkrPage.subscribe(subscribeEmail); await rssBookmarkrPage.unsubscribe(subscribeEmail);