Prepare stable Google Play review fixtures
This commit is contained in:
parent
0965523dc1
commit
4d321b92f5
|
|
@ -44,3 +44,18 @@ access field:
|
|||
- Confirm all data is synthetic and no real user can be messaged or located.
|
||||
- Confirm the password works from a clean Play-delivered install without a
|
||||
second factor, one-time code, developer browser session, or location gate.
|
||||
|
||||
After both dedicated accounts have registered, confirmed their email, and set
|
||||
their fixed passwords through the production UI, an operator can create the
|
||||
stable synthetic records from the production checkout:
|
||||
|
||||
```bash
|
||||
./scripts/prepare-play-review.sh \
|
||||
REVIEWER_EMAIL COUNTERPART_EMAIL \
|
||||
--confirm whoneedhelp.com \
|
||||
/srv/who_need_help-production/.env
|
||||
```
|
||||
|
||||
The command does not create or change credentials. It refuses missing,
|
||||
unconfirmed, suspended, passwordless, staff, or duplicate accounts and is
|
||||
idempotent for its one request and one activity.
|
||||
|
|
|
|||
|
|
@ -41,8 +41,9 @@ require Play Console. It contains no account credentials or signing keys.
|
|||
- Create a dedicated non-staff production reviewer account with a fixed,
|
||||
reusable password. Put its credentials only in Play Console App access and
|
||||
the operator-controlled password manager.
|
||||
- Create stable synthetic `Play review` request and activity records with a
|
||||
second synthetic counterpart, then verify every reviewer instruction from a
|
||||
- Register and confirm two dedicated non-staff accounts with fixed passwords,
|
||||
then create their stable synthetic `Play review` request and activity using
|
||||
`scripts/prepare-play-review.sh`. Verify every reviewer instruction from a
|
||||
clean installation.
|
||||
- Complete App content: App access, Ads, Content rating, Target audience,
|
||||
News-app declaration, Data Safety, background-location declaration if Play
|
||||
|
|
|
|||
|
|
@ -62,6 +62,251 @@ defmodule WhoNeedHelp.Release do
|
|||
end)
|
||||
end
|
||||
|
||||
@play_review_request_title "Play review — medicine pickup"
|
||||
@play_review_activity_title "Play review — public cinema meetup"
|
||||
|
||||
@doc """
|
||||
Prepares stable, synthetic records for Google Play review using two existing
|
||||
ordinary accounts.
|
||||
|
||||
Both accounts must already be confirmed, active, have accepted the terms,
|
||||
have a password, and have no staff roles. The function never creates or
|
||||
changes credentials. Repeating it returns the same request and activity.
|
||||
"""
|
||||
def prepare_play_review(reviewer_email, counterpart_email)
|
||||
when is_binary(reviewer_email) and is_binary(counterpart_email) do
|
||||
load_app()
|
||||
|
||||
with {:ok, reviewer} <- play_review_user(reviewer_email, :reviewer),
|
||||
{:ok, counterpart} <- play_review_user(counterpart_email, :counterpart),
|
||||
:ok <- different_play_review_users(reviewer, counterpart),
|
||||
{:ok, medicine_category} <- active_category("medicine-pickup", :help),
|
||||
{:ok, cinema_category} <- active_category("cinema-meetup", :activity),
|
||||
{:ok, request} <-
|
||||
ensure_play_review_request(reviewer, counterpart, medicine_category),
|
||||
{:ok, activity} <-
|
||||
ensure_play_review_activity(reviewer, counterpart, cinema_category) do
|
||||
{:ok,
|
||||
%{
|
||||
reviewer_id: reviewer.id,
|
||||
counterpart_id: counterpart.id,
|
||||
request_id: request.id,
|
||||
request_path: "/requests/#{request.id}",
|
||||
activity_id: activity.id,
|
||||
activity_path: "/activities/#{activity.id}"
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_play_review(_reviewer_email, _counterpart_email),
|
||||
do: {:error, :invalid_email}
|
||||
|
||||
defp play_review_user(email, role) do
|
||||
email = email |> String.trim() |> String.downcase()
|
||||
|
||||
case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) do
|
||||
nil ->
|
||||
{:error, {role, :user_not_found}}
|
||||
|
||||
user ->
|
||||
cond do
|
||||
user.moderation_status != :active ->
|
||||
{:error, {role, :account_not_active}}
|
||||
|
||||
is_nil(user.confirmed_at) ->
|
||||
{:error, {role, :email_not_confirmed}}
|
||||
|
||||
is_nil(user.accepted_terms_at) ->
|
||||
{:error, {role, :terms_not_accepted}}
|
||||
|
||||
not (is_binary(user.hashed_password) and byte_size(user.hashed_password) > 0) ->
|
||||
{:error, {role, :password_not_set}}
|
||||
|
||||
WhoNeedHelp.Accounts.staff_roles(user) != [] ->
|
||||
{:error, {role, :staff_account_forbidden}}
|
||||
|
||||
true ->
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp different_play_review_users(%{id: id}, %{id: id}),
|
||||
do: {:error, :accounts_must_be_distinct}
|
||||
|
||||
defp different_play_review_users(_reviewer, _counterpart), do: :ok
|
||||
|
||||
defp active_category(slug, mode) do
|
||||
case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Catalog.Category,
|
||||
slug: slug,
|
||||
mode: mode,
|
||||
active: true
|
||||
) do
|
||||
nil -> {:error, {:category_not_found, slug}}
|
||||
category -> {:ok, category}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_play_review_request(reviewer, counterpart, category) do
|
||||
existing =
|
||||
WhoNeedHelp.Help.HelpRequest
|
||||
|> where(
|
||||
[request],
|
||||
request.requester_id == ^reviewer.id and
|
||||
request.title == ^@play_review_request_title and
|
||||
is_nil(request.hidden_at)
|
||||
)
|
||||
|> order_by([request], desc: request.inserted_at)
|
||||
|> limit(1)
|
||||
|> WhoNeedHelp.Repo.one()
|
||||
|
||||
with {:ok, request} <- reusable_or_create_request(existing, reviewer, category),
|
||||
{:ok, _assignment} <- ensure_play_review_assignment(request, counterpart) do
|
||||
{:ok, WhoNeedHelp.Repo.get!(WhoNeedHelp.Help.HelpRequest, request.id)}
|
||||
end
|
||||
end
|
||||
|
||||
defp reusable_or_create_request(
|
||||
%{status: status} = request,
|
||||
_reviewer,
|
||||
_category
|
||||
)
|
||||
when status in [:open, :matched, :in_progress],
|
||||
do: {:ok, request}
|
||||
|
||||
defp reusable_or_create_request(nil, reviewer, category) do
|
||||
WhoNeedHelp.Help.create_request(WhoNeedHelp.Accounts.Scope.for_user(reviewer), %{
|
||||
"title" => @play_review_request_title,
|
||||
"description" =>
|
||||
"Synthetic Google Play review flow. Coordinate only with the assigned review counterpart.",
|
||||
"pickup_instructions" =>
|
||||
"Synthetic review data only; no medicine, payment, or real-world handover is required.",
|
||||
"structured_data" => %{"pickup_status" => "already_paid"},
|
||||
"location_label" => "Kyiv public review area — synthetic data",
|
||||
"latitude" => 50.4501,
|
||||
"longitude" => 30.5234,
|
||||
"location_radius_meters" => 1_000,
|
||||
"location_visibility" => "approximate_public",
|
||||
"urgency" => "scheduled",
|
||||
"expires_at" => DateTime.add(DateTime.utc_now(:second), 120, :day),
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
})
|
||||
end
|
||||
|
||||
defp reusable_or_create_request(_finished_request, _reviewer, _category),
|
||||
do: {:error, :review_request_not_reusable}
|
||||
|
||||
defp ensure_play_review_assignment(request, counterpart) do
|
||||
assignment =
|
||||
WhoNeedHelp.Repo.get_by(WhoNeedHelp.Help.Assignment,
|
||||
request_id: request.id,
|
||||
active: true
|
||||
)
|
||||
|
||||
case assignment do
|
||||
nil when request.status == :open ->
|
||||
WhoNeedHelp.Help.accept_request(
|
||||
WhoNeedHelp.Accounts.Scope.for_user(counterpart),
|
||||
request.id
|
||||
)
|
||||
|
||||
%{helper_id: helper_id} = assignment when helper_id == counterpart.id ->
|
||||
{:ok, assignment}
|
||||
|
||||
nil ->
|
||||
{:error, :review_assignment_missing}
|
||||
|
||||
_other_assignment ->
|
||||
{:error, :review_request_assigned_to_another_user}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_play_review_activity(reviewer, counterpart, category) do
|
||||
existing =
|
||||
WhoNeedHelp.Activities.Activity
|
||||
|> where(
|
||||
[activity],
|
||||
activity.creator_id == ^reviewer.id and
|
||||
activity.title == ^@play_review_activity_title and
|
||||
is_nil(activity.hidden_at)
|
||||
)
|
||||
|> order_by([activity], desc: activity.inserted_at)
|
||||
|> limit(1)
|
||||
|> WhoNeedHelp.Repo.one()
|
||||
|
||||
with {:ok, activity} <- reusable_or_create_activity(existing, reviewer, category),
|
||||
{:ok, participant} <- ensure_play_review_participant(activity, counterpart),
|
||||
{:ok, _participant} <- approve_play_review_participant(activity, participant, reviewer) do
|
||||
{:ok, WhoNeedHelp.Repo.get!(WhoNeedHelp.Activities.Activity, activity.id)}
|
||||
end
|
||||
end
|
||||
|
||||
defp reusable_or_create_activity(%{status: :open} = activity, _reviewer, _category),
|
||||
do: {:ok, activity}
|
||||
|
||||
defp reusable_or_create_activity(nil, reviewer, category) do
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
WhoNeedHelp.Activities.create_activity(WhoNeedHelp.Accounts.Scope.for_user(reviewer), %{
|
||||
"title" => @play_review_activity_title,
|
||||
"description" =>
|
||||
"Synthetic Google Play review meetup in a public place. No real attendance is expected.",
|
||||
"structured_data" => %{"film_or_genre" => "Synthetic public screening"},
|
||||
"location_label" => "Kyiv public cinema area — synthetic data",
|
||||
"latitude" => 50.4508,
|
||||
"longitude" => 30.5248,
|
||||
"location_visibility" => "approximate_public",
|
||||
"starts_at" => DateTime.add(now, 120, :day),
|
||||
"join_deadline" => DateTime.add(now, 110, :day),
|
||||
"capacity" => 4,
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
})
|
||||
end
|
||||
|
||||
defp reusable_or_create_activity(_finished_activity, _reviewer, _category),
|
||||
do: {:error, :review_activity_not_reusable}
|
||||
|
||||
defp ensure_play_review_participant(activity, counterpart) do
|
||||
case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Activities.Participant,
|
||||
activity_id: activity.id,
|
||||
user_id: counterpart.id
|
||||
) do
|
||||
nil ->
|
||||
WhoNeedHelp.Activities.request_to_join(
|
||||
WhoNeedHelp.Accounts.Scope.for_user(counterpart),
|
||||
activity.id
|
||||
)
|
||||
|
||||
%{status: status} = participant when status in [:requested, :approved] ->
|
||||
{:ok, participant}
|
||||
|
||||
_participant ->
|
||||
{:error, :review_participant_not_reusable}
|
||||
end
|
||||
end
|
||||
|
||||
defp approve_play_review_participant(_activity, %{status: :approved} = participant, _reviewer),
|
||||
do: {:ok, participant}
|
||||
|
||||
defp approve_play_review_participant(activity, %{status: :requested} = participant, reviewer) do
|
||||
case WhoNeedHelp.Activities.approve_participant(
|
||||
WhoNeedHelp.Accounts.Scope.for_user(reviewer),
|
||||
participant.id
|
||||
) do
|
||||
{:ok, _activity} ->
|
||||
{:ok,
|
||||
WhoNeedHelp.Repo.get_by!(WhoNeedHelp.Activities.Participant,
|
||||
activity_id: activity.id,
|
||||
user_id: participant.user_id
|
||||
)}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def await_migrations do
|
||||
load_app()
|
||||
Enum.each(repos(), &await_repo/1)
|
||||
|
|
|
|||
100
scripts/prepare-play-review.sh
Executable file
100
scripts/prepare-play-review.sh
Executable file
|
|
@ -0,0 +1,100 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
EXPECTED_ROOT=/srv/who_need_help-production
|
||||
EXPECTED_PROJECT=who_need_help_production
|
||||
EXPECTED_ORIGIN=https://whoneedhelp.com
|
||||
|
||||
if [ "$#" -ne 5 ] || [ "$3" != "--confirm" ] || [ "$4" != "whoneedhelp.com" ]; then
|
||||
echo "Usage: $0 REVIEWER_EMAIL COUNTERPART_EMAIL --confirm whoneedhelp.com ENV_FILE" >&2
|
||||
echo "Both existing accounts must be active, confirmed, password-enabled, and non-staff." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REVIEWER_EMAIL=$1
|
||||
COUNTERPART_EMAIL=$2
|
||||
ENV_FILE=$5
|
||||
|
||||
if [ "$(realpath --canonicalize-existing "$ROOT")" != "$EXPECTED_ROOT" ]; then
|
||||
echo "Play review fixtures must run from $EXPECTED_ROOT." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Environment file does not exist: $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_env() {
|
||||
key=$1
|
||||
awk -F= -v key="$key" '
|
||||
$1 == key {
|
||||
value = substr($0, index($0, "=") + 1)
|
||||
sub(/\r$/, "", value)
|
||||
|
||||
if ((value ~ /^".*"$/) || (value ~ /^\047.*\047$/)) {
|
||||
value = substr(value, 2, length(value) - 2)
|
||||
}
|
||||
|
||||
count++
|
||||
}
|
||||
|
||||
END {
|
||||
if (count == 1) print value
|
||||
else exit 1
|
||||
}
|
||||
' "$ENV_FILE"
|
||||
}
|
||||
|
||||
if [ "$(read_env DEPLOYMENT_ENV)" != production ]; then
|
||||
echo "Play review fixtures may only target DEPLOYMENT_ENV=production." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project=$(read_env COMPOSE_PROJECT_NAME)
|
||||
if [ "$project" != "$EXPECTED_PROJECT" ]; then
|
||||
echo "Unexpected production Compose project: $project" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(read_env WNH_BASE_URL)" != "$EXPECTED_ORIGIN" ]; then
|
||||
echo "Production origin must be $EXPECTED_ORIGIN." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
container=$("$ROOT/scripts/compose.sh" "$ENV_FILE" ps -q app | head -n 1)
|
||||
|
||||
if [ -z "$container" ]; then
|
||||
container=$("$ROOT/scripts/compose.sh" "$ENV_FILE" ps -q web | head -n 1)
|
||||
fi
|
||||
|
||||
if [ -z "$container" ]; then
|
||||
echo "No running production app or web container was found for project $project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_image=$(read_env APP_IMAGE)
|
||||
observed_image=$(docker inspect --format '{{.Config.Image}}' "$container")
|
||||
container_state=$(docker inspect --format '{{.State.Status}}' "$container")
|
||||
container_health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container")
|
||||
|
||||
if [ "$observed_image" != "$expected_image" ]; then
|
||||
echo "Running container image does not match APP_IMAGE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$container_state" != running ] || [ "$container_health" != healthy ]; then
|
||||
echo "Production application container is not running and healthy." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
encode() {
|
||||
printf %s "$1" | base64 | tr -d '\n'
|
||||
}
|
||||
|
||||
reviewer=$(encode "$REVIEWER_EMAIL")
|
||||
counterpart=$(encode "$COUNTERPART_EMAIL")
|
||||
expression="case WhoNeedHelp.Release.prepare_play_review(Base.decode64!(\"$reviewer\"), Base.decode64!(\"$counterpart\")) do {:ok, result} -> IO.inspect(result); {:error, reason} -> raise \"Play review preparation failed: #{inspect(reason)}\" end"
|
||||
|
||||
docker exec "$container" /app/bin/who_need_help rpc "$expression"
|
||||
93
test/who_need_help/release_test.exs
Normal file
93
test/who_need_help/release_test.exs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
defmodule WhoNeedHelp.ReleaseTest do
|
||||
use WhoNeedHelp.DataCase, async: false
|
||||
|
||||
import Ecto.Query
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.Activities.{Activity, Participant}
|
||||
alias WhoNeedHelp.Catalog
|
||||
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||
alias WhoNeedHelp.Release
|
||||
alias WhoNeedHelp.Repo
|
||||
|
||||
setup do
|
||||
Catalog.seed_defaults()
|
||||
|
||||
reviewer = user_fixture(display_name: "Play reviewer") |> set_password()
|
||||
counterpart = user_fixture(display_name: "Play counterpart") |> set_password()
|
||||
|
||||
%{reviewer: reviewer, counterpart: counterpart}
|
||||
end
|
||||
|
||||
test "prepares stable non-staff reviewer records idempotently", %{
|
||||
reviewer: reviewer,
|
||||
counterpart: counterpart
|
||||
} do
|
||||
assert {:ok, first} = Release.prepare_play_review(reviewer.email, counterpart.email)
|
||||
assert {:ok, second} = Release.prepare_play_review(reviewer.email, counterpart.email)
|
||||
assert first == second
|
||||
|
||||
assert first.request_path == "/requests/#{first.request_id}"
|
||||
assert first.activity_path == "/activities/#{first.activity_id}"
|
||||
assert Accounts.staff_roles(reviewer) == []
|
||||
assert Accounts.staff_roles(counterpart) == []
|
||||
|
||||
assert %HelpRequest{status: :matched, requester_id: reviewer_id} =
|
||||
Repo.get!(HelpRequest, first.request_id)
|
||||
|
||||
assert reviewer_id == reviewer.id
|
||||
|
||||
assert %Assignment{helper_id: counterpart_id, active: true} =
|
||||
Repo.get_by!(Assignment, request_id: first.request_id)
|
||||
|
||||
assert counterpart_id == counterpart.id
|
||||
|
||||
assert %Activity{status: :open, creator_id: ^reviewer_id} =
|
||||
Repo.get!(Activity, first.activity_id)
|
||||
|
||||
assert %Participant{status: :approved, user_id: ^counterpart_id} =
|
||||
Repo.get_by!(Participant,
|
||||
activity_id: first.activity_id,
|
||||
user_id: counterpart.id
|
||||
)
|
||||
|
||||
assert Repo.aggregate(
|
||||
from(request in HelpRequest,
|
||||
where:
|
||||
request.requester_id == ^reviewer.id and
|
||||
request.title == "Play review — medicine pickup"
|
||||
),
|
||||
:count
|
||||
) == 1
|
||||
|
||||
assert Repo.aggregate(
|
||||
from(activity in Activity,
|
||||
where:
|
||||
activity.creator_id == ^reviewer.id and
|
||||
activity.title == "Play review — public cinema meetup"
|
||||
),
|
||||
:count
|
||||
) == 1
|
||||
end
|
||||
|
||||
test "rejects an account without a reusable password", %{
|
||||
reviewer: reviewer,
|
||||
counterpart: counterpart
|
||||
} do
|
||||
passwordless = user_fixture(display_name: "Passwordless reviewer")
|
||||
|
||||
assert {:error, {:reviewer, :password_not_set}} =
|
||||
Release.prepare_play_review(passwordless.email, counterpart.email)
|
||||
|
||||
assert {:error, :accounts_must_be_distinct} =
|
||||
Release.prepare_play_review(reviewer.email, reviewer.email)
|
||||
end
|
||||
|
||||
test "rejects staff accounts", %{counterpart: counterpart} do
|
||||
staff = staff_user_fixture([:support], display_name: "Staff reviewer") |> set_password()
|
||||
|
||||
assert {:error, {:reviewer, :staff_account_forbidden}} =
|
||||
Release.prepare_play_review(staff.email, counterpart.email)
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user