diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 739e569f..adee20ef 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -144,26 +144,32 @@ vitest: - js/junit.xml expire_in: 30 days -# cypress: -# stage: test -# services: -# - name: postgis/postgis:13.3 -# alias: postgres -# variables: -# MIX_ENV=e2e -# script: -# - mix ecto.create -# - mix ecto.migrate -# - mix run priv/repo/e2e.seed.exs -# - mix phx.server & -# - cd js -# - npx wait-on http://localhost:4000 -# - if [ -z "$CYPRESS_KEY" ]; then npx cypress run; else npx cypress run --record --parallel --key $CYPRESS_KEY; fi -# artifacts: -# expire_in: 2 day -# paths: -# - js/tests/e2e/screenshots/**/*.png -# - js/tests/e2e/videos/**/*.mp4 +e2e: + stage: test + services: + - name: postgis/postgis:14-3.2 + alias: postgres + variables: + MIX_ENV: "e2e" + before_script: + - mix deps.get + - mix ecto.create + - mix ecto.migrate + - mix run priv/repo/e2e.seed.exs + - cd js && yarn run build && npx playwright install && cd ../ + - mix phx.digest + script: + - mix phx.server & + - cd js + - npx wait-on http://localhost:4000 + - npx playwright test --project $BROWSER + parallel: + matrix: + - BROWSER: ['firefox', 'chromium'] + artifacts: + expire_in: 2 days + paths: + - js/playwright-report pages: stage: deploy diff --git a/config/e2e.exs b/config/e2e.exs index cbb64310..7135ea44 100644 --- a/config/e2e.exs +++ b/config/e2e.exs @@ -19,19 +19,39 @@ config :mobilizon, Mobilizon.Web.Endpoint, yarn: [cd: Path.expand("../js", __DIR__)] ] -require Logger +config :vite_phx, + release_app: :mobilizon, + # Hard code :prod as an environment as :e2e will not be recongnized + environment: :prod, + vite_manifest: "priv/static/manifest.json", + phx_manifest: "priv/static/cache_manifest.json", + dev_server_address: "http://localhost:5173" -cond do - System.get_env("INSTANCE_CONFIG") && - File.exists?("./config/#{System.get_env("INSTANCE_CONFIG")}") -> - import_config System.get_env("INSTANCE_CONFIG") +config :mobilizon, :instance, + name: "E2E Testing instance", + description: "E2E is safety", + hostname: "mobilizon1.com", + registrations_open: true, + registration_email_denylist: ["gmail.com", "deny@tcit.fr"], + demo: false, + default_language: "en", + allow_relay: true, + federating: true, + email_from: "mobilizon@mobilizon1.com", + email_reply_to: nil, + enable_instance_feeds: true, + koena_connect_link: true, + extra_categories: [ + %{ + id: :something_else, + label: "Quelque chose d'autre" + } + ] - System.get_env("DOCKER", "false") == "false" && File.exists?("./config/e2e.secret.exs") -> - import_config "e2e.secret.exs" - - System.get_env("DOCKER", "false") == "true" -> - Logger.info("Using environment configuration for Docker") - - true -> - Logger.error("No configuration file found") -end +config :mobilizon, Mobilizon.Storage.Repo, + adapter: Ecto.Adapters.Postgres, + username: System.get_env("MOBILIZON_DATABASE_USERNAME", "mobilizon_e2e"), + password: System.get_env("MOBILIZON_DATABASE_PASSWORD", "mobilizon_e2e"), + database: System.get_env("MOBILIZON_DATABASE_DBNAME", "mobilizon_e2e"), + hostname: System.get_env("MOBILIZON_DATABASE_HOST", "localhost"), + port: System.get_env("MOBILIZON_DATABASE_PORT") || "5432" diff --git a/js/.gitignore b/js/.gitignore index 5c5176c4..11369d99 100644 --- a/js/.gitignore +++ b/js/.gitignore @@ -2,8 +2,6 @@ node_modules /dist -/tests/e2e/videos/ -/tests/e2e/screenshots/ /coverage stats.html diff --git a/js/README.md b/js/README.md deleted file mode 100644 index e4cb29da..00000000 --- a/js/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# mobilizon - -## Project setup - -``` -yarn install -``` - -### Compiles and hot-reloads for development - -``` -yarn serve -``` - -### Compiles and minifies for production - -``` -yarn build -``` - -### Run your unit tests - -``` -yarn test:unit -``` - -### Run your end-to-end tests - -``` -yarn test:e2e -``` - -### Lints and fixes files - -``` -yarn lint -``` - -### Customize configuration - -See [Configuration Reference](https://cli.vuejs.org/config/). diff --git a/js/cypress.json b/js/cypress.json deleted file mode 100644 index 17b7cd74..00000000 --- a/js/cypress.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "pluginsFile": "tests/e2e/plugins/index.js", - "projectId": "86dpkx", - "baseUrl": "http://localhost:4000", - "viewportWidth": 1920, - "viewportHeight": 1080 -} diff --git a/js/playwright.config.ts b/js/playwright.config.ts index c3da6c26..03e0bf45 100644 --- a/js/playwright.config.ts +++ b/js/playwright.config.ts @@ -13,7 +13,7 @@ import { devices } from "@playwright/test"; const config: PlaywrightTestConfig = { testDir: "./tests/e2e", /* Maximum time one test can run for. */ - timeout: 30 * 1000, + timeout: 10 * 1000, expect: { /** * Maximum time expect() should wait for the condition to be met. @@ -36,7 +36,7 @@ const config: PlaywrightTestConfig = { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ actionTimeout: 0, /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://localhost:4005", + baseURL: "http://localhost:4000", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", diff --git a/js/src/App.vue b/js/src/App.vue index 175d22d4..b27d71ea 100644 --- a/js/src/App.vue +++ b/js/src/App.vue @@ -57,13 +57,17 @@ import { } from "vue"; import { LocationType } from "@/types/user-location.model"; import { useMutation, useQuery } from "@vue/apollo-composable"; -import { initializeCurrentActor } from "@/utils/identity"; +import { + initializeCurrentActor, + NoIdentitiesException, +} from "@/utils/identity"; import { useI18n } from "vue-i18n"; import { Snackbar } from "@/plugins/snackbar"; import { Notifier } from "@/plugins/notifier"; import { CONFIG } from "@/graphql/config"; import { IConfig } from "@/types/config.model"; import { useRouter } from "vue-router"; +import RouteName from "@/router/name"; const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG); @@ -130,7 +134,19 @@ interval.value = setInterval(async () => { onBeforeMount(async () => { if (initializeCurrentUser()) { - await initializeCurrentActor(); + try { + await initializeCurrentActor(); + } catch (err) { + if (err instanceof NoIdentitiesException) { + await router.push({ + name: RouteName.REGISTER_PROFILE, + params: { + email: localStorage.getItem(AUTH_USER_EMAIL), + userAlreadyActivated: "true", + }, + }); + } + } } }); diff --git a/js/src/components/NavBar.vue b/js/src/components/NavBar.vue index 8a85f7bd..73752e44 100644 --- a/js/src/components/NavBar.vue +++ b/js/src/components/NavBar.vue @@ -406,11 +406,11 @@ watch(identities, () => { // If we don't have any identities, the user has validated their account, // is logging for the first time but didn't create an identity somehow if (identities.value && identities.value.length === 0) { - console.debug( + console.warn( "We have no identities listed for current user", identities.value ); - console.debug("Pushing route to REGISTER_PROFILE"); + console.info("Pushing route to REGISTER_PROFILE"); router.push({ name: RouteName.REGISTER_PROFILE, params: { diff --git a/js/src/composition/apollo/user.ts b/js/src/composition/apollo/user.ts index daa130a2..b285d7f7 100644 --- a/js/src/composition/apollo/user.ts +++ b/js/src/composition/apollo/user.ts @@ -66,15 +66,7 @@ export async function updateLocale(locale: string) { })); } -export function registerAccount( - variables: { - preferredUsername: string; - name: string; - summary: string; - email: string; - }, - userAlreadyActivated: boolean -) { +export function registerAccount() { return useMutation< { registerPerson: IPerson }, { @@ -84,12 +76,12 @@ export function registerAccount( email: string; } >(REGISTER_PERSON, () => ({ - variables, update: ( store: ApolloCache<{ registerPerson: IPerson }>, - { data: localData }: FetchResult + { data: localData }: FetchResult, + { context } ) => { - if (userAlreadyActivated) { + if (context?.userAlreadyActivated) { const identitiesData = store.readQuery<{ identities: IPerson[] }>({ query: IDENTITIES, }); diff --git a/js/src/router/user.ts b/js/src/router/user.ts index 978cb9c8..85a1dd96 100644 --- a/js/src/router/user.ts +++ b/js/src/router/user.ts @@ -28,7 +28,7 @@ export const userRoutes: RouteRecordRaw[] = [ beforeEnter: beforeRegisterGuard, }, { - path: "/register/profile", + path: "/register/profile/:email/:userAlreadyActivated?", name: UserRouteName.REGISTER_PROFILE, component: (): Promise => import("@/views/Account/RegisterView.vue"), // We can only pass string values through params, therefore diff --git a/js/src/utils/identity.ts b/js/src/utils/identity.ts index fd3e6131..e4541ef5 100644 --- a/js/src/utils/identity.ts +++ b/js/src/utils/identity.ts @@ -11,7 +11,7 @@ import { computed, watch } from "vue"; export class NoIdentitiesException extends Error {} -export function saveActorData(obj: IPerson): void { +function saveActorData(obj: IPerson): void { localStorage.setItem(AUTH_USER_ACTOR_ID, `${obj.id}`); } diff --git a/js/src/views/Account/RegisterView.vue b/js/src/views/Account/RegisterView.vue index 8b52b6ca..f2ec0af4 100644 --- a/js/src/views/Account/RegisterView.vue +++ b/js/src/views/Account/RegisterView.vue @@ -1,120 +1,127 @@ @@ -173,61 +180,42 @@ const autoUpdateUsername = () => { identity.value.preferredUsername = convertToUsername(identity.value.name); }; +const { onDone, onError, mutate } = registerAccount(); + +onDone(async ({ data }) => { + validationSent.value = true; + window.localStorage.setItem("new-registered-user", "yes"); + + if (data && props.userAlreadyActivated) { + await changeIdentity(data.registerPerson); + + await router.push({ name: RouteName.HOME }); + } +}); + +onError((err) => { + errors.value = err.graphQLErrors.reduce( + (acc: { [key: string]: string }, error: any) => { + acc[error.details ?? error.field ?? "extra"] = Array.isArray( + error.message + ) + ? (error.message as string[]).join(",") + : error.message; + return acc; + }, + {} + ); + console.error("Error while registering person", err); + console.error("Errors while registering person", errors); + sendingValidation.value = false; +}); + const submit = async (): Promise => { sendingValidation.value = true; errors.value = {}; - const { onDone, onError } = registerAccount( + mutate( { email: props.email, ...identity.value }, - props.userAlreadyActivated + { context: { userAlreadyActivated: props.userAlreadyActivated } } ); - - onDone(async ({ data }) => { - validationSent.value = true; - window.localStorage.setItem("new-registered-user", "yes"); - - if (data && props.userAlreadyActivated) { - await changeIdentity(data.registerPerson); - - await router.push({ name: RouteName.HOME }); - } - }); - - onError((err) => { - errors.value = err.graphQLErrors.reduce( - (acc: { [key: string]: string }, error: any) => { - acc[error.details || error.field] = error.message; - return acc; - }, - {} - ); - console.error("Error while registering person", err); - console.error("Errors while registering person", errors); - sendingValidation.value = false; - }); }; - - diff --git a/js/src/views/HomeView.vue b/js/src/views/HomeView.vue index 544b27d3..2aeee3e2 100644 --- a/js/src/views/HomeView.vue +++ b/js/src/views/HomeView.vue @@ -365,7 +365,12 @@ onMounted(() => { const router = useRouter(); watch(loggedUser, (loggedUserValue) => { - if (loggedUserValue?.id && loggedUserValue?.settings === null) { + if ( + loggedUserValue?.id && + loggedUserValue?.settings === null && + loggedUserValue.defaultActor?.id + ) { + console.info("No user settings, going to onboarding", loggedUserValue); router.push({ name: RouteName.WELCOME_SCREEN, params: { step: "1" }, diff --git a/js/src/views/User/RegisterView.vue b/js/src/views/User/RegisterView.vue index 625480c3..685dce4f 100644 --- a/js/src/views/User/RegisterView.vue +++ b/js/src/views/User/RegisterView.vue @@ -91,7 +91,7 @@
diff --git a/js/tests/e2e/login.spec.ts b/js/tests/e2e/login.spec.ts index f35f84fa..f7f11dc6 100644 --- a/js/tests/e2e/login.spec.ts +++ b/js/tests/e2e/login.spec.ts @@ -59,8 +59,8 @@ test("Login rejects unknown users properly", async ({ page }) => { test("Tries to login with valid credentials", async ({ page, context }) => { await page.goto("/login"); - await page.locator("#email").fill("user@provider.org"); - await page.locator("#password").fill("valid_passw0rd"); + await page.locator("#email").fill("user@email.com"); + await page.locator("#password").fill("some password"); const loginButton = page.locator("form button", { hasText: "Login" }); @@ -69,5 +69,102 @@ test("Tries to login with valid credentials", async ({ page, context }) => { await loginButton.click(); await page.waitForURL("/"); expect(new URL(page.url()).pathname).toBe("/"); - expect((await context.storageState()).origins[0].localStorage).toBe("toto"); + const localStorage = ( + await context.storageState() + ).origins[0].localStorage.reduce((acc: Record, elem) => { + acc[elem.name] = elem.value; + return acc; + }, {}); + expect(localStorage["auth-user-role"]).toBe("USER"); + expect(localStorage["auth-access-token"]).toBeDefined(); + expect(localStorage["auth-refresh-token"]).toBeDefined(); + expect(localStorage["auth-user-email"]).toBe("user@email.com"); + // Changes each run + // expect(localStorage["auth-user-id"]).toBe("3"); + // Doesn't work in Chrome for some reason + // expect(localStorage['auth-user-actor-id']).toBe('2'); +}); + +test("Tries to login with valid credentials but unconfirmed account", async ({ + page, +}) => { + await page.goto("/login"); + await page.locator("#email").fill("unconfirmed@email.com"); + await page.locator("#password").fill("some password"); + await page.keyboard.press("Enter"); + + await expect(page.locator(".notification-danger")).toHaveText( + "User not found" + ); +}); + +test("Tries to login with valid credentials, confirmed account but no profile", async ({ + page, +}) => { + await page.goto("/login"); + await page.locator("#email").fill("confirmed@email.com"); + await page.locator("#password").fill("some password"); + await page.keyboard.press("Enter"); + + await page.waitForURL("/register/profile/confirmed@email.com/true"); + expect(page.url()).toContain("/register/profile/confirmed@email.com/true"); + + await expect(page.locator("p.prose").first()).toHaveText( + "Now, create your first profile:" + ); + + const displayNameField = page.locator("form > .field").first(); + await expect(displayNameField.locator("label")).toHaveText( + "Displayed nickname" + ); + const displayNameInput = displayNameField.locator("input"); + await displayNameInput.fill("Duplicate"); + + const usernameField = page.locator("form > .field").nth(1); + await expect(usernameField.locator("label")).toHaveText("Username"); + const usernameFieldInput = usernameField.locator("input"); + await usernameFieldInput.fill("test_user"); + + const descriptionField = page.locator("form > .field").nth(2); + await expect(descriptionField.locator("label")).toHaveText("Short bio"); + await descriptionField + .locator("textarea") + .fill("This shouln't work because it's using a dupublicated username"); + + const submitButton = page.locator('button[type="submit"]', { + hasText: "Create my profile", + }); + await submitButton.click(); + + await expect(page.locator("p.field-message-danger")).toHaveText( + "This username is already taken." + ); + + await displayNameInput.fill(""); + await displayNameInput.fill("Not"); + + await usernameFieldInput.fill(""); + await usernameFieldInput.fill("test_user_2"); + + await submitButton.click(); + + // cy.get("form .field input").first(0).clear().type("test_user_2"); + // cy.get("form .field input").eq(1).type("Not"); + // cy.get("form .field textarea").clear().type("This will now work"); + // cy.get("form").submit(); + + // cy.get(".navbar-link span.icon i").should( + // "have.class", + // "mdi-account-circle" + // ); + + await page.waitForURL("/"); + expect(page.url()).toContain("/"); + + await expect(page.locator(".notification-info")).toHaveText( + "Welcome to Mobilizon, Not!" + ); + await expect( + page.locator("button#user-menu-button span:not(.sr-only)") + ).toHaveClass("material-design-icon account-circle-icon"); }); diff --git a/lib/federation/activity_pub/types/actors.ex b/lib/federation/activity_pub/types/actors.ex index e612000c..f62ca43f 100644 --- a/lib/federation/activity_pub/types/actors.ex +++ b/lib/federation/activity_pub/types/actors.ex @@ -210,6 +210,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do if actor_type == :Application do Instances.refresh() end + FollowMailer.send_notification_to_admins(follower) follower_as_data = Convertible.model_to_as(follower) approve_if_manually_approves_followers(follower, follower_as_data) diff --git a/lib/graphql/resolvers/admin.ex b/lib/graphql/resolvers/admin.ex index e89fc132..bdb35c98 100644 --- a/lib/graphql/resolvers/admin.ex +++ b/lib/graphql/resolvers/admin.ex @@ -467,7 +467,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do end @spec get_instance(any, map(), Absinthe.Resolution.t()) :: - {:error, :unauthenticated | :unauthorized | :not_found} | {:ok, Mobilizon.Instances.Instance.t()} + {:error, :unauthenticated | :unauthorized | :not_found} + | {:ok, Mobilizon.Instances.Instance.t()} def get_instance(_parent, %{domain: domain}, %{ context: %{current_user: %User{role: role}} }) diff --git a/lib/graphql/schema/search.ex b/lib/graphql/schema/search.ex index 1fcaeca1..2cc36e50 100644 --- a/lib/graphql/schema/search.ex +++ b/lib/graphql/schema/search.ex @@ -24,7 +24,10 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do field(:tags, list_of(:tag), description: "The event's tags") field(:category, :event_category, description: "The event's category") field(:options, :event_options, description: "The event options") - field(:participant_stats, :participant_stats, description: "Statistics on the event's participants") + + field(:participant_stats, :participant_stats, + description: "Statistics on the event's participants" + ) resolve_type(fn %Event{}, _ -> @@ -55,7 +58,10 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do field(:tags, list_of(:tag), description: "The event's tags") field(:category, :event_category, description: "The event's category") field(:options, :event_options, description: "The event options") - field(:participant_stats, :participant_stats, description: "Statistics on the event's participants") + + field(:participant_stats, :participant_stats, + description: "Statistics on the event's participants" + ) end interface :group_search_result do @@ -198,7 +204,10 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do arg(:language_one_of, list_of(:string), description: "The list of languages this event can be in" ) - arg(:boost_languages, list_of(:string), description: "The user's languages that can benefit from a boost in search results") + + arg(:boost_languages, list_of(:string), + description: "The user's languages that can benefit from a boost in search results" + ) arg(:search_target, :search_target, default_value: :internal, @@ -238,7 +247,10 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do arg(:language_one_of, list_of(:string), description: "The list of languages this event can be in" ) - arg(:boost_languages, list_of(:string), description: "The user's languages that can benefit from a boost in search results") + + arg(:boost_languages, list_of(:string), + description: "The user's languages that can benefit from a boost in search results" + ) arg(:search_target, :search_target, default_value: :internal, diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index e09822e7..3890b103 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -541,7 +541,7 @@ defmodule Mobilizon.Events do |> filter_draft() |> filter_local_or_from_followed_instances_events() |> filter_public_visibility() - |> event_order(args.sort_by) + |> event_order(Map.get(args, :sort_by, :match_desc)) |> Page.build_page(page, limit, :begins_on) end @@ -1819,11 +1819,16 @@ defmodule Mobilizon.Events do |> event_order_begins_on_asc() end - defp event_order(query, :match_desc), do: order_by(query, [e, f], desc: f.rank, asc: e.begins_on) + defp event_order(query, :match_desc), + do: order_by(query, [e, f], desc: f.rank, asc: e.begins_on) + defp event_order(query, :start_time_desc), do: order_by(query, [e], asc: e.begins_on) defp event_order(query, :created_at_desc), do: order_by(query, [e], desc: e.publish_at) defp event_order(query, :created_at_asc), do: order_by(query, [e], asc: e.publish_at) - defp event_order(query, :participant_count_desc), do: order_by(query, [e], fragment("participant_stats->>'participant' DESC")) + + defp event_order(query, :participant_count_desc), + do: order_by(query, [e], fragment("participant_stats->>'participant' DESC")) + defp event_order(query, _), do: query defp event_order_begins_on_asc(query), diff --git a/priv/repo/e2e.seed.exs b/priv/repo/e2e.seed.exs index fba8c31c..39cc2e84 100644 --- a/priv/repo/e2e.seed.exs +++ b/priv/repo/e2e.seed.exs @@ -3,7 +3,7 @@ defmodule EndToEndSeed do def delete_user(email) do with {:ok, user} <- Users.get_user_by_email(email) do - Users.delete_user(user) + Users.delete_user(user, reserve_email: false) end end end