Prepare stable Google Play review fixtures

This commit is contained in:
SimpleTest 2026-08-03 20:01:44 +03:00
parent 0965523dc1
commit 4d321b92f5
5 changed files with 456 additions and 2 deletions

View File

@ -44,3 +44,18 @@ access field:
- Confirm all data is synthetic and no real user can be messaged or located. - 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 - Confirm the password works from a clean Play-delivered install without a
second factor, one-time code, developer browser session, or location gate. 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.

View File

@ -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, - Create a dedicated non-staff production reviewer account with a fixed,
reusable password. Put its credentials only in Play Console App access and reusable password. Put its credentials only in Play Console App access and
the operator-controlled password manager. the operator-controlled password manager.
- Create stable synthetic `Play review` request and activity records with a - Register and confirm two dedicated non-staff accounts with fixed passwords,
second synthetic counterpart, then verify every reviewer instruction from a then create their stable synthetic `Play review` request and activity using
`scripts/prepare-play-review.sh`. Verify every reviewer instruction from a
clean installation. clean installation.
- Complete App content: App access, Ads, Content rating, Target audience, - Complete App content: App access, Ads, Content rating, Target audience,
News-app declaration, Data Safety, background-location declaration if Play News-app declaration, Data Safety, background-location declaration if Play

View File

@ -62,6 +62,251 @@ defmodule WhoNeedHelp.Release do
end) end)
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 def await_migrations do
load_app() load_app()
Enum.each(repos(), &await_repo/1) Enum.each(repos(), &await_repo/1)

100
scripts/prepare-play-review.sh Executable file
View 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"

View 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